Compare commits

..
8 Commits
Author SHA1 Message Date
Nathan Rashleigh 9fbd222b23 throw 2026-06-22 22:45:04 +10:00
Nathan Rashleigh d5ef56c3ed dataclass refactor anlz 2026-06-22 22:18:46 +10:00
Nathan Rashleigh 5810a536bd so bad 2026-06-22 19:17:24 +10:00
Nathan Rashleigh 096bce803a change bpm detect 2026-06-22 18:06:45 +10:00
Nathan Rashleigh b85cea496f zzz 2026-06-22 00:15:27 +10:00
Nathan Rashleigh 9ee5a70266 fix artwork 2026-06-21 22:27:11 +10:00
Nathan Rashleigh ea1669babf claude idiot 2026-06-21 21:46:39 +10:00
Nathan Rashleigh 8b4ac2cdaf spotify 2026-06-21 18:33:00 +10:00
43 changed files with 4275 additions and 1288 deletions

No files matched your search

+31 -9
View File
@@ -1,9 +1,31 @@
[x] download playlist
[x] store everything by artist - album ## end to end
[ ] convert flac to mp3
[ ] sync to and from s3 api storage ### collection management
[ ] create entries in rekordbox db for playlists just spot should:
[ ] have a rekordbox vm download playlist from spotify into data/collection
[ ] analyse files downloaded by all users with rekordbox vm determine files missing from collection
[ ] store analysis metadata in pg 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
store ANLZ in metadata directory by isrc
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
+49 -5
View File
@@ -30,19 +30,63 @@ link plist dir:
# Dump tracks from Spotify playlists (ids, urls, or spotify: uris) # Dump tracks from Spotify playlists (ids, urls, or spotify: uris)
spotify ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX": 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 # Download via sockseek (csv path, url, search string, ...) using sldl.tpl.conf settings
sockseek input: sockseek input:
cd maple-py && uv run src/sockseek.py {{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}}
# Build a rekordbox USB layout at <dst> from the collection at <src>.
# Converts FLAC→MP3 V0, extracts artwork, writes ANLZ (with PWAV/PWV3
# blue/white waveforms) and PIONEER/rekordbox/export.pdb.
export src="data/collection" dst="data/usb":
cd maple-py && uv run python -m maple "{{justfile_directory() / src}}" "{{justfile_directory() / dst}}"
sync args: sync args:
s5cmd {{args}} s5cmd {{args}}
usb dst="/Volumes/MAPLE": # Sync data/usb/ → mounted USB stick (default /media/ntr/MAPLE), then
COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 maple-py/data/usb/Contents/ {{dst}}/Contents/ # safely unmount so the stick can be pulled without risk to the FAT
COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 --exclude='USBANLZ/' maple-py/data/usb/PIONEER/ {{dst}}/PIONEER/ # write-back cache.
rm -f {{dst}}/PIONEER/rekordbox/RBFLTR.DAT usb dst="/media/ntr/MAPLE":
@test -d {{dst}} || (echo "USB not mounted at {{dst}}"; exit 1)
@# -c (checksum), NOT --size-only: maple re-exports keep the SAME file size
@# (ANLZ section lengths are deterministic) but change content (waveforms,
@# pdb), so a size-only check silently skips them and leaves stale data.
rsync -rtvh --delete -c --no-perms --no-owner --no-group --modify-window=2 "{{justfile_directory()}}/data/usb/" "{{dst}}/"
sync
@# Try udisksctl first (no sudo, handles the udev event); fall back to
@# plain `umount` if udisks isn't available. Either way the recipe
@# exits 0 even if the unmount fails (e.g. someone has a file open).
@udisksctl unmount -b "$(findmnt -no SOURCE {{dst}})" 2>/dev/null && echo "unmounted {{dst}}" \
|| (umount {{dst}} 2>/dev/null && echo "unmounted {{dst}}") \
|| echo "warning: could not unmount {{dst}} — close any file browsers + retry"
# Build a FAT32 image of data/usb/ — ready to dd onto a stick.
# `size` must exceed the data/usb/ tree (rough rule: data size + ~50 MB FAT overhead).
usb-image size="2G" img="data/maple.img":
@command -v mcopy >/dev/null || (echo "mtools not installed (apt install mtools)"; exit 1)
@test -d "{{justfile_directory()}}/data/usb" || (echo "no data/usb (run \`just export\` first)"; exit 1)
rm -f "{{justfile_directory()}}/{{img}}"
truncate -s {{size}} "{{justfile_directory()}}/{{img}}"
mkfs.vfat -F 32 -n MAPLE "{{justfile_directory()}}/{{img}}"
cd "{{justfile_directory()}}/data/usb" && mcopy -i "{{justfile_directory()}}/{{img}}" -s -Q -p -m * ::/
@echo "image ready:" && ls -lh "{{justfile_directory()}}/{{img}}"
# DESTRUCTIVE: dd data/maple.img onto a block device (e.g. /dev/sdb).
# Double-check `lsblk` first — wrong device wipes that drive.
[confirm("usb-flash will OVERWRITE the entire device. Proceed?")]
usb-flash dev img="data/maple.img":
@test -b {{dev}} || (echo "{{dev}} is not a block device"; exit 1)
@test -f "{{justfile_directory()}}/{{img}}" || (echo "no image at {{img}}; run \`just usb-image\` first"; exit 1)
@lsblk -no NAME,SIZE,LABEL,MODEL {{dev}}
sudo dd if="{{justfile_directory()}}/{{img}}" of={{dev}} bs=4M status=progress oflag=direct conv=fsync
sudo sync
@echo "done — safe to remove (try: udisksctl power-off -b {{dev}})"
usb-pdb dst="/Volumes/MAPLE": usb-pdb dst="/Volumes/MAPLE":
@test -d {{dst}}/PIONEER/rekordbox || (echo "no rekordbox dir at {{dst}}/PIONEER"; exit 1) @test -d {{dst}}/PIONEER/rekordbox || (echo "no rekordbox dir at {{dst}}/PIONEER"; exit 1)
+57
View File
@@ -0,0 +1,57 @@
# maple-py conventions
Python conventions for the `maple-py/` package. These OVERRIDE default behavior;
follow them exactly. (General repo guidance is in the root `CLAUDE.md`; 4-space
indentation applies here too.)
The package is pyright-checked (basic mode, Python 3.14). `Track`
(`src/maple/track.py`) and the `Analysis` family (`src/maple/analyze.py`) are the
reference examples for all of the below.
## Imports at the top of the file
All imports go at module top. No lazy/local imports inside functions. Never wrap
an import in `try/except ImportError` to degrade gracefully — if an integral
dependency (e.g. `mixxx_analyzer`, `scipy`) is missing, the process must **die at
startup** via the top-level import failing. The only acceptable indented import
is an `if TYPE_CHECKING:` block to break a circular type-only reference.
## Fail loud — never swallow into None
"Don't swallow into None" is a general principle at every level, including call
sites. An operation that fails must **let the exception propagate** — never catch
it and return `None` / an empty / degraded result. BPM/beat analysis and waveform
extraction are integral: `_mixxx_analyze` raises rather than returning None,
`_decode_pcm` raises (`RuntimeError`) if ffmpeg is missing or fails to decode
rather than returning an empty array, and every `Analysis.analyze` call site (in
`anlz.py`, `cli.py`, `collection/core.py`) lets failures abort the run (including
not catching around `ThreadPoolExecutor` `fut.result()`).
Reserve `None` returns for *legitimate absence* (e.g. a cache miss in
`Analysis.load`), never for *failure*. The one tolerated catch is a genuinely
*optional* side-effect failing (e.g. writing the optimization cache via
`analysis.save()`), which logs and continues because it doesn't affect
correctness.
## Specific types over generic ones
- **Dataclasses, not bare dicts**, for structured values passed around.
- **`pathlib.Path`, not `str`**, for local filesystem paths (strings are fine for
serialization / rekordbox-USB paths that are inherently string data).
- **`typing.NamedTuple` (or a small dataclass), not bare tuples**, for fixed
records (e.g. `Beat(number, tempo_x100, time_ms)`, `Bands(low, mid, high)`).
- **No `None`-valued fields.** Absent values resolve at instantiation to a
concrete sentinel — `0.0` (0.0 = unknown), `""`, `[]`, `b""` — per `Track`.
`None` is acceptable only as a *factory return* signalling absence, never as a
stored field.
Idiomatic dataclass shape: plain `@dataclass` (no frozen/slots), `@property` for
computed views, `@classmethod from_*()` factories, concrete field defaults.
## Type every function parameter
Always annotate every parameter, including nested/inner functions and
`@classmethod` factory args (`self`/`cls` exempt). No bare `def f(res):`. Use the
real type even from a third-party lib — e.g. `mixxx_analyzer.AnalysisResult`
(referenced directly; no extra import needed under
`from __future__ import annotations`).
+4
View File
@@ -8,10 +8,14 @@ authors = [
] ]
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"aiohttp>=3.10",
"kaitaistruct>=0.11", "kaitaistruct>=0.11",
"loguru>=0.7",
"mixxx-analyzer>=0.1.2",
"mutagen>=1.47.0", "mutagen>=1.47.0",
"pillow>=12.2.0", "pillow>=12.2.0",
"pyrekordbox>=0.4.4", "pyrekordbox>=0.4.4",
"scipy>=1.10",
] ]
[project.scripts] [project.scripts]
+23
View File
@@ -0,0 +1,23 @@
{
"include": [
"src",
"tests"
],
"extraPaths": [
"src"
],
"exclude": [
"**/__pycache__",
"**/.venv",
"data",
"re"
],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.14",
"pythonPlatform": "Linux",
"typeCheckingMode": "basic",
"reportMissingImports": "error",
"reportMissingModuleSource": "warning",
"useLibraryCodeForTypes": true
}
+500
View File
@@ -0,0 +1,500 @@
"""Audio analysis: BPM + beat grid via mixxx-analyzer, plus waveform envelopes.
BPM and the beat grid come from `mixxx-analyzer` — Python bindings around
Mixxx's own analysis (the Queen Mary tempo tracker) — which produces a
CDJ-grade beat grid. Its wheel bundles a native binary, so there's no extra
system dependency. ffmpeg still decodes the source to 44.1 kHz mono float32 PCM
for the per-column waveform envelopes (PWAV / PWV3 / PWV4 / PWV5).
The decoded audio never touches disk — ffmpeg's `-f f32le` output is piped
straight to numpy.
Everything an analysis produces is held by the `Analysis` dataclass, composed of
`BeatGrid` (BPM + PQTZ beats), `MonoWaveforms` (PWAV/PWV2/PWV3) and
`BandWaveforms` (the 3-band colour streams). Build one with
`Analysis.analyze(path)` (or `Analysis.from_track(track)`); persist it with
`.save(cache_dir)` and read it back with `Analysis.load(cache_dir, isrc)`.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from base64 import b64decode, b64encode
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
import mixxx_analyzer
import numpy as np
from loguru import logger
from scipy.signal import butter, sosfilt
if TYPE_CHECKING:
from maple.track import Track
SAMPLE_RATE = 44100
# Waveform envelope dimensions.
# PWAV preview is **200 segments × 2 bytes** = 400 bytes — per Beat Link's
# reader, height = data[seg*2] & 0x1f, brightness = data[seg*2+1] & 0x07. (An
# earlier single-packed-byte reading was wrong; the player decoded it as garbled
# height/intensity pairs → "always full, never bright".) PWV2 = 100 columns × 1
# byte, 4-bit height. PWV3 (detail/scroll) genuinely IS 1 byte/col.
PWAV_SEGMENTS = 200 # PWAV: 200 segments × 2 bytes (height, brightness)
PWV2_COLS = 100 # PWV2: 100 single bytes (4-bit height)
SCROLL_FPS = 150 # PWV3 / PWV5 / PWV7 = ~150 cols/sec
PWAV_HEIGHT_LEVELS = 31 # 5-bit (0-31), bits 0-4
PWV2_HEIGHT_LEVELS = 15 # 4-bit (0-15), bits 0-3
PWV6_COLS = 1200 # 3-band / colour preview width (fixed by spec)
CACHE_VERSION = 9 # PWAV paired (2B/seg); treble-based brightness (not crest)
class Band(NamedTuple):
"""A band-pass frequency range, in Hz."""
lo_hz: int
hi_hz: int
# 3-band waveform split: bass (kick + sub), mid (snare, vocals, chords),
# high (hats, cymbals).
BAND_LOW = Band(20, 200)
BAND_MID = Band(200, 2000)
BAND_HIGH = Band(2000, 15000)
class Beat(NamedTuple):
"""One PQTZ beat-grid row. Unpacks straight into `struct.pack(">HHI", *beat)`."""
number: int # 1..4 (cycles from the anchor; no downbeat info)
tempo_x100: int # BPM × 100
time_ms: int # position in milliseconds
class Bands(NamedTuple):
"""A low/mid/high triple of per-column waveform byte-streams."""
low: bytes = b""
mid: bytes = b""
high: bytes = b""
# --------------------------------------------------------------------------- #
# mixxx-analyzer + ffmpeg decode #
# --------------------------------------------------------------------------- #
def _mixxx_analyze(path: Path) -> mixxx_analyzer.AnalysisResult:
"""Run mixxx-analyzer (Mixxx's BPM / beat-grid / key algorithms) on `path`.
Returns the `AnalysisResult` (fields incl. `bpm` and `beatgrid` — beat
positions in seconds). BPM/beat detection is integral to an export, so a
failure here propagates rather than degrading to an analysis with no grid."""
return mixxx_analyzer.analyze(str(path))
def _project_pqtz(beatgrid: list[float], bpm: float) -> list[Beat]:
"""Project a continuous PQTZ grid from mixxx's tempo + phase.
Returns `[Beat(number 1-4, tempo×100, time_ms), ...]`. mixxx gives an
accurate BPM and beat phase, but its raw beat list can have GAPS in quiet
sections (e.g. 907 detected beats where a continuous 160 BPM grid has
~1010). rekordbox/CDJs expect a gap-free grid — beat-sync and quantise lock
to it — so we anchor on mixxx's first beat, walk back to t≥0 to cover the
intro, and emit one beat every 60/bpm seconds through to the last detected
beat. beat_number cycles 1→4 from the anchor (no downbeat info)."""
if not beatgrid or bpm <= 0:
return []
interval_ms = 60000.0 / bpm
first_ms = beatgrid[0] * 1000.0
last_ms = beatgrid[-1] * 1000.0
n_before = int(first_ms / interval_ms) # preserve phase back to t≥0
anchor = first_ms - n_before * interval_ms
tempo_x100 = round(bpm * 100)
grid: list[Beat] = []
i = 0
t = anchor
while t <= last_ms + interval_ms:
grid.append(Beat((i % 4) + 1, tempo_x100, int(round(t))))
i += 1
t = anchor + i * interval_ms
return grid
def _decode_pcm(path: Path) -> np.ndarray:
"""Decode `path` to a 1D float32 numpy array of mono PCM at SAMPLE_RATE.
Raises `RuntimeError` if ffmpeg is missing or fails to decode — waveform
extraction is integral, so a failure here is fatal rather than swallowed
into an empty array (which would yield a waveform-less ANLZ).
"""
if not shutil.which("ffmpeg"):
raise RuntimeError("ffmpeg not on PATH; cannot extract waveform")
proc = subprocess.run(
["ffmpeg", "-nostdin", "-v", "error",
"-i", str(path),
"-f", "f32le", "-ac", "1", "-ar", str(SAMPLE_RATE), "-"],
capture_output=True,
)
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg failed for {path.name}: "
f"{proc.stderr.decode('utf-8', 'replace').strip()}"
)
return np.frombuffer(proc.stdout, dtype=np.float32)
# --------------------------------------------------------------------------- #
# Quantisation + per-column binning (the waveform DSP) #
# --------------------------------------------------------------------------- #
# Height: gate near-silence to 0, then a linear map to the level range
# (QUANT_GAMMA=1.0). Heights use per-track-normalised RMS loudness, which on the
# reference export spreads low (scroll ~5.7/31, overview ~13/31) with no
# saturation. Colour/whiteness use a sub-linear lift (COLOR_GAMMA) so quiet
# bands still read vivid mid-range instead of near-zero.
SILENCE_GATE_PCT = 0.02 # < 2% of peak → 0
QUANT_GAMMA = 1.0 # HEIGHT: linear; do NOT lift mids toward max
COLOR_GAMMA = 0.4 # COLOUR/whiteness: sub-linear lift
# PWAV (preview) height uses an ABSOLUTE loudness scale instead of per-track
# normalisation, so a quiet track/section reads short and a loud one tall
# (per-track norm always pinned the loudest column to full → "always too high").
# height = round(rms * PWAV_HEIGHT_SCALE), clamped to 31. The scale was DERIVED
# by least-squares fitting the genuine reference export's PWAV heights against
# per-column audio RMS across 3 tracks: 42.2 / 43.0 / 39.9 → 42 (mean err ~2/31;
# reference peaks at ~24, i.e. full ≈ RMS 0.74). (PWV3 keeps the per-track scale
# — it's good as-is.)
PWAV_HEIGHT_SCALE = 42
def _quantise(peaks: np.ndarray, levels: int, peak_max: float | None = None,
gamma: float | None = None) -> np.ndarray:
"""Gate + (optional gamma) + quantise to 0..levels uint8.
`peak_max` lets callers pass a SHARED normaliser (e.g. across bands) so the
relative energy between channels is preserved; defaults to this array's own
max. `gamma` overrides QUANT_GAMMA (use COLOR_GAMMA to lift colour)."""
g = QUANT_GAMMA if gamma is None else gamma
pm = float(peaks.max()) if peak_max is None else peak_max
if pm <= 0:
return np.zeros(peaks.size, dtype=np.uint8)
gated = np.where(peaks < pm * SILENCE_GATE_PCT, 0.0, peaks)
normalised = np.clip(gated / pm, 0.0, 1.0)
curved = normalised if g == 1.0 else np.power(normalised, g)
return np.clip(np.round(curved * levels), 0, levels).astype(np.uint8)
def _col_rms(values: np.ndarray, n_cols: int) -> np.ndarray:
"""Per-column RMS over n_cols evenly-split chunks (loudness, not peak)."""
chunks = np.array_split(values.astype(np.float64), n_cols)
return np.array([float(np.sqrt(np.mean(c * c))) if len(c) else 0.0 for c in chunks])
def _env_peaks(env: np.ndarray, n_cols: int) -> np.ndarray:
chunks = np.array_split(env, n_cols)
return np.array([float(c.max()) if len(c) else 0.0 for c in chunks])
def _packed_mono(samples: np.ndarray, hi_env: np.ndarray, n_cols: int,
height_levels: int = PWAV_HEIGHT_LEVELS,
abs_scale: float | None = None) -> bytes:
"""One byte per column = (whiteness << 5) | height — the real PWAV/PWV3
on-disk format. whiteness = lifted treble (high-band) peak.
height: when `abs_scale` is given, an ABSOLUTE loudness map
`round(rms * abs_scale)` (quiet → short, loud → tall — used by PWAV); when
None, per-track-normalised RMS via `_quantise` (loudest column → full —
used by PWV3)."""
if n_cols <= 0 or samples.size == 0:
return bytes(n_cols)
rms = _col_rms(np.abs(samples), n_cols)
if abs_scale is not None:
height = np.clip(np.round(rms * abs_scale), 0, height_levels).astype(np.uint8)
else:
height = _quantise(rms, height_levels)
white = _quantise(_env_peaks(hi_env, n_cols), 7, gamma=COLOR_GAMMA) # lifted
white = np.where(height > 0, white, 0).astype(np.uint8) # silence → no white
return ((white << 5) | height).astype(np.uint8).tobytes()
def _packed_preview(samples: np.ndarray, hi_env: np.ndarray,
segments: int = PWAV_SEGMENTS) -> bytes:
"""PWAV preview — **2 bytes per segment** (NOT one packed byte). This is the
format Beat Link / the CDJ actually read:
even byte: bits 0-4 = height (0-31) ← data[seg*2] & 0x1f
odd byte: bits 0-2 = brightness (0-7) ← data[seg*2+1] & 0x07 (>=5 → cyan)
height = absolute loudness `round(rms*PWAV_HEIGHT_SCALE)` (fit to the
reference: full ≈ RMS 0.74). brightness = the same treble (high-band)
whiteness PWV3 uses — it matches the reference brightness distribution
(~14-38% ≥5) far better than a crest factor (which sat ≥5 on ~all columns).
Whiteness is mirrored into the high 3 bits of both bytes, matching the
reference layout (even≈odd).
"""
if segments <= 0 or samples.size == 0:
return bytes(segments * 2)
rms = _col_rms(np.abs(samples), segments)
height = np.clip(np.round(rms * PWAV_HEIGHT_SCALE), 0, 31).astype(np.uint8)
# brightness: modest treble level (linear, no lift). The reference's exact
# brightness channel doesn't track any computable feature (uncorrelated with
# treble/height/crest across reference tracks), so this just gives some
# transient/treble sparkle without washing the whole preview cyan.
bright = _quantise(_env_peaks(hi_env, segments), 7)
bright = np.where(height > 0, bright, 0).astype(np.uint8) # silence → no bright
even = ((bright << 5) | height).astype(np.uint8) # &0x1f → height
odd = ((bright << 5) | bright).astype(np.uint8) # &0x07 → brightness
out = np.empty(segments * 2, dtype=np.uint8)
out[0::2] = even
out[1::2] = odd
return out.tobytes()
def _packed_tiny(samples: np.ndarray, n_cols: int) -> bytes:
"""PWV2: one byte per column, 4-bit height (0-15) in the low nibble."""
if n_cols <= 0 or samples.size == 0:
return bytes(n_cols)
return _quantise(_col_rms(np.abs(samples), n_cols), PWV2_HEIGHT_LEVELS).astype(np.uint8).tobytes()
def _bin_bands(lo_env: np.ndarray, mid_env: np.ndarray, hi_env: np.ndarray,
n_cols: int, shared: bool, levels: int = 255) -> Bands:
"""Bin three band envelopes (per-column PEAK) into n_cols, lifted by
COLOR_GAMMA. `shared=True` normalises all three by one SHARED peak → natural
bass→treble rolloff (PWV4 overview). `shared=False` normalises each band to
its OWN peak → vivid balanced colour where sustained bands read strong
(PWV5 colour scroll)."""
lp, mp, hp = _env_peaks(lo_env, n_cols), _env_peaks(mid_env, n_cols), _env_peaks(hi_env, n_cols)
if shared:
s = max(float(lp.max()), float(mp.max()), float(hp.max()), 1e-9)
nlo = nmd = nhi = s
else:
nlo, nmd, nhi = None, None, None
return Bands(
_quantise(lp, levels, nlo, gamma=COLOR_GAMMA).tobytes(),
_quantise(mp, levels, nmd, gamma=COLOR_GAMMA).tobytes(),
_quantise(hp, levels, nhi, gamma=COLOR_GAMMA).tobytes(),
)
def _band_envelopes(samples: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Per-sample low/mid/high envelopes (4-pole Butterworth band-pass + abs)."""
def env(band: Band) -> np.ndarray:
sos = butter(4, [band.lo_hz, band.hi_hz], btype="band", fs=sr, output="sos")
return np.abs(sosfilt(sos, samples)).astype(np.float32)
return env(BAND_LOW), env(BAND_MID), env(BAND_HIGH)
# --------------------------------------------------------------------------- #
# ISRC-keyed cache paths #
# --------------------------------------------------------------------------- #
def _sanitise_isrc(isrc: str) -> str:
"""Reduce an ISRC to a filename-safe form (uppercase, alnum only)."""
return "".join(c for c in (isrc or "").upper() if c.isalnum())
def cache_path_for(cache_dir: Path, isrc: str) -> Path | None:
"""Return the path the cache file for `isrc` lives at, or None if no isrc."""
s = _sanitise_isrc(isrc)
if not s:
return None
return cache_dir / f"{s}.json"
# --------------------------------------------------------------------------- #
# Composed analysis dataclasses #
# --------------------------------------------------------------------------- #
@dataclass
class BeatGrid:
"""BPM + the projected PQTZ beat grid. `bpm == 0.0` means unknown — there is
no `None` here, an absent grid is simply empty."""
bpm: float = 0.0
beats: list[Beat] = field(default_factory=list)
@property
def tempo_x100(self) -> int:
"""BPM × 100, the integer tempo every PQTZ/PQT2 row carries."""
return round(self.bpm * 100)
@classmethod
def from_mixxx(cls, res: mixxx_analyzer.AnalysisResult) -> "BeatGrid":
"""Build from a mixxx `AnalysisResult`. `_project_pqtz` tolerates an
empty beat list, so a result with no usable tempo/phase yields an empty
grid (bpm 0.0) rather than raising."""
bpm = float(res.bpm) if res.bpm else 0.0
beats = _project_pqtz(list(res.beatgrid), bpm) if res.beatgrid else []
return cls(bpm=bpm, beats=beats)
@dataclass
class MonoWaveforms:
"""The single-channel waveform streams.
`preview` = PWAV (200 segments × 2 bytes), `tiny` = PWV2 (100 × 1 byte,
4-bit height), `scroll` = PWV3 (~150 cols/sec, packed whiteness|height)."""
preview: bytes = b""
tiny: bytes = b""
scroll: bytes = b""
@classmethod
def from_audio(cls, samples: np.ndarray, hi_env: np.ndarray, scroll_cols: int) -> "MonoWaveforms":
return cls(
preview=_packed_preview(samples, hi_env, PWAV_SEGMENTS),
tiny=_packed_tiny(samples, PWV2_COLS),
scroll=_packed_mono(samples, hi_env, scroll_cols, PWAV_HEIGHT_LEVELS),
)
@dataclass
class BandWaveforms:
"""The 3-band colour streams. `preview` is SHARED-normalised (energy rolloff,
feeds PWV4/PWV6); `scroll` is per-band-normalised (vivid, feeds PWV5/PWV7)."""
preview: Bands = Bands()
scroll: Bands = Bands()
@classmethod
def from_envelopes(cls, lo_env: np.ndarray, mid_env: np.ndarray,
hi_env: np.ndarray, scroll_cols: int) -> "BandWaveforms":
return cls(
preview=_bin_bands(lo_env, mid_env, hi_env, PWV6_COLS, shared=True),
scroll=_bin_bands(lo_env, mid_env, hi_env, scroll_cols, shared=False),
)
@dataclass
class Analysis:
"""A track's full audio analysis: BPM + beat grid and every waveform stream
the rekordbox ANLZ writer needs. Build with `Analysis.analyze(path)` or
`Analysis.from_track(track)`; cache with `.save()` / `Analysis.load()`."""
isrc: str = ""
grid: BeatGrid = field(default_factory=BeatGrid)
mono: MonoWaveforms = field(default_factory=MonoWaveforms)
bands: BandWaveforms = field(default_factory=BandWaveforms)
# ---- views ----------------------------------------------------------- #
@property
def bpm(self) -> float:
return self.grid.bpm
@property
def beats(self) -> list[Beat]:
return self.grid.beats
# ---- construction ---------------------------------------------------- #
@classmethod
def analyze(cls, path: Path, *, isrc: str = "") -> "Analysis":
"""One-pass decode + analysis of an audio file.
BPM + beat grid come from mixxx-analyzer (independent of the waveform
decode, so a track still gets its grid even if ffmpeg fails). Always
returns a populated `Analysis` — empty waveforms when ffmpeg can't
decode the source."""
grid = BeatGrid.from_mixxx(_mixxx_analyze(path))
samples = _decode_pcm(path)
if samples.size == 0:
return cls(isrc=isrc, grid=grid)
scroll_cols = max(1, int(round(samples.size / SAMPLE_RATE * SCROLL_FPS)))
# Band envelopes first — the high band feeds the mono whiteness channel,
# and all three feed the colour sections.
lo_env, mid_env, hi_env = _band_envelopes(samples, SAMPLE_RATE)
return cls(
isrc=isrc,
grid=grid,
mono=MonoWaveforms.from_audio(samples, hi_env, scroll_cols),
bands=BandWaveforms.from_envelopes(lo_env, mid_env, hi_env, scroll_cols),
)
@classmethod
def from_track(cls, track: "Track") -> "Analysis":
"""Seed `isrc` from the track and compute the rest from `track.path`.
Returns a bare (isrc-only) `Analysis` when the track has no audio
downloaded yet (`track.path is None`)."""
if track.path is None:
return cls(isrc=track.isrc)
return cls.analyze(track.path, isrc=track.isrc)
# ---- cache I/O ------------------------------------------------------- #
def to_cache(self) -> dict:
"""Serialise to the on-disk JSON payload (bytes → base64). Mirrors the
`CACHE_VERSION` v9 layout so existing caches stay readable."""
return {
"version": CACHE_VERSION,
"isrc": _sanitise_isrc(self.isrc),
"bpm": self.grid.bpm or None,
"beats": [list(b) for b in self.grid.beats],
"preview_b64": b64encode(self.mono.preview).decode("ascii"),
"tiny_b64": b64encode(self.mono.tiny).decode("ascii"),
"scroll_b64": b64encode(self.mono.scroll).decode("ascii"),
# 3-band envelopes — kept separate from the mono streams so callers
# can ask for whichever they need without re-decoding.
"preview_low_b64": b64encode(self.bands.preview.low).decode("ascii"),
"preview_mid_b64": b64encode(self.bands.preview.mid).decode("ascii"),
"preview_high_b64": b64encode(self.bands.preview.high).decode("ascii"),
"scroll_low_b64": b64encode(self.bands.scroll.low).decode("ascii"),
"scroll_mid_b64": b64encode(self.bands.scroll.mid).decode("ascii"),
"scroll_high_b64": b64encode(self.bands.scroll.high).decode("ascii"),
}
@classmethod
def from_cache(cls, d: dict) -> "Analysis | None":
"""Rebuild from a `to_cache()` payload, or None if the version is stale."""
if d.get("version") != CACHE_VERSION:
return None
return cls(
isrc=d.get("isrc") or "",
grid=BeatGrid(
bpm=float(d.get("bpm") or 0.0),
beats=[Beat(*b) for b in d.get("beats", [])],
),
mono=MonoWaveforms(
preview=b64decode(d.get("preview_b64") or ""),
tiny=b64decode(d.get("tiny_b64") or ""),
scroll=b64decode(d.get("scroll_b64") or ""),
),
bands=BandWaveforms(
preview=Bands(
b64decode(d.get("preview_low_b64") or ""),
b64decode(d.get("preview_mid_b64") or ""),
b64decode(d.get("preview_high_b64") or ""),
),
scroll=Bands(
b64decode(d.get("scroll_low_b64") or ""),
b64decode(d.get("scroll_mid_b64") or ""),
b64decode(d.get("scroll_high_b64") or ""),
),
),
)
def save(self, cache_dir: Path) -> Path | None:
"""Persist this analysis under `cache_dir`, keyed by ISRC. No-op
(returns None) when the ISRC is empty."""
target = cache_path_for(cache_dir, self.isrc)
if target is None:
return None
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(self.to_cache(), separators=(",", ":")))
return target
@classmethod
def load(cls, cache_dir: Path, isrc: str) -> "Analysis | None":
"""Return the cached analysis for `isrc`, or None if
absent / stale / unreadable."""
target = cache_path_for(cache_dir, isrc)
if target is None or not target.exists():
return None
try:
return cls.from_cache(json.loads(target.read_text()))
except (json.JSONDecodeError, OSError, ValueError) as e:
logger.warning("analysis cache read failed for {}: {}", isrc, e)
return None
+479 -21
View File
@@ -1,41 +1,499 @@
"""Generate a minimal rekordbox ANLZ0000.DAT analysis file. """Generate a rekordbox ANLZ0000.{DAT,EXT} pair.
The CDJ/XDJ reads the path from a `PPTH` section to confirm which audio ANLZ0000.DAT — what CDJ-900-era through CDJ-3000 firmware reads to display
file the analysis applies to. We emit just that section -- no beat grid, the loaded-deck BPM, the touch-strip preview waveform, and the cue list:
no waveforms -- which is enough for the player to consider the track's
analysis "present" and surface the track in the library. Without an PPTH player-relative audio path
analyze file at the path the pdb references, some firmware hides the PVBR variable-bitrate seek index (zero-filled placeholder; matches the
track entirely. shape rekordbox writes for files where the seek index isn't useful)
PQTZ beat grid (per-beat: beat_number, tempo*100, time_ms)
PWAV 400-byte blue/white touch-strip preview
PWV2 100-byte "tiny" overview waveform
PCOB memory-cue list (empty placeholder)
PCOB hot-cue list (empty placeholder)
ANLZ0000.EXT — extended data; we write a minimal PPTH-only file so newer
firmware that opens .EXT as part of its load flow finds a valid container.
Color / 3-band waveforms (PWV4/PWV5/PWV6/PWV7) belong in .EXT/.2EX and
aren't generated yet.
Field discoveries documented by byte-diffing maple's output against a real
rekordbox export at data/q1-rekordbox/.
All multi-byte fields in ANLZ files are BIG-endian, unlike the pdb. All multi-byte fields in ANLZ files are BIG-endian, unlike the pdb.
""" """
from __future__ import annotations
from pathlib import Path from pathlib import Path
from struct import pack from struct import pack
from loguru import logger
from maple.analyze import Analysis, Beat
# Real rekordbox PMAI header carries three non-zero u4 markers in the bytes
# after the standard PMAI/len_header/len_file triple. Verified by hex-dump
# of data/q1-rekordbox/PIONEER/USBANLZ/P020/0002E10A/ANLZ0000.{DAT,EXT,2EX}
# — all three files share the same 16-byte trailer.
PMAI_HEADER_TRAILER = pack(">IIII", 0x00000001, 0x00010000, 0x00010000, 0x00000000)
ANLZ_HEADER_LEN = 28 # PMAI + 2 size u4s + the 16-byte trailer above
def _section_pth(path: str) -> bytes: def _section_pth(path: str) -> bytes:
"""PPTH section: u4 len_path, then UTF-16BE bytes (null-terminated).""" """PPTH section: u4 len_path, then UTF-16BE bytes (null-terminated)."""
utf16 = path.encode("utf-16-be") + b"\x00\x00" utf16 = path.encode("utf-16-be") + b"\x00\x00"
len_path = len(utf16) len_path = len(utf16)
body = pack(">I", len_path) + utf16 body = pack(">I", len_path) + utf16
# Section header: 4 fourcc + u4 len_header + u4 len_tag
fourcc = b"PPTH" fourcc = b"PPTH"
len_header = 16 # section header size (incl. fourcc + 2 u4s + len_path u4) len_header = 16 # fourcc + 2 u4 size fields + len_path u4
len_tag = 12 + len(body) # full section size len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body return fourcc + pack(">II", len_header, len_tag) + body
def build_minimal_anlz(audio_path: str) -> bytes: def _section_pvbr() -> bytes:
"""Return bytes of a minimal ANLZ0000.DAT pointing at `audio_path`.""" """PVBR section: 400-entry VBR seek index. We write a zero-filled
pth = _section_pth(audio_path) placeholder — rekordbox accepts this for fixed-bitrate sources and it
header_len = 28 matches the section's presence in real exports."""
file_len = header_len + len(pth) fourcc = b"PVBR"
header = b"PMAI" + pack(">II", header_len, file_len) len_header = 16 # prelude(12) + u4 unnamed0(4)
header += b"\x00" * (header_len - len(header)) # 400 u4 seek index + 4 trailer bytes = 1604 bytes of variable data
assert len(header) == header_len # (verified by len_tag=1620 in the real Q1.1 export).
return header + pth body_var = b"\x00" * 1604
unnamed0 = b"\x00\x00\x00\x00"
len_tag = len_header + len(body_var) # 16 + 1604 = 1620
return fourcc + pack(">II", len_header, len_tag) + unnamed0 + body_var
def write_anlz(audio_path: str, dest: Path) -> None: def _section_pqtz(beats: list[Beat]) -> bytes:
"""PQTZ: BeatGridTag — per-beat (beat_number, tempo×100, time_ms).
The CDJ's loaded-deck BPM readout, tempo slider and sync engine all
read from this section. Real rekordbox sets `_unnamed1=0x00080000`
(was 0 in maple's earlier writer — likely the cause of the deck-side
BPM display staying blank even when the grid was otherwise valid).
"""
fourcc = b"PQTZ"
len_header = 24 # prelude(12) + 3 u4 body-header fields
num_beats = len(beats)
body = pack(">III", 0x00000000, 0x00080000, num_beats)
for bn, tempo_x100, t_ms in beats:
body += pack(">HHI", bn, tempo_x100, t_ms)
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
def _section_pwav(preview: bytes) -> bytes:
"""PWAV: WavePreviewTag — 400-byte touch-strip preview, **400 columns ×
1 byte**, each byte = (whiteness << 5) | height (3-bit whiteness, 5-bit
height). `preview` is produced already in that form by
`maple.analyze._packed_mono`. (The earlier "200 height/intensity col-pairs"
reading was wrong — the player decoded those as garbled half-columns with
no whiteness.)
"""
fourcc = b"PWAV"
len_header = 20
len_tag = len_header + len(preview)
return fourcc + pack(">IIII", len_header, len_tag, len(preview), 0x00010000) + preview
def _section_pwv2(tiny: bytes) -> bytes:
"""PWV2: 100-byte tiny preview — 100 columns × 1 byte, 4-bit height
(bits 0-3, 0-15) in the low nibble. `tiny` is produced by
`maple.analyze._packed_tiny`."""
fourcc = b"PWV2"
len_header = 20
len_tag = len_header + len(tiny)
return fourcc + pack(">IIII", len_header, len_tag, len(tiny), 0x00010000) + tiny
def _section_pcob(cue_list_type: int) -> bytes:
"""PCOB: CueTag — cue/loop list. Real exports always include both
sections in a fixed order — hot-cues (type=1) FIRST, then memory-cues
(type=0) — and use `0xffffffff` as the memory_count sentinel for
empty lists. Body: u4 type, 2 bytes unnamed, u2 num_cues, u4
memory_count. Verified against data/q1-rekordbox/.../ANLZ0000.DAT
byte-for-byte."""
fourcc = b"PCOB"
len_header = 24 # prelude(12) + body fixed prefix(12)
body = pack(">I", cue_list_type) + b"\x00\x00" + pack(">HI", 0, 0xFFFFFFFF)
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
def _section_pwv3(scroll: bytes) -> bytes:
"""PWV3: WaveScrollTag — full-track mono scroll waveform. Written
into ANLZ0000.EXT (real rekordbox puts PWV3 there, not in .DAT).
The loaded-deck scroll display reads from here."""
fourcc = b"PWV3"
len_header = 24
len_entry_bytes = 1
len_entries = len(scroll)
len_tag = len_header + len(scroll)
return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960000) + scroll
def _section_pco2(cue_list_type: int) -> bytes:
"""PCO2: ExtendedCueTag — empty extended cue list. Real exports ship
both type=1 (hot) and type=0 (memory) variants in .EXT alongside the
PCOB sections. Body: u4 type, u4 reserved."""
fourcc = b"PCO2"
len_header = 20 # prelude(12) + body fixed prefix(8)
body = pack(">II", cue_list_type, 0)
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
# --------------------------------------------------------------------------- #
# Colour + 3-band waveform sections (live in .EXT / .2EX) #
# --------------------------------------------------------------------------- #
def _section_pwv4(low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV4: WaveColorPreviewTag — colour overview, 1200 cols × 6 bytes/col:
byte 0/1 : whiteness/brightness (= overall)
byte 2 : overall (full-spectrum) energy
byte 3 : bass (low band)
byte 4 : mid (mid band)
byte 5 : treble (high band)
`low/mid/high` arrive SHARED-normalised (0-255); `overall = max(bands)`."""
fourcc = b"PWV4"
len_header = 24
n = min(len(low), len(mid), len(high))
lo, md, hi = bytearray(low[:n]), bytearray(mid[:n]), bytearray(high[:n])
body = bytearray(n * 6)
for i in range(n):
overall = max(lo[i], md[i], hi[i])
body[i * 6 + 0] = overall
body[i * 6 + 1] = overall
body[i * 6 + 2] = overall
body[i * 6 + 3] = lo[i] # bass
body[i * 6 + 4] = md[i] # mid
body[i * 6 + 5] = hi[i] # treble
len_tag = len_header + len(body)
return fourcc + pack(">IIIII", len_header, len_tag, 6, n, 0x00000000) + bytes(body)
def _section_pwv5(scroll: bytes, low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV5: WaveColorScrollTag — colour scroll, u16 BE per col:
fedcba9876543210
RRRGGGBBBHHHHH--
Colour **R=treble, G=mid, B=bass** (per spec). Bands arrive per-band
normalised 0-255 → 3-bit via >>5. Height is the broadband value from the
mono `scroll` byte's low 5 bits, so colour and mono scrolls share a height.
"""
fourcc = b"PWV5"
len_header = 24
n = min(len(scroll), len(low), len(mid), len(high))
body = bytearray(n * 2)
for i in range(n):
r = high[i] >> 5 # red = treble
g = mid[i] >> 5 # green = mid
b = low[i] >> 5 # blue = bass
h = scroll[i] & 0x1F # broadband height (0-31), shared with PWV3
word = (r & 0x7) << 13 | (g & 0x7) << 10 | (b & 0x7) << 7 | (h & 0x1F) << 2
body[i * 2] = (word >> 8) & 0xFF
body[i * 2 + 1] = word & 0xFF
len_tag = len_header + len(body)
return fourcc + pack(">IIIII", len_header, len_tag, 2, n, 0x00960305) + bytes(body)
def _section_pwv6(low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV6: Wave3bandPreviewTag (CDJ-3000). 3 bytes/col, order **mid,
high, low** per the docs (NOT low-mid-high). Fixed 1200 cols."""
fourcc = b"PWV6"
len_header = 20
n = min(len(low), len(mid), len(high))
body = bytearray(n * 3)
for i in range(n):
body[i * 3 + 0] = mid[i]
body[i * 3 + 1] = high[i]
body[i * 3 + 2] = low[i]
len_entry_bytes = 3
len_entries = n
len_tag = len_header + len(body)
return fourcc + pack(">IIII", len_header, len_tag, len_entry_bytes, len_entries) + bytes(body)
def _section_pwv7(low: bytes, mid: bytes, high: bytes) -> bytes:
"""PWV7: Wave3bandScrollTag (CDJ-3000). Same 3-byte mid/high/low
encoding as PWV6 but variable length at ~150 cols/sec."""
fourcc = b"PWV7"
len_header = 24
n = min(len(low), len(mid), len(high))
body = bytearray(n * 3)
for i in range(n):
body[i * 3 + 0] = mid[i]
body[i * 3 + 1] = high[i]
body[i * 3 + 2] = low[i]
len_entry_bytes = 3
len_entries = n
len_tag = len_header + len(body)
return fourcc + pack(">IIIII", len_header, len_tag, len_entry_bytes, len_entries, 0x00960000) + bytes(body)
def _section_pqt2(beats: list[Beat]) -> bytes:
"""PQT2: extended beat grid (CDJ-3000+). Layout is only partially
documented; the format isn't in the kaitai schema or the deepsymmetry
spec page. The hex dump of a real Q1 PQT2 shows a 56-byte header
carrying num_beats, first_beat_ms, mid_ms, end_ms (matching the
corresponding PQTZ values), then per-beat 2-byte entries.
We write a placeholder: header fields mirror PQTZ where we know
them, the rest zero-filled. Per-beat entries are 2 bytes of zero
each (encoding unknown). Empirically the CDJ-2000NXS2+ uses the
presence of PQT2 — independently of its content — as a gate for the
deck-side waveform / BPM display.
"""
fourcc = b"PQT2"
len_header = 56
num_beats = len(beats)
first_beat_ms = beats[0][2] if beats else 0
if num_beats >= 2:
interval = beats[-1][2] - beats[-2][2]
end_ms = beats[-1][2] + interval
else:
end_ms = first_beat_ms
mid_ms = end_ms // 2
# 44 bytes of body header (11 × u4). First few fields known from the
# real Q1 PQT2 dump; later u4s look like an analyser-timestamp that
# we can't reproduce, so we zero them.
body_header = pack(
">11I",
0, # unknown1
0x01000002, # unknown2 — observed constant in real exports
0, # unknown3
0x00013200, # unknown4 — observed constant
first_beat_ms,
mid_ms,
end_ms,
num_beats,
0, # unknown9 (date/timestamp; not reproduced)
0, # unknown10
0, # unknown11
)
entries = b"\x00\x00" * num_beats
body = body_header + entries
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
def _section_pwvc() -> bytes:
"""PWVC: 20-byte tag at the tail of `.2EX`. Undocumented. Real exports
always include it; we emit a zero-filled placeholder of the expected
length so the file ends with the same section list."""
fourcc = b"PWVC"
len_header = 14
body = b"\x00" * 8
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
# XOR mask used by rekordbox to obfuscate the PSSI body. Mask is offset
# per byte by the (unmasked) `len_entries` value before XOR'ing in.
_PSSI_MASK = bytes.fromhex("CBE1EEFAE5EEADEEE9D2E9EBE1E9F3E8E9F4E1")
def _section_pssi(num_beats: int) -> bytes:
"""PSSI: SongStructureTag — phrase analysis. We emit an empty (0
phrases) placeholder so the CDJ-3000 sees a valid PSSI and doesn't
blank its phrase indicators. Mood=2 (mid), bank=0 (default).
Per spec, bytes 0x12 onwards are XOR-masked with `_PSSI_MASK[i] +
len_entries` (mod 256). Since `len_entries == 0`, the mask itself is
the XOR key.
"""
fourcc = b"PSSI"
len_header = 32
# Unmasked body fields:
len_entry_bytes = 0x18
len_entries = 0 # no phrases
mood = 2 # mid
unknown_u4 = 0
end_beat = num_beats # final phrase end beat = last beat in PQTZ
unknown2_u4 = 0
bank = 0 # default
unknown3 = 0
unmasked = pack(">I", len_entry_bytes) # offset 0x0c-0x0f (NOT masked per spec)
masked_block = (
pack(">H", len_entries) # 0x10-0x11
+ pack(">H", mood) # 0x12-0x13
+ pack(">I", unknown_u4) # 0x14-0x17
+ pack(">H", end_beat) # 0x18-0x19
+ pack(">I", unknown2_u4) # 0x1a-0x1d
+ pack(">B", bank) # 0x1e
+ pack(">B", unknown3) # 0x1f
)
# Apply mask: spec says "all bytes from offset 0x12 onwards" — that's
# the 2-byte mood field onward. Skip the first 2 bytes of masked_block
# (which are len_entries at 0x10).
masked_out = bytearray(masked_block[:2]) # len_entries unmasked
for i, b in enumerate(masked_block[2:]):
masked_out.append(b ^ ((_PSSI_MASK[i % len(_PSSI_MASK)] + len_entries) & 0xFF))
body = unmasked + bytes(masked_out)
# No entries to append (len_entries=0).
len_tag = 12 + len(body)
return fourcc + pack(">II", len_header, len_tag) + body
def _pmai_header(file_len: int) -> bytes:
"""28-byte PMAI header — fourcc + sizes + the canonical trailer that
rekordbox writes (was zero-padded in maple's earlier output)."""
return b"PMAI" + pack(">II", ANLZ_HEADER_LEN, file_len) + PMAI_HEADER_TRAILER
def build_dat(rb_path: str, analysis: Analysis | None) -> bytes:
"""Build ANLZ0000.DAT — the file every CDJ reads on track load.
Section order matches rekordbox's own output: PPTH, PVBR, PQTZ, PWAV,
PWV2, PCOB(memory), PCOB(hot).
"""
sections = [_section_pth(rb_path), _section_pvbr()]
if analysis is not None:
if analysis.beats:
sections.append(_section_pqtz(analysis.beats))
if analysis.mono.preview:
sections.append(_section_pwav(analysis.mono.preview))
if analysis.mono.tiny:
sections.append(_section_pwv2(analysis.mono.tiny))
sections.append(_section_pcob(1)) # hot cues (empty) — real writes hot-cues first
sections.append(_section_pcob(0)) # memory cues (empty)
body = b"".join(sections)
return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body
def build_ext(rb_path: str, analysis: Analysis | None = None) -> bytes:
"""Build ANLZ0000.EXT — extended analysis data the loaded-deck reads.
Section order matches the real Q1 reference:
PPTH, PWV3 (mono scroll), PCOB×2, PCO2×2,
[PQT2 — skipped, format undocumented],
PWV5 (colour scroll), PWV4 (colour preview), PSSI (empty).
"""
sections = [_section_pth(rb_path)]
if analysis is not None and analysis.mono.scroll:
sections.append(_section_pwv3(analysis.mono.scroll))
sections.append(_section_pcob(1))
sections.append(_section_pcob(0))
sections.append(_section_pco2(1))
sections.append(_section_pco2(0))
if analysis is not None and analysis.beats:
# PQT2 between PCO2 and PWV5 — matches real-export section order
# and (empirically) gates the CDJ's deck-side waveform/BPM display.
sections.append(_section_pqt2(analysis.beats))
if analysis is not None:
scroll = analysis.mono.scroll
s_lo, s_md, s_hi = analysis.bands.scroll
if scroll and s_lo and s_md and s_hi:
sections.append(_section_pwv5(scroll, s_lo, s_md, s_hi))
p_lo, p_md, p_hi = analysis.bands.preview
if p_lo and p_md and p_hi:
sections.append(_section_pwv4(p_lo, p_md, p_hi))
if analysis.beats:
sections.append(_section_pssi(len(analysis.beats)))
body = b"".join(sections)
return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body
def build_2ex(rb_path: str, analysis: Analysis | None = None) -> bytes:
"""Build ANLZ0000.2EX — CDJ-3000 3-band waveforms.
Section order matches the real Q1 reference:
PPTH, PWV7 (3-band scroll), PWV6 (3-band preview), PWVC (skipped).
"""
sections = [_section_pth(rb_path)]
if analysis is not None:
s_lo, s_md, s_hi = analysis.bands.scroll
if s_lo and s_md and s_hi:
sections.append(_section_pwv7(s_lo, s_md, s_hi))
p_lo, p_md, p_hi = analysis.bands.preview
if p_lo and p_md and p_hi:
sections.append(_section_pwv6(p_lo, p_md, p_hi))
sections.append(_section_pwvc()) # 20-byte trailer real exports always include
body = b"".join(sections)
return _pmai_header(ANLZ_HEADER_LEN + len(body)) + body
def build_anlz(rb_path: str, source_audio: Path | None = None) -> bytes:
"""Compatibility wrapper for callers that want the .DAT bytes only.
Analysis failures propagate — a broken analysis is fatal, not swallowed."""
analysis = Analysis.analyze(source_audio) if source_audio is not None else None
return build_dat(rb_path, analysis)
def build_minimal_anlz(rb_path: str) -> bytes:
"""PPTH-only-plus-empties-DAT. Used when no source audio is available."""
return build_dat(rb_path, analysis=None)
def _isrc_for(path: Path) -> str | None:
"""Best-effort ISRC read from a file tag (mp3/flac/m4a)."""
try:
from mutagen import File as MutagenFile
audio = MutagenFile(path, easy=True)
if audio is None or audio.tags is None:
return None
v = audio.tags.get("isrc")
if not v:
return None
return str(v[0]).strip() or None
except Exception:
return None
def write_anlz(
rb_path: str,
dest: Path,
source_audio: Path | None = None,
analysis_cache_dir: Path | None = None,
) -> Analysis | None:
"""Write ANLZ0000.DAT (and a minimal ANLZ0000.EXT alongside).
`dest` is the .DAT path. The .EXT companion is written next to it
automatically — every real CDJ export ships both files.
Returns the `Analysis` (cache hit or fresh mixxx run) or None when
no source audio was given.
"""
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(build_minimal_anlz(audio_path)) analysis: Analysis | None = None
cache_hit = False
if source_audio is not None:
isrc = _isrc_for(source_audio) if analysis_cache_dir else None
if isrc and analysis_cache_dir:
cached = Analysis.load(analysis_cache_dir, isrc)
if cached is not None:
analysis = cached
cache_hit = True
logger.info("anlz: cache hit for {} (isrc {})", source_audio.name, isrc)
if analysis is None:
# Not a cache hit — analyse fresh. Failures propagate (a broken
# analysis is fatal, not swallowed into a grid-less ANLZ).
analysis = Analysis.analyze(source_audio, isrc=isrc or "")
# Populate the cache on every successful fresh analysis so subsequent
# exports (and collection runs) can skip the ffmpeg+mixxx pass. The
# cache is the single source of truth — populated by whichever
# pipeline (`just collection` or `just export`) sees a track first.
if analysis is not None and not cache_hit and analysis_cache_dir is not None and isrc:
try:
cp = analysis.save(analysis_cache_dir)
if cp is not None:
logger.info("anlz: cached analysis for {} → {}", source_audio.name, cp)
except Exception as e:
logger.warning("could not write analysis cache for {}: {}", source_audio.name, e)
dest.write_bytes(build_dat(rb_path, analysis))
ext_path = dest.with_suffix(".EXT")
ext_path.write_bytes(build_ext(rb_path, analysis))
# .2EX carries the CDJ-3000's 3-band waveforms (PWV6/PWV7). Older
# firmware ignores the file; CDJ-3000 reads it for the three-band
# strip display.
two_ex_path = dest.with_suffix(".2EX")
two_ex_path.write_bytes(build_2ex(rb_path, analysis))
return analysis
+5 -2
View File
@@ -59,9 +59,12 @@ def extract_artwork(mp3: Path, usb_root: Path, next_id: int, cache: dict[str, in
abs_small = usb_root / rel_small.lstrip("/") abs_small = usb_root / rel_small.lstrip("/")
abs_large = usb_root / rel_large.lstrip("/") abs_large = usb_root / rel_large.lstrip("/")
abs_small.parent.mkdir(parents=True, exist_ok=True) abs_small.parent.mkdir(parents=True, exist_ok=True)
if not abs_small.exists(): # Always overwrite — the artwork_id↔file mapping is recomputed every
# export, and a stale a<id>.jpg left over from a previous run would
# silently render the wrong cover on the CDJ. The intra-run dedup
# (cache hit at the top of this function) already prevents wasteful
# rewrites within one export.
abs_small.write_bytes(_resize_jpeg(data, SMALL)) abs_small.write_bytes(_resize_jpeg(data, SMALL))
if not abs_large.exists():
abs_large.write_bytes(_resize_jpeg(data, LARGE)) abs_large.write_bytes(_resize_jpeg(data, LARGE))
cache[sha] = aid cache[sha] = aid
return aid, rel_small return aid, rel_small
+398 -36
View File
@@ -1,34 +1,154 @@
"""maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB.""" """maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB."""
import argparse import argparse
import os import os
import re
import sys import sys
from concurrent.futures import ThreadPoolExecutor import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from maple.anlz import write_anlz from loguru import logger
from maple.analyze import Analysis, cache_path_for
from maple.anlz import _isrc_for, write_anlz
from maple.artwork import _bucket as _artwork_bucket
from maple.artwork import extract_artwork from maple.artwork import extract_artwork
from maple.convert import convert_flac_to_mp3, verify_duration from maple.convert import convert_flac_to_mp3, verify_duration
from maple.copy import stage_audio, staged_path from maple.copy import stage_audio, staged_path
from maple.log import configure as configure_logging
from maple.meta import read_tags from maple.meta import read_tags
from maple.model import Album, Artist, Artwork, Database, Genre, PlaylistEntry, PlaylistNode, Track from maple.model import Album, Artist, Artwork, Database, Genre, PlaylistEntry, PlaylistNode, Track
from maple.enrich import enrich_from_manifest, load_spotify_manifest, write_bpm_tag
from maple.pdb.writer import write_pdb from maple.pdb.writer import write_pdb
from maple.scan import scan_audio, scan_playlists from maple.scan import scan_audio, scan_playlists
from maple.validate import TrackIssue, log_issues, validate_track
# Genre tag delimiters seen across our sources: Spotify-derived uses
# ';', European labels often use ',', some store '/'-separated lists.
_GENRE_SPLIT_RE = re.compile(r"\s*[;,/]\s*")
def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Database: def usbanlz_location(usb_path: str) -> tuple[int, int]:
"""rekordbox's ANLZ folder derivation from a USB-relative track path.
Returns ``(bucket, anlz_id)`` for ``/PIONEER/USBANLZ/P{bucket:03X}/{anlz_id:08X}/``.
`usb_path` is the forward-slashed, leading-slash USB-relative path that the
pdb stores as `file_path` (e.g. ``/Contents/Artist/Album/01 Title.mp3``).
CRITICAL: the CDJ/XDJ firmware recomputes this from `file_path` and reads a
track's analysis from the resulting folder, *ignoring* the pdb's
`analyze_path` string. So maple must write its ANLZ files here and nowhere
else, or the player shows no waveform/beatgrid. Verified 1002/1002 against a
genuine rekordbox export and confirmed on XDJ-RX2 hardware.
`anlz_id` is a polynomial hash of the path's UTF-16 code units mod 0x30D43
(200003 — hence ids never exceed 0x30D42); `bucket` is a fixed bit-extraction
from that id. (Algorithm: github.com/Radexito/DjManager protocol.md §2.)
"""
h = 0
for ch in usb_path:
h = (h * 0x34F5501D + ord(ch) * 0x93B6) & 0xFFFFFFFF
anlz_id = h % 0x30D43
bucket = (
((((((((((anlz_id >> 2) & 0x4000) | (anlz_id & 0x2000)) >> 3)
| (anlz_id & 0x200)) >> 1) | (anlz_id & 0xC0)) >> 3)
| (anlz_id & 0x4)) >> 1) | (anlz_id & 0x1)
)
return bucket, anlz_id
def split_genre_tags(raw: str) -> list[str]:
"""Tokenise a possibly multi-value genre string into normalised parts.
Each token's casing is preserved as-given. Empty parts are dropped.
Duplicates within the same source string are collapsed (first wins).
"""
if not raw:
return []
seen: set[str] = set()
out: list[str] = []
for token in _GENRE_SPLIT_RE.split(raw):
t = token.strip()
if not t:
continue
key = t.casefold()
if key in seen:
continue
seen.add(key)
out.append(t)
return out
def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple[Database, set[Path]]:
"""Build the rekordbox USB layout under `usb_root`.
Returns the populated `Database` plus the set of absolute paths that
were written (audio files, ANLZ files, artwork JPEGs). The caller adds
the pdb path and calls `prune_stale` to remove anything else under
`usb_root` left over from a previous export.
"""
db = Database() db = Database()
today = date.today().isoformat() today = date.today().isoformat()
artist_index: dict[str, int] = {} artist_index: dict[str, int] = {}
album_index: dict[tuple[int, str], int] = {} album_index: dict[tuple[int, str], int] = {}
# Key by casefolded name so "Dub Techno" and "dub techno" collapse to one row.
genre_index: dict[str, int] = {} genre_index: dict[str, int] = {}
# The user-facing genre→tracks map. Populated as tracks are processed.
genre_to_tracks: dict[str, list[int]] = defaultdict(list)
track_by_src: dict[Path, int] = {} track_by_src: dict[Path, int] = {}
artwork_cache: dict[str, int] = {} artwork_cache: dict[str, int] = {}
# USBANLZ folder occupancy. Two distinct paths can hash to the same
# P{bucket}/{id} folder (a hash collision; also happens for genuine
# duplicate paths). rekordbox keeps both by bumping the filename to
# ANLZ0001.DAT, ANLZ0002.DAT, … inside the shared folder (verified: a
# real export had `Celestica` + `Soak It Up` co-located at P049/00030991).
# Mirror that so colliding tracks don't clobber each other's analysis.
anlz_folder_seen: dict[str, int] = defaultdict(int)
written: set[Path] = set()
audio_files = scan_audio(input_dir) audio_files = scan_audio(input_dir)
logger.info("scanned {}: {} audio file(s)", input_dir, len(audio_files))
# ---------- Spotify enrichment (symmetric with `just collection`) -------- #
# Per the validation convention: both pipelines must be capable of
# bringing a track to "valid" state. Validate first; only files that
# actually need it pay the enrichment cost (mutagen tag rewrite +
# possible artwork fetch). Repeated exports of an already-valid
# collection skip the enrichment loop entirely.
by_key, by_isrc = load_spotify_manifest(input_dir / "_metadata")
cache_dir = input_dir / "_analysis"
if by_key or by_isrc:
logger.info(
"loaded spotify manifest: {} (artist,title) keys, {} ISRCs",
len(by_key), len(by_isrc),
)
enriched = 0
skipped = 0
for p in audio_files:
if not validate_track(p, analysis_cache_dir=cache_dir):
skipped += 1
continue
if enrich_from_manifest(p, by_key, by_isrc, analysis_cache_dir=cache_dir) is not None:
enriched += 1
logger.info(
"enrichment: {} enriched, {} already valid (skipped)",
enriched, skipped,
)
else:
logger.warning(
"no spotify manifest at {} — tracks won't be Spotify-enriched. "
"Run `just collection` first to fetch + persist the manifest.",
input_dir / "_metadata" / "spotify-tracks.json",
)
t = time.perf_counter()
initial_meta = {p: read_tags(p) for p in audio_files} initial_meta = {p: read_tags(p) for p in audio_files}
logger.debug("read tags in {:.2f}s", time.perf_counter() - t)
flac_jobs: list[tuple[Path, Path]] = [] flac_jobs: list[tuple[Path, Path]] = []
for original in audio_files: for original in audio_files:
if original.suffix.lower() == ".flac": if original.suffix.lower() == ".flac":
@@ -38,15 +158,87 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
def _job(pair: tuple[Path, Path]) -> tuple[Path, Path, bool]: def _job(pair: tuple[Path, Path]) -> tuple[Path, Path, bool]:
src, dst = pair src, dst = pair
return src, dst, convert_flac_to_mp3(src, dst) t0 = time.perf_counter()
converted = convert_flac_to_mp3(src, dst)
return src, dst, converted, time.perf_counter() - t0
workers = max(1, os.cpu_count() or 1) workers = max(1, os.cpu_count() or 1)
if flac_jobs:
logger.info("converting {} flac(s) → mp3 v0 (workers={})", len(flac_jobs), workers)
t = time.perf_counter()
n_converted = 0
with ThreadPoolExecutor(max_workers=workers) as ex: with ThreadPoolExecutor(max_workers=workers) as ex:
for src, dst, converted in ex.map(_job, flac_jobs): for i, (src, dst, converted, dt) in enumerate(ex.map(_job, flac_jobs), 1):
print(f"{'convert' if converted else 'cached '} {src.relative_to(input_dir)}") tag = "convert" if converted else "cached "
logger.info("[{}/{}] {} {} ({:.1f}s)", i, len(flac_jobs), tag, src.relative_to(input_dir), dt)
if converted:
n_converted += 1
verify_duration(src, dst) verify_duration(src, dst)
logger.info(
"flac→mp3 pass: {} converted, {} cached in {:.1f}s total",
n_converted, len(flac_jobs) - n_converted, time.perf_counter() - t,
)
for original in audio_files: # Parallel pre-analysis. The per-track build loop below is sequential
# (the pdb / artwork-id assignments depend on iteration order), but
# `Analysis.analyze` is the slow step and trivially parallel: ffmpeg +
# mixxx + scipy each release the GIL, so threads scale linearly with
# cores. After this pass every track has its ISRC-keyed analysis
# cached, so write_anlz in the build loop is a cache hit.
def _dest_for(p: Path) -> Path:
m = initial_meta[p]
if p.suffix.lower() == ".flac":
dest_abs, _ = staged_path(usb_root, m.artist, m.album, p.with_suffix(".mp3").name)
else:
dest_abs, _ = staged_path(usb_root, m.artist, m.album, p.name)
return dest_abs
# Make sure non-FLAC sources are staged to data/usb first so the
# analysis pass operates on the file the CDJ will actually read.
for p in audio_files:
if p.suffix.lower() != ".flac":
m = initial_meta[p]
stage_audio(p, usb_root, m.artist, m.album)
def _ensure_analysis_cache(p: Path) -> bool:
"""Populate `<cache>/<isrc>.json` if missing. Returns True iff a
fresh analysis actually ran (i.e. cache was cold). Analysis failures
propagate — a broken analysis is fatal, not skipped."""
dest = _dest_for(p)
if not dest.exists():
return False
isrc = _isrc_for(dest)
if not isrc:
return False
cp = cache_path_for(cache_dir, isrc)
if cp is None or cp.exists():
return False
a = Analysis.analyze(dest, isrc=isrc)
a.save(cache_dir)
return True
pre_t0 = time.perf_counter()
logger.info("pre-analysing {} tracks (workers={})...", len(audio_files), workers)
new_count = 0
done = 0
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(_ensure_analysis_cache, p): p for p in audio_files}
for fut in as_completed(futures):
done += 1
if fut.result(): # re-raises any worker analysis failure
new_count += 1
if done % 8 == 0 or done == len(audio_files):
logger.info(" [{}/{}] cache misses analysed: {}", done, len(audio_files), new_count)
logger.info(
"pre-analysis: {} new in {:.1f}s ({} already cached)",
new_count, time.perf_counter() - pre_t0, len(audio_files) - new_count,
)
logger.info("building track table ({} files)", len(audio_files))
build_t0 = time.perf_counter()
validation_issues: list[TrackIssue] = []
for track_idx, original in enumerate(audio_files, 1):
track_t0 = time.perf_counter()
meta = initial_meta[original] meta = initial_meta[original]
artist_id = artist_index.get(meta.artist) artist_id = artist_index.get(meta.artist)
if artist_id is None: if artist_id is None:
@@ -66,36 +258,89 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
meta = read_tags(dest_abs) meta = read_tags(dest_abs)
else: else:
dest_abs, file_path = stage_audio(original, usb_root, meta.artist, meta.album) dest_abs, file_path = stage_audio(original, usb_root, meta.artist, meta.album)
written.add(dest_abs.resolve())
# Re-enrich the STAGED file too — `stage_audio` and the FLAC→MP3
# converter cache by hash, so a cached dest may still carry stale
# tags from a previous export. Skip when the staged file already
# passes validation so re-exports of an unchanged collection don't
# re-mutate every track.
if (by_key or by_isrc) and validate_track(dest_abs, analysis_cache_dir=cache_dir):
if enrich_from_manifest(dest_abs, by_key, by_isrc, analysis_cache_dir=cache_dir) is not None:
meta = read_tags(dest_abs)
next_artwork_id = len(db.artwork) + 1 next_artwork_id = len(db.artwork) + 1
artwork_id, art_file_path = extract_artwork(dest_abs, usb_root, next_artwork_id, artwork_cache) artwork_id, art_file_path = extract_artwork(dest_abs, usb_root, next_artwork_id, artwork_cache)
if art_file_path is not None: if art_file_path is not None:
db.artwork.append(Artwork(id=artwork_id, path=art_file_path)) db.artwork.append(Artwork(id=artwork_id, path=art_file_path))
# Track artwork paths for the prune sweep — both this run's writes
# and any artwork_cache hits (where extract_artwork returned None
# for art_file_path because the jpeg already exists from a prior
# iteration). _bucket() is the same id-to-folder mapping artwork.py
# uses, so this rebuilds the absolute on-disk path exactly.
if artwork_id:
art_bucket = _artwork_bucket(artwork_id)
for fname in (f"a{artwork_id}.jpg", f"a{artwork_id}_m.jpg"):
written.add((usb_root / "PIONEER" / "Artwork" / art_bucket / fname).resolve())
genre_id = 0 # Genre: split multi-value tag strings ("dub techno;minimal techno",
if meta.genre: # "Drum & Bass, Électronique", etc.) into individual genre rows so the
genre_id = genre_index.get(meta.genre, 0) # CDJ's Genre filter is usable. The track FK points at the FIRST
if genre_id == 0: # (most-specific) token; every other token still gets its own row in
genre_id = len(db.genres) + 1 # the pdb's genre table so it appears in the browse list, and we
genre_index[meta.genre] = genre_id # record the {genre→track_ids} map so we can log + reuse it later.
db.genres.append(Genre(id=genre_id, name=meta.genre)) primary_genre_id = 0
tokens = split_genre_tags(meta.genre)
for n, token in enumerate(tokens):
key = token.casefold()
gid = genre_index.get(key)
if gid is None:
gid = len(db.genres) + 1
genre_index[key] = gid
db.genres.append(Genre(id=gid, name=token))
if n == 0:
primary_genre_id = gid
genre_to_tracks[token].append(len(db.tracks) + 1) # this track's id-to-be
genre_id = primary_genre_id
track_id = len(db.tracks) + 1 track_id = len(db.tracks) + 1
# Reference exports always populate analyze_path even when the # The USBANLZ folder is derived from the USB-relative `file_path` by
# ANLZ files are real; tracks with an empty analyze_path appear # rekordbox's bespoke path hash (see `usbanlz_location`). The CDJ/XDJ
# to be hidden from the player's library. Synthesise a stable # firmware RE-DERIVES this same path from `file_path` and reads the
# path keyed off the track id even if we don't write the .DAT. # analysis from there — it IGNORES the pdb's `analyze_path` string —
import zlib # so the folder MUST match rekordbox exactly or the player silently
h = zlib.crc32(file_path.encode()) & 0xFFFFFFFF # shows no waveform/beatgrid. Verified 1002/1002 vs a real export and
# rekordbox's USBANLZ P-bucket is 0..127 (P00..P7F) and the 8-hex id is an # on XDJ-RX2 hardware. (Earlier crc32 scheme matched 0/984 → grids
# opaque master.db value -- not reproducible without its DB (FORMAT_SPEC # missing.)
# §7), so maple derives both from a stable crc32(path). Mask the bucket to bucket_n, anlz_id = usbanlz_location(file_path)
# 0x7F so it has rekordbox's P00..P7F shape; the path used here is the same anlz_folder = f"/PIONEER/USBANLZ/P{bucket_n:03X}/{anlz_id:08X}"
# one written to the pdb row, so it's self-consistent by construction. anlz_index = anlz_folder_seen[anlz_folder] # 0, then 1, 2, … on collision
bucket = f"P{(h >> 20) & 0x7F:02X}" anlz_folder_seen[anlz_folder] += 1
analyze_id = f"{h & 0xFFFFFFFF:08X}" analyze_path = f"{anlz_folder}/ANLZ{anlz_index:04d}.DAT"
analyze_path = f"/PIONEER/USBANLZ/{bucket}/{analyze_id}/ANLZ0000.DAT" anlz_abs = (usb_root / analyze_path.lstrip("/")).resolve()
write_anlz(file_path, usb_root / analyze_path.lstrip("/")) # `_analysis/` sits at the input collection root; ISRC-keyed cache
# populated during `just collection`. write_anlz reads it before
# falling back to a fresh ffmpeg+mixxx pass.
analysis = write_anlz(
file_path, anlz_abs, source_audio=dest_abs,
analysis_cache_dir=input_dir / "_analysis",
)
written.add(anlz_abs)
# .EXT and .2EX sister files are written automatically next to .DAT.
written.add(anlz_abs.with_suffix(".EXT"))
written.add(anlz_abs.with_suffix(".2EX"))
# Prefer the analytically detected BPM over the file tag when we have one.
# CDJ browse panel reads pdb.tempo; loaded-deck readout reads the PQTZ
# we just wrote — keeping the two in sync avoids the "120.0 in browse,
# blank on deck" UX bug.
if analysis is not None and analysis.bpm:
tempo_x100 = analysis.grid.tempo_x100
# Also write the bpm TAG to the file (symmetric with `just
# collection`'s _write_bpm_tag). overwrite=False so any
# pre-existing label-set BPM is preserved.
write_bpm_tag(dest_abs, analysis.bpm, overwrite=False)
else:
tempo_x100 = meta.tempo
db.tracks.append(Track( db.tracks.append(Track(
id=track_id, id=track_id,
title=meta.title, title=meta.title,
@@ -110,7 +355,7 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
duration=meta.duration, duration=meta.duration,
track_number=meta.track_number, track_number=meta.track_number,
year=meta.year, year=meta.year,
tempo=meta.tempo, tempo=tempo_x100,
date_added=today, date_added=today,
analyze_path=analyze_path, analyze_path=analyze_path,
analyze_date=today, analyze_date=today,
@@ -120,9 +365,61 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
)) ))
track_by_src[original.resolve()] = track_id track_by_src[original.resolve()] = track_id
# Cross-cutting validation: same checks as `just collection`. The
# input audio_path is the staged file under data/usb/ so its tags
# are what end up on the player. analysis cache lives at the source
# collection root (input_dir/_analysis/<isrc>.json).
track_issues = validate_track(
dest_abs,
analysis_cache_dir=input_dir / "_analysis",
)
validation_issues.extend(track_issues)
track_dt = time.perf_counter() - track_t0
bpm_str = f"{analysis.bpm:.1f} bpm" if (analysis is not None and analysis.bpm) else f"tag {tempo_x100/100:.1f} bpm"
issue_tag = f" [{len(track_issues)} issue(s)]" if track_issues else ""
logger.info(
"[{}/{}] {} - {} ({}, {:.1f}s){}",
track_idx, len(audio_files),
meta.artist, meta.title,
bpm_str, track_dt, issue_tag,
)
logger.info(
"track table built in {:.1f}s ({} tracks, {} artists, {} albums, "
"{} artwork, {} genres)",
time.perf_counter() - build_t0,
len(db.tracks), len(db.artists), len(db.albums),
len(db.artwork), len(db.genres),
)
# Genre map summary: largest buckets first; helps spot mistagged tracks.
if genre_to_tracks:
logger.info("genre→tracks map ({} genres):", len(genre_to_tracks))
for genre, track_ids in sorted(
genre_to_tracks.items(), key=lambda kv: (-len(kv[1]), kv[0])
):
logger.info(" {:<32s} {:>3} track(s)", genre[:32], len(track_ids))
no_genre = sum(1 for t in db.tracks if t.genre_id == 0)
if no_genre:
logger.warning("{} of {} tracks have no genre tag", no_genre, len(db.tracks))
# Emit the validation report. Same TrackIssue shape as `just collection`,
# surfaced through loguru so the user gets visibility plus a roll-up at
# the end.
if validation_issues:
logger.warning(
"{} validation issue(s) across {} track(s)",
len(validation_issues),
len({i.track.display_name for i in validation_issues}),
)
log_issues(validation_issues, report=False)
else:
logger.info("validation: all {} tracks passed", len(audio_files))
if not with_playlists: if not with_playlists:
return db return db, written, validation_issues
playlists = scan_playlists(input_dir) playlists = scan_playlists(input_dir)
if playlists:
logger.info("scanning {} playlist(s)", len(playlists))
for sort_order, (name, paths) in enumerate(playlists.items(), start=1): for sort_order, (name, paths) in enumerate(playlists.items(), start=1):
playlist_id = len(db.playlist_nodes) + 1 playlist_id = len(db.playlist_nodes) + 1
db.playlist_nodes.append(PlaylistNode( db.playlist_nodes.append(PlaylistNode(
@@ -132,18 +429,58 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
name=name, name=name,
is_folder=False, is_folder=False,
)) ))
resolved = 0
for entry_index, p in enumerate(paths, start=1): for entry_index, p in enumerate(paths, start=1):
tid = track_by_src.get(p.resolve()) tid = track_by_src.get(p.resolve())
if tid is None: if tid is None:
print(f"warning: playlist {name!r} references missing file {p}", file=sys.stderr) logger.warning("playlist {!r} references missing file {}", name, p)
continue continue
db.playlist_entries.append(PlaylistEntry( db.playlist_entries.append(PlaylistEntry(
playlist_id=playlist_id, playlist_id=playlist_id,
track_id=tid, track_id=tid,
entry_index=entry_index, entry_index=entry_index,
)) ))
resolved += 1
logger.info(" {}: {} entries ({} resolved, {} missing)",
name, len(paths), resolved, len(paths) - resolved)
return db return db, written, validation_issues
def prune_stale(usb_root: Path, kept: set[Path]) -> tuple[int, int]:
"""Delete every file under `usb_root` not in `kept`, then drop empty dirs.
Returns (files_removed, dirs_removed). Operates only under `usb_root`,
so anything outside is untouched. The `kept` set is expected to hold
fully-resolved absolute paths.
"""
files_removed = 0
for p in usb_root.rglob("*"):
if not p.is_file():
continue
if p.resolve() in kept:
continue
try:
p.unlink()
files_removed += 1
except OSError as e:
logger.warning("failed to remove {}: {}", p, e)
# Bottom-up empty-dir removal: deepest first so each parent gets a
# chance to become empty once its children are gone.
dirs = sorted(
(d for d in usb_root.rglob("*") if d.is_dir()),
key=lambda d: len(d.parts),
reverse=True,
)
dirs_removed = 0
for d in dirs:
try:
d.rmdir()
dirs_removed += 1
except OSError:
pass # not empty; expected for the directories we still want
return files_removed, dirs_removed
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -151,16 +488,41 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("input_dir", nargs="?", type=Path, default=Path("./data/input")) parser.add_argument("input_dir", nargs="?", type=Path, default=Path("./data/input"))
parser.add_argument("output_root", nargs="?", type=Path, default=Path("./data/usb")) parser.add_argument("output_root", nargs="?", type=Path, default=Path("./data/usb"))
parser.add_argument("--no-playlists", action="store_true", help="Skip m3u8 playlist scanning") parser.add_argument("--no-playlists", action="store_true", help="Skip m3u8 playlist scanning")
parser.add_argument("--no-prune", action="store_true", help="Skip the post-build sweep of stale files")
parser.add_argument("--strict", action="store_true", help="Fail with non-zero exit if any track fails validation")
args = parser.parse_args(argv) args = parser.parse_args(argv)
configure_logging()
if not args.input_dir.is_dir(): if not args.input_dir.is_dir():
parser.error(f"input_dir {args.input_dir} is not a directory") parser.error(f"input_dir {args.input_dir} is not a directory")
db = build(args.input_dir, args.output_root, with_playlists=not args.no_playlists) overall_t0 = time.perf_counter()
logger.info("export: {} → {}", args.input_dir, args.output_root)
db, written, validation_issues = build(args.input_dir, args.output_root, with_playlists=not args.no_playlists)
pdb_path = args.output_root / "PIONEER" / "rekordbox" / "export.pdb" pdb_path = args.output_root / "PIONEER" / "rekordbox" / "export.pdb"
write_pdb(db, pdb_path) write_pdb(db, pdb_path)
written.add(pdb_path.resolve())
print(f"wrote {len(db.tracks)} tracks, {len(db.artists)} artists, {len(db.albums)} albums, " if not args.no_prune:
f"{len(db.playlist_nodes)} playlists ({len(db.playlist_entries)} entries) to {pdb_path}") t = time.perf_counter()
n_files, n_dirs = prune_stale(args.output_root, written)
if n_files or n_dirs:
logger.info(
"pruned {} stale file(s) + {} empty dir(s) in {:.1f}s",
n_files, n_dirs, time.perf_counter() - t,
)
else:
logger.debug("nothing to prune ({:.1f}s)", time.perf_counter() - t)
logger.info(
"done in {:.1f}s: {} tracks, {} artists, {} albums, "
"{} playlists ({} entries) → {}",
time.perf_counter() - overall_t0,
len(db.tracks), len(db.artists), len(db.albums),
len(db.playlist_nodes), len(db.playlist_entries), pdb_path,
)
if args.strict and validation_issues:
logger.error("--strict: refusing to succeed with {} validation issue(s)", len(validation_issues))
return 1
return 0 return 0
+38
View File
@@ -0,0 +1,38 @@
"""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)
"""
# Re-exports are lazy via __getattr__ so importing `maple.collection.index`
# (or any other submodule) doesn't drag the whole package — `maple.validate`
# depends on `maple.collection.index` and would otherwise circular-import
# through `maple.collection.core` → `maple.validate` → here.
__all__ = ["Collection", "CollectionIndex", "TrackIssue"]
def __getattr__(name):
if name == "Collection" or name == "TrackIssue":
from maple.collection.core import Collection, TrackIssue
return {"Collection": Collection, "TrackIssue": TrackIssue}[name]
if name == "CollectionIndex":
from maple.collection.index import CollectionIndex
return CollectionIndex
raise AttributeError(name)
@@ -0,0 +1,4 @@
"""Entrypoint for `python -m maple.collection`."""
from maple.collection.cli import main
raise SystemExit(main())
+196
View File
@@ -0,0 +1,196 @@
"""`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.enrich import save_spotify_manifest
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)
# Persist the full set of Spotify tracks so `just export` (and any
# later `just collection` run) can enrich tags without re-fetching.
save_spotify_manifest(collection.metadata_dir, playlists)
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.track.display_name, issue.field, 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:]))
+608
View File
@@ -0,0 +1,608 @@
"""`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.analyze import Analysis
from maple.enrich import write_spotify_tags as _shared_write_spotify_tags
from maple.validate import TrackIssue as _ValidateTrackIssue
from maple.validate import validate_track as _validate_track
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
BPM_DIVERGENCE_WARN = 0.05 # warn when aubio vs existing tag differs >5%
# Re-export the shared validator's TrackIssue so callers that imported it
# from maple.collection.core continue to work. There is one TrackIssue
# definition in the codebase — `maple.validate.TrackIssue` — and both the
# `just collection` and `just export` flows produce/consume the same shape.
TrackIssue = _ValidateTrackIssue
_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"
@property
def analysis_dir(self) -> Path:
"""`<root>/_analysis/<isrc>.json` — cached aubio output keyed on ISRC,
consumed by `just export` to skip the decode+detect pass."""
return self.root / "_analysis"
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)
self.analysis_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.track.display_name}: {i.field}: {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:
"""Delegate to the shared `maple.enrich.write_spotify_tags` so the
collection-side and export-side writes are byte-for-byte identical
and can't drift."""
_shared_write_spotify_tags(track, path)
def _existing_bpm(self, path: Path) -> float | None:
"""Return the parsed `bpm` tag value, or None if absent/invalid."""
try:
audio = MutagenFile(path, easy=True)
if audio is None or audio.tags is None:
return None
v = audio.tags.get("bpm")
if not v:
return None
return float(v[0])
except (ValueError, TypeError, Exception):
return None
def _write_bpm_tag(self, path: Path, bpm: float) -> None:
try:
audio = MutagenFile(path, easy=True)
if audio is None:
return
if audio.tags is None:
try:
audio.add_tags()
except Exception:
return
audio.tags["bpm"] = f"{bpm:.2f}"
audio.save()
except Exception as e:
logger.warning("failed to write bpm to {}: {}", path.name, e)
def process_track(
self,
playlist_name: str,
track: dict,
path: Path,
) -> list[TrackIssue]:
"""Reconcile a single downloaded file against Spotify + analyse it.
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.
4. If the file has no `bpm` tag yet, run aubio BPM detection
and write the analytical value. Existing tags are left alone.
Idempotent within a run — paths already processed are no-ops.
"""
if path in self._reconciled:
return []
self._reconciled.add(path)
# Source-of-truth tag writes first: ensures the file mirrors Spotify
# before validation reads its tags back. Mismatches caught here are
# written to the report regardless of the validator's subsequent
# findings (the validator may dedup them if Spotify still disagrees).
self.write_spotify_tags(track, path)
# Single audio analysis pass: BPM + beat grid + waveform envelopes.
# Cached under collection/_analysis/<isrc>.json so `just export`
# reads the bytes back instead of redoing ffmpeg+mixxx. Analysis
# failures propagate — a broken analysis is fatal, not skipped.
isrc = (track.get("isrc") or "").strip()
existing_bpm = self._existing_bpm(path)
analysis = Analysis.analyze(path, isrc=isrc)
if analysis.bpm:
if existing_bpm is None:
self._write_bpm_tag(path, analysis.bpm)
logger.info("{}: bpm {:.2f} (mixxx)", path.name, analysis.bpm)
else:
logger.debug(
"{}: bpm {:.2f} (existing tag, kept; mixxx measured {:.2f})",
path.name, existing_bpm, analysis.bpm,
)
if isrc:
cp = analysis.save(self.analysis_dir)
logger.info("cached analysis for {}: {} → {}", isrc, path.name, cp)
else:
logger.warning(
"skipping analysis cache for {}: no ISRC in spotify track ({} - {})",
path.name, ", ".join(track.get("artists") or []), track.get("title") or "?",
)
# Full cross-cutting validation pass. Runs the SAME checks as
# `just export`, so a clean run here means the track will export
# without issues — and vice versa.
return _validate_track(
path,
spotify_track=track,
analysis_cache_dir=self.analysis_dir,
playlist=playlist_name,
)
# --- validation -------------------------------------------------------- #
async def validate(
self,
session: aiohttp.ClientSession,
playlists: list[dict],
) -> list[TrackIssue]:
"""Per-track Spotify reconciliation + artwork embed.
Spotify is the source of truth: any field that differs from the
downloaded file's tags is recorded as a mismatch issue, then the
file's tags are overwritten with the Spotify values. Missing
embedded artwork is queued for an async batch fetch.
"""
resolved: list[tuple[str, dict, Path]] = []
for pl in playlists:
for t in pl["tracks"]:
path = self.index.lookup(", ".join(t["artists"]), t["title"])
if path is not None:
resolved.append((pl["name"] or pl["id"], t, path))
issues: list[TrackIssue] = []
sem = asyncio.Semaphore(ARTWORK_CONCURRENCY)
art_jobs: list[tuple[str, dict, Path]] = []
for playlist_name, track, path in resolved:
issues.extend(self.process_track(playlist_name, track, path))
if not has_embedded_art(path) and track.get("album_image_url"):
art_jobs.append((playlist_name, track, path))
async def _embed(job: tuple[str, dict, Path]) -> None:
playlist_name, track, path = job
async with sem:
data = await fetch_jpeg(session, track["album_image_url"])
if not data:
issues.append(TrackIssue(
playlist_name, ", ".join(track["artists"]), track["title"],
"artwork download failed",
))
return
try:
embed_art(path, data)
except Exception as e:
issues.append(TrackIssue(
playlist_name, ", ".join(track["artists"]), track["title"],
f"artwork embed failed: {e}",
))
if art_jobs:
await asyncio.gather(*(_embed(j) for j in art_jobs))
return issues
# --- output ------------------------------------------------------------ #
def write_m3u8_for(
self,
playlist: dict,
issues: list[TrackIssue] | None = None,
) -> Path:
"""Write a single playlist's m3u8 against the current index.
Track paths are emitted relative to the m3u8's parent directory so
the playlist is portable across devices that sync the collection.
Pass `issues=None` for incremental rewrites during download — the
list of unresolved tracks gets rebuilt on the final pass instead.
"""
self.playlists_dir.mkdir(parents=True, exist_ok=True)
playlist_name = playlist["name"] or playlist["id"]
out_path = self.playlists_dir / f"{_sanitise_name(playlist_name)}.m3u8"
lines = ["#EXTM3U"]
for t in playlist["tracks"]:
artist = ", ".join(t["artists"])
title = t["title"]
secs = round(t["duration_ms"] / 1000)
path = self.index.lookup(artist, title)
if path is None:
lines.append(f"# NOT FOUND: {artist} - {title}")
if issues is not None:
issues.append(TrackIssue(playlist_name, artist, title, "unresolved in collection"))
continue
rel = os.path.relpath(path.resolve(), start=out_path.parent.resolve())
lines.append(f"#EXTINF:{secs},{artist} - {title}")
lines.append(rel)
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return out_path
def write_m3u8s(
self,
playlists: list[dict],
issues: list[TrackIssue],
) -> list[Path]:
"""One m3u8 per playlist in `<root>/_playlists/`. Returns the paths written."""
return [self.write_m3u8_for(pl, issues) for pl in playlists]
+81
View File
@@ -0,0 +1,81 @@
"""Artist/title normalisation + `CollectionIndex` lookup table.
No I/O. Pure functions plus a small dataclass keyed on normalised
(artist, title) tuples so we can answer "is this track already in the
collection?" against an in-memory map.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
FUZZY_THRESHOLD = 0.85
_PARENS = re.compile(r"\s*[\(\[][^)\]]*[\)\]]\s*")
_NONWORD = re.compile(r"[^\w\s]+", flags=re.UNICODE)
_WS = re.compile(r"\s+")
def norm(s: str) -> str:
"""Lowercase + NFKC + strip non-word, preserves remix/version suffixes."""
s = unicodedata.normalize("NFKC", s or "").lower()
s = _NONWORD.sub(" ", s)
s = _WS.sub(" ", s).strip()
return s
def norm_loose(s: str) -> str:
"""Same as norm() but with parenthesised suffixes removed."""
s = unicodedata.normalize("NFKC", s or "").lower()
s = _PARENS.sub(" ", s)
s = _NONWORD.sub(" ", s)
s = _WS.sub(" ", s).strip()
return s
def first_artist(artists: str) -> str:
"""Split sldl/spotify multi-artist strings on common delimiters."""
if not artists:
return ""
for sep in (",", " feat.", " ft.", " & ", "&", " and ", ";"):
if sep in artists:
return artists.split(sep, 1)[0].strip()
return artists.strip()
def fuzzy_ratio(a: str, b: str) -> float:
"""Cheap token-overlap ratio in [0, 1]. No external dep."""
ta, tb = set(a.split()), set(b.split())
if not ta or not tb:
return 0.0
return len(ta & tb) / max(len(ta), len(tb))
@dataclass
class CollectionIndex:
"""Maps normalised (artist, title) pairs to file paths in the collection."""
by_strict: dict[tuple[str, str], Path] = field(default_factory=dict)
by_loose: dict[tuple[str, str], Path] = field(default_factory=dict)
def add(self, artist: str, title: str, path: Path) -> None:
a, t = norm(first_artist(artist)), norm(title)
if a and t:
self.by_strict.setdefault((a, t), path)
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
if la and lt:
self.by_loose.setdefault((la, lt), path)
def lookup(self, artist: str, title: str) -> Path | None:
a, t = norm(first_artist(artist)), norm(title)
if (a, t) in self.by_strict:
return self.by_strict[(a, t)]
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
if (la, lt) in self.by_loose:
return self.by_loose[(la, lt)]
for (ka, kt), p in self.by_loose.items():
if ka == la and fuzzy_ratio(kt, lt) >= FUZZY_THRESHOLD:
return p
return None
+149
View File
@@ -0,0 +1,149 @@
"""Sockseek subprocess plumbing.
Owns:
* the CLI argument shape (`args_for`) used to run sockseek/sldl,
* the temp-CSV writer that feeds tracks in,
* the async `download()` entrypoint that streams `--progress-json`
events out and forwards sockseek stderr through loguru.
`Collection.download()` is a thin wrapper around `download()` here so the
subprocess concerns stay in one file.
"""
from __future__ import annotations
import asyncio
import csv
import json
import os
import re
import shutil
import sys
import tempfile
from collections.abc import Awaitable, Callable
from pathlib import Path
from loguru import logger
NAME_FORMAT = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
_LEVEL_RE = re.compile(
r"^\s*\[?(?P<lvl>TRACE|TRC|DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR|FATAL|CRITICAL|CRT)\]?\b",
re.IGNORECASE,
)
_LEVEL_MAP = {
"TRC": "TRACE", "TRACE": "TRACE",
"DBG": "DEBUG", "DEBUG": "DEBUG",
"INF": "INFO", "INFO": "INFO",
"WRN": "WARNING", "WARN": "WARNING",
"ERR": "ERROR", "ERROR": "ERROR",
"FATAL": "CRITICAL", "CRT": "CRITICAL", "CRITICAL": "CRITICAL",
}
def log_level_for(line: str) -> str:
"""Map a sockseek/.NET-style log prefix to a loguru level. Default WARNING."""
m = _LEVEL_RE.match(line)
if not m:
return "WARNING"
return _LEVEL_MAP.get(m.group("lvl").upper(), "WARNING")
def args_for(root: Path, index_csv: Path, user: str, password: str) -> list[str]:
"""Mirror of src/sockseek.py:config_args(), pinned for FLAC-strict."""
return [
"--path", str(root),
"--index-path", str(index_csv),
"--user", user,
"--pass", password,
"--name-format", NAME_FORMAT,
"--format", "flac",
"--pref-format", "flac",
"--pref-min-bitrate", "200",
"--pref-max-bitrate", "2500",
"--strict-conditions",
"--fast-search",
"--concurrent-jobs", "4",
]
def write_download_csv(missing: list[dict], fh) -> None:
"""sldl-compatible CSV (Artist,Title,Album,Length)."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for t in missing:
w.writerow([
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
])
async def download(
root: Path,
index_csv: Path,
missing: list[dict],
on_event: Callable[[dict], Awaitable[None]] | None = None,
) -> int:
"""Shell out to sockseek for every track in `missing`.
When `on_event` is provided, sockseek is run with `--progress-json` and
every NDJSON envelope on stdout is parsed and awaited through the
callback. stderr is always piped and forwarded line-by-line through
loguru with a best-effort log-level guess.
"""
if not missing:
return 0
binary = shutil.which("sockseek") or shutil.which("sldl")
if binary is None:
logger.error("sockseek/sldl not on PATH; install slsk-batchdl first")
sys.exit(1)
user = os.environ.get("SOCKSEEK_USER")
password = os.environ.get("SOCKSEEK_PASS")
if not (user and password):
logger.error("set SOCKSEEK_USER and SOCKSEEK_PASS for downloads")
sys.exit(1)
index_csv.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", suffix=".csv", newline="", delete=False) as tf:
write_download_csv(missing, tf)
tf_path = Path(tf.name)
try:
args = [binary, str(tf_path), *args_for(root, index_csv, user, password)]
if on_event is not None:
args.append("--progress-json")
masked = ["***" if a == password else a for a in args]
logger.info("$ {}", " ".join(masked))
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE if on_event is not None else None,
stderr=asyncio.subprocess.PIPE,
)
async def _forward_stderr() -> None:
assert proc.stderr is not None
async for raw in proc.stderr:
line = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace")).rstrip()
if not line:
continue
logger.log(log_level_for(line), "sockseek: {}", line)
stderr_task = asyncio.create_task(_forward_stderr())
if on_event is not None:
assert proc.stdout is not None
async for raw in proc.stdout:
line = raw.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("{"):
continue
try:
evt = json.loads(line)
except ValueError:
continue
await on_event(evt)
rc = await proc.wait()
await stderr_task
return rc
finally:
tf_path.unlink(missing_ok=True)
+103
View File
@@ -0,0 +1,103 @@
"""Per-track validation helpers: ffprobe sanity, duration probe, embedded
artwork inspection + embed, async JPEG fetch for missing cover art.
Pure helpers — no Collection coupling.
"""
from __future__ import annotations
import io
import shutil
import subprocess
from pathlib import Path
import aiohttp
from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC, ID3, ID3NoHeaderError
from mutagen.mp4 import MP4, MP4Cover
DURATION_TOLERANCE_S = 5.0
ARTWORK_CONCURRENCY = 8
def ffprobe_ok(path: Path) -> bool:
if not shutil.which("ffprobe"):
return True # ffprobe absent → don't fail the run
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
capture_output=True, text=True,
)
return r.returncode == 0 and "audio" in r.stdout
def file_duration(path: Path) -> float:
audio = MutagenFile(path)
info = getattr(audio, "info", None)
return float(getattr(info, "length", 0.0) or 0.0)
def has_embedded_art(path: Path) -> bool:
suf = path.suffix.lower()
if suf == ".flac":
try:
return bool(FLAC(path).pictures)
except Exception:
return False
if suf == ".mp3":
try:
return bool(ID3(path).getall("APIC"))
except Exception:
return False
if suf in (".m4a", ".mp4"):
try:
tags = MP4(path).tags
return bool(tags and tags.get("covr"))
except Exception:
return False
return True # unknown format → don't complain
def embed_art(path: Path, jpeg_bytes: bytes) -> None:
suf = path.suffix.lower()
if suf == ".flac":
f = FLAC(path)
pic = Picture()
pic.data = jpeg_bytes
pic.type = 3 # FRONT_COVER
pic.mime = "image/jpeg"
f.clear_pictures()
f.add_picture(pic)
f.save()
elif suf == ".mp3":
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
tags.delall("APIC")
tags.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=jpeg_bytes))
tags.save(path)
elif suf in (".m4a", ".mp4"):
f = MP4(path)
f.tags = f.tags or {}
f["covr"] = [MP4Cover(jpeg_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
f.save()
async def fetch_jpeg(session: aiohttp.ClientSession, url: str) -> bytes | None:
try:
async with session.get(url) as resp:
resp.raise_for_status()
data = await resp.read()
except aiohttp.ClientError:
return None
if data[:3] == b"\xff\xd8\xff":
return data
try:
from PIL import Image
img = Image.open(io.BytesIO(data)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
return buf.getvalue()
except Exception:
return None
+307
View File
@@ -0,0 +1,307 @@
"""Spotify metadata enrichment shared by `just collection` and `just export`.
Per the project convention: a track is "valid" iff every export criterion
holds (artwork, tags, analysis). BOTH pipelines must be capable of
*bringing* a track to that state — validation isn't just reporting,
it's repair.
`just collection`:
1. Fetches Spotify playlists.
2. Persists every track to `<collection>/_metadata/spotify-tracks.json`.
3. Walks the collection, calls `write_spotify_tags` for each match.
`just export`:
1. Loads the manifest written by `just collection`.
2. Walks `data/usb/`'s staged files. For any file missing canonical
tags (typically ISRC), looks up its Spotify entry via the manifest
and calls `write_spotify_tags` BEFORE validation runs.
The shared write function lives here so the two pipelines are
byte-for-byte consistent in what they leave on disk.
"""
from __future__ import annotations
import json
from pathlib import Path
from loguru import logger
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
from maple.collection.index import first_artist, norm
from maple.collection.validate import embed_art, has_embedded_art
MANIFEST_NAME = "spotify-tracks.json"
# --------------------------------------------------------------------------- #
# Manifest persistence #
# --------------------------------------------------------------------------- #
def save_spotify_manifest(metadata_dir: Path, playlists: list[dict]) -> Path:
"""Persist Spotify-fetched playlists so `just export` can read them later.
Stored as a single JSON blob at `<metadata_dir>/spotify-tracks.json`.
Replaces any prior manifest atomically (tmp + rename).
"""
metadata_dir.mkdir(parents=True, exist_ok=True)
target = metadata_dir / MANIFEST_NAME
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(
json.dumps({"playlists": playlists}, indent=2, ensure_ascii=False, default=str)
)
tmp.replace(target)
n = sum(len(p.get("tracks") or []) for p in playlists)
logger.info("wrote spotify manifest: {} tracks across {} playlist(s) → {}",
n, len(playlists), target)
return target
def load_spotify_manifest(
metadata_dir: Path,
) -> tuple[dict[tuple[str, str], dict], dict[str, dict]]:
"""Load the manifest written by `just collection`.
Returns `(by_key, by_isrc)` where `by_key` is indexed by
`(first_artist_norm, title_norm)` and `by_isrc` is indexed by the
uppercased ISRC. Empty dicts if the manifest is absent/unreadable.
"""
target = metadata_dir / MANIFEST_NAME
if not target.exists():
return {}, {}
try:
data = json.loads(target.read_text())
except Exception as e:
logger.warning("could not read spotify manifest at {}: {}", target, e)
return {}, {}
by_key: dict[tuple[str, str], dict] = {}
by_isrc: dict[str, dict] = {}
for pl in data.get("playlists", []):
for t in pl.get("tracks", []):
artist = ", ".join(t.get("artists") or [])
k = (norm(first_artist(artist)), norm(t.get("title") or ""))
by_key.setdefault(k, t)
isrc = (t.get("isrc") or "").strip().upper()
if isrc:
by_isrc.setdefault(isrc, t)
return by_key, by_isrc
def find_spotify_track(
path: Path,
by_key: dict[tuple[str, str], dict],
by_isrc: dict[str, dict],
) -> dict | None:
"""Resolve a file to its Spotify track via ISRC tag, then (artist, title)."""
try:
audio = MutagenFile(path, easy=True)
except Exception:
return None
if audio is None or audio.tags is None:
return None
tags = audio.tags
def first(k: str) -> str:
if hasattr(tags, "get"):
v = tags.get(k)
if v:
return str(v[0])
return ""
isrc = first("isrc").upper().strip()
if isrc and isrc in by_isrc:
return by_isrc[isrc]
artist = first("artist")
title = first("title")
if artist or title:
k = (norm(first_artist(artist)), norm(title))
return by_key.get(k)
return None
# --------------------------------------------------------------------------- #
# Tag writing (the canonical write path) #
# --------------------------------------------------------------------------- #
def write_spotify_tags(track: dict, path: Path) -> bool:
"""Overwrite the file's tags with the Spotify-canonical values.
Both pipelines call this. Writes title, artist (`", ".join`),
album, date, genre (list), and ISRC via mutagen's easy interface.
Empty Spotify values leave the existing tag untouched.
Returns True when at least one tag was written + saved.
"""
try:
audio = MutagenFile(path, easy=True)
except Exception as e:
logger.warning("write_spotify_tags: cannot open {}: {}", path.name, e)
return False
if audio is None:
return False
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 False
wrote_any = False
title = (track.get("title") or "").strip()
if title:
audio.tags["title"] = title
wrote_any = True
artists = track.get("artists") or []
if artists:
audio.tags["artist"] = ", ".join(artists)
wrote_any = True
album = (track.get("album") or "").strip()
if album:
audio.tags["album"] = album
wrote_any = True
date = (track.get("release_date") or "").strip()
if date:
try:
audio.tags["date"] = date
wrote_any = True
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)
wrote_any = True
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
wrote_any = True
except (KeyError, ValueError) as e:
logger.debug("isrc tag write skipped on {}: {}", path.name, e)
if not wrote_any:
return False
try:
audio.save()
return True
except Exception as e:
logger.warning("write_spotify_tags: save failed for {}: {}", path.name, e)
return False
def write_bpm_tag(path: Path, bpm: float, *, overwrite: bool = False) -> bool:
"""Write a `bpm` tag to the file. When `overwrite=False` (the default),
leaves any existing tag value alone — preserves human/label-set BPMs."""
try:
audio = MutagenFile(path, easy=True)
except Exception as e:
logger.warning("write_bpm_tag: cannot open {}: {}", path.name, e)
return False
if audio is None:
return False
if audio.tags is None:
try:
audio.add_tags()
except Exception as e:
logger.warning("write_bpm_tag: cannot init tags on {}: {}", path.name, e)
return False
if not overwrite:
existing = audio.tags.get("bpm") if hasattr(audio.tags, "get") else None
if existing and (existing[0] if isinstance(existing, list) else existing):
return False # keep the existing tag
audio.tags["bpm"] = f"{bpm:.2f}"
try:
audio.save()
return True
except Exception as e:
logger.warning("write_bpm_tag: save failed for {}: {}", path.name, e)
return False
def download_jpeg(url: str, *, timeout: float = 15.0) -> bytes | None:
"""Sync GET of a Spotify album_image_url. Returns the raw bytes or
None on failure. Used by `embed_artwork_from_track` — kept sync so
both `just collection`'s async loop and `just export`'s sync loop
can call into the same helper without contortions."""
if not url:
return None
try:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "maple/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except Exception as e:
logger.warning("artwork fetch failed for {}: {}", url, e)
return None
def embed_artwork_from_track(track: dict, path: Path, *, force: bool = False) -> bool:
"""Fetch the Spotify cover art for `track` and embed it into `path`.
No-op when the file already has embedded art (unless `force=True`).
Skips silently when the track dict has no `album_image_url` (Spotify
occasionally returns tracks without images).
"""
if not force and has_embedded_art(path):
return False
url = (track.get("album_image_url") or "").strip()
if not url:
return False
data = download_jpeg(url)
if not data:
return False
try:
embed_art(path, data)
logger.info(
"embedded artwork for {} (from {})",
path.name, url.rsplit("/", 1)[-1][:40],
)
return True
except Exception as e:
logger.warning("embed_art failed for {}: {}", path.name, e)
return False
def enrich_from_manifest(
path: Path,
by_key: dict[tuple[str, str], dict],
by_isrc: dict[str, dict],
analysis_cache_dir: Path | None = None,
) -> dict | None:
"""Find this file's Spotify entry, bring it to "valid" state, return
the matched track dict (or None if no match).
Steps:
1. Write canonical text tags via `write_spotify_tags`.
2. Fetch + embed album art if the file lacks it (`embed_artwork_from_track`).
3. If `analysis_cache_dir` is supplied and the cache has an entry for
this track's ISRC, write the cached BPM as a tag. This lets the
enrich pass bring a track all the way to "validates" without a
separate aubio-analysis step.
"""
track = find_spotify_track(path, by_key, by_isrc)
if track is None:
return None
if write_spotify_tags(track, path):
logger.info(
"enriched {} from spotify (isrc={!r}, title={!r})",
path.name, (track.get("isrc") or "")[:12], (track.get("title") or "")[:32],
)
embed_artwork_from_track(track, path)
# Backfill BPM tag from the analysis cache when present. Avoids
# re-running aubio here — if the cache is cold, the per-track loop's
# write_anlz will populate it and write the tag in a later pass.
isrc = (track.get("isrc") or "").strip()
if analysis_cache_dir is not None and isrc:
from maple.analyze import Analysis
cached = Analysis.load(analysis_cache_dir, isrc)
if cached is not None and cached.bpm:
write_bpm_tag(path, cached.bpm, overwrite=False)
return track
+20
View File
@@ -0,0 +1,20 @@
"""Loguru sink config for maple's CLIs.
`configure(level)` (re)installs a single stderr sink with a compact format.
Idempotent — safe to call from every CLI entrypoint.
"""
import os
import sys
from loguru import logger
_FMT = "<green>{time:HH:mm:ss}</green> <level>{level:<7}</level> {message}"
def configure(level: str | None = None) -> None:
level = (level or os.environ.get("MAPLE_LOG_LEVEL") or "INFO").upper()
logger.remove()
logger.add(sys.stderr, format=_FMT, level=level, colorize=True, enqueue=False)
__all__ = ["configure", "logger"]
+7 -1
View File
@@ -17,7 +17,13 @@ TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2
# earlier output (data/usb), not a real export. Default to this install's real # earlier output (data/usb), not a real export. Default to this install's real
# value; override per-install if deck testing shows it matters. Players treat # value; override per-install if deck testing shows it matters. Players treat
# the whole blob as opaque. # the whole blob as opaque.
TRACK_OPAQUE_SEED = 0x21B4A7AB # Per-export "seed" stored at offset 0x18 of every TrackRow. Real rekordbox
# files carry the LE u4 0xd6d3a567 here (parses as opaque_seed_lo=0xa567,
# opaque_seed_hi=0xd6d3). Maple previously used 0x21B4A7AB which the kaitai
# would parse as different seed_lo/hi values — the difference may be benign
# for the CDJ's analyse-path lookup but matches every real reference export
# we have, so we mirror it.
TRACK_OPAQUE_SEED = 0xD6D3A567
# Range maple maps its stand-in opaque_track_id_lo (row 0x14) into, so it has the # Range maple maps its stand-in opaque_track_id_lo (row 0x14) into, so it has the
# shape of a real rekordbox Content ID. The real AceMo lo values all fall in this # shape of a real rekordbox Content ID. The real AceMo lo values all fall in this
+18
View File
@@ -2,6 +2,8 @@
from pathlib import Path from pathlib import Path
from struct import pack, unpack_from from struct import pack, unpack_from
from loguru import logger
from maple.model import ( from maple.model import (
DEFAULT_COLUMNS, DEFAULT_COLUMNS,
STANDARD_COLORS, STANDARD_COLORS,
@@ -98,11 +100,21 @@ def _next_sequence(path: Path) -> int:
def write_pdb(db: Database, path: str | Path) -> None: def write_pdb(db: Database, path: str | Path) -> None:
logger.info(
"building pdb → {} (tracks={} artists={} albums={} genres={} "
"artwork={} playlists={} entries={})",
path,
len(db.tracks), len(db.artists), len(db.albums), len(db.genres),
len(db.artwork), len(db.playlist_nodes), len(db.playlist_entries),
)
# Refuse to emit a pdb with dangling foreign keys -- a track pointing at a # Refuse to emit a pdb with dangling foreign keys -- a track pointing at a
# genre/artist/album/artwork row that doesn't exist makes the player follow # genre/artist/album/artwork row that doesn't exist makes the player follow
# the FK into an empty table and freeze (freeze-class, like the deck hang). # the FK into an empty table and freeze (freeze-class, like the deck hang).
fk_errors = validate_referential_integrity(db) fk_errors = validate_referential_integrity(db)
if fk_errors: if fk_errors:
logger.error("FK validation failed with {} error(s)", len(fk_errors))
for e in fk_errors:
logger.error(" {}", e)
raise ValueError("dangling foreign keys (would freeze the player):\n " + "\n ".join(fk_errors)) raise ValueError("dangling foreign keys (would freeze the player):\n " + "\n ".join(fk_errors))
# Layout (matching rekordbox): page 0 is the file header. Pages 1..2*N are # Layout (matching rekordbox): page 0 is the file header. Pages 1..2*N are
@@ -215,6 +227,7 @@ def write_pdb(db: Database, path: str | Path) -> None:
b.next_page = free_sentinel b.next_page = free_sentinel
for b in last_builders: for b in last_builders:
pages_by_index[b.page_index] = b.finalize() pages_by_index[b.page_index] = b.finalize()
overflow_used = overflow_next - (1 + 2 * num_tables)
# Materialise a small free pool of zero-filled pages and set next_unused PAST # Materialise a small free pool of zero-filled pages and set next_unused PAST
# it, mirroring a real export (e.g. data/MAPLE: 71 pages, next_unused=74 -> # it, mirroring a real export (e.g. data/MAPLE: 71 pages, next_unused=74 ->
@@ -232,6 +245,10 @@ def write_pdb(db: Database, path: str | Path) -> None:
next_unused_page = file_page_count + 2 next_unused_page = file_page_count + 2
sequence = _next_sequence(Path(path)) sequence = _next_sequence(Path(path))
logger.info(
"pdb layout: {} pages ({} reserved, {} overflow, {} free), sequence={}",
file_page_count, 2 * num_tables, overflow_used, FREE_POOL_PAGES, sequence,
)
file_header = b"" file_header = b""
file_header += pack("<I", 0) # magic file_header += pack("<I", 0) # magic
@@ -268,3 +285,4 @@ def write_pdb(db: Database, path: str | Path) -> None:
page = pages_by_index.get(idx, blank) page = pages_by_index.get(idx, blank)
assert len(page) == PAGE_SIZE assert len(page) == PAGE_SIZE
f.write(page) f.write(page)
logger.info("wrote {} ({} bytes)", out_path, out_path.stat().st_size)
+249
View File
@@ -0,0 +1,249 @@
"""Dump tracks from one or more Spotify playlists (Client Credentials flow).
just spotify # uses the placeholder ids below
just spotify "<url-or-id> ..." # explicit playlists
just spotify "--file lists.txt --json"
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
just spotify "<url-or-id> --csv --out tracks.csv"
Playlists can be ids, full URLs, or spotify: URIs. Works for any public
playlist; no user login needed. Requires SPOTIFY_CLIENT_ID /
SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just).
--csv writes one combined CSV with Artist,Title,Album,Length columns (Length
in seconds), the format consumed by sockseek/sldl. Each playlist is preceded
by a `# <name>` comment line as a separator. Writes to stdout, or to a file
with --out <path>.
Networking is async via aiohttp: playlists are fetched concurrently.
"""
import asyncio
import base64
import csv
import json
import os
import re
import sys
import urllib.parse
import aiohttp
async def get_token(session: aiohttp.ClientSession, client_id: str, client_secret: str) -> str:
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with session.post(
"https://accounts.spotify.com/api/token",
data={"grant_type": "client_credentials"},
headers={"Authorization": f"Basic {auth}"},
) as resp:
resp.raise_for_status()
body = await resp.json()
return body["access_token"]
async def api_get(session: aiohttp.ClientSession, url: str, token: str) -> dict:
async with session.get(url, headers={"Authorization": f"Bearer {token}"}) as resp:
resp.raise_for_status()
return await resp.json()
def parse_playlist_id(arg: str) -> str:
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg)
return m.group(1) if m else arg.strip()
def read_list_file(path: str) -> list[str]:
out = []
with open(path) as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def _largest_image(images: list[dict] | None) -> str | None:
if not images:
return None
# Spotify returns images sorted largest first, but defend against absent width.
ranked = sorted(images, key=lambda im: (im.get("width") or 0), reverse=True)
return ranked[0].get("url")
async def fetch_playlist(session: aiohttp.ClientSession, pid: str, token: str) -> dict:
meta = await api_get(
session,
f"https://api.spotify.com/v1/playlists/{pid}"
f"?fields={urllib.parse.quote('name,owner(display_name)')}",
token,
)
fields = (
"items(track(id,name,artists(id,name),album(name,images,release_date,release_date_precision),"
"duration_ms,external_ids(isrc),track_number,disc_number)),next"
)
url = (
f"https://api.spotify.com/v1/playlists/{pid}/tracks"
f"?limit=100&fields={urllib.parse.quote(fields)}"
)
tracks = []
while url:
page = await api_get(session, url, token)
for item in page.get("items", []):
t = item.get("track")
if not t:
continue
tracks.append(
{
"id": t.get("id"),
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"artist_ids": [a.get("id") for a in t["artists"] if a.get("id")],
"album": t["album"]["name"],
"album_image_url": _largest_image(t["album"].get("images")),
"release_date": t["album"].get("release_date"),
"release_date_precision": t["album"].get("release_date_precision"),
"duration_ms": t["duration_ms"],
"isrc": (t.get("external_ids") or {}).get("isrc"),
"track_number": t.get("track_number"),
"disc_number": t.get("disc_number"),
"genres": [],
}
)
url = page.get("next")
return {"id": pid, "name": meta.get("name"), "tracks": tracks}
async def fetch_artist_genres(
session: aiohttp.ClientSession,
artist_ids: list[str],
token: str,
) -> dict[str, list[str]]:
"""Batch `/v1/artists?ids=...` (max 50 ids per call). Returns id → genres."""
out: dict[str, list[str]] = {}
seen: set[str] = set()
deduped = [a for a in artist_ids if a and not (a in seen or seen.add(a))]
for i in range(0, len(deduped), 50):
batch = deduped[i:i + 50]
body = await api_get(
session,
f"https://api.spotify.com/v1/artists?ids={','.join(batch)}",
token,
)
for a in body.get("artists", []) or []:
if a and a.get("id"):
out[a["id"]] = a.get("genres") or []
return out
def _enrich_track_genres(playlists: list[dict], genres_by_artist: dict[str, list[str]]) -> None:
"""Populate each track's `genres` field with the union (dedup'd, preserving order)
of its artists' genres."""
for pl in playlists:
for t in pl["tracks"]:
seen: set[str] = set()
union: list[str] = []
for aid in t.get("artist_ids") or []:
for g in genres_by_artist.get(aid, []):
if g not in seen:
seen.add(g)
union.append(g)
t["genres"] = union
def write_csv(playlists, fh):
"""Single combined sockseek/sldl CSV, one `# <name>` comment per playlist."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for pl in playlists:
fh.write(f"# {pl['name']}\n")
for t in pl["tracks"]:
w.writerow(
[
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
]
)
async def fetch_playlists(ids: list[str], with_genres: bool = True) -> list[dict]:
"""Resolve env, open a session, fetch every playlist concurrently.
When `with_genres` is true (default), follow up with a batched
`/v1/artists` call to populate each track's `genres` field.
"""
client_id = os.environ.get("MAPLE_SPOTIFY_CLIENT_ID") or os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("MAPLE_SPOTIFY_CLIENT_SECRET") or os.environ.get("SPOTIFY_CLIENT_SECRET")
if not (client_id and client_secret):
sys.exit("set MAPLE_SPOTIFY_CLIENT_ID and MAPLE_SPOTIFY_CLIENT_SECRET")
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
token = await get_token(session, client_id, client_secret)
playlists = await asyncio.gather(
*(fetch_playlist(session, parse_playlist_id(x), token) for x in ids)
)
if with_genres:
artist_ids: list[str] = []
for pl in playlists:
for t in pl["tracks"]:
artist_ids.extend(t.get("artist_ids") or [])
genres = await fetch_artist_genres(session, artist_ids, token)
_enrich_track_genres(playlists, genres)
return playlists
async def main_async() -> None:
argv = sys.argv[1:]
as_json = "--json" in argv
as_csv = "--csv" in argv
argv = [a for a in argv if a not in ("--json", "--csv")]
inputs: list[str] = []
out_path: str | None = None
i = 0
while i < len(argv):
if argv[i] == "--file":
inputs += read_list_file(argv[i + 1])
i += 2
elif argv[i] == "--out":
out_path = argv[i + 1]
i += 2
else:
inputs.append(argv[i])
i += 1
if not inputs:
sys.exit(
"usage: spotify.py [--json|--csv] <url-or-id ...> "
"[--file list.txt] [--out path]"
)
playlists = await fetch_playlists(inputs)
if as_json:
print(json.dumps(playlists, indent=2, ensure_ascii=False))
return
if as_csv:
if out_path:
with open(out_path, "w", newline="") as fh:
write_csv(playlists, fh)
print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}")
else:
write_csv(playlists, sys.stdout)
return
for pl in playlists:
print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===")
for n, t in enumerate(pl["tracks"], 1):
print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}")
def main() -> None:
asyncio.run(main_async())
if __name__ == "__main__":
main()
+345
View File
@@ -0,0 +1,345 @@
"""Canonical `Track` — the unit shared by `just collection` and `just export`.
A *collection* is a sequence of `Track`s persisted under
`data/collection/`. An *export* reads a sequence of `Track`s and writes a
rekordbox USB at `data/usb/`. This dataclass is the bridge: any code that
talks about "a track" in maple should hold one of these.
`Track.validate()` is the project's single source of truth for what makes
a track "exportable". Every check that used to be scattered across
`maple/validate.py` lives here now; both pipelines call `track.validate()`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from maple import model
if TYPE_CHECKING:
from maple.analyze import Analysis
@dataclass
class TrackIssue:
"""A single failed validation criterion, bound to the `Track` it
came from. Repr-friendly via `__str__`."""
track: "Track"
field: str
reason: str
def __str__(self) -> str:
return f"{self.track.display_name}: {self.field}: {self.reason}"
@dataclass
class Track:
"""Canonical track metadata. Every field has a concrete default so the
type is always populated — "missing" data shows up as `""`/`0`/`False`
and is caught by `validate()`, not by Optional-handling at every call
site."""
# ---- identity --------------------------------------------------------- #
isrc: str = ""
title: str = ""
artists: list[str] = field(default_factory=list)
album: str = ""
# ---- Spotify-derived ------------------------------------------------- #
spotify_track_id: str = ""
spotify_artist_ids: list[str] = field(default_factory=list)
album_image_url: str = ""
release_date: str = "" # "YYYY", "YYYY-MM", or "YYYY-MM-DD"
release_date_precision: str = ""
genres: list[str] = field(default_factory=list)
track_number: int = 0
disc_number: int = 0
duration_ms: int = 0
# ---- on-disk audio --------------------------------------------------- #
# `path` is the only legitimately-Optional field — a Track may exist
# purely as a Spotify entry before the audio has been downloaded.
path: Path | None = None
file_size: int = 0
bitrate: int = 0 # kbps
sample_rate: int = 0 # Hz
sample_depth: int = 0 # bits per sample
has_artwork: bool = False # embedded cover-art presence
has_cached_analysis: bool = False # collection/_analysis/<isrc>.json exists
# ---- analytical ------------------------------------------------------ #
bpm: float = 0.0 # 0.0 = unknown
# ---- provenance ------------------------------------------------------ #
source: str = "" # "spotify", "local", "bandcamp", …
# ---- views ----------------------------------------------------------- #
@property
def primary_artist(self) -> str:
return self.artists[0] if self.artists else ""
@property
def display_name(self) -> str:
if not self.title:
return self.path.name if self.path else "<unknown>"
if self.artists:
return f"{', '.join(self.artists)} - {self.title}"
return self.title
@property
def primary_genre(self) -> str:
return self.genres[0] if self.genres else ""
@property
def year(self) -> int:
"""First 4 digits of release_date as an int (0 if absent/invalid)."""
if not self.release_date or len(self.release_date) < 4:
return 0
try:
return int(self.release_date[:4])
except ValueError:
return 0
@property
def duration_s(self) -> int:
return int(round(self.duration_ms / 1000)) if self.duration_ms else 0
# ---- ingress --------------------------------------------------------- #
@classmethod
def from_spotify(cls, d: dict) -> "Track":
"""Build from one row of `maple.spotify.fetch_playlist`'s output."""
return cls(
isrc=(d.get("isrc") or "").strip(),
title=d.get("title") or "",
artists=list(d.get("artists") or []),
album=d.get("album") or "",
spotify_track_id=d.get("id") or "",
spotify_artist_ids=list(d.get("artist_ids") or []),
album_image_url=d.get("album_image_url") or "",
release_date=d.get("release_date") or "",
release_date_precision=d.get("release_date_precision") or "",
genres=list(d.get("genres") or []),
track_number=int(d.get("track_number") or 0),
disc_number=int(d.get("disc_number") or 0),
duration_ms=int(d.get("duration_ms") or 0),
source="spotify",
)
@classmethod
def from_file(cls, path: Path, *, analysis_cache_dir: Path | None = None) -> "Track":
"""Construct from on-disk audio via mutagen.
When `analysis_cache_dir` is provided, also sets
`has_cached_analysis` from `<cache_dir>/<isrc>.json` so
`.validate()` can check it without a separate call site.
"""
from mutagen import File as MutagenFile
out = cls(path=path, source="local")
try:
audio = MutagenFile(path, easy=True)
except Exception:
return out
if audio is None:
return out
tags = audio.tags or {}
def first(k: str) -> str:
v = tags.get(k) if hasattr(tags, "get") else None
return (v[0] if v else "") if isinstance(v, list) else (v or "")
out.title = first("title")
artist = first("artist")
if artist:
out.artists = [a.strip() for a in artist.split(",") if a.strip()]
out.album = first("album")
out.isrc = first("isrc").strip()
out.release_date = first("date")
gtag = first("genre")
if gtag:
from maple.cli import split_genre_tags
out.genres = split_genre_tags(gtag)
try:
out.bpm = float(first("bpm")) if first("bpm") else 0.0
except ValueError:
out.bpm = 0.0
try:
out.track_number = int(first("tracknumber").split("/")[0])
except ValueError:
out.track_number = 0
info = getattr(audio, "info", None)
if info is not None:
out.duration_ms = int(round((getattr(info, "length", 0.0) or 0.0) * 1000))
bps = int(getattr(info, "bitrate", 0) or 0)
out.bitrate = bps // 1000 if bps > 1000 else bps
out.sample_rate = int(getattr(info, "sample_rate", 0) or 0)
out.sample_depth = int(getattr(info, "bits_per_sample", 0) or 0)
try:
out.file_size = path.stat().st_size
except OSError:
pass
# Presence flags so validate() can decide without extra plumbing.
from maple.collection.validate import has_embedded_art
out.has_artwork = has_embedded_art(path)
if analysis_cache_dir is not None and out.isrc:
from maple.analyze import cache_path_for
cp = cache_path_for(analysis_cache_dir, out.isrc)
out.has_cached_analysis = cp is not None and cp.exists()
return out
# ---- enrichment ------------------------------------------------------ #
def merge_spotify(self, d: dict) -> "Track":
"""Overlay Spotify-canonical fields onto this Track in place. Empty
Spotify fields are NOT applied (no clobbering)."""
if d.get("title"):
self.title = d["title"]
if d.get("artists"):
self.artists = list(d["artists"])
if d.get("album"):
self.album = d["album"]
if d.get("isrc"):
self.isrc = d["isrc"].strip()
if d.get("id"):
self.spotify_track_id = d["id"]
if d.get("artist_ids"):
self.spotify_artist_ids = list(d["artist_ids"])
if d.get("album_image_url"):
self.album_image_url = d["album_image_url"]
if d.get("release_date"):
self.release_date = d["release_date"]
if d.get("release_date_precision"):
self.release_date_precision = d["release_date_precision"]
if d.get("genres"):
self.genres = list(d["genres"])
if d.get("duration_ms"):
self.duration_ms = int(d["duration_ms"])
if d.get("track_number"):
self.track_number = int(d["track_number"])
if d.get("disc_number"):
self.disc_number = int(d["disc_number"])
if not self.source:
self.source = "spotify"
return self
def merge_analysis(self, analysis: "Analysis") -> "Track":
"""Merge fields from a `maple.analyze.Analysis`."""
if analysis.bpm:
self.bpm = float(analysis.bpm)
return self
# ---- validation ------------------------------------------------------ #
def validate(self) -> list[TrackIssue]:
"""Return the list of failed criteria. Empty list = ready to export.
The single place every "is this track exportable?" check lives.
Both `just collection` and `just export` call this — there is no
separate validation entrypoint.
"""
issues: list[TrackIssue] = []
def fail(field_name: str, reason: str) -> None:
issues.append(TrackIssue(self, field_name, reason))
# Required text tags — the spotify-canonical set.
if not self.title:
fail("title", "tag missing")
if not self.artists:
fail("artist", "tag missing")
if not self.album:
fail("album", "tag missing")
if not self.release_date:
fail("date", "tag missing")
if not self.genres:
fail("genre", "tag missing")
if not self.isrc:
fail("isrc", "tag missing")
# Analytical.
if self.bpm <= 0:
fail("bpm", "tag missing")
# On-disk.
if not self.has_artwork:
fail("artwork", "no embedded cover art")
if not self.has_cached_analysis:
fail("analysis", "no cached analysis")
# Sanity — only check when we have something to check.
if self.path is not None and self.duration_ms <= 0:
fail("duration", "≤ 0s — corrupt file or unreadable container")
return issues
def compare(self, canonical: "Track", *, fuzzy_threshold: float = 0.5) -> list[TrackIssue]:
"""Diff this Track against a canonical one (typically a Spotify
`from_spotify` Track). Returns mismatch issues — empty when the
two agree on every populated field."""
from maple.collection.index import first_artist, fuzzy_ratio, norm, norm_loose
out: list[TrackIssue] = []
def diff(field_name: str, sv: object, fv: object) -> None:
out.append(TrackIssue(self, field_name, f"spotify={sv!r} file={fv!r}"))
sp_artist = ", ".join(canonical.artists)
f_artist = ", ".join(self.artists)
if sp_artist and fuzzy_ratio(norm(first_artist(sp_artist)), norm(first_artist(f_artist))) < fuzzy_threshold:
diff("artist", sp_artist, f_artist)
if canonical.title and fuzzy_ratio(norm_loose(canonical.title), norm_loose(self.title)) < fuzzy_threshold:
diff("title", canonical.title, self.title)
if canonical.album and self.album and fuzzy_ratio(norm_loose(canonical.album), norm_loose(self.album)) < fuzzy_threshold:
diff("album", canonical.album, self.album)
elif canonical.album and not self.album:
diff("album", canonical.album, self.album)
if canonical.isrc and self.isrc and canonical.isrc.upper() != self.isrc.upper():
diff("isrc", canonical.isrc, self.isrc)
if canonical.release_date and self.release_date and canonical.release_date[:4] != self.release_date[:4]:
diff("date", canonical.release_date, self.release_date)
if canonical.duration_ms and self.duration_ms:
delta = abs(canonical.duration_ms - self.duration_ms) / 1000.0
if delta > 5.0:
diff("duration", f"{canonical.duration_s}s", f"{self.duration_s}s")
return out
# ---- export adapter -------------------------------------------------- #
def to_pdb_row(
self,
*,
track_id: int,
artist_id: int,
album_id: int,
genre_id: int = 0,
artwork_id: int = 0,
file_path: str,
analyze_path: str = "",
date_added: str = "",
tempo_x100: int | None = None,
) -> model.Track:
"""Project this canonical Track into the denormalised pdb row shape."""
if tempo_x100 is None:
tempo_x100 = round(self.bpm * 100) if self.bpm else 0
return model.Track(
id=track_id,
title=self.title,
artist_id=artist_id,
album_id=album_id,
filename=Path(file_path).name,
file_path=file_path,
file_size=self.file_size,
sample_rate=self.sample_rate or 44100,
sample_depth=self.sample_depth or 16,
bitrate=self.bitrate,
duration=self.duration_s,
track_number=self.track_number,
year=self.year,
disc_number=self.disc_number,
tempo=tempo_x100,
release_date=self.release_date,
date_added=date_added,
analyze_path=analyze_path,
analyze_date=date_added,
artwork_id=artwork_id,
genre_id=genre_id,
source_path=self.path,
)
+47
View File
@@ -0,0 +1,47 @@
"""Thin compatibility layer over `Track.validate()`.
Validation logic lives on `maple.track.Track` itself — this module just
re-exports `TrackIssue` and offers helpers so callers don't all need to
construct `Track` objects inline.
The `validate_track(path, ...)` wrapper is kept for the few sites that
hand around `Path`s rather than `Track`s; new code should prefer
`Track.from_file(path).validate()`.
"""
from __future__ import annotations
from pathlib import Path
from loguru import logger
from maple.track import Track, TrackIssue
__all__ = ["Track", "TrackIssue", "validate_track", "log_issues"]
def validate_track(
path: Path,
*,
spotify_track: dict | None = None,
analysis_cache_dir: Path | None = None,
playlist: str = "",
) -> list[TrackIssue]:
"""Construct a `Track` from `path` and run its validator.
`playlist` is unused — kept in the signature for API compatibility
with callers that still pass it.
"""
del playlist # not used; TrackIssue carries the track now
track = Track.from_file(path, analysis_cache_dir=analysis_cache_dir)
issues = track.validate()
if spotify_track is not None:
issues.extend(track.compare(Track.from_spotify(spotify_track)))
return issues
def log_issues(issues: list[TrackIssue], *, report: bool = True) -> None:
"""Emit every issue via `loguru.warning`. With `report=True`, also
binds `extra["report"]=True` so a report-file sink can capture them."""
bound = logger.bind(report=True) if report else logger
for i in issues:
bound.warning("{}", i)
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = []
# ///
"""Dump tracks from one or more Spotify playlists (Client Credentials flow).
just spotify # uses the placeholder ids below
just spotify "<url-or-id> ..." # explicit playlists
just spotify "--file lists.txt --json"
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
just spotify "<url-or-id> --csv --out tracks.csv"
Playlists can be ids, full URLs, or spotify: URIs. Works for any public
playlist; no user login needed. Requires SPOTIFY_CLIENT_ID /
SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just).
--csv writes one combined CSV with Artist,Title,Album,Length columns (Length
in seconds), the format consumed by sockseek/sldl. Each playlist is preceded
by a `# <name>` comment line as a separator. Writes to stdout, or to a file
with --out <path>.
"""
import base64
import csv
import json
import os
import re
import sys
import urllib.parse
import urllib.request
def get_token(client_id, client_secret):
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
data = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=data,
headers={"Authorization": f"Basic {auth}"},
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)["access_token"]
def api_get(url, token):
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
def parse_playlist_id(arg):
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg)
return m.group(1) if m else arg.strip()
def read_list_file(path):
out = []
with open(path) as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def fetch_playlist(pid, token):
meta = api_get(
f"https://api.spotify.com/v1/playlists/{pid}"
f"?fields={urllib.parse.quote('name,owner(display_name)')}",
token,
)
fields = "items(track(name,artists(name),album(name),duration_ms)),next"
url = (
f"https://api.spotify.com/v1/playlists/{pid}/tracks"
f"?limit=100&fields={urllib.parse.quote(fields)}"
)
tracks = []
while url:
page = api_get(url, token)
for item in page.get("items", []):
t = item.get("track")
if not t:
continue
tracks.append(
{
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"album": t["album"]["name"],
"duration_ms": t["duration_ms"],
}
)
url = page.get("next")
return {"id": pid, "name": meta.get("name"), "tracks": tracks}
def write_csv(playlists, fh):
"""Single combined sockseek/sldl CSV, one `# <name>` comment per playlist."""
w = csv.writer(fh)
w.writerow(["Artist", "Title", "Album", "Length"])
for pl in playlists:
fh.write(f"# {pl['name']}\n")
for t in pl["tracks"]:
w.writerow(
[
", ".join(t["artists"]),
t["title"],
t["album"],
round(t["duration_ms"] / 1000),
]
)
def main():
argv = sys.argv[1:]
as_json = "--json" in argv
as_csv = "--csv" in argv
argv = [a for a in argv if a not in ("--json", "--csv")]
inputs = []
out_path = None
i = 0
while i < len(argv):
if argv[i] == "--file":
inputs += read_list_file(argv[i + 1])
i += 2
elif argv[i] == "--out":
out_path = argv[i + 1]
i += 2
else:
inputs.append(argv[i])
i += 1
if not inputs:
sys.exit(
"usage: spotify.py [--json|--csv] <url-or-id ...> "
"[--file list.txt] [--out path]"
)
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
if not (client_id and client_secret):
sys.exit("set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET")
token = get_token(client_id, client_secret)
playlists = [fetch_playlist(parse_playlist_id(x), token) for x in inputs]
if as_json:
print(json.dumps(playlists, indent=2, ensure_ascii=False))
return
if as_csv:
if out_path:
with open(out_path, "w", newline="") as fh:
write_csv(playlists, fh)
print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}")
else:
write_csv(playlists, sys.stdout)
return
for pl in playlists:
print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===")
for n, t in enumerate(pl["tracks"], 1):
print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}")
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = ["kaitaistruct>=0.11", "pyrekordbox>=0.4.4"]
# ///
"""Decode a rekordbox USB export so we can spot how the analyze-path
bucket/id is derived from track metadata.
Reads `export.pdb` from a rekordbox-built USB stick (or `data/q1-rekordbox`)
plus optionally cross-references the rekordbox machine's `master.db` to
correlate Mac-internal UUIDs with the USB-export short hex ids.
Usage:
uv run tools/decode_rb_export.py <usb-root> [<master.db>]
`<usb-root>` is the directory holding `PIONEER/rekordbox/export.pdb`.
With `<master.db>` we also list the UUID + ContentLink for each track so
the per-track UUID→USB-id derivation can be eye-balled across rows.
"""
import re
import sys
from pathlib import Path
# Make the in-project kaitai schemas importable without an editable install.
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from kaitai.rekordbox_pdb import RekordboxPdb # noqa: E402
from kaitaistruct import KaitaiStream # noqa: E402
ANLZ_PATH_RE = re.compile(r"/PIONEER/USBANLZ/([^/]+)/([^/]+)/ANLZ0000\.DAT")
def iter_rows(pdb: RekordboxPdb, page_type):
"""Walk a table's page list, yielding every present row body.
Tolerates the page chain pointing past EOF (real exports do this on
the last page — `next_page.index >= num_pages` is a sentinel)."""
for tref in pdb.tables:
if tref.type != page_type:
continue
pref = tref.first_page
seen = set()
while pref is not None and pref.index not in seen:
seen.add(pref.index)
try:
page = pref.body
except Exception:
break # past-EOF next_page (free-pool sentinel)
if page.is_data_page:
for rg in page.row_groups:
for rref in rg.rows:
if rref.present:
yield rref.body
try:
nxt = page.next_page
except Exception:
break
if nxt is None or nxt.index == 0 or nxt.index == pref.index:
break
pref = nxt
def read_pdb_tracks(pdb_path: Path) -> list[dict]:
"""Return one dict per track row: id, title, file_path, analyze_path,
plus every opaque/seed field plus parsed bucket+anlz_id."""
with pdb_path.open("rb") as f:
pdb = RekordboxPdb(False, KaitaiStream(f))
tracks = []
for r in iter_rows(pdb, RekordboxPdb.PageType.tracks):
ap = r.analyze_path.body.text
m = ANLZ_PATH_RE.match(ap)
bucket = anlz_id = ""
if m:
bucket, anlz_id = m.group(1), m.group(2)
tracks.append({
"track_id": r.id,
"title": r.title.body.text,
"file_path": r.file_path.body.text,
"analyze_path": ap,
"bucket": bucket,
"anlz_id": anlz_id,
"opaque_track_id_lo": r.opaque_track_id_lo,
"opaque_seed_lo": r.opaque_seed_lo,
"opaque_seed_hi": r.opaque_seed_hi,
"tempo": r.tempo,
"duration": r.duration,
"file_size": r.file_size,
"bitmask": r.bitmask,
})
return tracks
def maybe_attach_master_db(tracks: list[dict], master_db_path: Path) -> None:
"""Look up each track in master.db by file path and attach UUID/etc."""
try:
from pyrekordbox import Rekordbox6Database
from pyrekordbox.db6 import tables as t
except ImportError as e:
print(f"(pyrekordbox unavailable: {e}; skipping master.db lookup)", file=sys.stderr)
return
db = Rekordbox6Database(path=str(master_db_path))
for tr in tracks:
# Look up by file name as a fallback — full FolderPath may differ if
# the rekordbox machine mounts the source from a different prefix.
title = tr["title"]
rows = db.session.query(t.DjmdContent).filter(t.DjmdContent.Title == title).limit(3).all()
if not rows:
tr["rb_uuid"] = ""
tr["rb_content_link"] = 0
tr["rb_file_id"] = ""
tr["rb_master_song_id"] = ""
continue
# Prefer one whose FolderPath ends with the same file name
fname = Path(tr["file_path"]).name
match = next((r for r in rows if (r.FolderPath or "").endswith(fname)), rows[0])
tr["rb_uuid"] = match.UUID or ""
tr["rb_content_link"] = match.ContentLink or 0
tr["rb_file_id"] = match.rb_file_id or ""
tr["rb_master_song_id"] = match.MasterSongID or ""
def fmt_int(v, w=10):
if not isinstance(v, int):
return f"{str(v):<{w}}"
return f"{v:>{w-2}d}=0x{v:08x}"[-w:].rjust(w)
def main(argv: list[str]) -> int:
if len(argv) < 1:
sys.exit(__doc__)
usb_root = Path(argv[0]).expanduser()
pdb_path = usb_root / "PIONEER" / "rekordbox" / "export.pdb"
if not pdb_path.exists():
sys.exit(f"export.pdb not found at {pdb_path}")
tracks = read_pdb_tracks(pdb_path)
print(f"# Read {len(tracks)} tracks from {pdb_path}")
if len(argv) >= 2:
master_db = Path(argv[1]).expanduser()
if master_db.exists():
maybe_attach_master_db(tracks, master_db)
print(f"# Cross-referenced against {master_db}")
else:
print(f"# (master.db at {master_db} not found, skipping)")
# Output: one TSV row per track plus a summary at the end.
cols = ["track_id", "bucket", "anlz_id", "opaque_track_id_lo",
"opaque_seed_lo", "opaque_seed_hi",
"rb_uuid", "rb_content_link", "rb_file_id", "rb_master_song_id",
"title"]
print()
print("\t".join(cols))
for tr in tracks:
row = []
for c in cols:
v = tr.get(c, "")
if isinstance(v, int):
row.append(f"{v}=0x{v:x}" if v > 255 else str(v))
else:
row.append(str(v)[:64])
print("\t".join(row))
# Quick correlation tests: print numeric distances + ratios so a
# multi-track export shows obvious patterns at a glance.
if len(tracks) >= 2:
print("\n# Successive anlz_id deltas (probe for sequential counter):")
ids = []
for tr in tracks:
try:
ids.append(int(tr["anlz_id"], 16))
except (ValueError, TypeError):
pass
ids.sort()
for a, b in zip(ids, ids[1:]):
print(f" {a:08x} → {b:08x} Δ = {b-a}")
print(f"\n# anlz_id range: {min(ids):08x} .. {max(ids):08x}")
print(f"# bucket set: {sorted({tr['bucket'] for tr in tracks if tr['bucket']})}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+345
View File
@@ -2,6 +2,94 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" 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]] [[package]]
name = "bidict" name = "bidict"
version = "0.23.1" 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" }, { 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.1" 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" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -86,15 +224,32 @@ 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" }, { 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]] [[package]]
name = "maple" name = "maple"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" },
{ name = "kaitaistruct" }, { name = "kaitaistruct" },
{ name = "loguru" },
{ name = "mixxx-analyzer" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pyrekordbox" }, { name = "pyrekordbox" },
{ name = "scipy" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -104,15 +259,74 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.10" },
{ name = "kaitaistruct", specifier = ">=0.11" }, { name = "kaitaistruct", specifier = ">=0.11" },
{ name = "loguru", specifier = ">=0.7" },
{ name = "mixxx-analyzer", specifier = ">=0.1.2" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.2.0" }, { name = "pillow", specifier = ">=12.2.0" },
{ name = "pyrekordbox", specifier = ">=0.4.4" }, { name = "pyrekordbox", specifier = ">=0.4.4" },
{ name = "scipy", specifier = ">=1.10" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }] dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "mixxx-analyzer"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/b3/c4026eed48e7685a99212ede8a1ce22bd2442a7d677ec4b970a7718d5b23/mixxx_analyzer-0.1.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:cb509c06d630fad709f85dd07fbd21a029d337617de0866af74279ca0a1f596b", size = 13932797, upload-time = "2026-03-01T15:44:59.349Z" },
{ url = "https://files.pythonhosted.org/packages/a6/30/bf8afbb52d862d2976045b4f6d00456dcb6320bb5e449384a41a0ce40735/mixxx_analyzer-0.1.2-py3-none-manylinux_2_35_x86_64.whl", hash = "sha256:53d8ec329bae7a7714dfe1cd34cbcf3976ddb55fae0ed74c4872f47085a9d9cf", size = 56567723, upload-time = "2026-03-01T15:45:02.771Z" },
{ url = "https://files.pythonhosted.org/packages/2e/88/e22c9e7c3d862daec693de61c5f2843ce8e567cd4d5e866040c599b57e9d/mixxx_analyzer-0.1.2-py3-none-win_amd64.whl", hash = "sha256:cc6509acfc78f0f3e04ac9f0b1440e9cd31a3c389f888bbd91beae1f249c32b7", size = 44947103, upload-time = "2026-03-01T15:45:05.66Z" },
]
[[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]] [[package]]
name = "mutagen" name = "mutagen"
version = "1.47.0" version = "1.47.0"
@@ -202,6 +416,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" }, { 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]] [[package]]
name = "psutil" name = "psutil"
version = "7.2.2" version = "7.2.2"
@@ -280,6 +537,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" },
{ url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" },
{ url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" },
{ url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" },
{ url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" },
{ url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" },
{ url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" },
{ url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" },
{ url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" },
{ url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@@ -364,3 +652,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [ 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" }, { 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" },
]
-17
View File
@@ -1,17 +0,0 @@
# maple-ts
TypeScript/Bun port of `maple-py`. Builds a rekordbox `export.pdb` USB layout from a folder of audio files and `.m3u8` playlists.
## Install
```sh
bun install
```
## Run
```sh
bun run src/cli.ts ./data/input ./data/usb
```
Requires `ffmpeg` on `PATH` for FLAC -> MP3 conversion.
-22
View File
@@ -1,22 +0,0 @@
{
"name": "maple",
"version": "0.1.0",
"description": "Build a rekordbox USB from a folder of audio + m3u8 playlists",
"type": "module",
"module": "src/cli.ts",
"bin": {
"maple": "src/cli.ts"
},
"scripts": {
"maple": "bun run src/cli.ts",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node-id3": "^0.2.6"
},
"dependencies": {
"music-metadata": "^10.5.0",
"node-id3": "^0.2.6"
}
}
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env bun
/** maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB. */
import { stat } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path";
import os from "node:os";
import { convertFlacToMp3, verifyDuration } from "./convert.ts";
import { stageAudio, stagedPath } from "./copy.ts";
import { readTags } from "./meta.ts";
import { newDatabase, newTrack, type Database } from "./model.ts";
import { writePdb } from "./pdb/writer.ts";
import { scanAudio, scanPlaylists } from "./scan.ts";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function withSuffix(file: string, newExt: string): string {
const ext = extname(file);
return file.slice(0, file.length - ext.length) + newExt;
}
async function isDir(p: string): Promise<boolean> {
try { return (await stat(p)).isDirectory(); } catch { return false; }
}
async function pMap<T, R>(items: T[], workers: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let i = 0;
const runners = Array.from({ length: Math.max(1, Math.min(workers, items.length)) }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
out[idx] = await fn(items[idx]!);
}
});
await Promise.all(runners);
return out;
}
export async function build(inputDir: string, usbRoot: string): Promise<Database> {
const db = newDatabase();
const dateAdded = today();
const artistIndex = new Map<string, number>();
const albumIndex = new Map<string, number>();
const trackBySrc = new Map<string, number>();
const audioFiles = await scanAudio(inputDir);
const initialMeta = new Map<string, Awaited<ReturnType<typeof readTags>>>();
for (const p of audioFiles) initialMeta.set(p, await readTags(p));
const flacJobs: Array<{ src: string; dst: string }> = [];
for (const original of audioFiles) {
if (extname(original).toLowerCase() === ".flac") {
const m = initialMeta.get(original)!;
const mp3Name = withSuffix(basename(original), ".mp3");
const { destAbs } = stagedPath(usbRoot, m.artist, m.album, mp3Name);
flacJobs.push({ src: original, dst: destAbs });
}
}
const workers = Math.max(1, os.cpus().length || 1);
const results = await pMap(flacJobs, workers, async (job) => {
const converted = await convertFlacToMp3(job.src, job.dst);
return { ...job, converted };
});
for (const r of results) {
const rel = r.src.startsWith(inputDir) ? r.src.slice(inputDir.length).replace(/^[\/\\]+/, "") : r.src;
console.log(`${r.converted ? "convert" : "cached "} ${rel}`);
await verifyDuration(r.src, r.dst);
}
for (const original of audioFiles) {
let meta = initialMeta.get(original)!;
let artistId = artistIndex.get(meta.artist);
if (artistId === undefined) {
artistId = db.artists.length + 1;
artistIndex.set(meta.artist, artistId);
db.artists.push({ id: artistId, name: meta.artist });
}
const albumKey = `${artistId}\x00${meta.album}`;
let albumId = albumIndex.get(albumKey);
if (albumId === undefined) {
albumId = db.albums.length + 1;
albumIndex.set(albumKey, albumId);
db.albums.push({ id: albumId, name: meta.album, artist_id: artistId });
}
let filePath: string;
if (extname(original).toLowerCase() === ".flac") {
const mp3Name = withSuffix(basename(original), ".mp3");
const s = stagedPath(usbRoot, meta.artist, meta.album, mp3Name);
filePath = s.filePath;
meta = await readTags(s.destAbs);
} else {
const s = await stageAudio(original, usbRoot, meta.artist, meta.album);
filePath = s.filePath;
}
const trackId = db.tracks.length + 1;
db.tracks.push(newTrack({
id: trackId,
title: meta.title,
artist_id: artistId,
album_id: albumId,
filename: basename(filePath),
file_path: filePath,
file_size: meta.file_size,
sample_rate: meta.sample_rate,
sample_depth: meta.sample_depth,
bitrate: meta.bitrate,
duration: meta.duration,
track_number: meta.track_number,
year: meta.year,
date_added: dateAdded,
source_path: original,
}));
trackBySrc.set(resolve(original), trackId);
}
const playlists = await scanPlaylists(inputDir);
let sortOrder = 1;
for (const [name, paths] of playlists) {
const playlistId = db.playlist_nodes.length + 1;
db.playlist_nodes.push({
id: playlistId,
parent_id: 0,
sort_order: sortOrder,
name,
is_folder: false,
});
sortOrder += 1;
let entryIndex = 1;
for (const p of paths) {
const tid = trackBySrc.get(resolve(p));
if (tid === undefined) {
console.error(`warning: playlist '${name}' references missing file ${p}`);
continue;
}
db.playlist_entries.push({
playlist_id: playlistId,
track_id: tid,
entry_index: entryIndex,
});
entryIndex += 1;
}
}
return db;
}
async function main(argv: string[]): Promise<number> {
const args = argv.slice(2);
const inputDir = args[0] ?? "./data/input";
const outputRoot = args[1] ?? "./data/usb";
if (!(await isDir(inputDir))) {
console.error(`error: input_dir ${inputDir} is not a directory`);
return 2;
}
const db = await build(inputDir, outputRoot);
const pdbPath = join(outputRoot, "PIONEER", "rekordbox", "export.pdb");
await writePdb(db, pdbPath);
console.log(
`wrote ${db.tracks.length} tracks, ${db.artists.length} artists, ${db.albums.length} albums, ` +
`${db.playlist_nodes.length} playlists (${db.playlist_entries.length} entries) to ${pdbPath}`
);
return 0;
}
if (import.meta.main) {
process.exit(await main(process.argv));
}
-90
View File
@@ -1,90 +0,0 @@
/**
* FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup.
*
* The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3
* frame on the produced MP3. Reruns skip conversion when the existing MP3
* carries the matching hash, so the cache is content-addressed against inputs.
*/
import { mkdir, access } from "node:fs/promises";
import { dirname } from "node:path";
import { parseFile } from "music-metadata";
import NodeID3 from "node-id3";
const HASH_FRAME_DESC = "MAPLE_SRC_SHA256";
export async function fileSha256(path: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
const stream = Bun.file(path).stream();
for await (const chunk of stream) hasher.update(chunk);
return hasher.digest("hex");
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
function readSourceHash(mp3: string): string | null {
const tags = NodeID3.read(mp3);
const frames = tags.userDefinedText;
if (!frames) return null;
for (const f of frames) {
if (f.description === HASH_FRAME_DESC && f.value) return f.value;
}
return null;
}
function writeSourceHash(mp3: string, sha: string): void {
NodeID3.update({ userDefinedText: [{ description: HASH_FRAME_DESC, value: sha }] }, mp3);
}
async function which(cmd: string): Promise<boolean> {
const p = Bun.spawn({ cmd: ["which", cmd], stdout: "pipe", stderr: "pipe" });
await p.exited;
return p.exitCode === 0;
}
export async function convertFlacToMp3(flac: string, mp3: string): Promise<boolean> {
const srcHash = await fileSha256(flac);
if (await exists(mp3)) {
try {
if (readSourceHash(mp3) === srcHash) return false;
} catch {}
}
if (!(await which("ffmpeg"))) throw new Error("ffmpeg not found on PATH");
await mkdir(dirname(mp3), { recursive: true });
const proc = Bun.spawn({
cmd: [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", flac,
"-codec:a", "libmp3lame",
"-q:a", "0",
"-joint_stereo", "1",
"-map_metadata", "0",
mp3,
],
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
if (proc.exitCode !== 0) throw new Error(`ffmpeg failed for ${flac}`);
writeSourceHash(mp3, srcHash);
return true;
}
async function duration(path: string): Promise<number> {
try {
const md = await parseFile(path, { duration: true });
return md.format.duration ?? 0;
} catch {
return 0;
}
}
export async function verifyDuration(src: string, dst: string, tolerance = 0.1): Promise<void> {
const [a, b] = await Promise.all([duration(src), duration(dst)]);
if (Math.abs(a - b) > tolerance) {
throw new Error(`duration mismatch: ${src} = ${a.toFixed(3)}s vs ${dst} = ${b.toFixed(3)}s (Δ ${Math.abs(a - b).toFixed(3)}s)`);
}
}
-29
View File
@@ -1,29 +0,0 @@
import { dirname, join, basename } from "node:path";
import { mkdir, copyFile, access } from "node:fs/promises";
const BAD = /[\\/:*?"<>|\x00-\x1f]/g;
export function sanitise(component: string): string {
const s = component.replace(BAD, "_").replace(/^[ .]+|[ .]+$/g, "");
return s || "_";
}
export function stagedPath(usbRoot: string, artist: string, album: string, filename: string): { destAbs: string; filePath: string } {
const relParts = ["Contents", sanitise(artist), sanitise(album), sanitise(filename)];
const destAbs = join(usbRoot, ...relParts);
const filePath = "/" + relParts.join("/");
return { destAbs, filePath };
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
export async function stageAudio(src: string, usbRoot: string, artist: string, album: string): Promise<{ destAbs: string; filePath: string }> {
const { destAbs, filePath } = stagedPath(usbRoot, artist, album, basename(src));
await mkdir(dirname(destAbs), { recursive: true });
if (!(await exists(destAbs))) {
await copyFile(src, destAbs);
}
return { destAbs, filePath };
}
-62
View File
@@ -1,62 +0,0 @@
import { parseFile } from "music-metadata";
import { basename, extname } from "node:path";
import { stat } from "node:fs/promises";
export interface TrackMeta {
title: string;
artist: string;
album: string;
track_number: number;
year: number;
duration: number;
bitrate: number;
sample_rate: number;
sample_depth: number;
file_size: number;
}
function toInt(v: unknown): number {
if (v === null || v === undefined) return 0;
let s = String(Array.isArray(v) ? v[0] : v).trim();
if (!s) return 0;
s = s.split("/")[0]!;
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : 0;
}
export async function readTags(path: string): Promise<TrackMeta> {
const { size: file_size } = await stat(path);
const stem = basename(path, extname(path));
try {
const md = await parseFile(path, { duration: true });
const c = md.common;
const info = md.format;
const bitrate_bps = info.bitrate ?? 0;
const bitrate = bitrate_bps > 1000 ? Math.floor(bitrate_bps / 1000) : bitrate_bps;
return {
title: c.title || stem,
artist: c.artist || "Unknown Artist",
album: c.album || "Unknown Album",
track_number: toInt(c.track?.no),
year: toInt(c.year ?? c.date),
duration: Math.floor(info.duration ?? 0),
bitrate,
sample_rate: info.sampleRate ?? 44100,
sample_depth: info.bitsPerSample ?? 16,
file_size,
};
} catch {
return {
title: stem,
artist: "Unknown Artist",
album: "Unknown Album",
track_number: 0,
year: 0,
duration: 0,
bitrate: 0,
sample_rate: 44100,
sample_depth: 16,
file_size,
};
}
}
-83
View File
@@ -1,83 +0,0 @@
export interface Artist {
id: number;
name: string;
}
export interface Album {
id: number;
name: string;
artist_id: number;
}
export interface Track {
id: number;
title: string;
artist_id: number;
album_id: number;
filename: string;
file_path: string;
file_size: number;
sample_rate: number;
sample_depth: number;
bitrate: number;
duration: number;
track_number: number;
year: number;
disc_number: number;
tempo: number;
comment: string;
date_added: string;
release_date: string;
mix_name: string;
analyze_path: string;
analyze_date: string;
source_path: string | null;
}
export interface PlaylistNode {
id: number;
parent_id: number;
sort_order: number;
name: string;
is_folder: boolean;
}
export interface PlaylistEntry {
playlist_id: number;
track_id: number;
entry_index: number;
}
export interface Database {
tracks: Track[];
artists: Artist[];
albums: Album[];
playlist_nodes: PlaylistNode[];
playlist_entries: PlaylistEntry[];
}
export function newDatabase(): Database {
return { tracks: [], artists: [], albums: [], playlist_nodes: [], playlist_entries: [] };
}
export function newTrack(init: Pick<Track, "id" | "title" | "artist_id" | "album_id" | "filename" | "file_path"> & Partial<Track>): Track {
return {
file_size: 0,
sample_rate: 44100,
sample_depth: 16,
bitrate: 0,
duration: 0,
track_number: 0,
year: 0,
disc_number: 0,
tempo: 0,
comment: "",
date_added: "",
release_date: "",
mix_name: "",
analyze_path: "",
analyze_date: "",
source_path: null,
...init,
};
}
-44
View File
@@ -1,44 +0,0 @@
/** Small little-endian write helpers backed by Uint8Array. */
export class LE {
parts: Uint8Array[] = [];
private len = 0;
push(u: Uint8Array): void { this.parts.push(u); this.len += u.length; }
u8(v: number): void {
const b = new Uint8Array(1); b[0] = v & 0xff; this.push(b);
}
u16(v: number): void {
const b = new Uint8Array(2);
b[0] = v & 0xff;
b[1] = (v >> 8) & 0xff;
this.push(b);
}
u32(v: number): void {
const b = new Uint8Array(4);
b[0] = v & 0xff;
b[1] = (v >>> 8) & 0xff;
b[2] = (v >>> 16) & 0xff;
b[3] = (v >>> 24) & 0xff;
this.push(b);
}
bytes(b: Uint8Array): void { this.push(b); }
length(): number { return this.len; }
build(): Uint8Array {
const out = new Uint8Array(this.len);
let o = 0;
for (const p of this.parts) { out.set(p, o); o += p.length; }
return out;
}
}
export function concat(parts: Uint8Array[]): Uint8Array {
let len = 0;
for (const p of parts) len += p.length;
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
-95
View File
@@ -1,95 +0,0 @@
import { LE } from "./buf.ts";
export const PAGE_SIZE = 4096;
const HEADER_SIZE = 40;
const ROW_GROUP_SIZE = 36;
const ROWS_PER_GROUP = 16;
function align4(o: number): number { return (o + 3) & ~3; }
function numRowGroups(n: number): number { return Math.floor((n + ROWS_PER_GROUP - 1) / ROWS_PER_GROUP); }
export class PageBuilder {
pageIndex: number;
pageType: number;
nextPage: number;
rowBytes: Uint8Array[] = [];
rowOffsets: number[] = [];
heapCursor = 0;
constructor(pageIndex: number, pageType: number) {
this.pageIndex = pageIndex;
this.pageType = pageType;
this.nextPage = pageIndex;
}
tryAdd(row: Uint8Array): boolean {
const aligned = align4(this.heapCursor);
const end = aligned + row.length;
const footerAfter = numRowGroups(this.rowBytes.length + 1) * ROW_GROUP_SIZE;
if (end + HEADER_SIZE + footerAfter > PAGE_SIZE) return false;
this.rowOffsets.push(aligned);
this.rowBytes.push(row);
this.heapCursor = end;
return true;
}
numRows(): number { return this.rowBytes.length; }
finalize(): Uint8Array {
const n = this.rowBytes.length;
const numRgs = numRowGroups(n);
const footerSize = numRgs * ROW_GROUP_SIZE;
const heapPadding = PAGE_SIZE - HEADER_SIZE - footerSize;
const usedSize = this.heapCursor;
const freeSize = heapPadding - usedSize;
if (freeSize < 0) throw new Error("page overflow");
const pageFlags = n > 0 ? 0x00 : 0x44;
const h = new LE();
h.u32(0); // magic
h.u32(this.pageIndex);
h.u32(this.pageType);
h.u32(this.nextPage);
h.u32(0); // unknown1
h.u32(0); // unknown2
h.u8(n & 0xff); h.u8(0); h.u8(0); h.u8(pageFlags);
h.u16(freeSize); h.u16(usedSize);
h.u16(0); // unknown5
h.u16(n); // num_rows_large
h.u16(0); // unknown6
h.u16(0); // unknown7
const header = h.build();
if (header.length !== HEADER_SIZE) throw new Error(`hdr len ${header.length}`);
const heap = new Uint8Array(heapPadding);
for (let i = 0; i < this.rowBytes.length; i++) {
heap.set(this.rowBytes[i]!, this.rowOffsets[i]!);
}
const footer = new LE();
for (let g = numRgs - 1; g >= 0; g--) {
const start = g * ROWS_PER_GROUP;
const grpOffsets = this.rowOffsets.slice(start, start + ROWS_PER_GROUP);
const grpCount = grpOffsets.length;
const padded = grpOffsets.slice();
while (padded.length < ROWS_PER_GROUP) padded.push(0);
const presence = (1 << grpCount) - 1;
const rg = new LE();
for (let i = padded.length - 1; i >= 0; i--) rg.u16(padded[i]!);
rg.u16(presence);
rg.u16(16);
const rgBytes = rg.build();
if (rgBytes.length !== ROW_GROUP_SIZE) throw new Error("rg size");
footer.bytes(rgBytes);
}
const footerBytes = footer.build();
if (footerBytes.length !== footerSize) throw new Error("footer size");
const out = new Uint8Array(PAGE_SIZE);
out.set(header, 0);
out.set(heap, HEADER_SIZE);
out.set(footerBytes, HEADER_SIZE + heapPadding);
return out;
}
}
-148
View File
@@ -1,148 +0,0 @@
import type { Album, Artist, PlaylistEntry, PlaylistNode, Track } from "../model.ts";
import { encodeDevicesql } from "./strings.ts";
import { LE, concat } from "./buf.ts";
const TRACK_FIXED_PREFIX = 0x5e;
const TRACK_OFS_STRINGS_START = TRACK_FIXED_PREFIX;
const TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2;
function pad2(b: Uint8Array): Uint8Array {
if (b.length & 1) {
const out = new Uint8Array(b.length + 1);
out.set(b);
return out;
}
return b;
}
export function serializeTrack(t: Track): Uint8Array {
const strings: string[] = [
"", // 0 isrc
"", // 1 texter
"1", // 2 unknown_string_2
"2", // 3 unknown_string_3
"", // 4 unknown_string_4
"", // 5 message
"ON", // 6 kuvo_public
"ON", // 7 autoload_hot_cues
"", // 8 unknown_string_5
"", // 9 unknown_string_6
t.date_added, // 10
t.release_date, // 11
t.mix_name, // 12
"", // 13
t.analyze_path, // 14
t.analyze_date, // 15
t.comment, // 16
t.title, // 17
"", // 18
t.filename, // 19
t.file_path, // 20
];
const encoded = strings.map(encodeDevicesql);
const offsets: number[] = [];
let cursor = TRACK_HEADER_LEN;
for (const e of encoded) {
offsets.push(cursor);
cursor += e.length;
}
const blob = concat(encoded);
const p = new LE();
p.u16(0x24); p.u16(0); // subtype, index_shift
p.u32(788224); // bitmask
p.u32(t.sample_rate);
p.u32(0); // composer_id
p.u32(t.file_size);
p.u32(0); // unknown
p.u16(19048);
p.u16(30967);
p.u32(0); // artwork_id
p.u32(0); // key_id
p.u32(0); // original_artist_id
p.u32(0); // label_id
p.u32(0); // remixer_id
p.u32(t.bitrate);
p.u32(t.track_number);
p.u32(t.tempo);
p.u32(0); // genre_id
p.u32(t.album_id);
p.u32(t.artist_id);
p.u32(t.id);
p.u16(t.disc_number);
p.u16(0); // play_count
p.u16(t.year);
p.u16(t.sample_depth);
p.u16(t.duration);
p.u16(41);
p.u8(0); p.u8(0); // color_id, rating
p.u16(1);
p.u16(3);
const prefix = p.build();
if (prefix.length !== TRACK_FIXED_PREFIX) throw new Error(`prefix len ${prefix.length}`);
const ofs = new LE();
for (const o of offsets) ofs.u16(o);
return pad2(concat([prefix, ofs.build(), blob]));
}
export function serializeArtist(a: Artist): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x0a;
const farThreshold = 0x100;
const r = new LE();
if (nearOfs < farThreshold) {
r.u16(0x60); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("artist near offset mismatch");
r.bytes(name);
} else {
const far = 0x0e;
r.u16(0x64); r.u16(a.id & 0xffff);
r.u32(a.id);
r.u8(0x03);
r.u8(0);
r.u16(far);
r.bytes(name);
}
return pad2(r.build());
}
export function serializeAlbum(a: Album): Uint8Array {
const name = encodeDevicesql(a.name);
const nearOfs = 0x16;
const r = new LE();
r.u16(0x80); r.u16(a.id & 0xffff);
r.u32(0);
r.u32(a.artist_id);
r.u32(a.id);
r.u32(0);
r.u8(0x03);
r.u8(nearOfs);
if (r.length() !== nearOfs) throw new Error("album near offset mismatch");
r.bytes(name);
return pad2(r.build());
}
export function serializePlaylistTree(n: PlaylistNode): Uint8Array {
const r = new LE();
r.u32(n.parent_id);
r.u32(0);
r.u32(n.sort_order);
r.u32(n.id);
r.u32(n.is_folder ? 1 : 0);
r.bytes(encodeDevicesql(n.name));
return pad2(r.build());
}
export function serializePlaylistEntry(e: PlaylistEntry): Uint8Array {
const r = new LE();
r.u32(e.entry_index);
r.u32(e.track_id);
r.u32(e.playlist_id);
return r.build();
}
-33
View File
@@ -1,33 +0,0 @@
const EMPTY = new Uint8Array([0x03]);
function isAscii(s: string): boolean {
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false;
return true;
}
export function encodeDevicesql(s: string): Uint8Array {
if (!s) return EMPTY;
if (isAscii(s) && s.length < 127) {
const ascii = new TextEncoder().encode(s);
const length = ascii.length + 1;
const lengthAndKind = ((length << 1) | 1) & 0xff;
const out = new Uint8Array(1 + ascii.length);
out[0] = lengthAndKind;
out.set(ascii, 1);
return out;
}
const utf16 = new Uint8Array(s.length * 2);
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
utf16[i * 2] = c & 0xff;
utf16[i * 2 + 1] = (c >> 8) & 0xff;
}
const totalLength = utf16.length + 4;
const out = new Uint8Array(4 + utf16.length);
out[0] = 0x90;
out[1] = totalLength & 0xff;
out[2] = (totalLength >> 8) & 0xff;
out[3] = 0x00;
out.set(utf16, 4);
return out;
}
-122
View File
@@ -1,122 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Database } from "../model.ts";
import { PAGE_SIZE, PageBuilder } from "./pager.ts";
import {
serializeAlbum,
serializeArtist,
serializePlaylistEntry,
serializePlaylistTree,
serializeTrack,
} from "./rows.ts";
import { LE } from "./buf.ts";
const PAGE_TYPE_TRACKS = 0;
const PAGE_TYPE_ARTISTS = 2;
const PAGE_TYPE_ALBUMS = 3;
const PAGE_TYPE_PLAYLIST_TREE = 7;
const PAGE_TYPE_PLAYLIST_ENTRIES = 8;
const FILE_HEADER_SIZE = 28;
const TABLE_ENTRY_SIZE = 16;
interface Chain {
pageType: number;
pages: Uint8Array[];
first: number;
last: number;
}
function buildChain(pageType: number, rows: Uint8Array[], startIndex: number): Chain {
const builders: PageBuilder[] = [];
let idx = startIndex;
let current = new PageBuilder(idx, pageType);
for (const row of rows) {
if (!current.tryAdd(row)) {
builders.push(current);
idx += 1;
current = new PageBuilder(idx, pageType);
if (!current.tryAdd(row)) {
throw new Error(`row of size ${row.length} too large for an empty page`);
}
}
}
builders.push(current);
const out: Uint8Array[] = [];
for (let i = 0; i < builders.length; i++) {
const p = builders[i]!;
if (i === builders.length - 1) p.nextPage = p.pageIndex;
else p.nextPage = p.pageIndex + 1;
out.push(p.finalize());
}
return { pageType, pages: out, first: startIndex, last: idx };
}
export async function writePdb(db: Database, path: string): Promise<void> {
const chains: Chain[] = [];
let nextIdx = 1;
const tables: Array<[number, Uint8Array[]]> = [
[PAGE_TYPE_TRACKS, db.tracks.map(serializeTrack)],
[PAGE_TYPE_ARTISTS, db.artists.map(serializeArtist)],
[PAGE_TYPE_ALBUMS, db.albums.map(serializeAlbum)],
[PAGE_TYPE_PLAYLIST_TREE, db.playlist_nodes.map(serializePlaylistTree)],
[PAGE_TYPE_PLAYLIST_ENTRIES, db.playlist_entries.map(serializePlaylistEntry)],
];
for (const [ptype, rows] of tables) {
const c = buildChain(ptype, rows, nextIdx);
chains.push(c);
nextIdx = c.last + 1;
}
const numTables = chains.length;
const nextUnused = nextIdx;
const h = new LE();
h.u32(0);
h.u32(PAGE_SIZE);
h.u32(numTables);
h.u32(nextUnused);
h.u32(0);
h.u32(1);
h.u32(0);
const fileHeader = h.build();
if (fileHeader.length !== FILE_HEADER_SIZE) throw new Error("file header size");
const tbl = new LE();
for (const c of chains) {
tbl.u32(c.pageType);
tbl.u32(0);
tbl.u32(c.first);
tbl.u32(c.last);
}
const tablesBlock = tbl.build();
if (tablesBlock.length !== numTables * TABLE_ENTRY_SIZE) throw new Error("tables size");
const headerFull = new Uint8Array(fileHeader.length + tablesBlock.length);
headerFull.set(fileHeader, 0);
headerFull.set(tablesBlock, fileHeader.length);
if (headerFull.length > PAGE_SIZE) throw new Error("too many tables for one page");
const page0 = new Uint8Array(PAGE_SIZE);
page0.set(headerFull, 0);
let totalLen = PAGE_SIZE;
for (const c of chains) totalLen += c.pages.length * PAGE_SIZE;
const out = new Uint8Array(totalLen);
let off = 0;
out.set(page0, off); off += PAGE_SIZE;
for (const c of chains) {
for (const p of c.pages) {
if (p.length !== PAGE_SIZE) throw new Error("page size mismatch");
out.set(p, off); off += PAGE_SIZE;
}
}
await mkdir(dirname(path), { recursive: true });
await writeFile(path, out);
}
-46
View File
@@ -1,46 +0,0 @@
import { readdir } from "node:fs/promises";
import { join, extname, basename, dirname, isAbsolute, resolve } from "node:path";
const AUDIO_EXTS = new Set([".mp3", ".flac", ".wav", ".aiff", ".aif", ".m4a"]);
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await walk(p)));
} else if (e.isFile()) {
out.push(p);
}
}
return out;
}
export async function scanAudio(inputDir: string): Promise<string[]> {
const all = await walk(inputDir);
return all.filter((p) => AUDIO_EXTS.has(extname(p).toLowerCase())).sort();
}
export async function parseM3u8(playlistPath: string): Promise<string[]> {
const base = dirname(playlistPath);
const text = await Bun.file(playlistPath).text();
const entries: string[] = [];
for (const raw of text.split(/\r?\n/)) {
const s = raw.trim();
if (!s || s.startsWith("#")) continue;
entries.push(isAbsolute(s) ? resolve(s) : resolve(join(base, s)));
}
return entries;
}
export async function scanPlaylists(inputDir: string): Promise<Map<string, string[]>> {
const all = await walk(inputDir);
const playlists = all.filter((p) => extname(p).toLowerCase() === ".m3u8").sort();
const out = new Map<string, string[]>();
for (const p of playlists) {
const stem = basename(p, extname(p));
out.set(stem, await parseM3u8(p));
}
return out;
}
-24
View File
@@ -1,24 +0,0 @@
import { expect, test } from "bun:test";
import { encodeDevicesql } from "../src/pdb/strings.ts";
test("empty encodes to 0x03", () => {
expect(Array.from(encodeDevicesql(""))).toEqual([0x03]);
});
test("short ascii uses short form", () => {
const out = encodeDevicesql("ABC");
// length = 3+1 = 4; (4<<1)|1 = 9
expect(out[0]).toBe(9);
expect(Array.from(out.slice(1))).toEqual([0x41, 0x42, 0x43]);
});
test("non-ascii uses utf-16 form", () => {
const out = encodeDevicesql("é");
expect(out[0]).toBe(0x90);
// total_length = utf16 bytes (2) + 4 = 6
expect(out[1]).toBe(6);
expect(out[2]).toBe(0);
expect(out[3]).toBe(0);
expect(out[4]).toBe(0xe9);
expect(out[5]).toBe(0x00);
});
-30
View File
@@ -1,30 +0,0 @@
import { expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { newDatabase, newTrack } from "../src/model.ts";
import { writePdb } from "../src/pdb/writer.ts";
import { PAGE_SIZE } from "../src/pdb/pager.ts";
test("writes a multi-page-aligned pdb file", async () => {
const dir = await mkdtemp(join(tmpdir(), "maple-ts-"));
try {
const db = newDatabase();
db.artists.push({ id: 1, name: "A" });
db.albums.push({ id: 1, name: "Alb", artist_id: 1 });
db.tracks.push(newTrack({
id: 1, title: "T", artist_id: 1, album_id: 1,
filename: "t.mp3", file_path: "/Contents/A/Alb/t.mp3",
}));
db.playlist_nodes.push({ id: 1, parent_id: 0, sort_order: 1, name: "P", is_folder: false });
db.playlist_entries.push({ playlist_id: 1, track_id: 1, entry_index: 1 });
const out = join(dir, "export.pdb");
await writePdb(db, out);
const buf = await readFile(out);
expect(buf.length % PAGE_SIZE).toBe(0);
expect(buf.length).toBeGreaterThanOrEqual(PAGE_SIZE * 6);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+28 -10
View File
@@ -4,23 +4,41 @@
{ {
"path": ".", "path": ".",
}, },
{
"path": "h:ome\\ntr\\code\\rekordcrate",
},
{
"path": "U:sers\\nathanrashleigh\\code\\code\\rekordcrate",
},
{
"path": "C:\\Users\\ntr\\code\\rekordcrate"
},
], ],
"settings": "settings":
{ {
"LSP":
{
"LSP-pyright":
{
// Use maple-py/ as the python project root so pyrightconfig.json
// there is picked up (extraPaths=["src"], venvPath=".venv",
// pythonVersion="3.14"). The interpreter is the uv-managed venv.
"settings":
{
"python.pythonPath": "$folder/maple-py/.venv/bin/python",
"python.analysis.extraPaths":
[
"$folder/maple-py/src",
],
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticMode": "openFilesOnly",
"python.analysis.autoImportCompletions": true,
"python.analysis.indexing": true,
},
// Pin the LSP server's working directory to maple-py/ so file
// lookups resolve relative to where pyrightconfig.json lives.
"initializationOptions":
{
"pythonPath": "$folder/maple-py/.venv/bin/python",
},
},
},
"cargo_build": "cargo_build":
{ {
"paths": "paths":
{ {
"C:\\Users\\ntr\\code\\maple\\maple-rs": "$folder/maple-rs":
{ {
"variants": "variants":
{ {