#!/usr/bin/python3 import logging import os import sys import yt_dlp # type: ignore[import] from dataclasses import dataclass logging.basicConfig() logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) def get_ytdlp_options(output_dir: str) -> dict: """yt_dlp download and convertion options.""" def match_filter(info: dict, *, incomplete) -> str | None: duration = info.get("duration") duration_min = 60 if duration is not None and int(duration) < duration_min: return "Duration too short: < _duration_min" return None return { "format": "bestaudio/best", "match_filter": match_filter, "postprocessors": [ { "key": "FFmpegExtractAudio", # "preferredcodec": "m4a", }, { "key": "FFmpegMetadata", "add_metadata": True, }, { "key": "EmbedThumbnail", "already_have_thumbnail": False, }, ], "outtmpl": f"{output_dir}/%(title)s.%(ext)s", "restrictfilenames": True, "ignoreerrors": True, "writethumbnail": True, } def parse_raw_lines(lines: list[str]) -> list[list[str]]: """Parse collections of name + link(s) (Usually stored in a text file). """ entries: list[list[str]] = list() entry: list[str] = list() for index, line in enumerate(lines): # entries are separated by an empty line. if line == "": entries.append(entry) entry = list() continue entry.append(line) # handle the last entry when reaching the end of the file. if index + 1 == len(lines): entries.append(entry) entry = list() return entries @dataclass(frozen=True) class Link: """A music link.""" url: str is_enabled: bool @dataclass(frozen=True) class Collection: """A music collection.""" name: str links: tuple[Link, ...] is_enabled: bool def __eq__(self, other) -> bool: if isinstance(other, Collection): return self.name == other.name raise NotImplementedError def sanitize_entry_informations( entry: str, indicator: str = "#" ) -> tuple[str, bool]: is_comment = entry.startswith(indicator) if is_comment: entry = entry.split(indicator, 1)[1].lstrip() return entry, not is_comment def create_link(entry: str) -> Link: url, is_enabled = sanitize_entry_informations(entry) return Link(url=url, is_enabled=is_enabled) def create_collection(entry: list[str]) -> Collection: """Create a collection from a raw entry.""" name, is_enabled = sanitize_entry_informations(entry[0]) links = [create_link(_link) for _link in entry[1:]] return Collection( name=name, links=tuple(links), is_enabled=is_enabled ) def get_collection_dir(collection: Collection, parent_dir: str) -> str: return os.path.join(parent_dir, collection.name) def download_collection(collection: Collection, directory: str) -> None: """Download a music collection to the local filesystem.""" # create directory and download/convert with opinionated settings. os.makedirs(directory, exist_ok=True) with yt_dlp.YoutubeDL(get_ytdlp_options(directory)) as downloader: for link in collection.links: if not link.is_enabled: logger.info(f"Skipping {collection.name}, {link}") continue logger.info(f"Downloading {collection.name}, {link}") downloader.download(link.url) def main() -> int: """Main entrypoint.""" # argument handling if len(sys.argv) != 2: return 1 with open(sys.argv[1], "r") as file: filedata = file.read().splitlines() for entry in parse_raw_lines(filedata): collection = create_collection(entry) output_dir = get_collection_dir(collection, os.getcwd()) if os.path.isdir(output_dir) or not collection.is_enabled: logger.info(f"Skipping {collection.name}") continue logger.info(f"Handling {collection.name}") download_collection(collection, output_dir) return 0 if __name__ == "__main__": exit(main())