spotify
This commit is contained in:
1 parent
f06d3aa0f9
commit
8b4ac2cdaf
14 files changed
+1744
-177
No files matched your search
@@ -1,9 +1,30 @@
|
||||
[x] download playlist
|
||||
[x] store everything by artist - album
|
||||
[ ] convert flac to mp3
|
||||
[ ] sync to and from s3 api storage
|
||||
[ ] create entries in rekordbox db for playlists
|
||||
[ ] have a rekordbox vm
|
||||
[ ] analyse files downloaded by all users with rekordbox vm
|
||||
[ ] store analysis metadata in pg
|
||||
[ ]
|
||||
|
||||
## end to end
|
||||
|
||||
### collection management
|
||||
just spot should:
|
||||
download playlist from spotify into data/collection
|
||||
determine files missing from collection
|
||||
download each of those files with sockseek
|
||||
- should be flac
|
||||
- get artwork if missing
|
||||
- correct tags
|
||||
- quick validate
|
||||
update m3u8 matching files in spotify to files in collection
|
||||
|
||||
### prepare for decks
|
||||
|
||||
create data/usb directory
|
||||
convert to mp3 v0 jstereo
|
||||
- output into the data/usb directory
|
||||
write pdb
|
||||
- match playlist tree structure
|
||||
generate anlz
|
||||
- detect bpm is important
|
||||
- waveforms are nice but not essential
|
||||
|
||||
|
||||
### sync (later)
|
||||
sync collection and usb to cloud
|
||||
|
||||
|
||||
@@ -30,12 +30,16 @@ link plist dir:
|
||||
|
||||
# Dump tracks from Spotify playlists (ids, urls, or spotify: uris)
|
||||
spotify ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX":
|
||||
cd maple-py && uv run src/spotify.py {{ids}} --csv
|
||||
cd maple-py && uv run python -m maple.spotify {{ids}} --csv
|
||||
|
||||
# Download via sockseek (csv path, url, search string, ...) using sldl.tpl.conf settings
|
||||
sockseek input:
|
||||
cd maple-py && uv run src/sockseek.py {{input}}
|
||||
|
||||
# Sync data/collection with one or more Spotify playlists (download + m3u8s).
|
||||
collection ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX":
|
||||
cd maple-py && uv run python -m maple.collection {{ids}}
|
||||
|
||||
sync args:
|
||||
s5cmd {{args}}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Generated
+300
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user