diff --git a/README.md b/README.md index a25cd56..155df30 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Config file values can be overridden using command-line arguments. | `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` | | `--cover-size` / `cover_size` | Cover size. | `1200` | | `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` | +| `--database-path` / `database_path` | Path to the downloaded media database file. | `null` | | `--codec-song` / `codec_song` | Song codec. | `aac-legacy` | | `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` | | `--codec-music-video` / `codec_music_video` | Comma-separated music video codec priority. | `h264,h265` | @@ -245,6 +246,11 @@ The following variables can be used in the template folders/files and/or in the - `png`: Lossless format. - `raw`: Raw cover without processing (requires `save_cover` to save separately). +### Database path + +You can specify any path for storing a database file of downloaded media. +This is useful if you want to avoid waiting for Gamdl to fetch metadata for checking if a media item has already been downloaded. + ## Embedding Gamdl can be used as a library in Python scripts. Here's a basic example of downloading a song by its ID: diff --git a/gamdl/cli.py b/gamdl/cli.py index f59e267..8812c60 100644 --- a/gamdl/cli.py +++ b/gamdl/cli.py @@ -319,6 +319,12 @@ def load_config_file( default=downloader_sig.parameters["truncate"].default, help="Maximum length of the file/folder names.", ) +@click.option( + "--database-path", + type=Path, + default=downloader_sig.parameters["database_path"].default, + help="Path to the downloaded media database file.", +) # DownloaderSong specific options @click.option( "--codec-song", @@ -401,6 +407,7 @@ def main( exclude_tags: list[str], cover_size: int, truncate: int, + database_path: Path, codec_song: SongCodec, synced_lyrics_format: SyncedLyricsFormat, codec_music_video: list[MusicVideoCodec], @@ -471,6 +478,7 @@ def main( exclude_tags, cover_size, truncate, + database_path, log_level in ("WARNING", "ERROR"), ) diff --git a/gamdl/database.py b/gamdl/database.py new file mode 100644 index 0000000..06fcc89 --- /dev/null +++ b/gamdl/database.py @@ -0,0 +1,50 @@ +import sqlite3 +from pathlib import Path + + +class Database: + INITIAL_QUERY = """ + CREATE TABLE IF NOT EXISTS media ( + media_id TEXT PRIMARY KEY, + media_path TEXT NOT NULL + ) + """ + WRITE_MEDIA_QUERY = """ + INSERT OR REPLACE INTO media (media_id, media_path) VALUES (?, ?) + """ + GET_MEDIA_QUERY = """ + SELECT media_path FROM media WHERE media_id = ? + """ + + def __init__(self, file_path: Path): + self.file_path = file_path + self._initialize_db() + + def _initialize_db(self): + self.file_path.parent.mkdir(parents=True, exist_ok=True) + + with sqlite3.connect(self.file_path) as conn: + conn.execute(self.INITIAL_QUERY) + conn.commit() + + def write_media(self, media_id: str, media_path: Path): + with sqlite3.connect(self.file_path) as conn: + conn.execute( + self.WRITE_MEDIA_QUERY, + ( + media_id, + str(media_path.absolute()), + ), + ) + conn.commit() + + def get_media(self, media_id: str) -> Path | None: + with sqlite3.connect(self.file_path) as conn: + cursor = conn.execute( + self.GET_MEDIA_QUERY, + (media_id,), + ) + result = cursor.fetchone() + if result: + return Path(result[0]) + return None diff --git a/gamdl/downloader.py b/gamdl/downloader.py index 15850f3..0023458 100644 --- a/gamdl/downloader.py +++ b/gamdl/downloader.py @@ -23,7 +23,9 @@ from pywidevine import PSSH, Cdm, Device from yt_dlp import YoutubeDL from .apple_music_api import AppleMusicApi +from .database import Database from .enums import CoverFormat, DownloadMode, MediaFileFormat, RemuxMode +from .exceptions import MediaFileAlreadyExistsException from .hardcoded_wvd import HARDCODED_WVD from .itunes_api import ItunesApi from .models import ( @@ -90,6 +92,7 @@ class Downloader: exclude_tags: list[str] = None, cover_size: int = 1200, truncate: int = None, + database_path: Path = None, silent: bool = False, skip_processing: bool = False, ): @@ -121,12 +124,14 @@ class Downloader: self.exclude_tags = exclude_tags self.cover_size = cover_size self.truncate = truncate + self.database_path = database_path self.silent = silent self.skip_processing = skip_processing self._set_temp_path() self._set_exclude_tags() self._set_binaries_path_full() self._set_truncate() + self._set_database() self._set_subprocess_additional_args() def _set_temp_path(self): @@ -146,6 +151,12 @@ class Downloader: if self.truncate is not None: self.truncate = None if self.truncate < 4 else self.truncate + def _set_database(self): + if self.database_path is not None: + self.database = Database(self.database_path) + else: + self.database = None + def _set_subprocess_additional_args(self): if self.silent: self.subprocess_additional_args = { @@ -347,6 +358,18 @@ class Downloader: ) -> bool: return bool(media_metadata["attributes"].get("playParams")) + def check_database_and_raise(self, media_id: str) -> None: + if self.database is None: + return + + final_path_database = self.database.get_media(media_id) + if ( + final_path_database is not None + and final_path_database.exists() + and not self.overwrite + ): + raise MediaFileAlreadyExistsException(final_path_database) + def get_playlist_tags( self, playlist_attributes: dict, @@ -707,6 +730,12 @@ class Downloader: ) logger.info(f"[{colored_media_id}] Download completed successfully") + if self.database is not None: + self.database.write_media( + download_info.media_id, + download_info.final_path, + ) + if ( download_info.cover_path and not self.save_cover ) or not download_info.cover_path: diff --git a/gamdl/downloader_music_video.py b/gamdl/downloader_music_video.py index 28f5581..8d8a972 100644 --- a/gamdl/downloader_music_video.py +++ b/gamdl/downloader_music_video.py @@ -474,19 +474,18 @@ class DownloaderMusicVideo: if not media_id and not media_metadata: raise ValueError("Either media_id or media_metadata must be provided") - if not media_metadata: - logger.debug( - f"[{color_text(media_id, colorama.Style.DIM)}] " - "Getting Music Video metadata" - ) - media_metadata = self.downloader.apple_music_api.get_music_video(media_id) - download_info.media_metadata = media_metadata - - if not media_id: + if media_metadata: media_id = self.downloader.get_media_id_of_library_media(media_metadata) download_info.media_id = media_id colored_media_id = color_text(media_id, colorama.Style.DIM) + self.downloader.check_database_and_raise(media_id) + + if not media_metadata: + logger.debug(f"[{colored_media_id}] Getting Music Video metadata") + media_metadata = self.downloader.apple_music_api.get_music_video(media_id) + download_info.media_metadata = media_metadata + if not self.downloader.is_media_streamable(media_metadata): raise MediaNotStreamableException() diff --git a/gamdl/downloader_post.py b/gamdl/downloader_post.py index fad2d82..25bc347 100644 --- a/gamdl/downloader_post.py +++ b/gamdl/downloader_post.py @@ -108,19 +108,18 @@ class DownloaderPost: if not media_id and not media_metadata: raise ValueError("Either media_id or media_metadata must be provided") - if not media_metadata: - logger.debug( - f"[{color_text(media_id, colorama.Style.DIM)}] " - "Getting Post Video metadata" - ) - media_metadata = self.downloader.apple_music_api.get_post(media_id) - download_info.media_metadata = media_metadata - - if not media_id: + if media_metadata: media_id = media_metadata["id"] download_info.media_id = media_id colored_media_id = color_text(media_id, colorama.Style.DIM) + self.downloader.check_database_and_raise(media_id) + + if not media_metadata: + logger.debug(f"[{colored_media_id}] Getting Post Video metadata") + media_metadata = self.downloader.apple_music_api.get_post(media_id) + download_info.media_metadata = media_metadata + if not self.downloader.is_media_streamable(media_metadata): raise MediaNotStreamableException() diff --git a/gamdl/downloader_song.py b/gamdl/downloader_song.py index 2fad8ec..ba6a4e9 100644 --- a/gamdl/downloader_song.py +++ b/gamdl/downloader_song.py @@ -631,18 +631,18 @@ class DownloaderSong: if not media_id and not media_metadata: raise ValueError("Either media_id or media_metadata must be provided") - if not media_metadata: - logger.debug( - f"[{color_text(media_id, colorama.Style.DIM)}] Getting Song metadata" - ) - media_metadata = self.downloader.apple_music_api.get_song(media_id) - download_info.media_metadata = media_metadata - - if not media_id: + if media_metadata: media_id = self.downloader.get_media_id_of_library_media(media_metadata) download_info.media_id = media_id colored_media_id = color_text(media_id, colorama.Style.DIM) + self.downloader.check_database_and_raise(media_id) + + if not media_metadata: + logger.debug(f"[{colored_media_id}] Getting Song metadata") + media_metadata = self.downloader.apple_music_api.get_song(media_id) + download_info.media_metadata = media_metadata + if not self.downloader.is_media_streamable(media_metadata): raise MediaNotStreamableException()