Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions votify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -428,6 +442,8 @@ def main(
overwrite,
exclude_tags,
truncate,
download_archive,
break_on_existing,
)
downloader_audio = DownloaderAudio(
downloader,
Expand Down Expand Up @@ -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(
Expand Down
60 changes: 60 additions & 0 deletions votify/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -97,13 +99,16 @@ 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
self._set_binaries_full_path()
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)
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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')
Expand All @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions votify/downloader_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -167,4 +170,5 @@ def _download(
tags,
playlist_metadata,
playlist_track,
episode_id,
)
8 changes: 6 additions & 2 deletions votify/downloader_episode_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -150,4 +153,5 @@ def _download(
tags,
playlist_metadata,
playlist_track,
episode_id,
)
8 changes: 6 additions & 2 deletions votify/downloader_music_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -253,4 +256,5 @@ def _download(
tags,
playlist_metadata,
playlist_track,
music_video_id,
)
8 changes: 6 additions & 2 deletions votify/downloader_song.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -264,4 +267,5 @@ def _download(
tags,
playlist_metadata,
playlist_track,
track_id,
)
5 changes: 5 additions & 0 deletions votify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down