diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05d05155..5d8ecedf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: test: strategy: matrix: - version: ['3.8', '3.12'] + version: ['3.9', '3.13'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -18,18 +18,22 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.version }} + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.version }} + version: '0.8.8' - name: Install dependencies - run: | - pip install -e .[dev] + run: uv sync - name: Lint if: '!cancelled()' - run: ruff check --output-format=github + run: uv run ruff check --output-format=github - name: Format check if: '!cancelled()' - run: ruff format --check + run: uv run ruff format --check - name: Type check if: '!cancelled()' - run: mypy + run: uv run mypy - name: Install ffmpeg run: | sudo apt update @@ -38,8 +42,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }} - run: | - pytest -vv --exitfirst + run: uv run pytest -vv --exitfirst publish: needs: test if: startsWith(github.ref, 'refs/tags/v') @@ -49,14 +52,16 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: 3.9 + version: '0.8.8' - name: Install dependencies - run: | - pip install -e . + run: uv sync - name: Build package - run: | - pip install --upgrade build - python -m build + run: uv build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.gitignore b/.gitignore index 1aacbe0c..06fd5530 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,13 @@ dist/ *.egg-info/ __pycache__/ *.mp3 +*.opus +*.m4a +*.flac .vscode .venv .env .coverage* .idea .python-version -.DS_store \ No newline at end of file +.DS_Store diff --git a/README.md b/README.md index bff78979..e78de5c2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ # Soundcloud Music Downloader + +## Status of the project + +As of version 3, this script is a wrapper around `yt-dlp` with some defaults/patches for backwards compatibility. +Development is not active and new features will likely not be merged, especially if they can be covered with the +use of `--yt-dlp-args`. Bug reports/fixes are welcome. + ## Description This script is able to download music from SoundCloud and set id3tag to the downloaded music. Compatible with Windows, OS X, Linux. - ## System requirements * python3 @@ -91,6 +97,7 @@ scdl me -f --add-description Adds the description to a seperate txt file (can be read by some players) --no-playlist Skip downloading playlists --opus Prefer downloading opus streams over mp3 streams +--yt-dlp-args String with custom args to forward to yt-dlp ``` diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 4c663af9..00000000 --- a/mypy.ini +++ /dev/null @@ -1,3 +0,0 @@ -[mypy] -packages = scdl, tests -check_untyped_defs = true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..dc366e18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[project] +name = "scdl" +version = "3.0.0" +authors=[ + {name = "7x11x13"}, {name = "FlyinGrub"} +] +description = "Download Music from Souncloud" +readme = "README.md" +requires-python = ">=3.9.0" +dependencies = [ + "docopt-ng>=0.9.0", + "mutagen>=1.47.0", + "soundcloud-v2>=1.6.0", + "yt-dlp>=2025.2.19", +] +classifiers = [ + "Programming Language :: Python", + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Multimedia :: Sound/Audio", +] + +[dependency-groups] +dev = [ + "music-tag>=0.4.3", + "mypy>=1.13.0", + "pytest>=8.3.4", + "pytest-dotenv>=0.5.2", + "ruff>=0.8.3", + "types-requests>=2.32.0.20241016", + "typing_extensions>=4.12.2; python_version < '3.11'", +] + +[project.urls] +Issues = "https://github.com/scdl-org/scdl/issues" +Repository = "https://github.com/scdl-org/scdl" +Changelog = "https://github.com/scdl-org/scdl/blob/master/CHANGELOG.md" + +[project.scripts] +scdl = "scdl.scdl:_main" + +[tool.uv] +package = true + +[tool.ruff] +target-version = "py39" +line-length = 120 + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN", + "C90", "D", + "S", "BLE", "FBT", "A", "EM", "FA", "G", "SLF", "PTH", + "PLR", "TRY", + "PLW2901", "ANN204", + "COM812", "ISC001", + "EXE" +] + +[tool.mypy] +packages = ["scdl", "tests"] +check_untyped_defs = true +disable_error_code = ["import-untyped"] + +[[tool.mypy.overrides]] +module = "scdl.patches.*" +ignore_errors = true diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 782d5b92..00000000 --- a/ruff.toml +++ /dev/null @@ -1,13 +0,0 @@ -target-version = "py37" -line-length = 100 - -[lint] -select = ["ALL"] -ignore = [ - "C90", "D", - "S", "BLE", "FBT", "A", "EM", "FA", "G", "SLF", "PTH", - "PLR", "TRY", - "PLW2901", "ANN204", - "COM812", "ISC001", - "EXE" -] \ No newline at end of file diff --git a/scdl/__init__.py b/scdl/__init__.py index 600ae279..9f97493b 100644 --- a/scdl/__init__.py +++ b/scdl/__init__.py @@ -1,3 +1,6 @@ """Python Soundcloud Music Downloader.""" -__version__ = "v2.12.4" +from . import patches # noqa: F401, I001 +from scdl.scdl import download_url + +__all__ = ["download_url"] diff --git a/scdl/metadata_assembler.py b/scdl/metadata_assembler.py deleted file mode 100644 index 48b8893a..00000000 --- a/scdl/metadata_assembler.py +++ /dev/null @@ -1,171 +0,0 @@ -from base64 import b64encode -from dataclasses import dataclass -from functools import singledispatch -from typing import Optional, Union - -from mutagen import ( - FileType, - aiff, - flac, - id3, - mp3, - mp4, - oggopus, - oggspeex, - oggtheora, - wave, -) - -JPEG_MIME_TYPE: str = "image/jpeg" - - -@dataclass(frozen=True) -class MetadataInfo: - artist: str - title: str - description: Optional[str] - genre: Optional[str] - - artwork_jpeg: Optional[bytes] - - link: Optional[str] - date: Optional[str] - - album_title: Optional[str] - album_author: Optional[str] - album_track_num: Optional[int] - album_total_track_num: Optional[int] - - -@singledispatch -def assemble_metadata(file: FileType, meta: MetadataInfo) -> None: - raise NotImplementedError - - -def _get_flac_pic(jpeg_data: bytes) -> flac.Picture: - pic = flac.Picture() - pic.data = jpeg_data - pic.mime = JPEG_MIME_TYPE - pic.type = id3.PictureType.COVER_FRONT - return pic - - -def _get_apic(jpeg_data: bytes) -> id3.APIC: - return id3.APIC( - encoding=3, - mime=JPEG_MIME_TYPE, - type=3, - desc="Cover", - data=jpeg_data, - ) - - -def _assemble_vorbis_tags(file: FileType, meta: MetadataInfo) -> None: - file["artist"] = meta.artist - file["title"] = meta.title - - if meta.genre: - file["genre"] = meta.genre - - if meta.link: - # https://getmusicbee.com/forum/index.php?topic=39759.0 - file["WWWAUDIOFILE"] = meta.link - - if meta.date: - file["date"] = meta.date - - if meta.album_title: - file["album"] = meta.album_title - - if meta.album_author: - file["albumartist"] = meta.album_author - - if meta.album_track_num is not None: - file["tracknumber"] = str(meta.album_track_num) - - if meta.description: - # https://xiph.org/vorbis/doc/v-comment.html - # prefer 'description' over 'comment' - file["description"] = meta.description - - -@assemble_metadata.register(flac.FLAC) -def _(file: flac.FLAC, meta: MetadataInfo) -> None: - _assemble_vorbis_tags(file, meta) - - if meta.artwork_jpeg: - file.add_picture(_get_flac_pic(meta.artwork_jpeg)) - - -@assemble_metadata.register(oggtheora.OggTheora) -@assemble_metadata.register(oggspeex.OggSpeex) -@assemble_metadata.register(oggopus.OggOpus) -def _(file: oggopus.OggOpus, meta: MetadataInfo) -> None: - _assemble_vorbis_tags(file, meta) - - if meta.artwork_jpeg: - pic = _get_flac_pic(meta.artwork_jpeg).write() - file["metadata_block_picture"] = b64encode(pic).decode() - - -@assemble_metadata.register(aiff.AIFF) -@assemble_metadata.register(mp3.MP3) -@assemble_metadata.register(wave.WAVE) -def _(file: Union[wave.WAVE, mp3.MP3], meta: MetadataInfo) -> None: - file["TIT2"] = id3.TIT2(encoding=3, text=meta.title) - file["TPE1"] = id3.TPE1(encoding=3, text=meta.artist) - - if meta.description: - file["COMM"] = id3.COMM(encoding=3, lang="ENG", text=meta.description) - - if meta.genre: - file["TCON"] = id3.TCON(encoding=3, text=meta.genre) - - if meta.link: - file["WOAF"] = id3.WOAF(url=meta.link) - - if meta.date: - file["TDAT"] = id3.TDAT(encoding=3, text=meta.date) - - if meta.album_title: - file["TALB"] = id3.TALB(encoding=3, text=meta.album_title) - - if meta.album_author: - file["TPE2"] = id3.TPE2(encoding=3, text=meta.album_author) - - if meta.album_track_num is not None: - file["TRCK"] = id3.TRCK(encoding=3, text=str(meta.album_track_num)) - - if meta.artwork_jpeg: - file["APIC"] = _get_apic(meta.artwork_jpeg) - - -@assemble_metadata.register(mp4.MP4) -def _(file: mp4.MP4, meta: MetadataInfo) -> None: - file["\251ART"] = meta.artist - file["\251nam"] = meta.title - - if meta.genre: - file["\251gen"] = meta.genre - - if meta.link: - # https://getmusicbee.com/forum/index.php?topic=39759.0 - file["----:com.apple.iTunes:WWWAUDIOFILE"] = meta.link.encode() - - if meta.date: - file["\251day"] = meta.date - - if meta.album_title: - file["\251alb"] = meta.album_title - - if meta.album_author: - file["aART"] = meta.album_author - - if meta.album_track_num is not None: - file["trkn"] = [(meta.album_track_num, meta.album_total_track_num)] - - if meta.description: - file["\251cmt"] = meta.description - - if meta.artwork_jpeg: - file["covr"] = [mp4.MP4Cover(meta.artwork_jpeg)] diff --git a/scdl/patches/__init__.py b/scdl/patches/__init__.py new file mode 100644 index 00000000..9bd39c26 --- /dev/null +++ b/scdl/patches/__init__.py @@ -0,0 +1,13 @@ +from . import ( + old_archive_ids, + sync_download_archive, + thumbnail_selection, + trim_filenames, +) + +__all__ = [ + "old_archive_ids", + "sync_download_archive", + "thumbnail_selection", + "trim_filenames", +] diff --git a/scdl/patches/mutagen_postprocessor.py b/scdl/patches/mutagen_postprocessor.py new file mode 100644 index 00000000..0767a2aa --- /dev/null +++ b/scdl/patches/mutagen_postprocessor.py @@ -0,0 +1,283 @@ +# https://github.com/yt-dlp/yt-dlp/pull/11817 +import base64 +import collections +import functools +import os +import re +from typing import ClassVar + +import mutagen +from mutagen import ( + FileType, + aiff, + dsdiff, + dsf, + flac, + id3, + mp3, + mp4, + oggopus, + oggspeex, + oggtheora, + oggvorbis, + trueaudio, + wave, +) +from yt_dlp.compat import imghdr +from yt_dlp.postprocessor.common import PostProcessor +from yt_dlp.utils import PostProcessingError, date_from_str, variadic + + +class MutagenPostProcessorError(PostProcessingError): + pass + + +class MutagenPP(PostProcessor): + _MUTAGEN_SUPPORTED_EXTS = ("alac", "aiff", "flac", "mp3", "m4a", "ogg", "opus", "vorbis", "wav") + _VORBIS_METADATA: ClassVar[dict[str, str]] = { + "title": "title", + "artist": "artist", + "genre": "genre", + "album": "album", + "albumartist": "album_artist", + "comment": "description", + "composer": "composer", + "tracknumber": "track", + "WWWAUDIOFILE": "purl", # https://getmusicbee.com/forum/index.php?topic=39759.0 + } + _ID3_METADATA: ClassVar[dict[str, str]] = { + "TIT2": "title", + "TPE1": "artist", + "COMM": "description", + "TCON": "genre", + "WOAF": "purl", + "TALB": "album", + "TPE2": "album_artist", + "TRCK": "track", + "TCOM": "composer", + "TPOS": "disc", + } + _MP4_METADATA: ClassVar[dict[str, str]] = { + "\251ART": "artist", + "\251nam": "title", + "\251gen": "genre", + "\251alb": "album", + "aART": "album_artist", + "\251cmt": "description", + "\251wrt": "composer", + "disk": "disc", + "tvsh": "show", + "tvsn": "season_number", + "egid": "episode_id", + "tven": "episode_sort", + } + + def __init__(self, post_overwrites: bool, downloader=None): + super().__init__(downloader) + self._post_overwrites = post_overwrites + + def _get_flac_pic(self, thumbnail: dict) -> flac.Picture: + pic = flac.Picture() + pic.data = thumbnail["data"] + pic.mime = f"image/{thumbnail['type']}" + pic.type = id3.PictureType.COVER_FRONT + return pic + + def _get_metadata_dict(self, info): + meta_prefix = "meta" + metadata = collections.defaultdict(dict) + + def add(meta_list, info_list=None): + value = next( + ( + info[key] + for key in [f"{meta_prefix}_", *variadic(info_list or meta_list)] + if info.get(key) is not None + ), + None, + ) + if value not in ("", None): + value = ", ".join(map(str, variadic(value))) + value = value.replace("\0", "") # nul character cannot be passed in command line + metadata["common"].update({meta_f: value for meta_f in variadic(meta_list)}) + + # Info on media metadata/metadata supported by ffmpeg: + # https://wiki.multimedia.cx/index.php/FFmpeg_Metadata + # https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/ + # https://kodi.wiki/view/Video_file_tagging + + add("title", ("track", "title")) + add("date", "upload_date") + add(("description", "synopsis"), "description") + add(("purl", "comment"), "webpage_url") + add("track", "track_number") + add("artist", ("artist", "artists", "creator", "creators", "uploader", "uploader_id")) + add("composer", ("composer", "composers")) + add("genre", ("genre", "genres")) + add("album") + add("album_artist", ("album_artist", "album_artists")) + add("disc", "disc_number") + add("show", "series") + add("season_number") + add("episode_id", ("episode", "episode_id")) + add("episode_sort", "episode_number") + if "embed-metadata" in self.get_param("compat_opts", []): + add("comment", "description") + metadata["common"].pop("synopsis", None) + + meta_regex = rf"{re.escape(meta_prefix)}(?P\d+)?_(?P.+)" + for key, value in info.items(): + mobj = re.fullmatch(meta_regex, key) + if value is not None and mobj: + metadata[mobj.group("i") or "common"][mobj.group("key")] = value.replace("\0", "") + return metadata + + @functools.singledispatchmethod + def _assemble_metadata(self, file: FileType, meta: dict) -> None: # noqa: ARG002 + raise MutagenPostProcessorError(f"Filetype {file.__class__.__name__} is not currently supported") + + @_assemble_metadata.register(flac.FLAC) + def _(self, file: flac.FLAC, meta: dict) -> None: + for file_key, meta_key in self._VORBIS_METADATA.items(): + if meta.get(meta_key): + file[file_key] = meta[meta_key] + + if meta.get("date"): + # Vorbis uses ISO 8601 format YYYY-MM-DD + date = date_from_str(meta["date"]) + file["date"] = date.strftime("%Y-%m-%d") + + if meta.get("thumbnail"): + pic = self._get_flac_pic(meta["thumbnail"]) + file.add_picture(pic) + + @_assemble_metadata.register(oggvorbis.OggVorbis) + @_assemble_metadata.register(oggtheora.OggTheora) + @_assemble_metadata.register(oggspeex.OggSpeex) + @_assemble_metadata.register(oggopus.OggOpus) + def _(self, file: oggopus.OggOpus, meta: dict) -> None: + for file_key, meta_key in self._VORBIS_METADATA.items(): + if meta.get(meta_key): + file[file_key] = meta[meta_key] + + if meta.get("date"): + # Vorbis uses ISO 8601 format YYYY-MM-DD + date = date_from_str(meta["date"]) + file["date"] = date.strftime("%Y-%m-%d") + + if meta.get("thumbnail"): + pic = self._get_flac_pic(meta["thumbnail"]) + file["METADATA_BLOCK_PICTURE"] = base64.b64encode(pic.write()).decode("ascii") + + @_assemble_metadata.register(trueaudio.TrueAudio) + @_assemble_metadata.register(dsf.DSF) + @_assemble_metadata.register(dsdiff.DSDIFF) + @_assemble_metadata.register(aiff.AIFF) + @_assemble_metadata.register(mp3.MP3) + @_assemble_metadata.register(wave.WAVE) + def _(self, file: wave.WAVE, meta: dict) -> None: + for file_key, meta_key in self._ID3_METADATA.items(): + if meta.get(meta_key): + id3_class = getattr(id3, file_key) + if issubclass(id3_class, id3.UrlFrame): + file[file_key] = id3_class(url=meta[meta_key]) + else: + file[file_key] = id3_class(encoding=id3.Encoding.UTF8, text=meta[meta_key]) + + if meta.get("date"): + # ID3 uses ISO 8601 format YYYY-MM-DD + date = date_from_str(meta["date"]) + file["TDRC"] = id3.TDRC(encoding=id3.Encoding.UTF8, text=date.strftime("%Y-%m-%d")) + + if meta.get("thumbnail"): + file["APIC"] = id3.APIC( + encoding=3, + mime=f'image/{meta["thumbnail"]["type"]}', + type=3, + desc="Cover (front)", + data=meta["thumbnail"]["data"], + ) + + @_assemble_metadata.register(mp4.MP4) + def _(self, file: mp4.MP4, meta: dict) -> None: + for file_key, meta_key in self._MP4_METADATA.items(): + if meta.get(meta_key): + file[file_key] = meta[meta_key] + + if meta.get("date"): + # no standard but iTunes uses YYYY-MM-DD format + date = date_from_str(meta["date"]) + file["\251day"] = date.strftime("%Y-%m-%d") + + if meta.get("purl"): + # https://getmusicbee.com/forum/index.php?topic=39759.0 + file["----:com.apple.iTunes:WWWAUDIOFILE"] = meta["purl"].encode() + file["purl"] = meta["purl"] + + if meta.get("track"): + file["trkn"] = [(meta["track"], 0)] + + if meta.get("covr"): + f = {"jpeg": mp4.MP4Cover.FORMAT_JPEG, "png": mp4.MP4Cover.FORMAT_PNG} + file["covr"] = [mp4.MP4Cover(meta["covr"]["data"], f[meta["covr"]["type"]])] + + def _get_thumbnail(self, info: dict): + if not info.get("thumbnails"): + self.to_screen("There aren't any thumbnails to embed") + return None + + idx = next((-i for i, t in enumerate(info["thumbnails"][::-1], 1) if t.get("filepath")), None) + if idx is None: + self.to_screen("There are no thumbnails on disk") + return None + thumbnail_filename = info["thumbnails"][idx]["filepath"] + if not os.path.exists(thumbnail_filename): + self.report_warning("Skipping embedding the thumbnail because the file is missing.") + return None + + with open(thumbnail_filename, "rb") as thumbfile: + thumb_data = thumbfile.read() + + self._delete_downloaded_files( + thumbnail_filename, + info=info, + ) + + type_ = imghdr.what(h=thumb_data) + if not type_: + self.report_warning("Could not determine thumbnail image type") + return None + + if type_ not in {"jpeg", "png"}: + self.report_warning(f"Incompatible thumbnail image type: {type_}") + return None + + return {"data": thumb_data, "type": type_} + + def run(self, info: dict): + thumbnail = self._get_thumbnail(info) + if not info["__real_download"] and not self._post_overwrites: + return [], info + + filename = info["filepath"] + metadata = self._get_metadata_dict(info)["common"] + + if thumbnail: + metadata["thumbnail"] = thumbnail + + if not metadata: + self.to_screen("There isn't any metadata to add") + return [], info + + if info["ext"] not in self._MUTAGEN_SUPPORTED_EXTS: + raise MutagenPostProcessorError(f'Unsupported file extension: {info["ext"]}') + + self.to_screen(f'Adding metadata to "{filename}"') + try: + f = mutagen.File(filename) + self._assemble_metadata(f, metadata) + f.save() + except Exception as err: + raise MutagenPostProcessorError("Unable to embed metadata") from err + + return [], info diff --git a/scdl/patches/old_archive_ids.py b/scdl/patches/old_archive_ids.py new file mode 100644 index 00000000..4cab6c13 --- /dev/null +++ b/scdl/patches/old_archive_ids.py @@ -0,0 +1,13 @@ +from yt_dlp import YoutubeDL + + +def in_download_archive(self, info_dict): + if not self.archive: + return False + + vid_ids = [self._make_archive_id(info_dict)] + vid_ids.extend(info_dict.get("_old_archive_ids") or []) + return any(id_ in self.archive for id_ in vid_ids) or any(id_.split()[1] in self.archive for id_ in vid_ids if id_) + + +YoutubeDL.in_download_archive = in_download_archive diff --git a/scdl/patches/original_filename_preprocessor.py b/scdl/patches/original_filename_preprocessor.py new file mode 100644 index 00000000..57570f5e --- /dev/null +++ b/scdl/patches/original_filename_preprocessor.py @@ -0,0 +1,32 @@ +import email.message +import urllib.parse +from pathlib import Path + +from yt_dlp.networking.common import Request, Response +from yt_dlp.postprocessor.common import PostProcessor + + +def _parse_header(content_disposition): + if not content_disposition: + return {} + message = email.message.Message() + message["content-type"] = content_disposition + return dict(message.get_params({})) + + +class OriginalFilenamePP(PostProcessor): + def run(self, info): + for format in info.get("formats", ()): + if format.get("format_id") == "download": + res: Response = self._downloader.urlopen(Request(format["url"], headers=format["http_headers"])) + params = _parse_header(res.get_header("content-disposition")) + if "filename" not in params: + break + filename = urllib.parse.unquote(params["filename"][-1], encoding="utf-8") + old_outtmpl = self._downloader.params["outtmpl"]["default"] + self._downloader.params["outtmpl"]["default"] = ( + Path(old_outtmpl).with_name(filename).with_suffix(".%(ext)s").as_posix() + ) + break + + return [], info diff --git a/scdl/patches/switch_outtmpl_preprocessor.py b/scdl/patches/switch_outtmpl_preprocessor.py new file mode 100644 index 00000000..00c96370 --- /dev/null +++ b/scdl/patches/switch_outtmpl_preprocessor.py @@ -0,0 +1,16 @@ +# https://github.com/yt-dlp/yt-dlp/issues/11583 workaround +from yt_dlp.postprocessor.common import PostProcessor + + +class OuttmplPP(PostProcessor): + def __init__(self, video_outtmpl: str, playlist_outtmpl: str, downloader=None): + super().__init__(downloader) + self._outtmpls = {False: video_outtmpl, True: playlist_outtmpl} + + def run(self, info): + in_playlist = info.get("playlist_uploader") is not None + self._downloader.params["outtmpl"]["default"] = self._outtmpls[in_playlist] + if not in_playlist: + for meta in ("track", "album_artist", "album"): + info[f"meta_{meta}"] = None + return [], info diff --git a/scdl/patches/sync_download_archive.py b/scdl/patches/sync_download_archive.py new file mode 100644 index 00000000..eb396939 --- /dev/null +++ b/scdl/patches/sync_download_archive.py @@ -0,0 +1,69 @@ +import errno +from functools import partial +from pathlib import Path + +from yt_dlp import YoutubeDL +from yt_dlp.utils import locked_file + + +class SyncDownloadHelper: + def __init__(self, scdl_args, ydl: YoutubeDL): + self._ydl = ydl + self._enabled = bool(scdl_args.get("sync")) + self._sync_file = scdl_args.get("sync") + self._all_files: dict[str, Path] = {} + self._downloaded: set[str] = set() + self._init() + + def _init(self): + if not self._enabled: + return + + # track downloaded ids/filenames + def track_downloaded(d): + if d["status"] != "finished": + return + + info = d["info_dict"] + + id_ = f"soundcloud {info['id']}" + self._downloaded.add(id_) + self._all_files[id_] = d["filename"] + + self._ydl.add_progress_hook(track_downloaded) + + # add already downloaded files to the archive + try: + with locked_file(self._sync_file, "r", encoding="utf-8") as archive_file: + for line in archive_file: + line = line.strip() + if not line: + continue + ie, id_, filename = line.split(maxsplit=2) + self._ydl.archive.add(f"{ie} {id_}") + self._all_files[f"{ie} {id_}"] = Path(filename) + except OSError as ioe: + if ioe.errno != errno.ENOENT: + raise + + # track ids checked against the archive + old_match_entry = self._ydl._match_entry + + def _match_entry(ydl, info_dict, incomplete=False, silent=False): + self._downloaded.add(ydl._make_archive_id(info_dict)) + return old_match_entry(info_dict, incomplete, silent) + + self._ydl._match_entry = partial(_match_entry, self._ydl) + + def post_download(self): + if not self._enabled: + return + + # remove extra files + to_remove = {self._all_files[key] for key in (set(self._all_files.keys()) - self._downloaded)} + self._ydl._delete_downloaded_files(*to_remove) + + with locked_file(self._sync_file, "w", encoding="utf-8") as archive_file: + for k, v in self._all_files.items(): + if k in self._downloaded: + archive_file.write(f"{k} {v}\n") diff --git a/scdl/patches/thumbnail_selection.py b/scdl/patches/thumbnail_selection.py new file mode 100644 index 00000000..36b66d33 --- /dev/null +++ b/scdl/patches/thumbnail_selection.py @@ -0,0 +1,41 @@ +# https://github.com/yt-dlp/yt-dlp/pull/11809 +import yt_dlp +import yt_dlp.options as options +from yt_dlp.YoutubeDL import YoutubeDL + + +def _sort_thumbnails_patched(self, thumbnails): + thumbnails.sort( + key=lambda t: ( + t.get("id") == self.params.get("thumbnail_id") if t.get("id") is not None else False, + t.get("preference") if t.get("preference") is not None else -1, + t.get("width") if t.get("width") is not None else -1, + t.get("height") if t.get("height") is not None else -1, + t.get("id") if t.get("id") is not None else "", + t.get("url"), + ) + ) + + +old_parse_options = yt_dlp.parse_options + + +def parse_options_patched(argv=None): + parsed = old_parse_options(argv) + parsed[3]["thumbnail_id"] = parsed[1].thumbnail_id + return parsed + + +old_create_parser = options.create_parser + + +def create_parser_patched(): + parser = old_create_parser() + thumbnail = parser.get_option_group("--write-thumbnail") + thumbnail.add_option("--thumbnail-id", metavar="ID", dest="thumbnail_id", help="ID of thumbnail to write to disk") + return parser + + +YoutubeDL._sort_thumbnails = _sort_thumbnails_patched +yt_dlp.parse_options = parse_options_patched +options.create_parser = create_parser_patched diff --git a/scdl/patches/trim_filenames.py b/scdl/patches/trim_filenames.py new file mode 100644 index 00000000..6e73f556 --- /dev/null +++ b/scdl/patches/trim_filenames.py @@ -0,0 +1,113 @@ +# https://github.com/yt-dlp/yt-dlp/pull/12023 + +import os +import platform +import re +import sys +from pathlib import Path + +import yt_dlp.__init__ +from yt_dlp import YoutubeDL, options +from yt_dlp.__init__ import validate_options as old_validate_options +from yt_dlp.utils import OUTTMPL_TYPES, preferredencoding, replace_extension +from yt_dlp.YoutubeDL import _catch_unsafe_extension_error + + +def evaluate_outtmpl(self, outtmpl, info_dict, *args, trim_filename=False, **kwargs): + outtmpl, info_dict = self.prepare_outtmpl(outtmpl, info_dict, *args, **kwargs) + if not trim_filename: + return self.escape_outtmpl(outtmpl) % info_dict + + ext_suffix = ".%(ext\0s)s" + suffix = "" + if outtmpl.endswith(ext_suffix): + outtmpl = outtmpl[: -len(ext_suffix)] + suffix = ext_suffix % info_dict + outtmpl = self.escape_outtmpl(outtmpl) + filename = outtmpl % info_dict + + def parse_trim_file_name(trim_file_name): + if trim_file_name is None or trim_file_name == "none": + return 0, None + mobj = re.match(r"(?:(?P\d+)(?Pb|c)?|none)", trim_file_name) + return int(mobj.group("length")), mobj.group("mode") or "c" + + max_file_name, mode = parse_trim_file_name(self.params.get("trim_file_name")) + if max_file_name == 0: + # no maximum + return filename + suffix + + encoding = sys.getfilesystemencoding() if platform.system() != "Windows" else "utf-16-le" + + def trim_filename(name: str): + if mode == "b": + name = name.encode(encoding) + name = name[:max_file_name] + return name.decode(encoding, "ignore") + return name[:max_file_name] + + filename = os.path.join(*map(trim_filename, Path(filename).parts or ".")) + return filename + suffix + + +@_catch_unsafe_extension_error +def _prepare_filename(self, info_dict, *, outtmpl=None, tmpl_type=None): + assert None in (outtmpl, tmpl_type), "outtmpl and tmpl_type are mutually exclusive" + if outtmpl is None: + outtmpl = self.params["outtmpl"].get(tmpl_type or "default", self.params["outtmpl"]["default"]) + try: + outtmpl = self._outtmpl_expandpath(outtmpl) + filename = self.evaluate_outtmpl(outtmpl, info_dict, True, trim_filename=True) + if not filename: + return None + + if tmpl_type in ("", "temp"): + final_ext, ext = self.params.get("final_ext"), info_dict.get("ext") + if final_ext and ext and final_ext != ext and filename.endswith(f".{final_ext}"): + filename = replace_extension(filename, ext, final_ext) + elif tmpl_type: + force_ext = OUTTMPL_TYPES[tmpl_type] + if force_ext: + filename = replace_extension(filename, force_ext, info_dict.get("ext")) + return filename + except ValueError as err: + self.report_error("Error in output template: " + str(err) + " (encoding: " + repr(preferredencoding()) + ")") + return None + + +def new_validate_options(opts): + def validate(cndn, name, value=None, msg=None): + if cndn: + return True + raise ValueError((msg or 'invalid {name} "{value}" given').format(name=name, value=value)) + + def validate_regex(name, value, regex): + return validate(value is None or re.match(regex, value), name, value) + + ret = old_validate_options(opts) + validate_regex("trim filenames", opts.trim_file_name, r"(?:\d+[bc]?|none)") + return ret + + +old_create_parser = options.create_parser + + +def create_parser_patched(): + parser = old_create_parser() + filesystem = parser.get_option_group("--trim-filenames") + filesystem.remove_option("--trim-filenames") + filesystem.add_option( + "--trim-filenames", + "--trim-file-names", + metavar="LENGTH", + dest="trim_file_name", + default="none", + help="Limit the filename length (excluding extension) to the specified number of characters or bytes", + ) + return parser + + +YoutubeDL.evaluate_outtmpl = evaluate_outtmpl +YoutubeDL._prepare_filename = _prepare_filename +yt_dlp.__init__.validate_options = new_validate_options +options.create_parser = create_parser_patched diff --git a/scdl/scdl.cfg b/scdl/scdl.cfg index 4102805e..9f280be2 100644 --- a/scdl/scdl.cfg +++ b/scdl/scdl.cfg @@ -2,15 +2,7 @@ client_id = auth_token = path = . -name_format = {title} -playlist_name_format = {playlist[title]}_{title} +name_format = [%(id)s] %(uploader)s - %(title)s.%(ext)s +playlist_name_format = %(playlist_index)s. %(uploader)s - %(title)s.%(ext)s -# example name_format values: -# {timestamp}_{user[username]}_{title} -# {id}_{user[username]}_{title} -# {id}_{user[id]}_{title} -# list of all BasicTrack attributes can be found at: https://github.com/7x11x13/soundcloud.py/blob/main/soundcloud/resource/track.py#L35 - -# playlist_name_format playlist attributes: -# playlist[author] - username of playlist author -# playlist[title] - name of playlist \ No newline at end of file +# For name formats see https://github.com/yt-dlp/yt-dlp/?tab=readme-ov-file#output-template \ No newline at end of file diff --git a/scdl/scdl.py b/scdl/scdl.py index 7ce7d485..162cef97 100644 --- a/scdl/scdl.py +++ b/scdl/scdl.py @@ -2,14 +2,14 @@ Usage: scdl (-l | -s | me) [-a | -f | -C | -t | -p | -r] - [-c | --force-metadata][-n ][-o ][--hidewarnings][--debug | --error] + [-c | --force-metadata][-o ][--hidewarnings][--debug | --error] [--path ][--addtofile][--addtimestamp][--onlymp3][--hide-progress][--min-size ] - [--max-size ][--remove][--no-album-tag][--no-playlist-folder] + [--max-size ][--no-album-tag][--no-playlist-folder] [--download-archive ][--sync ][--extract-artist][--flac][--original-art] [--original-name][--original-metadata][--no-original][--only-original] [--name-format ][--strict-playlist][--playlist-name-format ] [--client-id ][--auth-token ][--overwrite][--no-playlist][--opus] - [--add-description] + [--add-description][--yt-dlp-args ] scdl -h | --help scdl --version @@ -49,7 +49,6 @@ instead of making a playlist subfolder --onlymp3 Download only mp3 files --path [path] Use a custom path for downloaded files - --remove Remove any files not downloaded from execution --sync [file] Compares an archive file to a playlist and downloads/removes any changed tracks --flac Convert original files to .flac. Only works if the original @@ -72,79 +71,47 @@ --no-playlist Skip downloading playlists --add-description Adds the description to a separate txt file --opus Prefer downloading opus streams over mp3 streams + --yt-dlp-args [argstring] String with custom args to forward to yt-dlp """ -import atexit +from __future__ import annotations + import configparser -import contextlib -import io -import itertools +import importlib +import importlib.metadata import logging -import math -import mimetypes import os -import pathlib -import shutil -import subprocess +import posixpath +import shlex import sys -import tempfile -import threading -import time -import traceback -import typing -import urllib.parse -import warnings -from dataclasses import asdict -from functools import lru_cache -from types import TracebackType -from typing import IO, Generator, List, NoReturn, Optional, Set, Tuple, Type, Union - -from tqdm import tqdm - -if sys.version_info < (3, 8): - from typing_extensions import TypedDict -else: - from typing import TypedDict - -if sys.version_info < (3, 11): - from typing_extensions import NotRequired -else: - from typing import NotRequired - -import filelock -import mutagen -import requests +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + from docopt import docopt -from pathvalidate import sanitize_filename from soundcloud import ( AlbumPlaylist, - BasicAlbumPlaylist, - BasicTrack, - MiniTrack, - PlaylistLike, - PlaylistStreamItem, - PlaylistStreamRepostItem, SoundCloud, Track, - TrackLike, - TrackStreamItem, - TrackStreamRepostItem, - Transcoding, User, ) +from yt_dlp import YoutubeDL +from yt_dlp.utils import locked_file + +from scdl import utils +from scdl.patches.mutagen_postprocessor import MutagenPP +from scdl.patches.original_filename_preprocessor import OriginalFilenamePP +from scdl.patches.switch_outtmpl_preprocessor import OuttmplPP +from scdl.patches.sync_download_archive import SyncDownloadHelper + +if TYPE_CHECKING: + if sys.version_info < (3, 11): + from typing_extensions import Unpack + else: + from typing import Unpack -from scdl import __version__, utils -from scdl.metadata_assembler import MetadataInfo, assemble_metadata - -mimetypes.init() - +logging.setLoggerClass(utils.YTLogger) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -logger.addFilter(utils.ColorizeFilter()) - -FFMPEG_PIPE_CHUNK_SIZE = 1024 * 1024 # 1 mb - -files_to_keep = [] class SCDLArgs(TypedDict): @@ -153,11 +120,11 @@ class SCDLArgs(TypedDict): add_description: bool addtimestamp: bool addtofile: bool - auth_token: Optional[str] + auth_token: str | None c: bool - client_id: Optional[str] + client_id: str | None debug: bool - download_archive: Optional[str] + download_archive: str | None error: bool extract_artist: bool f: bool @@ -166,17 +133,16 @@ class SCDLArgs(TypedDict): hide_progress: bool hidewarnings: bool l: str # noqa: E741 - max_size: Optional[int] + max_size: str | None me: bool - min_size: Optional[int] - n: Optional[str] + min_size: str | None + n: str | None name_format: str no_album_tag: bool no_original: bool no_playlist: bool no_playlist_folder: bool - o: Optional[int] - offset: NotRequired[int] + o: int | None only_original: bool onlymp3: bool opus: bool @@ -185,124 +151,20 @@ class SCDLArgs(TypedDict): original_name: bool overwrite: bool p: bool - path: Optional[str] + path: Path playlist_name_format: str - playlist_offset: NotRequired[int] r: bool - remove: bool strict_playlist: bool - sync: Optional[str] - s: Optional[str] + sync: str | None + s: str | None t: bool + yt_dlp_args: str -class PlaylistInfo(TypedDict): - author: str - id: int - title: str - tracknumber_int: int - tracknumber: str - tracknumber_total: int - - -class SoundCloudException(Exception): # noqa: N818 - pass - - -class MissingFilenameError(SoundCloudException): - def __init__(self, content_disp_header: Optional[str]): - super().__init__( - f"Could not get filename from content-disposition header: {content_disp_header}", - ) - - -class InvalidFilesizeError(SoundCloudException): - def __init__(self, min_size: float, max_size: float, size: float): - super().__init__( - f"File size: {size} not within --min-size={min_size} and --max-size={max_size}", - ) - - -class RegionBlockError(SoundCloudException): - def __init__(self): - super().__init__("Track is not available in your location. Try using a VPN") - - -class FFmpegError(SoundCloudException): - def __init__(self, return_code: int, errors: str): - super().__init__(f"FFmpeg error ({return_code}): {errors}") - - -def handle_exception( - exc_type: Type[BaseException], - exc_value: BaseException, - exc_traceback: Optional[TracebackType], -) -> NoReturn: - if issubclass(exc_type, KeyboardInterrupt): - logger.error("\nGoodbye!") - else: - logger.error("".join(traceback.format_exception(exc_type, exc_value, exc_traceback))) - sys.exit(1) - - -sys.excepthook = handle_exception - - -file_lock_dirs: List[pathlib.Path] = [] - - -def clean_up_locks() -> None: - with contextlib.suppress(OSError): - for dir in file_lock_dirs: - for lock in dir.glob("*.scdl.lock"): - lock.unlink() - - -atexit.register(clean_up_locks) - - -class SafeLock: - def __init__( - self, - lock_file: Union[str, os.PathLike], - timeout: float = -1, - ) -> None: - self._lock = filelock.FileLock(lock_file, timeout=timeout) - self._soft_lock = filelock.SoftFileLock(lock_file, timeout=timeout) - self._using_soft_lock = False - - def __enter__(self): - try: - self._lock.acquire() - self._using_soft_lock = False - return self._lock - except NotImplementedError: - self._soft_lock.acquire() - self._using_soft_lock = True - return self._soft_lock - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_value: Optional[BaseException], - traceback: Optional[TracebackType], - ) -> None: - if self._using_soft_lock: - self._soft_lock.release() - else: - self._lock.release() - - -def get_filelock(path: Union[pathlib.Path, str], timeout: int = 10) -> SafeLock: - path = pathlib.Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - path = path.resolve() - file_lock_dirs.append(path.parent) - lock_path = str(path) + ".scdl.lock" - return SafeLock(lock_path, timeout=timeout) +__version__ = importlib.metadata.version("scdl") -def main() -> None: +def _main() -> None: """Main function, parses the URL from command line arguments""" logger.addHandler(logging.StreamHandler()) @@ -315,15 +177,14 @@ def main() -> None: logger.level = logging.ERROR if "XDG_CONFIG_HOME" in os.environ: - config_file = pathlib.Path(os.environ["XDG_CONFIG_HOME"], "scdl", "scdl.cfg") + config_file = Path(os.environ["XDG_CONFIG_HOME"], "scdl", "scdl.cfg") else: - config_file = pathlib.Path.home().joinpath(".config", "scdl", "scdl.cfg") + config_file = Path.home().joinpath(".config", "scdl", "scdl.cfg") # import conf file - config = get_config(config_file) + config = _get_config(config_file) - logger.info("Soundcloud Downloader") - logger.debug(arguments) + logger.info(f"[scdl] SCDL version {__version__}") client_id = arguments["--client-id"] or config["scdl"]["client_id"] token = arguments["--auth-token"] or config["scdl"]["auth_token"] @@ -333,60 +194,41 @@ def main() -> None: if not client.is_client_id_valid(): if arguments["--client-id"]: logger.warning( - "Invalid client_id specified by --client-id argument. " - "Using a dynamically generated client_id...", + "[scdl] Invalid client_id specified by --client-id argument. " + "Using a dynamically generated client_id", ) elif config["scdl"]["client_id"]: logger.warning( - f"Invalid client_id in {config_file}. Using a dynamically generated client_id...", + f"[scdl] Invalid client_id in {config_file}. Using a dynamically generated client_id", ) else: - logger.info("Generating dynamic client_id") + logger.info("[scdl] Generating dynamic client_id") client = SoundCloud(None, token if token else None) if not client.is_client_id_valid(): - logger.error("Dynamically generated client_id is not valid") + logger.error("[scdl] Dynamically generated client_id is not valid") sys.exit(1) config["scdl"]["client_id"] = client.client_id + arguments["--client-id"] = client.client_id # save client_id config_file.parent.mkdir(parents=True, exist_ok=True) - with get_filelock(config_file), open(config_file, "w", encoding="UTF-8") as f: + with locked_file(config_file, "w", encoding="utf-8") as f: config.write(f) if (token or arguments["me"]) and not client.is_auth_token_valid(): if arguments["--auth-token"]: - logger.error("Invalid auth_token specified by --auth-token argument") + logger.error("[scdl] Invalid auth_token specified by --auth-token argument") else: - logger.error(f"Invalid auth_token in {config_file}") + logger.error(f"[scdl] Invalid auth_token in {config_file}") sys.exit(1) if arguments["-o"] is not None: try: - arguments["--offset"] = int(arguments["-o"]) - 1 - if arguments["--offset"] < 0: + arguments["-o"] = int(arguments["-o"]) + if arguments["-o"] < 1: raise ValueError except Exception: - logger.error("Offset should be a positive integer...") - sys.exit(1) - logger.debug("offset: %d", arguments["--offset"]) - - if arguments["--min-size"] is not None: - try: - arguments["--min-size"] = utils.size_in_bytes(arguments["--min-size"]) - except Exception: - logger.exception("Min size should be an integer with a possible unit suffix") - sys.exit(1) - logger.debug("min-size: %d", arguments["--min-size"]) - - if arguments["--max-size"] is not None: - try: - arguments["--max-size"] = utils.size_in_bytes(arguments["--max-size"]) - except Exception: - logger.error("Max size should be an integer with a possible unit suffix") + logger.error("[scdl] Offset should be a positive integer") sys.exit(1) - logger.debug("max-size: %d", arguments["--max-size"]) - - if arguments["--hidewarnings"]: - warnings.filterwarnings("ignore") if not arguments["--name-format"]: arguments["--name-format"] = config["scdl"]["name_format"] @@ -401,87 +243,29 @@ def main() -> None: arguments["-l"] = me.permalink_url if arguments["-s"]: - url = search_soundcloud(client, arguments["-s"]) + url = _search_soundcloud(client, arguments["-s"]) if url: arguments["-l"] = url else: - logger.error("Search failed. Exiting...") + logger.error("[scdl] Search failed") sys.exit(1) - arguments["-l"] = validate_url(client, arguments["-l"]) - - if arguments["--download-archive"]: - try: - path = pathlib.Path(arguments["--download-archive"]).resolve() - arguments["--download-archive"] = path - except Exception: - logger.error(f"Invalid download archive file {arguments['--download-archive']}") - sys.exit(1) - - if arguments["--sync"]: - try: - path = pathlib.Path(arguments["--sync"]).resolve() - arguments["--download-archive"] = path - arguments["--sync"] = path - except Exception: - logger.error(f"Invalid sync archive file {arguments['--sync']}") - sys.exit(1) + arguments["--path"] = Path(arguments["--path"] or config["scdl"]["path"] or ".").resolve() - # convert arguments dict to python_args (kwargs-friendly args) + # convert arguments dict to python-friendly kwarg names (no hyphens) python_args = {} for key, value in arguments.items(): key = key.strip("-").replace("-", "_") python_args[key] = value - # change download path - dl_path: str = arguments["--path"] or config["scdl"]["path"] - if os.path.exists(dl_path): - os.chdir(dl_path) - else: - if arguments["--path"]: - logger.error(f"Invalid download path '{dl_path}' specified by --path argument") - else: - logger.error(f"Invalid download path '{dl_path}' in {config_file}") - sys.exit(1) - logger.debug("Downloading to " + os.getcwd() + "...") - - download_url(client, typing.cast(SCDLArgs, python_args)) - - if arguments["--remove"]: - remove_files() - - -def validate_url(client: SoundCloud, url: str) -> str: - """If url is a valid soundcloud.com url, return it. - Otherwise, try to fix the url so that it is valid. - If it cannot be fixed, exit the program. - """ - if url.startswith(("https://m.soundcloud.com", "http://m.soundcloud.com", "m.soundcloud.com")): - url = url.replace("m.", "", 1) - if url.startswith( - ("https://www.soundcloud.com", "http://www.soundcloud.com", "www.soundcloud.com"), - ): - url = url.replace("www.", "", 1) - if url.startswith("soundcloud.com"): - url = "https://" + url - if url.startswith(("https://soundcloud.com", "http://soundcloud.com")): - return urllib.parse.urljoin(url, urllib.parse.urlparse(url).path) - - # see if link redirects to soundcloud.com - try: - resp = requests.get(url) - if url.startswith(("https://soundcloud.com", "http://soundcloud.com")): - return urllib.parse.urljoin(resp.url, urllib.parse.urlparse(resp.url).path) - except Exception: - # see if given a username instead of url - if client.resolve(f"https://soundcloud.com/{url}"): - return f"https://soundcloud.com/{url}" + url = python_args.pop("l") + + assert url is not None - logger.error("URL is not valid") - sys.exit(1) + download_url(url, **python_args) -def search_soundcloud(client: SoundCloud, query: str) -> Optional[str]: +def _search_soundcloud(client: SoundCloud, query: str) -> str | None: """Search SoundCloud and return the URL of the first result.""" try: results = list(client.search(query, limit=1)) @@ -498,1177 +282,275 @@ def search_soundcloud(client: SoundCloud, query: str) -> Optional[str]: return None -def get_config(config_file: pathlib.Path) -> configparser.ConfigParser: +def _get_config(config_file: Path) -> configparser.RawConfigParser: """Gets config from scdl.cfg""" - config = configparser.ConfigParser() - - default_config_file = pathlib.Path(__file__).with_name("scdl.cfg") - - with get_filelock(config_file): - # load default config first - with open(default_config_file, encoding="UTF-8") as f: - config.read_file(f) - - # load config file if it exists - if config_file.exists(): - with open(config_file, encoding="UTF-8") as f: - config.read_file(f) + config = configparser.RawConfigParser() - # save config to disk - config_file.parent.mkdir(parents=True, exist_ok=True) - with open(config_file, "w", encoding="UTF-8") as f: - config.write(f) + default_config_file = Path(__file__).with_name("scdl.cfg") - return config + config_file.parent.mkdir(parents=True, exist_ok=True) - -def truncate_str(s: str, length: int) -> str: - """Truncate string to a certain number of bytes using the file system encoding""" - encoding = sys.getfilesystemencoding() - bytes = s.encode(encoding) - bytes = bytes[:length] - return bytes.decode(encoding, errors="ignore") - - -def sanitize_str( - filename: str, - ext: str = "", - replacement_char: str = "�", - max_length: int = 255, -) -> str: - """Sanitizes a string for use as a filename. Does not allow the file to be hidden""" - if filename.startswith("."): - filename = "_" + filename - if filename.endswith(".") and not ext: - filename = filename + "_" - max_filename_length = max_length - len(ext) - sanitized = sanitize_filename( - filename, - replacement_text=replacement_char, - max_len=max_filename_length, - ) - # sanitize_filename truncates incorrectly, use our own method - sanitized = truncate_str(sanitized, max_filename_length) - return sanitized + ext - - -def download_url(client: SoundCloud, kwargs: SCDLArgs) -> None: - """Detects if a URL is a track or a playlist, and parses the track(s) - to the track downloader - """ - url = kwargs["l"] - item = client.resolve(url) - logger.debug(item) - offset = kwargs.get("offset", 0) - if item is None: - logger.error("URL is not valid") - sys.exit(1) - elif isinstance(item, Track): - logger.info("Found a track") - download_track(client, item, kwargs) - elif isinstance(item, AlbumPlaylist): - logger.info("Found a playlist") - kwargs["playlist_offset"] = offset - download_playlist(client, item, kwargs) - elif isinstance(item, User): - user = item - logger.info("Found a user profile") - if kwargs.get("f"): - logger.info(f"Retrieving all likes of user {user.username}...") - likes = client.get_user_likes(user.id, limit=1000) - for i, like in itertools.islice(enumerate(likes, 1), offset, None): - logger.info(f"like n°{i} of {user.likes_count}") - if isinstance(like, TrackLike): - download_track( - client, - like.track, - kwargs, - exit_on_fail=kwargs["strict_playlist"], - ) - elif isinstance(like, PlaylistLike): - playlist = client.get_playlist(like.playlist.id) - assert playlist is not None - download_playlist(client, playlist, kwargs) - else: - logger.error(f"Unknown like type {like}") - if kwargs.get("strict_playlist"): - sys.exit(1) - logger.info(f"Downloaded all likes of user {user.username}!") - elif kwargs.get("C"): - logger.info(f"Retrieving all commented tracks of user {user.username}...") - comments = client.get_user_comments(user.id, limit=1000) - for i, comment in itertools.islice(enumerate(comments, 1), offset, None): - logger.info(f"comment n°{i} of {user.comments_count}") - track = client.get_track(comment.track.id) - assert track is not None - download_track( - client, - track, - kwargs, - exit_on_fail=kwargs["strict_playlist"], - ) - logger.info(f"Downloaded all commented tracks of user {user.username}!") - elif kwargs.get("t"): - logger.info(f"Retrieving all tracks of user {user.username}...") - tracks = client.get_user_tracks(user.id, limit=1000) - for i, track in itertools.islice(enumerate(tracks, 1), offset, None): - logger.info(f"track n°{i} of {user.track_count}") - download_track(client, track, kwargs, exit_on_fail=kwargs["strict_playlist"]) - logger.info(f"Downloaded all tracks of user {user.username}!") - elif kwargs.get("a"): - logger.info(f"Retrieving all tracks & reposts of user {user.username}...") - items = client.get_user_stream(user.id, limit=1000) - for i, stream_item in itertools.islice(enumerate(items, 1), offset, None): - logger.info( - f"item n°{i} of " - f"{user.track_count + user.reposts_count if user.reposts_count else '?'}", - ) - if isinstance(stream_item, (TrackStreamItem, TrackStreamRepostItem)): - download_track( - client, - stream_item.track, - kwargs, - exit_on_fail=kwargs["strict_playlist"], - ) - elif isinstance(stream_item, (PlaylistStreamItem, PlaylistStreamRepostItem)): - download_playlist(client, stream_item.playlist, kwargs) - else: - logger.error(f"Unknown item type {stream_item.type}") - if kwargs.get("strict_playlist"): - sys.exit(1) - logger.info(f"Downloaded all tracks & reposts of user {user.username}!") - elif kwargs.get("p"): - logger.info(f"Retrieving all playlists of user {user.username}...") - playlists = client.get_user_playlists(user.id, limit=1000) - for i, playlist in itertools.islice(enumerate(playlists, 1), offset, None): - logger.info(f"playlist n°{i} of {user.playlist_count}") - download_playlist(client, playlist, kwargs) - logger.info(f"Downloaded all playlists of user {user.username}!") - elif kwargs.get("r"): - logger.info(f"Retrieving all reposts of user {user.username}...") - reposts = client.get_user_reposts(user.id, limit=1000) - for i, repost in itertools.islice(enumerate(reposts, 1), offset, None): - logger.info(f"item n°{i} of {user.reposts_count or '?'}") - if isinstance(repost, TrackStreamRepostItem): - download_track( - client, - repost.track, - kwargs, - exit_on_fail=kwargs["strict_playlist"], - ) - elif isinstance(repost, PlaylistStreamRepostItem): - download_playlist(client, repost.playlist, kwargs) - else: - logger.error(f"Unknown item type {repost.type}") - if kwargs.get("strict_playlist"): - sys.exit(1) - logger.info(f"Downloaded all reposts of user {user.username}!") - else: - logger.error("Please provide a download type...") - sys.exit(1) - else: - logger.error(f"Unknown item type {item.kind}") - sys.exit(1) - - -def remove_files() -> None: - """Removes any pre-existing tracks that were not just downloaded""" - logger.info("Removing local track files that were not downloaded...") - files = [f for f in os.listdir(".") if os.path.isfile(f)] - for f in files: - if f not in files_to_keep: - os.remove(f) - - -def sync( - client: SoundCloud, - playlist: Union[AlbumPlaylist, BasicAlbumPlaylist], - playlist_info: PlaylistInfo, - kwargs: SCDLArgs, -) -> Tuple[Union[BasicTrack, MiniTrack], ...]: - """Downloads/Removes tracks that have been changed on playlist since last archive file""" - logger.info("Comparing tracks...") - archive = kwargs.get("sync") - assert archive is not None - with get_filelock(archive): - with open(archive) as f: - try: - old = [int(i) for i in "".join(f.readlines()).strip().split("\n")] - except OSError as ioe: - logger.error(f"Error trying to read download archive {archive}") - logger.debug(ioe) - sys.exit(1) - except ValueError as verr: - logger.error("Error trying to convert track ids. Verify archive file is not empty.") - logger.debug(verr) - sys.exit(1) - - new = [track.id for track in playlist.tracks] - add = set(new).difference(old) # find tracks to download - rem = set(old).difference(new) # find tracks to remove - - if not (add or rem): - logger.info("No changes found. Exiting...") - sys.exit(0) - - if rem: - for track_id in rem: - removed = False - track = client.get_track(track_id) - if track is None: - logger.warning(f"Could not find track with id: {track_id}. Skipping removal") - continue - for ext in (".mp3", ".m4a", ".opus", ".flac", ".wav"): - filename = get_filename( - track, - kwargs, - ext, - playlist_info=playlist_info, - ) - if filename in os.listdir("."): - removed = True - os.remove(filename) - logger.info(f"Removed {filename}") - if not removed: - logger.info(f"Could not find {filename} to remove") - with open(archive, "w") as f: - for track_id in old: - if track_id not in rem: - f.write(str(track_id) + "\n") - else: - logger.info("No tracks to remove.") - - if add: - return tuple(track for track in playlist.tracks if track.id in add) - logger.info("No tracks to download. Exiting...") - sys.exit(0) - - -def download_playlist( - client: SoundCloud, - playlist: Union[AlbumPlaylist, BasicAlbumPlaylist], - kwargs: SCDLArgs, -) -> None: - """Downloads a playlist""" - if kwargs.get("no_playlist"): - logger.info("Skipping playlist...") - return - playlist_name = playlist.title.encode("utf-8", "ignore").decode("utf-8") - playlist_name = sanitize_str(playlist_name) - playlist_info: PlaylistInfo = { - "author": playlist.user.username, - "id": playlist.id, - "title": playlist.title, - "tracknumber_int": 0, - "tracknumber": "0", - "tracknumber_total": playlist.track_count, - } - - if not kwargs.get("no_playlist_folder"): - if not os.path.exists(playlist_name): - os.makedirs(playlist_name) - os.chdir(playlist_name) + # load default config first + with open(default_config_file, encoding="utf-8") as f: + config.read_file(f) try: - n = kwargs.get("n") - if n is not None: # Order by creation date and get the n lasts tracks - playlist.tracks = tuple( - sorted(playlist.tracks, key=lambda track: track.id, reverse=True)[: int(n)], - ) - kwargs["playlist_offset"] = 0 - s = kwargs.get("sync") - if s: - if os.path.isfile(s): - playlist.tracks = sync(client, playlist, playlist_info, kwargs) - else: - logger.error(f"Invalid sync archive file {kwargs.get('sync')}") - sys.exit(1) - - tracknumber_digits = len(str(len(playlist.tracks))) - for counter, track in itertools.islice( - enumerate(playlist.tracks, 1), - kwargs.get("playlist_offset", 0), - None, - ): - logger.debug(track) - logger.info(f"Track n°{counter}") - playlist_info["tracknumber_int"] = counter - playlist_info["tracknumber"] = str(counter).zfill(tracknumber_digits) - if isinstance(track, MiniTrack): - if playlist.secret_token: - track = client.get_tracks([track.id], playlist.id, playlist.secret_token)[0] - else: - track = client.get_track(track.id) # type: ignore[assignment] - assert isinstance(track, BasicTrack) - download_track( - client, - track, - kwargs, - playlist_info, - kwargs["strict_playlist"], - ) - finally: - if not kwargs.get("no_playlist_folder"): - os.chdir("..") - + with locked_file(config_file, "r", encoding="utf-8") as f: + config.read_file(f) + except Exception as err: + logger.warning(f"Error while reading config file: {err}") -def try_utime(path: str, filetime: float) -> None: try: - os.utime(path, (time.time(), filetime)) - except Exception: - logger.error("Cannot update utime of file") - - -def is_downloading_to_stdout(kwargs: SCDLArgs) -> bool: - return kwargs.get("name_format") == "-" - - -@contextlib.contextmanager -def get_stdout() -> Generator[IO, None, None]: - # Credits: https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/utils/_utils.py#L575 - if sys.platform == "win32": - import msvcrt - - # stdout may be any IO stream, e.g. when using contextlib.redirect_stdout - with contextlib.suppress(io.UnsupportedOperation): - msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - - yield getattr(sys.stdout, "buffer", sys.stdout) + with locked_file(config_file, "w", encoding="utf-8") as f: + config.write(f) + except Exception as err: + logger.warning(f"Error while writing config file: {err}") + return config -def get_filename( - track: Union[BasicTrack, Track], - kwargs: SCDLArgs, - ext: Optional[str] = None, - original_filename: Optional[str] = None, - playlist_info: Optional[PlaylistInfo] = None, -) -> str: - # Force stdout name on tracks that are being downloaded to stdout - if is_downloading_to_stdout(kwargs): - return "stdout" - username = track.user.username - title = track.title.encode("utf-8", "ignore").decode("utf-8") +def _convert_v2_name_format(s: str) -> str: + replacements = { + "{id}": "%(id)s", + "{user[username]}": "%(uploader)s", + "{user[id]}": "%(uploader_id)s", + "{user[permalink_url]}": "%(uploader_url)s", + "{timestamp}": "%(timestamp)s", + "{title}": "%(title)s", + "{description}": "%(description)s", + "{duration}": "%(duration)s", + "{permalink_url}": "%(webpage_url)s", + "{license}": "%(license)s", + "{playback_count}": "%(view_count)s", + "{likes_count}": "%(like_count)s", + "{comment_count}": "%(comment_count)s", + "{reposts_count}": "%(respost_count)s", + "{playlist[author]}": "%(playlist_uploader)s", + "{playlist[title]}": "%(playlist)s", + "{playlist[id]}": "%(playlist_id)s", + "{playlist[tracknumber]}": "%(playlist_index)s", + "{playlist[tracknumber_total]}": "%(playlist_count)s", + } + for old, new in replacements.items(): + s = s.replace(old, new) + if not s.endswith(".%(ext)s"): + s += ".%(ext)s" + return s - if kwargs.get("addtofile") and username not in title and "-" not in title: - title = f"{username} - {title}" - logger.debug(f'Adding "{username}" to filename') - timestamp = str(int(track.created_at.timestamp())) - if kwargs.get("addtimestamp"): - title = timestamp + "_" + title +def _build_ytdl_output_filename(scdl_args: SCDLArgs, in_playlist: bool, force_suffix: str | None = None) -> str: + if scdl_args.get("name_format") == "-": + return "-" - if not kwargs.get("addtofile") and not kwargs.get("addtimestamp"): - if playlist_info: - title = kwargs["playlist_name_format"].format( - **asdict(track), - playlist=playlist_info, - timestamp=timestamp, - ) - else: - title = kwargs["name_format"].format(**asdict(track), timestamp=timestamp) - - if original_filename is not None: - original_filename = original_filename.encode("utf-8", "ignore").decode("utf-8") - ext = os.path.splitext(original_filename)[1] - return sanitize_str(title, ext or "") - - -def download_original_file( - client: SoundCloud, - track: Union[BasicTrack, Track], - title: str, - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo] = None, -) -> Tuple[Optional[str], bool]: - logger.info("Downloading the original file.") - to_stdout = is_downloading_to_stdout(kwargs) - - # Get the requests stream - url = client.get_track_original_download(track.id, track.secret_token) - - if not url: - logger.info("Could not get original download link") - return None, False - - r = requests.get(url, stream=True) - if r.status_code == 401: - logger.info("The original file has no download left.") - return None, False - - if r.status_code == 404: - logger.info("Could not get name from stream - using basic name") - return None, False - - # Find filename - header = r.headers.get("content-disposition") - params = utils.parse_header(header) - if "filename" in params: - filename = urllib.parse.unquote(params["filename"][-1], encoding="utf-8") + playlist_format = "%(playlist|)s" + if in_playlist: + track_format = _convert_v2_name_format(scdl_args["playlist_name_format"]) else: - raise MissingFilenameError(header) - - orig_filename = filename - _, ext = os.path.splitext(filename) - - if not kwargs.get("original_name"): - orig_filename, ext = os.path.splitext(filename) - - # Find file extension - ext = ( - ext - or mimetypes.guess_extension(r.headers["content-type"]) - or ("." + r.headers["x-amz-meta-file-type"]) - ) - orig_filename += ext - - filename = get_filename( - track, - kwargs, - original_filename=orig_filename, - playlist_info=playlist_info, - ) - - logger.debug(f"filename : {filename}") - encoding_to_flac = bool(kwargs.get("flac")) and can_convert(orig_filename) - - if encoding_to_flac: - filename = filename[:-4] + ".flac" - - # Skip if file ID or filename already exists - # We are always re-downloading to stdout - if not to_stdout and already_downloaded(track, title, filename, kwargs): - return filename, True - - re_encode_to_out( - track, - r, - ext[1:] if not encoding_to_flac else "flac", - not encoding_to_flac, # copy the stream only if we aren't re-encoding to flac - filename, - kwargs, - playlist_info=playlist_info, - skip_re_encoding=not encoding_to_flac, - ) - - return filename, False - - -def get_transcoding_m3u8( - client: SoundCloud, - transcoding: Transcoding, - kwargs: SCDLArgs, -) -> str: - url = transcoding.url - bitrate_KBps = 256 / 8 if "aac" in transcoding.preset else 128 / 8 # noqa: N806 - total_bytes = bitrate_KBps * transcoding.duration - - min_size = kwargs.get("min_size") or 0 - max_size = kwargs.get("max_size") or math.inf # max size of 0 treated as no max size - - if not min_size <= total_bytes <= max_size: - raise InvalidFilesizeError(min_size, max_size, total_bytes) - - if url is not None: - headers = client._get_default_headers() - if client.auth_token: - headers["Authorization"] = f"OAuth {client.auth_token}" - - params = { - "client_id": client.client_id, - } - - r: Optional[requests.Response] = None - delay: int = 0 - - # If we got ratelimited - while not r or r.status_code == 429: - if delay > 0: - logger.warning(f"Got rate-limited, delaying for {delay}sec") - time.sleep(delay) - - r = requests.get(url, headers=headers, params=params) - delay = (delay or 1) * 2 # exponential backoff, what could possibly go wrong - - if r.status_code != 200: - raise SoundCloudException(f"Unable to get transcoding m3u8({r.status_code}): {r.text}") - - logger.debug(r.url) - return r.json()["url"] - raise SoundCloudException(f"Transcoding does not contain URL: {transcoding}") - - -def download_hls( - client: SoundCloud, - track: Union[BasicTrack, Track], - title: str, - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo] = None, -) -> Tuple[str, bool]: - if not track.media.transcodings: - raise SoundCloudException(f"Track {track.permalink_url} has no transcodings available") - - logger.debug(f"Transcodings: {track.media.transcodings}") - - transcodings = [t for t in track.media.transcodings if t.format.protocol == "hls"] - to_stdout = is_downloading_to_stdout(kwargs) - - # ordered in terms of preference best -> worst - valid_presets = [("mp3", ".mp3")] - - if not kwargs.get("onlymp3"): - if kwargs.get("opus"): - valid_presets = [("opus", ".opus"), *valid_presets] - valid_presets = [("aac_256k", ".m4a"), ("aac", ".m4a"), *valid_presets] - - transcoding = None - ext = None - for preset_name, preset_ext in valid_presets: - for t in transcodings: - if t.preset.startswith(preset_name): - transcoding = t - ext = preset_ext - if transcoding: - break + track_format = _convert_v2_name_format(scdl_args["name_format"]) + + if scdl_args.get("addtimestamp") or scdl_args.get("addtofile"): + track_format = "%(title)s.%(ext)s" + if scdl_args.get("addtofile"): + track_format = "%(uploader)s - " + track_format + if scdl_args.get("addtimestamp"): + track_format = "%(timestamp)s_" + track_format + + base = scdl_args["path"] + if scdl_args.get("no_playlist_folder") or not in_playlist: + ret = base / track_format else: - raise SoundCloudException( - "Could not find valid transcoding. Available transcodings: " - f"{[t.preset for t in track.media.transcodings if t.format.protocol == 'hls']}", + ret = base / playlist_format / track_format + + if force_suffix: + ret = ret.with_suffix(force_suffix) + + return ret.as_posix() + + +def _build_ytdl_format_specifier(scdl_args: SCDLArgs) -> str: + fmt = "ba" + if scdl_args.get("min_size"): + fmt += f"[filesize_approx>={scdl_args['min_size']}]" + if scdl_args.get("max_size"): + fmt += f"[filesize_approx<={scdl_args['max_size']}]" + if scdl_args.get("no_original"): + fmt += "[format_id!=download]" + if scdl_args.get("only_original"): + fmt += "[format_id=download]" + if scdl_args.get("onlymp3"): + fmt += "[format_id*=mp3]" + return fmt + + +def _build_ytdl_params(url: str, scdl_args: SCDLArgs) -> tuple[str, dict, list]: + # return download url, ytdl params, and postprocessors + + if scdl_args.get("a"): + pass + elif scdl_args.get("t"): + url = posixpath.join(url, "tracks") + elif scdl_args.get("f"): + url = posixpath.join(url, "likes") + elif scdl_args.get("C"): + url = posixpath.join(url, "comments") + elif scdl_args.get("p"): + url = posixpath.join(url, "sets") + elif scdl_args.get("r"): + url = posixpath.join(url, "reposts") + + params: dict = {} + + # default params + params["--embed-metadata"] = True + params["--embed-thumbnail"] = True + params["--remux-video"] = "aac>m4a" + params["--extractor-args"] = "soundcloud:formats=*_aac,*_mp3" # ignore opus by default + params["--use-extractors"] = "soundcloud.*" + params["--output-na-placeholder"] = "" + params["--parse-metadata"] = [] + params["--trim-filenames"] = "240b" + postprocessors = [ + ( + OuttmplPP( + _build_ytdl_output_filename(scdl_args, False), + _build_ytdl_output_filename(scdl_args, True), + ), + "pre_process", ) + ] - filename = get_filename(track, kwargs, ext=ext, playlist_info=playlist_info) - logger.debug(f"filename : {filename}") - # Skip if file ID or filename already exists - if not to_stdout and already_downloaded(track, title, filename, kwargs): - return filename, True - - # Get the requests stream - url = get_transcoding_m3u8(client, transcoding, kwargs) - _, ext = os.path.splitext(filename) - - re_encode_to_out( - track, - url, - preset_name - if not preset_name.startswith("aac") - else "ipod", # We are encoding aac files to m4a, so an ipod codec is used - True, # no need to fully re-encode the whole hls stream - filename, - kwargs, - playlist_info, - ) - - return filename, False - - -def download_track( - client: SoundCloud, - track: Union[BasicTrack, Track], - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo] = None, - exit_on_fail: bool = True, -) -> None: - """Downloads a track""" - try: - title = track.title - title = title.encode("utf-8", "ignore").decode("utf-8") - logger.info(f"Downloading {title}") - - # Not streamable - if not track.streamable: - logger.warning("Track is not streamable...") - - # Geoblocked track - if track.policy == "BLOCK": - raise RegionBlockError - - # Get user_id from the client - me = client.get_me() if kwargs["auth_token"] else None - client_user_id = me and me.id - - lock = get_filelock(pathlib.Path(f"./{track.id}"), 0) - - # Downloadable track - filename = None - is_already_downloaded = False - if ( - (track.downloadable or track.user_id == client_user_id) - and not kwargs["onlymp3"] - and not kwargs.get("no_original") - and client.auth_token - ): - try: - with lock: - filename, is_already_downloaded = download_original_file( - client, - track, - title, - kwargs, - playlist_info, - ) - except filelock.Timeout: - logger.debug(f"Could not acquire lock: {lock}. Skipping") - return - - if filename is None: - if kwargs.get("only_original"): - raise SoundCloudException( - f'Track "{track.permalink_url}" does not have original file ' - "available. Not downloading...", - ) - try: - with lock: - filename, is_already_downloaded = download_hls( - client, - track, - title, - kwargs, - playlist_info, - ) - except filelock.Timeout: - logger.debug(f"Could not acquire lock: {lock}. Skipping") - return - - if kwargs.get("remove"): - files_to_keep.append(filename) - - record_download_archive(track, kwargs) - if kwargs["add_description"]: - create_description_file(track.description, filename) - - to_stdout = is_downloading_to_stdout(kwargs) - - # Skip if file ID or filename already exists - if is_already_downloaded and not kwargs.get("force_metadata"): - raise SoundCloudException(f"{filename} already downloaded.") - - # If file does not exist an error occurred - # If we are downloading to stdout and reached this point, then most likely - # we downloaded the track - if not os.path.isfile(filename) and not to_stdout: - raise SoundCloudException(f"An error occurred downloading {filename}.") - - # Add metadata to an already existing file if needed - if is_already_downloaded and kwargs.get("force_metadata"): - with open(filename, "rb") as f: - file_data = io.BytesIO(f.read()) - - _add_metadata_to_stream(track, file_data, kwargs, playlist_info) - - with open(filename, "wb") as f: - file_data.seek(0) - f.write(file_data.getbuffer()) - - # Try to change the real creation date - if not to_stdout: - filetime = int(time.mktime(track.created_at.timetuple())) - try_utime(filename, filetime) - - logger.info(f"{filename} Downloaded.\n") - except SoundCloudException as err: - logger.error(err) - if exit_on_fail: - sys.exit(1) - - -def can_convert(filename: str) -> bool: - ext = os.path.splitext(filename)[1] - return "wav" in ext or "aif" in ext - - -def create_description_file(description: Optional[str], filename: str) -> None: - """ - Creates txt file containing the description - """ - desc = description or "" - if desc: - try: - description_filename = pathlib.Path(filename).with_suffix(".txt") - with open(description_filename, "w", encoding="utf-8") as f: - f.write(desc) - logger.info("Created description txt file") - except OSError as ioe: - logger.error("Error trying to write description txt file...") - logger.error(ioe) - - -def already_downloaded( - track: Union[BasicTrack, Track], - title: str, - filename: str, - kwargs: SCDLArgs, -) -> bool: - """Returns True if the file has already been downloaded""" - already_downloaded = False - - if os.path.isfile(filename): - already_downloaded = True - if kwargs.get("flac") and can_convert(filename) and os.path.isfile(filename[:-4] + ".flac"): - already_downloaded = True - if kwargs.get("download_archive") and in_download_archive(track, kwargs): - already_downloaded = True - - if kwargs.get("flac") and can_convert(filename) and os.path.isfile(filename): - already_downloaded = False - - if kwargs.get("overwrite"): - already_downloaded = False - - if already_downloaded: - if kwargs.get("c") or kwargs.get("remove") or kwargs.get("force_metadata"): - return True - logger.error(f'Track "{title}" already exists!') - logger.error("Exiting... (run again with -c to continue)") - sys.exit(1) - return False - - -def in_download_archive(track: Union[BasicTrack, Track], kwargs: SCDLArgs) -> bool: - """Returns True if a track_id exists in the download archive""" - archive_filename = kwargs.get("download_archive") - if not archive_filename: - return False - - try: - with get_filelock(archive_filename), open(archive_filename, "a+", encoding="utf-8") as file: - file.seek(0) - track_id = str(track.id) - for line in file: - if line.strip() == track_id: - return True - except OSError as ioe: - logger.error("Error trying to read download archive...") - logger.error(ioe) - - return False - - -def record_download_archive(track: Union[BasicTrack, Track], kwargs: SCDLArgs) -> None: - """Write the track_id in the download archive""" - archive_filename = kwargs.get("download_archive") - if not archive_filename: - return - - try: - with get_filelock(archive_filename), open(archive_filename, "a", encoding="utf-8") as file: - file.write(f"{track.id}\n") - except OSError as ioe: - logger.error("Error trying to write to download archive...") - logger.error(ioe) - - -def _try_get_artwork(url: str, size: str = "original") -> Optional[requests.Response]: - new_artwork_url = url.replace("large", size) - - try: - artwork_response = requests.get(new_artwork_url, allow_redirects=False, timeout=5) - - if artwork_response.status_code != 200: - return None - - content_type = artwork_response.headers.get("Content-Type", "").lower() - if content_type not in ("image/png", "image/jpeg", "image/jpg"): - return None + if scdl_args.get("strict_playlist"): + params["--abort-on-error"] = True - return artwork_response - except requests.RequestException: - return None + if not scdl_args.get("c") and not scdl_args.get("download_archive") and not scdl_args.get("sync"): + params["--break-on-existing"] = True + # if not scdl_args.get("force_metadata"): + # https://github.com/yt-dlp/yt-dlp/issues/1467 + # params["--no-post-overwrites"] = True # noqa: ERA001 -def build_ffmpeg_encoding_args( - input_file: str, - output_file: str, - out_codec: str, - kwargs: SCDLArgs, - *args: str, -) -> List[str]: - supported = get_ffmpeg_supported_options() - ffmpeg_args = [ - "ffmpeg", - "-loglevel", - "debug" if kwargs["debug"] else "error", - # Input stream - "-i", - input_file, - # Encoding - "-f", - out_codec, - ] + if scdl_args.get("o"): + params["--playlist-items"] = f"{scdl_args.get('o')}:" - if not kwargs.get("hide_progress"): - ffmpeg_args += [ - # Progress to stderr - "-progress", - "pipe:2", + if scdl_args.get("extract_artist"): + params["--parse-metadata"] += [ + r"%(title)s:(?P.*?)\s+[-−–—―]\s*(?P.*)", # noqa: RUF001 ] - if "-stats_period" in supported: - # more frequent progress updates - ffmpeg_args += [ - "-stats_period", - "0.1", - ] - - ffmpeg_args += [ - # User provided arguments - *args, - # Output file - output_file, - ] - return ffmpeg_args + if scdl_args.get("debug"): + params["--verbose"] = True -def _write_streaming_response_to_pipe( - response: requests.Response, - pipe: Union[IO[bytes], io.BytesIO], - kwargs: SCDLArgs, -) -> None: - total_length = int(response.headers["content-length"]) + if scdl_args.get("error"): + params["--quiet"] = True - min_size = kwargs.get("min_size") or 0 - max_size = kwargs.get("max_size") or math.inf # max size of 0 treated as no max size + if scdl_args.get("download_archive"): + params["--download-archive"] = scdl_args.get("download_archive") - if not min_size <= total_length <= max_size: - raise InvalidFilesizeError(min_size, max_size, total_length) + if scdl_args.get("hide_progress"): + params["--no-progress"] = True - logger.info("Receiving the streaming response") - received = 0 - chunk_size = 8192 + if scdl_args.get("max_size"): + params["--max-filesize"] = scdl_args.get("max_size") + if scdl_args.get("min_size"): + params["--min-filesize"] = scdl_args.get("min_size") - with memoryview(bytearray(chunk_size)) as buffer: - for chunk in tqdm( - iter(lambda: response.raw.read(chunk_size), b""), - total=(total_length / chunk_size) + 1, - disable=bool(kwargs.get("hide_progress")), - unit="Kb", - unit_scale=chunk_size / 1024, - ): - if not chunk: - break + params["-f"] = _build_ytdl_format_specifier(scdl_args) - buffer_view = buffer[: len(chunk)] - buffer_view[:] = chunk + if scdl_args.get("flac"): + params["--recode-video"] = "aiff>flac/alac>flac/wav>flac" - received += len(chunk) - pipe.write(buffer_view) + if not scdl_args.get("no_album_tag"): + params["--parse-metadata"] += [ + "%(playlist)s:%(meta_album)s", + "%(playlist_uploader)s:%(meta_album_artist)s", + "%(playlist_index)s:%(meta_track)s", + ] - pipe.flush() + if scdl_args.get("original_name") and not scdl_args.get("no_original"): + postprocessors.append((OriginalFilenamePP(), "pre_process")) - if received != total_length: - logger.error("connection closed prematurely, download incomplete") - sys.exit(1) + if not scdl_args.get("original_art"): + params["--thumbnail-id"] = "t500x500" - if not isinstance(pipe, io.BytesIO): - pipe.close() + if scdl_args.get("name_format") == "-": + # https://github.com/yt-dlp/yt-dlp/issues/8815 + # https://github.com/yt-dlp/yt-dlp/issues/126 + params["--embed-metadata"] = False + params["--embed-thumbnail"] = False + if scdl_args.get("original_metadata"): + params["--embed-metadata"] = False + params["--embed-thumbnail"] = False + else: + postprocessors.append((MutagenPP(scdl_args["force_metadata"]), "post_process")) -def _add_metadata_to_stream( - track: Union[BasicTrack, Track], - stream: io.BytesIO, - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo] = None, -) -> None: - logger.info("Applying metadata...") + if scdl_args.get("auth_token"): + params["--username"] = "oauth" + params["--password"] = scdl_args.get("auth_token") - artwork_base_url = track.artwork_url or track.user.avatar_url - artwork_response = None + if scdl_args.get("overwrite"): + params["--force-overwrites"] = True - if kwargs.get("original_art"): - artwork_response = _try_get_artwork(artwork_base_url, "original") + if scdl_args.get("no_playlist"): + params["--match-filters"] = "!playlist_uploader" - if artwork_response is None: - artwork_response = _try_get_artwork(artwork_base_url, "t500x500") + if scdl_args.get("add_description"): + params["--print-to-file"] = ( + "description", + _build_ytdl_output_filename(scdl_args, False, ".txt"), + ) - artist: str = track.user.username - if bool(kwargs.get("extract_artist")): - for dash in (" - ", " − ", " – ", " — ", " ― "): # noqa: RUF001 - if dash not in track.title: - continue + if scdl_args.get("opus"): + params["--extractor-args"] = "soundcloud:formats=*_aac,*_opus,*_mp3" + + argv = [] + for param, value in params.items(): + if value is False: + continue + if value is True: + argv.append(param) + elif isinstance(value, list): + for v in value: + argv.append(param) + argv.append(v) + elif isinstance(value, tuple): + argv.append(param) + argv += list(value) + else: + argv.append(param) + argv.append(value) - artist_title = track.title.split(dash, maxsplit=1) - artist = artist_title[0].strip() - track.title = artist_title[1].strip() - break + logger.debug(f"[debug] yt-dlp args: {url} {' '.join(argv)}") - album_available: bool = (playlist_info is not None) and not kwargs.get("no_album_tag") + return url, utils.cli_to_api(argv), postprocessors - metadata = MetadataInfo( - artist=artist, - title=track.title, - description=track.description, - genre=track.genre, - artwork_jpeg=artwork_response.content if artwork_response else None, - link=track.permalink_url, - date=track.created_at.strftime("%Y-%m-%d %H:%M:%S"), - album_title=playlist_info["title"] if album_available else None, # type: ignore[index] - album_author=playlist_info["author"] if album_available else None, # type: ignore[index] - album_track_num=playlist_info["tracknumber_int"] if album_available else None, # type: ignore[index] - album_total_track_num=playlist_info["tracknumber_total"] if album_available else None, # type: ignore[index] - ) - mutagen_file = mutagen.File(stream) +def download_url(url: str, **scdl_args: Unpack[SCDLArgs]) -> None: + url, params, postprocessors = _build_ytdl_params(url, scdl_args) - try: - # Delete all the existing tags and write our own tags - if mutagen_file is not None: - stream.seek(0) - mutagen_file.delete(stream) - assemble_metadata(mutagen_file, metadata) - except NotImplementedError: - logger.error( - "Metadata assembling for this track is unsupported.\n" - "Please create an issue at https://github.com/flyingrub/scdl/issues " - "and we will look into it", - ) + params["logger"] = logger - kwargs_no_sensitive = {k: v for k, v in kwargs.items() if k not in ("auth_token",)} - logger.error( - f"Here is the information that you should attach to your issue:\n" - f"- Track: {track.permalink_url}\n" - f"- First 16 bytes: {stream.getvalue()[:16].hex()}\n" - f"- Identified as: {type(mutagen_file)}\n" - f"- Configuration: {kwargs_no_sensitive}", - ) - return - - stream.seek(0) - mutagen_file.save(stream) - - -def re_encode_to_out( - track: Union[BasicTrack, Track], - in_data: Union[requests.Response, str], - out_codec: str, - should_copy: bool, - filename: str, - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo], - skip_re_encoding: bool = False, -) -> None: - to_stdout = is_downloading_to_stdout(kwargs) - - encoded = re_encode_to_buffer( - track, - in_data, - out_codec, - should_copy, - kwargs, - playlist_info, - skip_re_encoding, - ) - - # see https://github.com/python/mypy/issues/5512 - with get_stdout() if to_stdout else open(filename, "wb") as out_handle: # type: ignore[attr-defined] - shutil.copyfileobj(encoded, out_handle) - - -def _is_ffmpeg_progress_line(parameters: List[str]) -> bool: - return len(parameters) == 2 and parameters[0] in ( - "progress", - "speed", - "drop_frames", - "dup_frames", - "out_time", - "out_time_ms", - "out_time_us", - "total_size", - "bitrate", - ) - - -def _get_ffmpeg_pipe( - in_data: Union[requests.Response, str], # streaming response or url - out_codec: str, - should_copy: bool, - output_file: str, - kwargs: SCDLArgs, -) -> subprocess.Popen: - logger.info("Creating the ffmpeg pipe...") - - commands = build_ffmpeg_encoding_args( - in_data if isinstance(in_data, str) else "-", - output_file, - out_codec, - kwargs, - *( - ( - "-c", - "copy", - ) - if should_copy - else () - ), - ) - - logger.debug(f"ffmpeg command: {' '.join(commands)}") - return subprocess.Popen( - commands, - stdin=subprocess.PIPE, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - bufsize=FFMPEG_PIPE_CHUNK_SIZE, - ) - - -def _is_unsupported_codec_for_streaming(codec: str) -> bool: - return codec in ("ipod", "flac") - - -def _re_encode_ffmpeg( - in_data: Union[requests.Response, str], # streaming response or url - out_file_name: str, - out_codec: str, - track_duration_ms: int, - should_copy: bool, - kwargs: SCDLArgs, -) -> io.BytesIO: - pipe = _get_ffmpeg_pipe(in_data, out_codec, should_copy, out_file_name, kwargs) - - logger.info("Encoding..") - errors_output = "" - stdout = io.BytesIO() - - # Sadly, we have to iterate both stdout and stderr at the same times in order for - # things to work. This is why we have 2 threads that are reading stderr, and - # writing stuff to stdin at the same time. I don't think there is any other way - # to get this working and make it as fast as it is now. - - # A function that reads encoded track to our `stdout` BytesIO object - def read_stdout() -> None: - assert pipe.stdout is not None - shutil.copyfileobj(pipe.stdout, stdout, FFMPEG_PIPE_CHUNK_SIZE) - pipe.stdout.close() - - stdout_thread = None - stdin_thread = None - - # Read from stdout only if we expect ffmpeg to write something there - if out_file_name == "pipe:1": - stdout_thread = threading.Thread(target=read_stdout, daemon=True) - - # Stream the response to ffmpeg if needed - if isinstance(in_data, requests.Response): - assert pipe.stdin is not None - stdin_thread = threading.Thread( - target=_write_streaming_response_to_pipe, - args=(in_data, pipe.stdin, kwargs), - daemon=True, - ) - - # Start the threads - if stdout_thread: - stdout_thread.start() - if stdin_thread: - stdin_thread.start() - - # Read progress from stderr line by line - hide_progress = bool(kwargs.get("hide_progress")) - total_sec = track_duration_ms / 1000 - with tqdm(total=total_sec, disable=hide_progress, unit="s") as progress: - last_secs = 0.0 - assert pipe.stderr is not None - for line in io.TextIOWrapper(pipe.stderr, encoding="utf-8", errors=None): - parameters = line.split("=", maxsplit=1) - if hide_progress or not _is_ffmpeg_progress_line(parameters): - errors_output += line - continue - - if not line.startswith("out_time_ms"): - continue - - try: - seconds = int(parameters[1]) / 1_000_000 - except ValueError: - seconds = 0.0 - - seconds = min(seconds, total_sec) # clamp just to be sure - changed = seconds - last_secs - last_secs = seconds - progress.update(changed) - - # Wait for threads to finish - if stdout_thread: - stdout_thread.join() - if stdin_thread: - stdin_thread.join() - - logger.debug(f"FFmpeg output: {errors_output}") - - # Make sure that process has exited and get its exit code - pipe.wait() - if pipe.returncode != 0: - raise FFmpegError(pipe.returncode, errors_output) - - # Read from the temp file, if needed - if out_file_name != "pipe:1": - with open(out_file_name, "rb") as f: - shutil.copyfileobj(f, stdout) - - stdout.seek(0) - return stdout - - -def _copy_stream( - in_data: requests.Response, # streaming response or url - kwargs: SCDLArgs, -) -> io.BytesIO: - result = io.BytesIO() - _write_streaming_response_to_pipe(in_data, result, kwargs) - result.seek(0) - return result - - -def re_encode_to_buffer( - track: Union[BasicTrack, Track], - in_data: Union[requests.Response, str], # streaming response or url - out_codec: str, - should_copy: bool, - kwargs: SCDLArgs, - playlist_info: Optional[PlaylistInfo] = None, - skip_re_encoding: bool = False, -) -> io.BytesIO: - if skip_re_encoding and isinstance(in_data, requests.Response): - encoded_data = _copy_stream(in_data, kwargs) - else: - streaming_supported = not _is_unsupported_codec_for_streaming(out_codec) - if streaming_supported: - out_file_name = "pipe:1" # stdout - encoded_data = _re_encode_ffmpeg( - in_data, out_file_name, out_codec, track.duration, should_copy, kwargs - ) - else: - with tempfile.TemporaryDirectory() as d: - out_file_name = str(pathlib.Path(d) / "scdl") - encoded_data = _re_encode_ffmpeg( - in_data, out_file_name, out_codec, track.duration, should_copy, kwargs - ) - - # Remove original metadata, add our own, and we are done - if not kwargs.get("original_metadata"): - _add_metadata_to_stream(track, encoded_data, kwargs, playlist_info) + # we handle this with custom MutagenPP for now + params["postprocessors"] = [ + pp for pp in params["postprocessors"] if pp["key"] not in ("EmbedThumbnail", "FFmpegMetadata") + ] - encoded_data.seek(0) - return encoded_data + yt_dlp_args = scdl_args.get("yt_dlp_args") + if yt_dlp_args: + argv = shlex.split(yt_dlp_args) + overrides = utils.cli_to_api(argv) + params = {**params, **overrides} + with YoutubeDL(params) as ydl: + if scdl_args["client_id"]: + ydl.cache.store("soundcloud", "client_id", scdl_args["client_id"]) + for pp, when in postprocessors: + ydl.add_post_processor(pp, when) -@lru_cache(maxsize=1) -def get_ffmpeg_supported_options() -> Set[str]: - """Returns supported ffmpeg options which we care about""" - if shutil.which("ffmpeg") is None: - logger.error("ffmpeg is not installed") - sys.exit(1) - r = subprocess.run( - ["ffmpeg", "-help", "long", "-loglevel", "quiet"], - check=True, - stdout=subprocess.PIPE, - encoding="utf-8", - ) - supported = set() - for line in r.stdout.splitlines(): - if line.startswith("-"): - opt = line.split(maxsplit=1)[0] - supported.add(opt) - return supported + sync = SyncDownloadHelper(scdl_args, ydl) + ydl.download(url) + sync.post_download() if __name__ == "__main__": - main() + _main() diff --git a/scdl/utils.py b/scdl/utils.py index 61c5deca..b57644d4 100644 --- a/scdl/utils.py +++ b/scdl/utils.py @@ -1,83 +1,49 @@ -"""Copied from -https://github.com/davidfischer-ch/pytoolbox/blob/master/pytoolbox/logging.py -""" - -import email.message -import logging -import re -from types import MappingProxyType -from typing import Dict, Optional +from logging import Logger -from termcolor import colored +import yt_dlp +import yt_dlp.options -__all__ = ("ColorizeFilter",) +"""Copied from +https://github.com/yt-dlp/yt-dlp/blob/0b6b7742c2e7f2a1fcb0b54ef3dd484bab404b3f/devscripts/cli_to_api.py +""" +_create_parser = yt_dlp.options.create_parser -class ColorizeFilter(logging.Filter): - COLOR_BY_LEVEL = MappingProxyType( +def _parse_patched_options(opts): + patched_parser = _create_parser() + patched_parser.defaults.update( { - logging.DEBUG: "blue", - logging.WARNING: "yellow", - logging.ERROR: "red", - logging.INFO: "white", - }, + "ignoreerrors": False, + "retries": 0, + "fragment_retries": 0, + "extract_flat": False, + "concat_playlist": "never", + } ) + yt_dlp.options.create_parser = lambda: patched_parser + try: + return yt_dlp.parse_options(opts) + finally: + yt_dlp.options.create_parser = _create_parser - def filter(self, record: logging.LogRecord) -> bool: - record.raw_msg = record.msg - color = self.COLOR_BY_LEVEL.get(record.levelno) - if color: - record.msg = colored(record.msg, color) # type: ignore[arg-type] - return True - - -def size_in_bytes(insize: str) -> int: - """Return the size in bytes from strings such as '5 mb' into 5242880. - - >>> size_in_bytes('1m') - 1048576 - >>> size_in_bytes('1.5m') - 1572864 - >>> size_in_bytes('2g') - 2147483648 - >>> size_in_bytes(None) - Traceback (most recent call last): - raise ValueError('no string specified') - ValueError: no string specified - >>> size_in_bytes('') - Traceback (most recent call last): - raise ValueError('no string specified') - ValueError: no string specified - """ - if insize is None or insize.strip() == "": - raise ValueError("no string specified") - - units = { - "k": 1024, - "m": 1024**2, - "g": 1024**3, - "t": 1024**4, - "p": 1024**5, - } - match = re.search(r"^\s*([0-9\.]+)\s*([kmgtp])?", insize, re.IGNORECASE) - - if match is None: - raise ValueError("match not found") - size, unit = match.groups() +_default_opts = _parse_patched_options([]).ydl_opts - if size: - size = float(size) - if unit: - size = size * units[unit.lower().strip()] +def cli_to_api(opts): + opts = yt_dlp.parse_options(opts).ydl_opts - return int(size) + diff = {k: v for k, v in opts.items() if _default_opts[k] != v} + if "postprocessors" in diff: + diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in _default_opts["postprocessors"]] + return diff -def parse_header(content_disposition: Optional[str]) -> Dict[str, str]: - if not content_disposition: - return {} - message = email.message.Message() - message["content-type"] = content_disposition - return dict(message.get_params({})) +class YTLogger(Logger): + def debug(self, msg: object, *args, **kwargs): + # For compatibility with youtube-dl, both debug and info are passed into debug + # You can distinguish them by the prefix '[debug] ' + if isinstance(msg, str) and msg.startswith("[debug] "): + super().debug(msg, *args, **kwargs) + else: + self.info(msg, *args, **kwargs) diff --git a/setup.py b/setup.py deleted file mode 100755 index 97f890b0..00000000 --- a/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -from os import path - -from setuptools import find_packages, setup - -import scdl - -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="scdl", - version=scdl.__version__, - packages=find_packages(), - include_package_data=True, - author="FlyinGrub", - author_email="flyinggrub@gmail.com", - description="Download Music from Souncloud", - long_description=long_description, - long_description_content_type="text/markdown", - install_requires=[ - "docopt-ng", - "mutagen>=1.45.0", - "termcolor", - "requests", - "tqdm", - "pathvalidate", - "soundcloud-v2>=1.5.2", - "filelock>=3.0.0", - "typing_extensions; python_version < '3.11'", - ], - extras_require={ - "dev": [ - "pytest", - "pytest-cov", - "pytest-dotenv", - "music-tag", - "ruff", - "mypy", - "types-requests", - "types-tqdm", - ], - }, - url="https://github.com/flyingrub/scdl", - classifiers=[ - "Programming Language :: Python", - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Internet", - "Topic :: Multimedia :: Sound/Audio", - ], - python_requires=">=3.7", - entry_points={ - "console_scripts": [ - "scdl = scdl.scdl:main", - ], - }, -) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..543ab06d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.register_assert_rewrite("tests.utils") diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 9f5858e9..abdf9427 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -51,22 +51,6 @@ def test_playlist(tmp_path: Path) -> None: assert_track_playlist_2(tmp_path) -def test_n(tmp_path: Path) -> None: - os.chdir(tmp_path) - r = call_scdl_with_auth( - "-l", - "https://soundcloud.com/one-thousand-and-one/sets/test-playlist/s-ZSLfNrbPoXR", - "--playlist-name-format", - "{playlist[tracknumber]}_{title}", - "--onlymp3", - "-n", - "1", - ) - assert r.returncode == 0 - assert_track(tmp_path / "test playlist", "1_test track 2.mp3", check_metadata=False) - assert_not_track(tmp_path / "test playlist", "2_testing - test track.mp3") - - def test_offset(tmp_path: Path) -> None: os.chdir(tmp_path) r = call_scdl_with_auth( @@ -111,7 +95,7 @@ def test_no_strict_playlist(tmp_path: Path) -> None: "--playlist-name-format", "{playlist[tracknumber]}_{title}", "--onlymp3", - "--max-size=10kb", + "--max-size=10k", ) assert r.returncode == 0 assert_not_track(tmp_path / "test playlist", "1_testing - test track.mp3") @@ -142,18 +126,18 @@ def test_sync(tmp_path: Path) -> None: "https://soundcloud.com/7x11x13/wan-bushi-eurodance-vibes-part-123", "--onlymp3", "--name-format", - "{title}", + "remove_this", "--path", "test playlist", ) assert r.returncode == 0 assert_track( tmp_path / "test playlist", - "Wan Bushi - Eurodance Vibes (part 1+2+3).mp3", + "remove_this.mp3", check_metadata=False, ) with open("archive.txt", "w", encoding="utf-8") as f: - f.writelines(["1032303631"]) + f.writelines(["soundcloud 1032303631 ./test playlist/remove_this.mp3"]) r = call_scdl_with_auth( "-l", "https://soundcloud.com/one-thousand-and-one/sets/test-playlist/s-ZSLfNrbPoXR", @@ -163,6 +147,9 @@ def test_sync(tmp_path: Path) -> None: "archive.txt", ) assert r.returncode == 0 - assert_not_track(tmp_path / "test playlist", "Wan Bushi - Eurodance Vibes (part 1+2+3).mp3") + assert_not_track(tmp_path / "test playlist", "remove_this.mp3") with open("archive.txt") as f: - assert f.read().split() == ["1855267053", "1855318536"] + assert [line.split()[1] for line in f.read().splitlines()] == [ + "1855267053", + "1855318536", + ] diff --git a/tests/test_track.py b/tests/test_track.py index 86347b9b..3c48f007 100644 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,10 +1,9 @@ -import math import os from pathlib import Path import pytest -from tests.utils import assert_not_track, assert_track, call_scdl_with_auth +from tests.utils import assert_track, call_scdl_with_auth @pytest.mark.skipif(not os.getenv("AUTH_TOKEN"), reason="No auth token specified") @@ -33,7 +32,8 @@ def test_original_to_stdout(tmp_path: Path) -> None: with open("track.wav", "wb") as f: assert isinstance(r.stdout, bytes) f.write(r.stdout) - assert_track(tmp_path, "track.wav", "copy", "saves", None) + # https://github.com/yt-dlp/yt-dlp/issues/8815 + assert_track(tmp_path, "track.wav", "copy", "saves", None, check_metadata=False) def test_mp3_to_stdout(tmp_path: Path) -> None: @@ -52,7 +52,8 @@ def test_mp3_to_stdout(tmp_path: Path) -> None: assert isinstance(r.stdout, bytes) f.write(r.stdout) - assert_track(tmp_path, "track.mp3") + # https://github.com/yt-dlp/yt-dlp/issues/8815 + assert_track(tmp_path, "track.mp3", check_metadata=False) @pytest.mark.skipif(not os.getenv("AUTH_TOKEN"), reason="No auth token specified") @@ -72,7 +73,8 @@ def test_flac_to_stdout(tmp_path: Path) -> None: f.write(r.stdout) assert r.returncode == 0 - assert_track(tmp_path, "track.flac", "copy", "saves", None) + # https://github.com/yt-dlp/yt-dlp/issues/8815 + assert_track(tmp_path, "track.flac", "copy", "saves", None, check_metadata=False) @pytest.mark.skipif(not os.getenv("AUTH_TOKEN"), reason="No auth token specified") @@ -107,7 +109,7 @@ def test_m4a(tmp_path: Path) -> None: tmp_path, "track.m4a", "Wan Bushi - Eurodance Vibes (part 1+2+3)", - "7x11x13", + "Wan Bushi", "Electronic", None, ) @@ -208,6 +210,14 @@ def test_force_metadata(tmp_path: Path) -> None: assert r.returncode == 0 assert_track(tmp_path, "track.wav", "og title", "og artist", "og genre", 0) + r = call_scdl_with_auth( + "-l", + "https://soundcloud.com/violinbutterflynet/original", + "--name-format", + "track", + ) + assert_track(tmp_path, "track.wav", "og title", "og artist", "og genre", 0) + r = call_scdl_with_auth( "-l", "https://soundcloud.com/violinbutterflynet/original", @@ -263,10 +273,10 @@ def test_maxsize(tmp_path: Path) -> None: "-l", "https://soundcloud.com/one-thousand-and-one/test-track", "--onlymp3", - "--max-size=10kb", + "--max-size=10k", ) - assert r.returncode == 1 - assert "not within --min-size=0 and --max-size=10240" in r.stderr + assert r.returncode == 0 + assert "format is not available" in r.stderr def test_minsize(tmp_path: Path) -> None: @@ -275,10 +285,10 @@ def test_minsize(tmp_path: Path) -> None: "-l", "https://soundcloud.com/one-thousand-and-one/test-track", "--onlymp3", - "--min-size=1mb", + "--min-size=1m", ) - assert r.returncode == 1 - assert f"not within --min-size={1024**2} and --max-size={math.inf}" in r.stderr + assert r.returncode == 0 + assert "format is not available" in r.stderr def test_only_original(tmp_path: Path) -> None: @@ -288,8 +298,8 @@ def test_only_original(tmp_path: Path) -> None: "https://soundcloud.com/one-thousand-and-one/test-track-2/s-fgLQFAzNIMP", "--only-original", ) - assert r.returncode == 1 - assert "does not have original file available" in r.stderr + assert r.returncode == 0 + assert "Requested format is not available" in r.stderr def test_overwrite(tmp_path: Path) -> None: @@ -310,8 +320,8 @@ def test_overwrite(tmp_path: Path) -> None: "track", "--onlymp3", ) - assert r.returncode == 1 - assert "already exists" in r.stderr + assert r.returncode == 0 + assert "has already been downloaded" in r.stderr r = call_scdl_with_auth( "-l", @@ -322,6 +332,7 @@ def test_overwrite(tmp_path: Path) -> None: "--overwrite", ) assert r.returncode == 0 + assert "Deleting existing file" in r.stderr def test_path(tmp_path: Path) -> None: @@ -338,30 +349,6 @@ def test_path(tmp_path: Path) -> None: assert_track(tmp_path, "track.mp3", check_metadata=False) -def test_remove(tmp_path: Path) -> None: - os.chdir(tmp_path) - r = call_scdl_with_auth( - "-l", - "https://soundcloud.com/one-thousand-and-one/test-track", - "--name-format", - "track", - "--onlymp3", - ) - assert r.returncode == 0 - assert_track(tmp_path, "track.mp3", check_metadata=False) - r = call_scdl_with_auth( - "-l", - "https://soundcloud.com/one-thousand-and-one/test-track-2/s-fgLQFAzNIMP", - "--name-format", - "track2", - "--remove", - "--onlymp3", - ) - assert r.returncode == 0 - assert_track(tmp_path, "track2.mp3", check_metadata=False) - assert_not_track(tmp_path, "track.mp3") - - def test_download_archive(tmp_path: Path) -> None: os.chdir(tmp_path) r = call_scdl_with_auth( @@ -383,8 +370,8 @@ def test_download_archive(tmp_path: Path) -> None: "--onlymp3", "--download-archive=archive.txt", ) - assert r.returncode == 1 - assert "already exists" in r.stderr + assert r.returncode == 0 + assert "already been recorded in the archive" in r.stderr def test_description_file(tmp_path: Path) -> None: @@ -402,3 +389,16 @@ def test_description_file(tmp_path: Path) -> None: assert desc_file.exists() with open(desc_file, encoding="utf-8") as f: assert f.read().splitlines() == ["test description:", "9439290883"] + + +def test_trim_filenames(tmp_path: Path) -> None: + os.chdir(tmp_path) + r = call_scdl_with_auth( + "-l", + "https://soundcloud.com/one-thousand-and-one/test-track", + "--name-format", + "a" * 500, + "--onlymp3", + ) + assert r.returncode == 0 + assert_track(tmp_path, "a" * 240 + ".mp3") diff --git a/tests/test_user.py b/tests/test_user.py index e6e8b3b1..aff58ca3 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -14,12 +14,10 @@ def test_all(tmp_path: Path) -> None: "-l", "https://soundcloud.com/one-thousand-and-one", "-a", - "-o", - "3", "--onlymp3", ) assert r.returncode == 0 - assert count_files(tmp_path) == 3 + assert count_files(tmp_path) == 5 def test_tracks(tmp_path: Path) -> None: diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..737041e2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,468 @@ +version = 1 +requires-python = ">=3.9.0" + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, + { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, + { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, + { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, + { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, + { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, + { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, + { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, + { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, + { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, + { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, + { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, + { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, + { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326 }, + { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614 }, + { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450 }, + { url = "https://files.pythonhosted.org/packages/a4/23/65af317914a0308495133b2d654cf67b11bbd6ca16637c4e8a38f80a5a69/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", size = 140135 }, + { url = "https://files.pythonhosted.org/packages/f2/41/6190102ad521a8aa888519bb014a74251ac4586cde9b38e790901684f9ab/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", size = 150413 }, + { url = "https://files.pythonhosted.org/packages/7b/ab/f47b0159a69eab9bd915591106859f49670c75f9a19082505ff16f50efc0/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", size = 142992 }, + { url = "https://files.pythonhosted.org/packages/28/89/60f51ad71f63aaaa7e51a2a2ad37919985a341a1d267070f212cdf6c2d22/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", size = 144871 }, + { url = "https://files.pythonhosted.org/packages/0c/48/0050550275fea585a6e24460b42465020b53375017d8596c96be57bfabca/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", size = 146756 }, + { url = "https://files.pythonhosted.org/packages/dc/b5/47f8ee91455946f745e6c9ddbb0f8f50314d2416dd922b213e7d5551ad09/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", size = 141034 }, + { url = "https://files.pythonhosted.org/packages/84/79/5c731059ebab43e80bf61fa51666b9b18167974b82004f18c76378ed31a3/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", size = 149434 }, + { url = "https://files.pythonhosted.org/packages/ca/f3/0719cd09fc4dc42066f239cb3c48ced17fc3316afca3e2a30a4756fe49ab/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", size = 152443 }, + { url = "https://files.pythonhosted.org/packages/f7/0e/c6357297f1157c8e8227ff337e93fd0a90e498e3d6ab96b2782204ecae48/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", size = 150294 }, + { url = "https://files.pythonhosted.org/packages/54/9a/acfa96dc4ea8c928040b15822b59d0863d6e1757fba8bd7de3dc4f761c13/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", size = 145314 }, + { url = "https://files.pythonhosted.org/packages/73/1c/b10a63032eaebb8d7bcb8544f12f063f41f5f463778ac61da15d9985e8b6/charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", size = 94724 }, + { url = "https://files.pythonhosted.org/packages/c5/77/3a78bf28bfaa0863f9cfef278dbeadf55efe064eafff8c7c424ae3c4c1bf/charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", size = 102159 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dacite" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0f/cf0943f4f55f0fbc7c6bd60caf1343061dff818b02af5a0d444e473bb78d/dacite-1.8.1-py3-none-any.whl", hash = "sha256:cc31ad6fdea1f49962ea42db9421772afe01ac5442380d9a99fcf3d188c61afe", size = 14309 }, +] + +[[package]] +name = "docopt-ng" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/50/8d6806cf13138127692ae6ff79ddeb4e25eb3b0bcc3c1bd033e7e04531a9/docopt_ng-0.9.0.tar.gz", hash = "sha256:91c6da10b5bb6f2e9e25345829fb8278c78af019f6fc40887ad49b060483b1d7", size = 32264 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4a/c3b77fc1a24510b08918b43a473410c0168f6e657118807015f1f1edceea/docopt_ng-0.9.0-py3-none-any.whl", hash = "sha256:bfe4c8b03f9fca424c24ee0b4ffa84bf7391cb18c29ce0f6a8227a3b01b81ff9", size = 16689 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "music-tag" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mutagen" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/f4/ebcdd2fc9bfaf569b795250090e4f4088dc65a5a3e32c53baa9bfc3fc296/music-tag-0.4.3.tar.gz", hash = "sha256:0aab6e6eeda8df0f5316ec2d2190bd74561b7e03562ab091ce8d5687cdbcfff6", size = 23153 } + +[[package]] +name = "mutagen" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/5f/d4/b33ddd40dad230efb317898a2d1c267c04edba73bc5086bf77edeb410fb2/mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", size = 11013906 }, + { url = "https://files.pythonhosted.org/packages/f4/e6/f414bca465b44d01cd5f4a82761e15044bedd1bf8025c5af3cc64518fac5/mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", size = 10180657 }, + { url = "https://files.pythonhosted.org/packages/38/e9/fc3865e417722f98d58409770be01afb961e2c1f99930659ff4ae7ca8b7e/mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", size = 12586394 }, + { url = "https://files.pythonhosted.org/packages/2e/35/f4d8b6d2cb0b3dad63e96caf159419dda023f45a358c6c9ac582ccaee354/mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", size = 13103591 }, + { url = "https://files.pythonhosted.org/packages/22/1d/80594aef135f921dd52e142fa0acd19df197690bd0cde42cea7b88cf5aa2/mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", size = 9634690 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "ruff" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, + { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, + { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, + { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, + { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, + { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, + { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, + { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, + { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, + { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, + { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, + { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, + { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, + { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, + { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, + { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, + { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, +] + +[[package]] +name = "scdl" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "docopt-ng" }, + { name = "mutagen" }, + { name = "soundcloud-v2" }, + { name = "yt-dlp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "music-tag" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-dotenv" }, + { name = "ruff" }, + { name = "types-requests" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "docopt-ng", specifier = ">=0.9.0" }, + { name = "mutagen", specifier = ">=1.47.0" }, + { name = "soundcloud-v2", specifier = ">=1.6.0" }, + { name = "yt-dlp", specifier = ">=2025.2.19" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "music-tag", specifier = ">=0.4.3" }, + { name = "mypy", specifier = ">=1.13.0" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-dotenv", specifier = ">=0.5.2" }, + { name = "ruff", specifier = ">=0.8.3" }, + { name = "types-requests", specifier = ">=2.32.0.20241016" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.12.2" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "soundcloud-v2" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dacite" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/bb/ba779b3cb9597ddf88bb7b31bd6d2a984f972ee3a8f198d32935540058a7/soundcloud-v2-1.6.0.tar.gz", hash = "sha256:462513146c0ffc9ec729c1c616f4f72b0dcd33f81478c64207f265f072e78243", size = 16361 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/3b/0945ba33081a8c165d7400d22c61968cf4d2472cdc121062842b38952ef9/soundcloud_v2-1.6.0-py3-none-any.whl", hash = "sha256:5e3a6cfcec80f59a5ca6fa631d9bde75140b5472892249197178e5229b402cdb", size = 20684 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "types-requests" +version = "2.32.0.20241016" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/3c/4f2a430c01a22abd49a583b6b944173e39e7d01b688190a5618bd59a2e22/types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", size = 18065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/01/485b3026ff90e5190b5e24f1711522e06c79f4a56c8f4b95848ac072e20f/types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747", size = 15836 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "yt-dlp" +version = "2025.2.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/36/ef300ba4a228b74612d4013b43ed303a0d6d2de17a71fc37e0b821577e0a/yt_dlp-2025.2.19.tar.gz", hash = "sha256:f33ca76df2e4db31880f2fe408d44f5058d9f135015b13e50610dfbe78245bea", size = 2929199 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/45/6d1b759e68f5363b919828fb0e0c167a1cd5003b5b7c74cc0f0c2096be4f/yt_dlp-2025.2.19-py3-none-any.whl", hash = "sha256:3ed218eaeece55e9d715afd41abc450dc406ee63bf79355169dfde312d38fdb8", size = 3186543 }, +]