diff --git a/README.md b/README.md index 9e21cde..e0aa8ba 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ votify [OPTIONS] URLS... ```bash votify "https://open.spotify.com/artist/0gxyHStUsqpMadRV0Di1Qt" ``` +- Incremental sync of a regularly updated podcast or playlist + ```bash + votify --download-archive archive.txt --break-on-existing "https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk" + ``` ### Interactive prompt controls @@ -155,6 +159,8 @@ Config file values can be overridden using command-line arguments. | `--save-cover` / `save_cover` | Save cover as a separate file. | `false` | | `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` | | `--overwrite` / `overwrite` | Overwrite existing files. | `false` | +| `--download-archive` / `download_archive` | Path to download archive file to record downloaded items. | `null` | +| `--break-on-existing` / `break_on_existing` | Stop downloading when a media item that has already been downloaded is encountered. | `false` | | `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` | | `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` | | `--audio-quality`, `-a` / `audio_quality` | Audio quality for songs and podcasts. | `aac-medium` | @@ -235,6 +241,24 @@ The following variables can be used in the template folder/file and/or in the `e - `mp4box` - `mp4decrypt` +### Download Archive + +Votify supports download archives to avoid re-downloading the same content, similar to _yt-dlp_'s functionality. + +#### How it works + +- When `--download-archive` is specified, Votify records the Spotify ID of each successfully downloaded item in the specified archive file; +- Before downloading, Votify checks if the item already exists as a file or is recorded in the archive; +- If either condition is true, the item is skipped. + +#### Break on existing + +The `--break-on-existing` option stops the download process when encountering an already-downloaded item. This is useful for downloading new episodes of a podcast or new songs in a playlist without ploughing through the entire list. + +```bash +votify --download-archive archive.txt --break-on-existing "https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk" +``` + ### Credits - [spotify-oggmp4-dl](https://github.com/DevLARLEY/spotify-oggmp4-dl) diff --git a/votify/cli.py b/votify/cli.py index 97a54e1..db67970 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -35,6 +35,7 @@ VideoFormat, ) from .spotify_api import SpotifyApi +from .models import BreakOnExistingException from .utils import color_text, prompt_path logger = logging.getLogger("votify") @@ -283,6 +284,17 @@ def load_config_file( is_flag=True, help="Overwrite existing files.", ) +@click.option( + "--download-archive", + type=Path, + default=None, + help="Path to download archive file to record downloaded items.", +) +@click.option( + "--break-on-existing", + is_flag=True, + help="Stop downloading when a media item that has already been downloaded is encountered.", +) @click.option( "--exclude-tags", type=str, @@ -393,6 +405,8 @@ def main( video_format: VideoFormat, remux_mode_video: RemuxModeVideo, no_config_file: bool, + download_archive: Path, + break_on_existing: bool, ) -> None: colorama.just_fix_windows_console() logger.setLevel(log_level) @@ -428,6 +442,8 @@ def main( overwrite, exclude_tags, truncate, + download_archive, + break_on_existing, ) downloader_audio = DownloaderAudio( downloader, @@ -607,6 +623,10 @@ def main( playlist_metadata=download_queue_item.playlist_metadata, playlist_track=index, ) + except BreakOnExistingException as e: + logger.info(f'({queue_progress}) {str(e)}') + logger.info("Stopping due to --break-on-existing") + return except Exception as e: error_count += 1 logger.error( diff --git a/votify/downloader.py b/votify/downloader.py index b77827a..2923013 100644 --- a/votify/downloader.py +++ b/votify/downloader.py @@ -69,6 +69,8 @@ def __init__( overwrite: bool = False, exclude_tags: str = None, truncate: int = None, + download_archive: Path = None, + break_on_existing: bool = False, silence: bool = False, skip_cleanup: bool = False, ): @@ -97,6 +99,8 @@ def __init__( self.overwrite = overwrite self.exclude_tags = exclude_tags self.truncate = truncate + self.download_archive = download_archive + self.break_on_existing = break_on_existing self.silence = silence self.skip_cleanup = skip_cleanup self.cdm = None @@ -104,6 +108,7 @@ def __init__( self._set_exclude_tags_list() self._set_truncate() self._set_subprocess_additional_args() + self._load_download_archive() def _set_binaries_full_path(self): self.aria2c_path_full = shutil.which(self.aria2c_path) @@ -132,6 +137,57 @@ def _set_subprocess_additional_args(self): else: self.subprocess_additional_args = {} + def _load_download_archive(self): + """Load the download archive file into memory.""" + self.downloaded_ids = set() + if self.download_archive and self.download_archive.exists(): + try: + with self.download_archive.open('r', encoding='utf-8') as f: + self.downloaded_ids = {line.strip() for line in f if line.strip()} + except Exception as e: + logger.warning(f"Failed to load download archive: {e}") + + def is_downloaded(self, media_id: str) -> bool: + """Check if a media item has already been downloaded.""" + return media_id in self.downloaded_ids + + def add_to_archive(self, media_id: str): + """Add a media ID to the download archive.""" + if not self.download_archive: + return + + # Only add if not already in archive + if media_id in self.downloaded_ids: + return + + # Add to in-memory set + self.downloaded_ids.add(media_id) + + # Append to file + try: + self.download_archive.parent.mkdir(parents=True, exist_ok=True) + with self.download_archive.open('a', encoding='utf-8') as f: + f.write(f"{media_id}\n") + except Exception as e: + logger.warning(f"Failed to write to download archive: {e}") + + def check_existing_file_or_archive(self, final_path: Path, media_id: str) -> bool: + """Check if file exists or is in download archive. Raises BreakOnExistingError if break_on_existing is True.""" + from .models import BreakOnExistingException + + # Check if file already exists + file_exists = final_path.exists() and not self.overwrite + + # Check if in download archive + in_archive = self.is_downloaded(media_id) + + exists = file_exists or in_archive + + if exists and self.break_on_existing: + raise BreakOnExistingException(f"Item {media_id} already downloaded, breaking due to --break-on-existing") + + return exists + def set_cdm(self) -> None: self.cdm = Cdm.from_device(Device.load(self.wvd_path)) @@ -530,6 +586,7 @@ def _final_processing( tags: dict, playlist_metadata: dict, playlist_track: int, + media_id: str = None, ): if self.save_cover and cover_path.exists() and not self.overwrite: logger.debug(f'Cover already exists at "{cover_path}", skipping') @@ -552,6 +609,9 @@ def _final_processing( final_path, playlist_track, ) + # Add to download archive if media was successfully processed + if media_id and media_temp_path: + self.add_to_archive(media_id) def cleanup_temp_path(self): if self.temp_path.exists() and not self.skip_cleanup: diff --git a/votify/downloader_episode.py b/votify/downloader_episode.py index 0e0e759..ff91b46 100644 --- a/votify/downloader_episode.py +++ b/votify/downloader_episode.py @@ -120,8 +120,11 @@ def _download( ) decrypted_path = None remuxed_path = None - if final_path.exists() and not self.downloader.overwrite: - logger.warning(f'Track already exists at "{final_path}", skipping') + if self.downloader.check_existing_file_or_archive(final_path, episode_id): + if final_path.exists(): + logger.warning(f'Episode already exists at "{final_path}", skipping') + else: + logger.warning(f'Episode {episode_id} already in download archive, skipping') else: decryption_key = ( self.DEFAULT_EPISODE_DECRYPTION_KEY.hex() @@ -167,4 +170,5 @@ def _download( tags, playlist_metadata, playlist_track, + episode_id, ) diff --git a/votify/downloader_episode_video.py b/votify/downloader_episode_video.py index 89f2947..b5e9418 100644 --- a/votify/downloader_episode_video.py +++ b/votify/downloader_episode_video.py @@ -83,8 +83,11 @@ def _download( COVER_SIZE_X_KEY_MAPPING_EPISODE, ) remuxed_path = None - if final_path.exists() and not self.downloader.overwrite: - logger.warning(f'Episode already exists at "{final_path}", skipping') + if self.downloader.check_existing_file_or_archive(final_path, episode_id): + if final_path.exists(): + logger.warning(f'Episode already exists at "{final_path}", skipping') + else: + logger.warning(f'Episode {episode_id} already in download archive, skipping') return else: key_id, decryption_key = ( @@ -150,4 +153,5 @@ def _download( tags, playlist_metadata, playlist_track, + episode_id, ) diff --git a/votify/downloader_music_video.py b/votify/downloader_music_video.py index bceb2aa..80be7fd 100644 --- a/votify/downloader_music_video.py +++ b/votify/downloader_music_video.py @@ -195,8 +195,11 @@ def _download( COVER_SIZE_X_KEY_MAPPING_VIDEO, ) remuxed_path = None - if final_path.exists() and not self.downloader.overwrite: - logger.warning(f'Music video already exists at "{final_path}", skipping') + if self.downloader.check_existing_file_or_archive(final_path, music_video_id): + if final_path.exists(): + logger.warning(f'Music video already exists at "{final_path}", skipping') + else: + logger.warning(f'Music video {music_video_id} already in download archive, skipping') return else: key_id, decryption_key = self.downloader.get_widevine_decryption_key( @@ -253,4 +256,5 @@ def _download( tags, playlist_metadata, playlist_track, + music_video_id, ) diff --git a/votify/downloader_song.py b/votify/downloader_song.py index fac7332..4f38054 100644 --- a/votify/downloader_song.py +++ b/votify/downloader_song.py @@ -212,8 +212,11 @@ def _download( remuxed_path = None if self.lrc_only: pass - elif final_path.exists() and not self.downloader.overwrite: - logger.warning(f'Track already exists at "{final_path}", skipping') + elif self.downloader.check_existing_file_or_archive(final_path, track_id): + if final_path.exists(): + logger.warning(f'Track already exists at "{final_path}", skipping') + else: + logger.warning(f'Track {track_id} already in download archive, skipping') else: if not decryption_key: logger.debug("Getting decryption key") @@ -264,4 +267,5 @@ def _download( tags, playlist_metadata, playlist_track, + track_id, ) diff --git a/votify/models.py b/votify/models.py index 5acbdb5..597c0ab 100644 --- a/votify/models.py +++ b/votify/models.py @@ -5,6 +5,11 @@ from .enums import AudioQuality +class BreakOnExistingException(Exception): + """Exception raised when break-on-existing is triggered.""" + pass + + @dataclass class Lyrics: synced: str = None