This commit is contained in:
Nathan Rashleigh committed 2026-06-21 18:33:00 +10:00
1 parent f06d3aa0f9
commit 8b4ac2cdaf
14 files changed
+1744 -177

No files matched your search

+2
View File
@@ -8,7 +8,9 @@ authors = [
]
requires-python = ">=3.14"
dependencies = [
"aiohttp>=3.10",
"kaitaistruct>=0.11",
"loguru>=0.7",
"mutagen>=1.47.0",
"pillow>=12.2.0",
"pyrekordbox>=0.4.4",
+27
View File
@@ -0,0 +1,27 @@
"""Sync data/collection with one or more Spotify playlists.
Pipeline (one shot, in `maple.collection.cli.main_async`):
1. Async-fetch each Spotify playlist via aiohttp (concurrently).
2. Diff against the Collection's index (sldl `index.sldl.csv` + tag scan).
3. Write the missing rows as a CSV and shell out to `sockseek` to download
them (streaming `--progress-json` events back so each completed track
updates its playlist's m3u8 immediately).
4. Validate every track that now resolves to a file: ffprobe-decodable,
duration within 5s of Spotify, fuzzy tag match, embedded cover art.
If artwork is missing, fetch the largest image from
`track.album.images` and embed it.
5. Write one m3u8 per playlist into `<root>/_playlists/<name>.m3u8` with
paths relative to the m3u8 file (portable across synced devices).
Usage:
uv run python -m maple.collection <id-or-url ...> [--collection PATH]
[--no-download] [--no-validate]
Env (from .env, loaded by just):
MAPLE_SPOTIFY_CLIENT_ID, MAPLE_SPOTIFY_CLIENT_SECRET (spotify.py reads)
SOCKSEEK_USER, SOCKSEEK_PASS (forwarded to sockseek)
"""
from maple.collection.core import Collection, TrackIssue
from maple.collection.index import CollectionIndex
__all__ = ["Collection", "CollectionIndex", "TrackIssue"]
@@ -0,0 +1,4 @@
"""Entrypoint for `python -m maple.collection`."""
from maple.collection.cli import main
raise SystemExit(main())
+192
View File
@@ -0,0 +1,192 @@
"""`just collection` CLI entrypoint.
End-to-end pipeline: fetch Spotify playlists, diff against the on-disk
collection, run sockseek for the missing tracks, validate the result,
write per-playlist m3u8s, and emit a report.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
import aiohttp
from loguru import logger
from maple.collection.core import Collection, TrackIssue
from maple.collection.index import first_artist, norm
from maple.log import configure as configure_logging
from maple.spotify import fetch_playlists
def parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(prog="maple.collection", description="Sync data/collection with Spotify playlists.")
p.add_argument("ids", nargs="+", help="Spotify playlist ids, URLs, or spotify: URIs")
p.add_argument("--collection", help="Override collection root (default: <repo>/data/collection)")
p.add_argument("--no-download", action="store_true", help="Skip the sockseek download step")
p.add_argument("--no-validate", action="store_true", help="Skip ffprobe/duration/tag/art validation")
return p.parse_args(argv)
async def main_async(argv: list[str]) -> int:
args = parse_args(argv)
configure_logging()
collection = Collection(Path(args.collection).resolve()) if args.collection else Collection.from_repo()
collection.ensure_dirs()
collection.attach_report_sink()
logger.info("collection root: {}", collection.root)
# Startup hygiene: drop unfinished downloads, prune the sldl index, and
# rewrite any stale entries out of existing m3u8s.
removed_incomplete = collection.remove_unfinished_downloads()
if removed_incomplete:
logger.warning("removed {} unfinished download(s)", removed_incomplete)
kept, removed = collection.validate_index_file()
if removed:
logger.warning("index: pruned {} row(s) ({} kept)", removed, kept)
pruned_pl, rewritten_pl = collection.validate_playlists()
if pruned_pl or rewritten_pl:
logger.warning(
"playlists: pruned {} stale entr(ies), rewrote {} absolute path(s)",
pruned_pl, rewritten_pl,
)
playlists = await fetch_playlists(args.ids)
total = sum(len(p["tracks"]) for p in playlists)
logger.info("fetched {} playlist(s), {} tracks", len(playlists), total)
collection.refresh_index()
logger.info(
"existing in collection: {} strict / {} loose keys",
len(collection.index.by_strict),
len(collection.index.by_loose),
)
missing = collection.find_missing(playlists)
logger.info("missing tracks to download: {}", len(missing))
# Issues accumulate from both the streaming callback (per-track on
# completion) and the end-of-run validate sweep. process_track dedups
# paths so neither path double-reports.
issues: list[TrackIssue] = []
if missing and not args.no_download:
# (artist_norm, title_norm) -> [(playlist_dict, spotify_track)] so the
# callback can both rewrite the right m3u8 and reconcile tags against
# the canonical Spotify entry (sockseek echoes back only artist/title).
track_to_playlists: dict[tuple[str, str], list[tuple[dict, dict]]] = {}
for pl in playlists:
for t in pl["tracks"]:
key = (
norm(first_artist(", ".join(t["artists"]))),
norm(t["title"]),
)
track_to_playlists.setdefault(key, []).append((pl, t))
def _human_size(n: int) -> str:
for unit in ("B", "KiB", "MiB", "GiB"):
if n < 1024:
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}TiB"
async def on_event(evt: dict) -> None:
etype = evt.get("type")
data = evt.get("data") or {}
if etype == "search_start":
logger.info(
"search: {} - {}",
data.get("artist") or "?", data.get("title") or "?",
)
return
if etype == "download_start":
size = data.get("size") or 0
logger.info(
"downloading: {} - {} ({} {}, from {})",
data.get("artist") or "?",
data.get("title") or "?",
_human_size(size) if size else "?",
(data.get("extension") or "").lstrip(".") or "?",
data.get("username") or "?",
)
return
if etype != "track_state":
return
outcome = (data.get("terminalOutcome") or "").lower()
skip_reason = (data.get("skipReason") or "").lower()
failure_reason = (data.get("failureReason") or "").strip()
artist = data.get("artist") or ""
title = data.get("title") or ""
success = outcome == "succeeded" or (
outcome == "skipped" and skip_reason == "alreadyexists"
)
if not success:
# Surface failures/skips so the user sees why a track didn't land.
if outcome == "skipped":
logger.info("skipped: {} - {} ({})", artist, title, skip_reason or "skipped")
else:
logger.warning(
"failed: {} - {} ({})",
artist, title, failure_reason or outcome or "unknown",
)
return
download_path = data.get("downloadPath")
if not download_path:
return
path = Path(download_path)
if not path.exists():
logger.debug("track_state success but path missing: {}", download_path)
return
collection.index.add(artist, title, path)
key = (norm(first_artist(artist)), norm(title))
affected = track_to_playlists.get(key, [])
if not affected:
logger.info("downloaded {} - {} ({})", artist, title, path.name)
return
# Reconcile against the Spotify track (writes Spotify tags
# + records any mismatches). One spotify track might appear
# in several playlists; process_track dedups via collection._reconciled.
spotify_track = affected[0][1]
new_issues = collection.process_track(
affected[0][0]["name"] or affected[0][0]["id"],
spotify_track,
path,
)
for issue in new_issues:
logger.warning(
"{} - {}: {}", issue.artist, issue.title, issue.reason,
)
issues.extend(new_issues)
for pl, _ in affected:
out = collection.write_m3u8_for(pl)
logger.info(
"downloaded {} - {} → updated {}",
artist, title, out.name,
)
rc = await collection.download(missing, on_event=on_event)
if rc != 0:
logger.warning("sockseek exited with {}", rc)
collection.refresh_index()
if not args.no_validate:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
issues.extend(await collection.validate(session, playlists))
for out in collection.write_m3u8s(playlists, issues):
logger.info("wrote {}", out)
collection.report_issues(issues)
logger.info("{} issue(s) recorded in {}", len(issues), collection.report_path)
return 0
def main() -> int:
return asyncio.run(main_async(sys.argv[1:]))
+582
View File
@@ -0,0 +1,582 @@
"""`Collection` dataclass: the on-disk music collection rooted at `root`.
Owns dir layout, the in-memory `CollectionIndex` of (artist, title) →
file paths, startup validation (drop unfinished downloads, prune the
sldl index, prune stale m3u8 entries), the missing-track diff, validation
of downloaded tracks, the per-playlist m3u8 writer, and the loguru file
sink used for `collection-report.txt`.
Sockseek subprocess plumbing lives in `maple.collection.sockseek`; this
module just calls into it from `Collection.download()`.
"""
from __future__ import annotations
import asyncio
import csv
import os
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
import aiohttp
from loguru import logger
from mutagen import File as MutagenFile
from maple.collection.index import (
CollectionIndex,
first_artist,
fuzzy_ratio,
norm,
norm_loose,
)
from maple.collection.sockseek import download as run_sockseek
from maple.collection.validate import (
ARTWORK_CONCURRENCY,
DURATION_TOLERANCE_S,
embed_art,
fetch_jpeg,
ffprobe_ok,
file_duration,
has_embedded_art,
)
from maple.scan import AUDIO_EXTS
@dataclass
class TrackIssue:
playlist: str
artist: str
title: str
reason: str
_M3U_BAD = re.compile(r"[\\/\x00]+")
def _sanitise_name(name: str) -> str:
s = _M3U_BAD.sub("_", name or "playlist").strip().lstrip(".")
return (s or "playlist")[:200]
@dataclass
class Collection:
"""A music collection rooted at `root` on disk."""
root: Path
index: CollectionIndex = field(default_factory=CollectionIndex)
# Paths whose tags have already been reconciled against Spotify this run.
# `process_track` consults this so the on_event hot path and the
# end-of-run `validate` sweep don't double-handle the same file.
_reconciled: set[Path] = field(default_factory=set, repr=False)
# --- paths ------------------------------------------------------------- #
@classmethod
def from_repo(cls) -> "Collection":
"""Default location: `<repo>/data/collection`."""
here = Path(__file__).resolve()
# src/maple/collection/core.py → src/maple/collection → src/maple → src → maple-py → repo
return cls((here.parents[4] / "data" / "collection").resolve())
@property
def metadata_dir(self) -> Path:
return self.root / "_metadata"
@property
def playlists_dir(self) -> Path:
return self.root / "_playlists"
@property
def index_csv(self) -> Path:
return self.metadata_dir / "index.sldl.csv"
@property
def report_path(self) -> Path:
return self.metadata_dir / "collection-report.txt"
def ensure_dirs(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self.metadata_dir.mkdir(parents=True, exist_ok=True)
self.playlists_dir.mkdir(parents=True, exist_ok=True)
# --- startup validation ----------------------------------------------- #
def remove_unfinished_downloads(self) -> int:
"""Delete sldl's `*.incomplete` leftover files. Returns count removed."""
if not self.root.exists():
return 0
count = 0
for p in self.root.rglob("*.incomplete"):
if "_metadata" in p.parts or "_playlists" in p.parts:
continue
try:
p.unlink()
count += 1
except OSError as e:
logger.warning("failed to remove {}: {}", p, e)
return count
def validate_index_file(self) -> tuple[int, int]:
"""Rewrite `index.sldl.csv` dropping duplicate rows and orphaned-path rows.
Returns (kept, removed). Failure rows (no filepath) are kept so
sldl can still honour `--skip-not-found` on the next run.
"""
if not self.index_csv.exists():
return (0, 0)
with self.index_csv.open(newline="", encoding="utf-8", errors="replace") as fh:
rows = list(csv.reader(fh))
if len(rows) <= 1:
return (max(0, len(rows) - 1), 0)
header = rows[0]
cols = {h.strip().lower(): i for i, h in enumerate(header)}
path_i = cols.get("filepath", 0)
kept: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
removed = 0
for row in rows[1:]:
key = tuple(row)
if key in seen:
removed += 1
continue
seen.add(key)
rel = row[path_i] if path_i < len(row) else ""
if rel:
p = self.root / rel if not Path(rel).is_absolute() else Path(rel)
if not p.exists():
removed += 1
continue
kept.append(row)
if removed > 0:
with self.index_csv.open("w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(header)
w.writerows(kept)
return (len(kept), removed)
def validate_playlists(self) -> tuple[int, int]:
"""Sweep `_playlists/*.m3u8`. Two passes per entry:
1. Lines whose file doesn't resolve are replaced with a
`# NOT FOUND: <title>` comment so the playlist still tells you
what's missing.
2. Lines that resolve but were written with an absolute path get
rewritten relative to the m3u8's parent dir — playlists need to
survive being synced to another machine where the collection
sits at a different root.
Returns (pruned, rewritten)."""
if not self.playlists_dir.exists():
return (0, 0)
total_pruned = 0
total_rewritten = 0
for m3u in self.playlists_dir.glob("*.m3u8"):
lines = m3u.read_text(encoding="utf-8").splitlines()
out: list[str] = []
pending_extinf: str | None = None
pruned_here = 0
rewritten_here = 0
for line in lines:
s = line.strip()
if not s:
out.append(line)
continue
if s.startswith("#EXTINF"):
pending_extinf = line
continue
if s.startswith("#"):
if pending_extinf is not None:
out.append(pending_extinf)
pending_extinf = None
out.append(line)
continue
# Bare file path (relative to m3u or absolute).
was_absolute = Path(s).is_absolute()
target = Path(s) if was_absolute else (m3u.parent / s)
if target.exists():
if pending_extinf is not None:
out.append(pending_extinf)
pending_extinf = None
if was_absolute:
rel = os.path.relpath(target.resolve(), start=m3u.parent.resolve())
out.append(rel)
rewritten_here += 1
else:
out.append(line)
else:
desc = s
if pending_extinf and "," in pending_extinf:
desc = pending_extinf.split(",", 1)[1].strip() or s
out.append(f"# NOT FOUND: {desc}")
pending_extinf = None
pruned_here += 1
if pruned_here or rewritten_here:
m3u.write_text("\n".join(out) + "\n", encoding="utf-8")
if pruned_here:
logger.warning("pruned {} missing entries from {}", pruned_here, m3u.name)
if rewritten_here:
logger.warning("rewrote {} absolute path(s) → relative in {}", rewritten_here, m3u.name)
total_pruned += pruned_here
total_rewritten += rewritten_here
return (total_pruned, total_rewritten)
# --- report sink ------------------------------------------------------- #
def attach_report_sink(self) -> int:
"""Install a loguru sink that mirrors every reported issue to disk.
Filters on `extra["report"] is True` so unrelated log lines don't
leak into the report. Returns the loguru handle so callers can
detach it later.
"""
self.metadata_dir.mkdir(parents=True, exist_ok=True)
if self.report_path.exists():
self.report_path.unlink()
return logger.add(
self.report_path,
format="{message}",
filter=lambda r: r["extra"].get("report") is True,
level="INFO",
)
def report_issues(self, issues: list[TrackIssue]) -> None:
"""Emit each issue through the report sink; emit `ok` if none."""
bound = logger.bind(report=True)
if not issues:
bound.info("ok")
return
for i in issues:
bound.warning(f"[{i.playlist}] {i.artist} - {i.title}: {i.reason}")
# --- indexing ---------------------------------------------------------- #
def refresh_index(self) -> CollectionIndex:
"""Rebuild `self.index` from sldl's csv + an on-disk tag scan."""
idx = CollectionIndex()
self._load_sldl_index(idx)
self._scan_collection_tags(idx)
self.index = idx
return idx
def _load_sldl_index(self, idx: CollectionIndex) -> None:
if not self.index_csv.exists():
return
with self.index_csv.open(newline="", encoding="utf-8", errors="replace") as fh:
reader = csv.reader(fh)
try:
header = next(reader)
except StopIteration:
return
cols = {h.strip().lower(): i for i, h in enumerate(header)}
path_i = cols.get("filepath") or cols.get("path") or 0
artist_i = cols.get("artist")
title_i = cols.get("title")
state_i = cols.get("state")
for row in reader:
if not row:
continue
if state_i is not None and state_i < len(row):
state = row[state_i].strip().lower()
if state in {"2", "failed", "notfounderror", "error"}:
continue
try:
rel = row[path_i]
except IndexError:
continue
if not rel:
continue
path = self.root / rel if not Path(rel).is_absolute() else Path(rel)
if not path.exists():
continue
artist = row[artist_i] if artist_i is not None and artist_i < len(row) else ""
title = row[title_i] if title_i is not None and title_i < len(row) else ""
idx.add(artist, title, path)
def _scan_collection_tags(self, idx: CollectionIndex) -> None:
if not self.root.exists():
return
for p in self.root.rglob("*"):
if not p.is_file() or p.suffix.lower() not in AUDIO_EXTS:
continue
if "_metadata" in p.parts or "_playlists" in p.parts:
continue
try:
audio = MutagenFile(p, easy=True)
except Exception:
continue
if audio is None:
continue
tags = audio.tags or {}
artist = (tags.get("artist") or [""])[0] if hasattr(tags, "get") else ""
title = (tags.get("title") or [""])[0] if hasattr(tags, "get") else ""
if not title:
title = p.stem
idx.add(str(artist), str(title), p)
# --- planning + downloads --------------------------------------------- #
def find_missing(self, playlists: list[dict]) -> list[dict]:
"""Return the Spotify tracks not currently resolvable in this collection."""
seen: set[tuple[str, str]] = set()
missing: list[dict] = []
for pl in playlists:
for t in pl["tracks"]:
artist = ", ".join(t["artists"])
if self.index.lookup(artist, t["title"]) is not None:
continue
key = (norm(first_artist(artist)), norm(t["title"]))
if key in seen:
continue
seen.add(key)
missing.append(t)
return missing
async def download(
self,
missing: list[dict],
on_event: Callable[[dict], Awaitable[None]] | None = None,
) -> int:
"""Thin wrapper around `maple.collection.sockseek.download`."""
return await run_sockseek(self.root, self.index_csv, missing, on_event)
# --- per-track Spotify reconciliation --------------------------------- #
def compare_against_spotify(
self,
playlist_name: str,
track: dict,
path: Path,
) -> list[TrackIssue]:
"""Compare a downloaded file's tags + duration against Spotify.
Spotify is the source of truth. Each mismatched field yields a
`TrackIssue` whose `reason` reads as
`<field> mismatch: spotify=<sv> file=<fv>`. Empty/missing file
tags count as mismatches against a non-empty Spotify value.
"""
sp_artist = ", ".join(track.get("artists") or [])
sp_title = (track.get("title") or "").strip()
sp_album = (track.get("album") or "").strip()
sp_isrc = (track.get("isrc") or "").strip()
sp_duration = (track.get("duration_ms") or 0) / 1000.0
sp_date = (track.get("release_date") or "").strip()
try:
audio = MutagenFile(path, easy=True)
tags = audio.tags if audio is not None else None
except Exception:
tags = None
f_artist = (tags.get("artist") or [""])[0] if tags and hasattr(tags, "get") else ""
f_title = (tags.get("title") or [""])[0] if tags and hasattr(tags, "get") else ""
f_album = (tags.get("album") or [""])[0] if tags and hasattr(tags, "get") else ""
f_isrc = (tags.get("isrc") or [""])[0] if tags and hasattr(tags, "get") else ""
f_date = (tags.get("date") or [""])[0] if tags and hasattr(tags, "get") else ""
f_duration = file_duration(path)
issues: list[TrackIssue] = []
def mismatch(field: str, sv: object, fv: object) -> None:
issues.append(TrackIssue(
playlist_name, sp_artist, sp_title,
f"{field} mismatch: spotify={sv!r} file={fv!r} ({path.name})",
))
# Artist + title use fuzzy compare because tags & spotify quote
# remixers / features differently. Album + ISRC + date are exact-ish.
if sp_artist and fuzzy_ratio(norm(first_artist(sp_artist)), norm(first_artist(f_artist))) < 0.5:
mismatch("artist", sp_artist, f_artist)
if sp_title and fuzzy_ratio(norm_loose(sp_title), norm_loose(f_title)) < 0.5:
mismatch("title", sp_title, f_title)
if sp_album and f_album and fuzzy_ratio(norm_loose(sp_album), norm_loose(f_album)) < 0.5:
mismatch("album", sp_album, f_album)
elif sp_album and not f_album:
mismatch("album", sp_album, f_album)
if sp_isrc and f_isrc and sp_isrc.upper() != f_isrc.upper():
mismatch("isrc", sp_isrc, f_isrc)
# Compare only the year portion of `date` — file tags are usually "YYYY"
# while Spotify is "YYYY[-MM[-DD]]".
if sp_date and f_date and sp_date[:4] != f_date[:4]:
mismatch("date", sp_date, f_date)
if sp_duration > 0 and abs(f_duration - sp_duration) > DURATION_TOLERANCE_S:
mismatch("duration", f"{sp_duration:.1f}s", f"{f_duration:.1f}s")
return issues
def write_spotify_tags(self, track: dict, path: Path) -> None:
"""Overwrite the file's tags with the Spotify values.
Spotify is canonical: title, artist (joined ", "), album, date,
genre(s), and ISRC are written. Missing Spotify values leave the
existing tag untouched.
"""
try:
audio = MutagenFile(path, easy=True)
except Exception as e:
logger.warning("write_spotify_tags: cannot open {}: {}", path.name, e)
return
if audio is None:
return
if audio.tags is None:
try:
audio.add_tags()
except Exception as e:
logger.warning("write_spotify_tags: cannot init tags on {}: {}", path.name, e)
return
title = (track.get("title") or "").strip()
if title:
audio.tags["title"] = title
artists = track.get("artists") or []
if artists:
audio.tags["artist"] = ", ".join(artists)
album = (track.get("album") or "").strip()
if album:
audio.tags["album"] = album
date = (track.get("release_date") or "").strip()
if date:
try:
audio.tags["date"] = date
except (KeyError, ValueError) as e:
logger.debug("date tag write skipped on {}: {}", path.name, e)
genres = track.get("genres") or []
if genres:
try:
audio.tags["genre"] = list(genres)
except (KeyError, ValueError) as e:
logger.debug("genre tag write skipped on {}: {}", path.name, e)
isrc = (track.get("isrc") or "").strip()
if isrc:
try:
audio.tags["isrc"] = isrc
except (KeyError, ValueError) as e:
logger.debug("isrc tag write skipped on {}: {}", path.name, e)
try:
audio.save()
except Exception as e:
logger.warning("write_spotify_tags: save failed for {}: {}", path.name, e)
def process_track(
self,
playlist_name: str,
track: dict,
path: Path,
) -> list[TrackIssue]:
"""Reconcile a single downloaded file against Spotify.
Steps:
1. ffprobe sanity check (one issue + early return on failure).
2. Compare every relevant field; collect mismatch issues.
3. Write Spotify's values back into the file's tags.
Idempotent within a run — paths already processed are no-ops.
"""
if path in self._reconciled:
return []
self._reconciled.add(path)
sp_artist = ", ".join(track.get("artists") or [])
sp_title = track.get("title") or ""
if not ffprobe_ok(path):
return [TrackIssue(playlist_name, sp_artist, sp_title, f"ffprobe failed: {path}")]
issues = self.compare_against_spotify(playlist_name, track, path)
self.write_spotify_tags(track, path)
return issues
# --- validation -------------------------------------------------------- #
async def validate(
self,
session: aiohttp.ClientSession,
playlists: list[dict],
) -> list[TrackIssue]:
"""Per-track Spotify reconciliation + artwork embed.
Spotify is the source of truth: any field that differs from the
downloaded file's tags is recorded as a mismatch issue, then the
file's tags are overwritten with the Spotify values. Missing
embedded artwork is queued for an async batch fetch.
"""
resolved: list[tuple[str, dict, Path]] = []
for pl in playlists:
for t in pl["tracks"]:
path = self.index.lookup(", ".join(t["artists"]), t["title"])
if path is not None:
resolved.append((pl["name"] or pl["id"], t, path))
issues: list[TrackIssue] = []
sem = asyncio.Semaphore(ARTWORK_CONCURRENCY)
art_jobs: list[tuple[str, dict, Path]] = []
for playlist_name, track, path in resolved:
issues.extend(self.process_track(playlist_name, track, path))
if not has_embedded_art(path) and track.get("album_image_url"):
art_jobs.append((playlist_name, track, path))
async def _embed(job: tuple[str, dict, Path]) -> None:
playlist_name, track, path = job
async with sem:
data = await fetch_jpeg(session, track["album_image_url"])
if not data:
issues.append(TrackIssue(
playlist_name, ", ".join(track["artists"]), track["title"],
"artwork download failed",
))
return
try:
embed_art(path, data)
except Exception as e:
issues.append(TrackIssue(
playlist_name, ", ".join(track["artists"]), track["title"],
f"artwork embed failed: {e}",
))
if art_jobs:
await asyncio.gather(*(_embed(j) for j in art_jobs))
return issues
# --- output ------------------------------------------------------------ #
def write_m3u8_for(
self,
playlist: dict,
issues: list[TrackIssue] | None = None,
) -> Path:
"""Write a single playlist's m3u8 against the current index.
Track paths are emitted relative to the m3u8's parent directory so
the playlist is portable across devices that sync the collection.
Pass `issues=None` for incremental rewrites during download — the
list of unresolved tracks gets rebuilt on the final pass instead.
"""
self.playlists_dir.mkdir(parents=True, exist_ok=True)
playlist_name = playlist["name"] or playlist["id"]
out_path = self.playlists_dir / f"{_sanitise_name(playlist_name)}.m3u8"
lines = ["#EXTM3U"]
for t in playlist["tracks"]:
artist = ", ".join(t["artists"])
title = t["title"]
secs = round(t["duration_ms"] / 1000)
path = self.index.lookup(artist, title)
if path is None:
lines.append(f"# NOT FOUND: {artist} - {title}")
if issues is not None:
issues.append(TrackIssue(playlist_name, artist, title, "unresolved in collection"))
continue
rel = os.path.relpath(path.resolve(), start=out_path.parent.resolve())
lines.append(f"#EXTINF:{secs},{artist} - {title}")
lines.append(rel)
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return out_path
def write_m3u8s(
self,
playlists: list[dict],
issues: list[TrackIssue],
) -> list[Path]:
"""One m3u8 per playlist in `<root>/_playlists/`. Returns the paths written."""
return [self.write_m3u8_for(pl, issues) for pl in playlists]
+81
View File
@@ -0,0 +1,81 @@
"""Artist/title normalisation + `CollectionIndex` lookup table.
No I/O. Pure functions plus a small dataclass keyed on normalised
(artist, title) tuples so we can answer "is this track already in the
collection?" against an in-memory map.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
FUZZY_THRESHOLD = 0.85
_PARENS = re.compile(r"\s*[\(\[][^)\]]*[\)\]]\s*")
_NONWORD = re.compile(r"[^\w\s]+", flags=re.UNICODE)
_WS = re.compile(r"\s+")
def norm(s: str) -> str:
"""Lowercase + NFKC + strip non-word, preserves remix/version suffixes."""
s = unicodedata.normalize("NFKC", s or "").lower()
s = _NONWORD.sub(" ", s)
s = _WS.sub(" ", s).strip()
return s
def norm_loose(s: str) -> str:
"""Same as norm() but with parenthesised suffixes removed."""
s = unicodedata.normalize("NFKC", s or "").lower()
s = _PARENS.sub(" ", s)
s = _NONWORD.sub(" ", s)
s = _WS.sub(" ", s).strip()
return s
def first_artist(artists: str) -> str:
"""Split sldl/spotify multi-artist strings on common delimiters."""
if not artists:
return ""
for sep in (",", " feat.", " ft.", " & ", "&", " and ", ";"):
if sep in artists:
return artists.split(sep, 1)[0].strip()
return artists.strip()
def fuzzy_ratio(a: str, b: str) -> float:
"""Cheap token-overlap ratio in [0, 1]. No external dep."""
ta, tb = set(a.split()), set(b.split())
if not ta or not tb:
return 0.0
return len(ta & tb) / max(len(ta), len(tb))
@dataclass
class CollectionIndex:
"""Maps normalised (artist, title) pairs to file paths in the collection."""
by_strict: dict[tuple[str, str], Path] = field(default_factory=dict)
by_loose: dict[tuple[str, str], Path] = field(default_factory=dict)
def add(self, artist: str, title: str, path: Path) -> None:
a, t = norm(first_artist(artist)), norm(title)
if a and t:
self.by_strict.setdefault((a, t), path)
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
if la and lt:
self.by_loose.setdefault((la, lt), path)
def lookup(self, artist: str, title: str) -> Path | None:
a, t = norm(first_artist(artist)), norm(title)
if (a, t) in self.by_strict:
return self.by_strict[(a, t)]
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
if (la, lt) in self.by_loose:
return self.by_loose[(la, lt)]
for (ka, kt), p in self.by_loose.items():
if ka == la and fuzzy_ratio(kt, lt) >= FUZZY_THRESHOLD:
return p
return None
+149
View File
@@ -0,0 +1,149 @@
"""Sockseek subprocess plumbing.
Owns:
* the CLI argument shape (`args_for`) used to run sockseek/sldl,
* the temp-CSV writer that feeds tracks in,
* the async `download()` entrypoint that streams `--progress-json`
events out and forwards sockseek stderr through loguru.
`Collection.download()` is a thin wrapper around `download()` here so the
subprocess concerns stay in one file.
"""
from __future__ import annotations
import asyncio
import csv
import json
import os
import re
import shutil
import sys
import tempfile
from collections.abc import Awaitable, Callable
from pathlib import Path
from loguru import logger
NAME_FORMAT = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_LEVEL_RE = re.compile(
r"^\s*\[?(?P<lvl>TRACE|TRC|DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR|FATAL|CRITICAL|CRT)\]?\b",
re.IGNORECASE,
)
_LEVEL_MAP = {
"TRC": "TRACE", "TRACE": "TRACE",
"DBG": "DEBUG", "DEBUG": "DEBUG",
"INF": "INFO", "INFO": "INFO",
"WRN": "WARNING", "WARN": "WARNING",
"ERR": "ERROR", "ERROR": "ERROR",
"FATAL": "CRITICAL", "CRT": "CRITICAL", "CRITICAL": "CRITICAL",
}
def log_level_for(line: str) -> str:
"""Map a sockseek/.NET-style log prefix to a loguru level. Default WARNING."""
m = _LEVEL_RE.match(line)
if not m:
return "WARNING"
return _LEVEL_MAP.get(m.group("lvl").upper(), "WARNING")
def args_for(root: Path, index_csv: Path, user: str, password: str) -> list[str]:
"""Mirror of src/sockseek.py:config_args(), pinned for FLAC-strict."""
return [
"--path", str(root),
"--index-path", str(index_csv),
"--user", user,
"--pass", password,
"--name-format", NAME_FORMAT,
"--format", "flac",
"--pref-format", "flac",
"--pref-min-bitrate", "200",
"--pref-max-bitrate", "2500",
"--strict-conditions",
"--fast-search",
"--concurrent-jobs", "4",
]
def write_download_csv(missing: list[dict], fh) -> None:
"""sldl-compatible CSV (Artist,Title,Album,Length)."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for t in missing:
w.writerow([
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
])
async def download(
root: Path,
index_csv: Path,
missing: list[dict],
on_event: Callable[[dict], Awaitable[None]] | None = None,
) -> int:
"""Shell out to sockseek for every track in `missing`.
When `on_event` is provided, sockseek is run with `--progress-json` and
every NDJSON envelope on stdout is parsed and awaited through the
callback. stderr is always piped and forwarded line-by-line through
loguru with a best-effort log-level guess.
"""
if not missing:
return 0
binary = shutil.which("sockseek") or shutil.which("sldl")
if binary is None:
logger.error("sockseek/sldl not on PATH; install slsk-batchdl first")
sys.exit(1)
user = os.environ.get("SOCKSEEK_USER")
password = os.environ.get("SOCKSEEK_PASS")
if not (user and password):
logger.error("set SOCKSEEK_USER and SOCKSEEK_PASS for downloads")
sys.exit(1)
index_csv.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", suffix=".csv", newline="", delete=False) as tf:
write_download_csv(missing, tf)
tf_path = Path(tf.name)
try:
args = [binary, str(tf_path), *args_for(root, index_csv, user, password)]
if on_event is not None:
args.append("--progress-json")
masked = ["***" if a == password else a for a in args]
logger.info("$ {}", " ".join(masked))
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE if on_event is not None else None,
stderr=asyncio.subprocess.PIPE,
)
async def _forward_stderr() -> None:
assert proc.stderr is not None
async for raw in proc.stderr:
line = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace")).rstrip()
if not line:
continue
logger.log(log_level_for(line), "sockseek: {}", line)
stderr_task = asyncio.create_task(_forward_stderr())
if on_event is not None:
assert proc.stdout is not None
async for raw in proc.stdout:
line = raw.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("{"):
continue
try:
evt = json.loads(line)
except ValueError:
continue
await on_event(evt)
rc = await proc.wait()
await stderr_task
return rc
finally:
tf_path.unlink(missing_ok=True)
+103
View File
@@ -0,0 +1,103 @@
"""Per-track validation helpers: ffprobe sanity, duration probe, embedded
artwork inspection + embed, async JPEG fetch for missing cover art.
Pure helpers — no Collection coupling.
"""
from __future__ import annotations
import io
import shutil
import subprocess
from pathlib import Path
import aiohttp
from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC, ID3, ID3NoHeaderError
from mutagen.mp4 import MP4, MP4Cover
DURATION_TOLERANCE_S = 5.0
ARTWORK_CONCURRENCY = 8
def ffprobe_ok(path: Path) -> bool:
if not shutil.which("ffprobe"):
return True # ffprobe absent → don't fail the run
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
capture_output=True, text=True,
)
return r.returncode == 0 and "audio" in r.stdout
def file_duration(path: Path) -> float:
audio = MutagenFile(path)
info = getattr(audio, "info", None)
return float(getattr(info, "length", 0.0) or 0.0)
def has_embedded_art(path: Path) -> bool:
suf = path.suffix.lower()
if suf == ".flac":
try:
return bool(FLAC(path).pictures)
except Exception:
return False
if suf == ".mp3":
try:
return bool(ID3(path).getall("APIC"))
except Exception:
return False
if suf in (".m4a", ".mp4"):
try:
tags = MP4(path).tags
return bool(tags and tags.get("covr"))
except Exception:
return False
return True # unknown format → don't complain
def embed_art(path: Path, jpeg_bytes: bytes) -> None:
suf = path.suffix.lower()
if suf == ".flac":
f = FLAC(path)
pic = Picture()
pic.data = jpeg_bytes
pic.type = 3 # FRONT_COVER
pic.mime = "image/jpeg"
f.clear_pictures()
f.add_picture(pic)
f.save()
elif suf == ".mp3":
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
tags.delall("APIC")
tags.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=jpeg_bytes))
tags.save(path)
elif suf in (".m4a", ".mp4"):
f = MP4(path)
f.tags = f.tags or {}
f["covr"] = [MP4Cover(jpeg_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
f.save()
async def fetch_jpeg(session: aiohttp.ClientSession, url: str) -> bytes | None:
try:
async with session.get(url) as resp:
resp.raise_for_status()
data = await resp.read()
except aiohttp.ClientError:
return None
if data[:3] == b"\xff\xd8\xff":
return data
try:
from PIL import Image
img = Image.open(io.BytesIO(data)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
return buf.getvalue()
except Exception:
return None
+20
View File
@@ -0,0 +1,20 @@
"""Loguru sink config for maple's CLIs.
`configure(level)` (re)installs a single stderr sink with a compact format.
Idempotent — safe to call from every CLI entrypoint.
"""
import os
import sys
from loguru import logger
_FMT = "<green>{time:HH:mm:ss}</green> <level>{level:<7}</level> {message}"
def configure(level: str | None = None) -> None:
level = (level or os.environ.get("MAPLE_LOG_LEVEL") or "INFO").upper()
logger.remove()
logger.add(sys.stderr, format=_FMT, level=level, colorize=True, enqueue=False)
__all__ = ["configure", "logger"]
+249
View File
@@ -0,0 +1,249 @@
"""Dump tracks from one or more Spotify playlists (Client Credentials flow).
just spotify # uses the placeholder ids below
just spotify "<url-or-id> ..." # explicit playlists
just spotify "--file lists.txt --json"
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
just spotify "<url-or-id> --csv --out tracks.csv"
Playlists can be ids, full URLs, or spotify: URIs. Works for any public
playlist; no user login needed. Requires SPOTIFY_CLIENT_ID /
SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just).
--csv writes one combined CSV with Artist,Title,Album,Length columns (Length
in seconds), the format consumed by sockseek/sldl. Each playlist is preceded
by a `# <name>` comment line as a separator. Writes to stdout, or to a file
with --out <path>.
Networking is async via aiohttp: playlists are fetched concurrently.
"""
import asyncio
import base64
import csv
import json
import os
import re
import sys
import urllib.parse
import aiohttp
async def get_token(session: aiohttp.ClientSession, client_id: str, client_secret: str) -> str:
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with session.post(
"https://accounts.spotify.com/api/token",
data={"grant_type": "client_credentials"},
headers={"Authorization": f"Basic {auth}"},
) as resp:
resp.raise_for_status()
body = await resp.json()
return body["access_token"]
async def api_get(session: aiohttp.ClientSession, url: str, token: str) -> dict:
async with session.get(url, headers={"Authorization": f"Bearer {token}"}) as resp:
resp.raise_for_status()
return await resp.json()
def parse_playlist_id(arg: str) -> str:
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg)
return m.group(1) if m else arg.strip()
def read_list_file(path: str) -> list[str]:
out = []
with open(path) as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def _largest_image(images: list[dict] | None) -> str | None:
if not images:
return None
# Spotify returns images sorted largest first, but defend against absent width.
ranked = sorted(images, key=lambda im: (im.get("width") or 0), reverse=True)
return ranked[0].get("url")
async def fetch_playlist(session: aiohttp.ClientSession, pid: str, token: str) -> dict:
meta = await api_get(
session,
f"https://api.spotify.com/v1/playlists/{pid}"
f"?fields={urllib.parse.quote('name,owner(display_name)')}",
token,
)
fields = (
"items(track(id,name,artists(id,name),album(name,images,release_date,release_date_precision),"
"duration_ms,external_ids(isrc),track_number,disc_number)),next"
)
url = (
f"https://api.spotify.com/v1/playlists/{pid}/tracks"
f"?limit=100&fields={urllib.parse.quote(fields)}"
)
tracks = []
while url:
page = await api_get(session, url, token)
for item in page.get("items", []):
t = item.get("track")
if not t:
continue
tracks.append(
{
"id": t.get("id"),
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"artist_ids": [a.get("id") for a in t["artists"] if a.get("id")],
"album": t["album"]["name"],
"album_image_url": _largest_image(t["album"].get("images")),
"release_date": t["album"].get("release_date"),
"release_date_precision": t["album"].get("release_date_precision"),
"duration_ms": t["duration_ms"],
"isrc": (t.get("external_ids") or {}).get("isrc"),
"track_number": t.get("track_number"),
"disc_number": t.get("disc_number"),
"genres": [],
}
)
url = page.get("next")
return {"id": pid, "name": meta.get("name"), "tracks": tracks}
async def fetch_artist_genres(
session: aiohttp.ClientSession,
artist_ids: list[str],
token: str,
) -> dict[str, list[str]]:
"""Batch `/v1/artists?ids=...` (max 50 ids per call). Returns id → genres."""
out: dict[str, list[str]] = {}
seen: set[str] = set()
deduped = [a for a in artist_ids if a and not (a in seen or seen.add(a))]
for i in range(0, len(deduped), 50):
batch = deduped[i:i + 50]
body = await api_get(
session,
f"https://api.spotify.com/v1/artists?ids={','.join(batch)}",
token,
)
for a in body.get("artists", []) or []:
if a and a.get("id"):
out[a["id"]] = a.get("genres") or []
return out
def _enrich_track_genres(playlists: list[dict], genres_by_artist: dict[str, list[str]]) -> None:
"""Populate each track's `genres` field with the union (dedup'd, preserving order)
of its artists' genres."""
for pl in playlists:
for t in pl["tracks"]:
seen: set[str] = set()
union: list[str] = []
for aid in t.get("artist_ids") or []:
for g in genres_by_artist.get(aid, []):
if g not in seen:
seen.add(g)
union.append(g)
t["genres"] = union
def write_csv(playlists, fh):
"""Single combined sockseek/sldl CSV, one `# <name>` comment per playlist."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for pl in playlists:
fh.write(f"# {pl['name']}\n")
for t in pl["tracks"]:
w.writerow(
[
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
]
)
async def fetch_playlists(ids: list[str], with_genres: bool = True) -> list[dict]:
"""Resolve env, open a session, fetch every playlist concurrently.
When `with_genres` is true (default), follow up with a batched
`/v1/artists` call to populate each track's `genres` field.
"""
client_id = os.environ.get("MAPLE_SPOTIFY_CLIENT_ID") or os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("MAPLE_SPOTIFY_CLIENT_SECRET") or os.environ.get("SPOTIFY_CLIENT_SECRET")
if not (client_id and client_secret):
sys.exit("set MAPLE_SPOTIFY_CLIENT_ID and MAPLE_SPOTIFY_CLIENT_SECRET")
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
token = await get_token(session, client_id, client_secret)
playlists = await asyncio.gather(
*(fetch_playlist(session, parse_playlist_id(x), token) for x in ids)
)
if with_genres:
artist_ids: list[str] = []
for pl in playlists:
for t in pl["tracks"]:
artist_ids.extend(t.get("artist_ids") or [])
genres = await fetch_artist_genres(session, artist_ids, token)
_enrich_track_genres(playlists, genres)
return playlists
async def main_async() -> None:
argv = sys.argv[1:]
as_json = "--json" in argv
as_csv = "--csv" in argv
argv = [a for a in argv if a not in ("--json", "--csv")]
inputs: list[str] = []
out_path: str | None = None
i = 0
while i < len(argv):
if argv[i] == "--file":
inputs += read_list_file(argv[i + 1])
i += 2
elif argv[i] == "--out":
out_path = argv[i + 1]
i += 2
else:
inputs.append(argv[i])
i += 1
if not inputs:
sys.exit(
"usage: spotify.py [--json|--csv] <url-or-id ...> "
"[--file list.txt] [--out path]"
)
playlists = await fetch_playlists(inputs)
if as_json:
print(json.dumps(playlists, indent=2, ensure_ascii=False))
return
if as_csv:
if out_path:
with open(out_path, "w", newline="") as fh:
write_csv(playlists, fh)
print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}")
else:
write_csv(playlists, sys.stdout)
return
for pl in playlists:
print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===")
for n, t in enumerate(pl["tracks"], 1):
print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}")
def main() -> None:
asyncio.run(main_async())
if __name__ == "__main__":
main()
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = []
# ///
"""Dump tracks from one or more Spotify playlists (Client Credentials flow).
just spotify # uses the placeholder ids below
just spotify "<url-or-id> ..." # explicit playlists
just spotify "--file lists.txt --json"
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
just spotify "<url-or-id> --csv --out tracks.csv"
Playlists can be ids, full URLs, or spotify: URIs. Works for any public
playlist; no user login needed. Requires SPOTIFY_CLIENT_ID /
SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just).
--csv writes one combined CSV with Artist,Title,Album,Length columns (Length
in seconds), the format consumed by sockseek/sldl. Each playlist is preceded
by a `# <name>` comment line as a separator. Writes to stdout, or to a file
with --out <path>.
"""
import base64
import csv
import json
import os
import re
import sys
import urllib.parse
import urllib.request
def get_token(client_id, client_secret):
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
data = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=data,
headers={"Authorization": f"Basic {auth}"},
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)["access_token"]
def api_get(url, token):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def parse_playlist_id(arg):
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg)
return m.group(1) if m else arg.strip()
def read_list_file(path):
out = []
with open(path) as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def fetch_playlist(pid, token):
meta = api_get(
f"https://api.spotify.com/v1/playlists/{pid}"
f"?fields={urllib.parse.quote('name,owner(display_name)')}",
token,
)
fields = "items(track(name,artists(name),album(name),duration_ms)),next"
url = (
f"https://api.spotify.com/v1/playlists/{pid}/tracks"
f"?limit=100&fields={urllib.parse.quote(fields)}"
)
tracks = []
while url:
page = api_get(url, token)
for item in page.get("items", []):
t = item.get("track")
if not t:
continue
tracks.append(
{
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"album": t["album"]["name"],
"duration_ms": t["duration_ms"],
}
)
url = page.get("next")
return {"id": pid, "name": meta.get("name"), "tracks": tracks}
def write_csv(playlists, fh):
"""Single combined sockseek/sldl CSV, one `# <name>` comment per playlist."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for pl in playlists:
fh.write(f"# {pl['name']}\n")
for t in pl["tracks"]:
w.writerow(
[
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
]
)
def main():
argv = sys.argv[1:]
as_json = "--json" in argv
as_csv = "--csv" in argv
argv = [a for a in argv if a not in ("--json", "--csv")]
inputs = []
out_path = None
i = 0
while i < len(argv):
if argv[i] == "--file":
inputs += read_list_file(argv[i + 1])
i += 2
elif argv[i] == "--out":
out_path = argv[i + 1]
i += 2
else:
inputs.append(argv[i])
i += 1
if not inputs:
sys.exit(
"usage: spotify.py [--json|--csv] <url-or-id ...> "
"[--file list.txt] [--out path]"
)
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
if not (client_id and client_secret):
sys.exit("set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET")
token = get_token(client_id, client_secret)
playlists = [fetch_playlist(parse_playlist_id(x), token) for x in inputs]
if as_json:
print(json.dumps(playlists, indent=2, ensure_ascii=False))
return
if as_csv:
if out_path:
with open(out_path, "w", newline="") as fh:
write_csv(playlists, fh)
print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}")
else:
write_csv(playlists, sys.stdout)
return
for pl in playlists:
print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===")
for n, t in enumerate(pl["tracks"], 1):
print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}")
if __name__ == "__main__":
main()
+300
View File
@@ -2,6 +2,94 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "aiohappyeyeballs"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
]
[[package]]
name = "aiohttp"
version = "3.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "bidict"
version = "0.23.1"
@@ -29,6 +117,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "greenlet"
version = "3.5.1"
@@ -68,6 +197,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -86,12 +224,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "maple"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "kaitaistruct" },
{ name = "loguru" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pyrekordbox" },
@@ -104,7 +257,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.10" },
{ name = "kaitaistruct", specifier = ">=0.11" },
{ name = "loguru", specifier = ">=0.7" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pyrekordbox", specifier = ">=0.4.4" },
@@ -113,6 +268,51 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mutagen"
version = "1.47.0"
@@ -202,6 +402,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
name = "psutil"
version = "7.2.2"
@@ -364,3 +607,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]
[[package]]
name = "yarl"
version = "1.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
]