summaryrefslogblamecommitdiffstats
path: root/.bin/music
blob: 0c280add44f4ac80d240b2777f69adc82be07008 (plain) (tree)
1
2
3
4
5
6
7
8
9

                  
              

          
 
                                     
 
                                 
 


                                    
 
 

                                                 
 


                                                              
 

                                                                 
 
                   
 

                                   
                                     


                                            
                                          












                                                     
                               


     
















                                                         
 



                                                                  
 
                  
 






































































                                                                        


                  


                          





                                           









                                                                  





                          
#!/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())
remember that computers suck.