Compare commits
8
Commits
f06d3aa0f9
...
9fbd222b23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fbd222b23 | ||
|
|
d5ef56c3ed | ||
|
|
5810a536bd | ||
|
|
096bce803a | ||
|
|
b85cea496f | ||
|
|
9ee5a70266 | ||
|
|
ea1669babf | ||
|
|
8b4ac2cdaf |
No files matched your search
@@ -1,9 +1,31 @@
|
||||
[x] download playlist
|
||||
[x] store everything by artist - album
|
||||
[ ] convert flac to mp3
|
||||
[ ] sync to and from s3 api storage
|
||||
[ ] create entries in rekordbox db for playlists
|
||||
[ ] have a rekordbox vm
|
||||
[ ] analyse files downloaded by all users with rekordbox vm
|
||||
[ ] store analysis metadata in pg
|
||||
[ ]
|
||||
|
||||
## end to end
|
||||
|
||||
### collection management
|
||||
just spot should:
|
||||
download playlist from spotify into data/collection
|
||||
determine files missing from collection
|
||||
download each of those files with sockseek
|
||||
- should be flac
|
||||
- get artwork if missing
|
||||
- correct tags
|
||||
- quick validate
|
||||
update m3u8 matching files in spotify to files in collection
|
||||
|
||||
### prepare for decks
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -30,19 +30,63 @@ link plist dir:
|
||||
|
||||
# Dump tracks from Spotify playlists (ids, urls, or spotify: uris)
|
||||
spotify ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX":
|
||||
cd maple-py && uv run src/spotify.py {{ids}} --csv
|
||||
cd maple-py && uv run python -m maple.spotify {{ids}} --csv
|
||||
|
||||
# Download via sockseek (csv path, url, search string, ...) using sldl.tpl.conf settings
|
||||
sockseek input:
|
||||
cd maple-py && uv run src/sockseek.py {{input}}
|
||||
|
||||
# Sync data/collection with one or more Spotify playlists (download + m3u8s).
|
||||
collection ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX":
|
||||
cd maple-py && uv run python -m maple.collection {{ids}}
|
||||
|
||||
# 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:
|
||||
s5cmd {{args}}
|
||||
|
||||
usb dst="/Volumes/MAPLE":
|
||||
COPYFILE_DISABLE=1 rsync -rtvh --delete --size-only --no-perms --no-owner --no-group --modify-window=2 maple-py/data/usb/Contents/ {{dst}}/Contents/
|
||||
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/
|
||||
rm -f {{dst}}/PIONEER/rekordbox/RBFLTR.DAT
|
||||
# Sync data/usb/ → mounted USB stick (default /media/ntr/MAPLE), then
|
||||
# safely unmount so the stick can be pulled without risk to the FAT
|
||||
# write-back cache.
|
||||
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":
|
||||
@test -d {{dst}}/PIONEER/rekordbox || (echo "no rekordbox dir at {{dst}}/PIONEER"; exit 1)
|
||||
|
||||
@@ -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`).
|
||||
@@ -8,10 +8,14 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"aiohttp>=3.10",
|
||||
"kaitaistruct>=0.11",
|
||||
"loguru>=0.7",
|
||||
"mixxx-analyzer>=0.1.2",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=12.2.0",
|
||||
"pyrekordbox>=0.4.4",
|
||||
"scipy>=1.10",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
file the analysis applies to. We emit just that section -- no beat grid,
|
||||
no waveforms -- which is enough for the player to consider the track's
|
||||
analysis "present" and surface the track in the library. Without an
|
||||
analyze file at the path the pdb references, some firmware hides the
|
||||
track entirely.
|
||||
ANLZ0000.DAT — what CDJ-900-era through CDJ-3000 firmware reads to display
|
||||
the loaded-deck BPM, the touch-strip preview waveform, and the cue list:
|
||||
|
||||
PPTH player-relative audio path
|
||||
PVBR variable-bitrate seek index (zero-filled placeholder; matches the
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
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:
|
||||
"""PPTH section: u4 len_path, then UTF-16BE bytes (null-terminated)."""
|
||||
utf16 = path.encode("utf-16-be") + b"\x00\x00"
|
||||
len_path = len(utf16)
|
||||
body = pack(">I", len_path) + utf16
|
||||
# Section header: 4 fourcc + u4 len_header + u4 len_tag
|
||||
fourcc = b"PPTH"
|
||||
len_header = 16 # section header size (incl. fourcc + 2 u4s + len_path u4)
|
||||
len_tag = 12 + len(body) # full section size
|
||||
len_header = 16 # fourcc + 2 u4 size fields + len_path u4
|
||||
len_tag = 12 + len(body)
|
||||
return fourcc + pack(">II", len_header, len_tag) + body
|
||||
|
||||
|
||||
def build_minimal_anlz(audio_path: str) -> bytes:
|
||||
"""Return bytes of a minimal ANLZ0000.DAT pointing at `audio_path`."""
|
||||
pth = _section_pth(audio_path)
|
||||
header_len = 28
|
||||
file_len = header_len + len(pth)
|
||||
header = b"PMAI" + pack(">II", header_len, file_len)
|
||||
header += b"\x00" * (header_len - len(header))
|
||||
assert len(header) == header_len
|
||||
return header + pth
|
||||
def _section_pvbr() -> bytes:
|
||||
"""PVBR section: 400-entry VBR seek index. We write a zero-filled
|
||||
placeholder — rekordbox accepts this for fixed-bitrate sources and it
|
||||
matches the section's presence in real exports."""
|
||||
fourcc = b"PVBR"
|
||||
len_header = 16 # prelude(12) + u4 unnamed0(4)
|
||||
# 400 u4 seek index + 4 trailer bytes = 1604 bytes of variable data
|
||||
# (verified by len_tag=1620 in the real Q1.1 export).
|
||||
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.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
|
||||
@@ -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_large = usb_root / rel_large.lstrip("/")
|
||||
abs_small.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not abs_small.exists():
|
||||
abs_small.write_bytes(_resize_jpeg(data, SMALL))
|
||||
if not abs_large.exists():
|
||||
abs_large.write_bytes(_resize_jpeg(data, LARGE))
|
||||
# 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_large.write_bytes(_resize_jpeg(data, LARGE))
|
||||
cache[sha] = aid
|
||||
return aid, rel_small
|
||||
+400
-38
@@ -1,34 +1,154 @@
|
||||
"""maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB."""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
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 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.convert import convert_flac_to_mp3, verify_duration
|
||||
from maple.copy import stage_audio, staged_path
|
||||
from maple.log import configure as configure_logging
|
||||
from maple.meta import read_tags
|
||||
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.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()
|
||||
today = date.today().isoformat()
|
||||
|
||||
artist_index: dict[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] = {}
|
||||
# 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] = {}
|
||||
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)
|
||||
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}
|
||||
logger.debug("read tags in {:.2f}s", time.perf_counter() - t)
|
||||
|
||||
flac_jobs: list[tuple[Path, Path]] = []
|
||||
for original in audio_files:
|
||||
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]:
|
||||
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)
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
for src, dst, converted in ex.map(_job, flac_jobs):
|
||||
print(f"{'convert' if converted else 'cached '} {src.relative_to(input_dir)}")
|
||||
verify_duration(src, dst)
|
||||
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:
|
||||
for i, (src, dst, converted, dt) in enumerate(ex.map(_job, flac_jobs), 1):
|
||||
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)
|
||||
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]
|
||||
artist_id = artist_index.get(meta.artist)
|
||||
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)
|
||||
else:
|
||||
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
|
||||
artwork_id, art_file_path = extract_artwork(dest_abs, usb_root, next_artwork_id, artwork_cache)
|
||||
if art_file_path is not None:
|
||||
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
|
||||
if meta.genre:
|
||||
genre_id = genre_index.get(meta.genre, 0)
|
||||
if genre_id == 0:
|
||||
genre_id = len(db.genres) + 1
|
||||
genre_index[meta.genre] = genre_id
|
||||
db.genres.append(Genre(id=genre_id, name=meta.genre))
|
||||
# Genre: split multi-value tag strings ("dub techno;minimal techno",
|
||||
# "Drum & Bass, Électronique", etc.) into individual genre rows so the
|
||||
# CDJ's Genre filter is usable. The track FK points at the FIRST
|
||||
# (most-specific) token; every other token still gets its own row in
|
||||
# the pdb's genre table so it appears in the browse list, and we
|
||||
# record the {genre→track_ids} map so we can log + reuse it later.
|
||||
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
|
||||
# Reference exports always populate analyze_path even when the
|
||||
# ANLZ files are real; tracks with an empty analyze_path appear
|
||||
# to be hidden from the player's library. Synthesise a stable
|
||||
# path keyed off the track id even if we don't write the .DAT.
|
||||
import zlib
|
||||
h = zlib.crc32(file_path.encode()) & 0xFFFFFFFF
|
||||
# rekordbox's USBANLZ P-bucket is 0..127 (P00..P7F) and the 8-hex id is an
|
||||
# opaque master.db value -- not reproducible without its DB (FORMAT_SPEC
|
||||
# §7), so maple derives both from a stable crc32(path). Mask the bucket to
|
||||
# 0x7F so it has rekordbox's P00..P7F shape; the path used here is the same
|
||||
# one written to the pdb row, so it's self-consistent by construction.
|
||||
bucket = f"P{(h >> 20) & 0x7F:02X}"
|
||||
analyze_id = f"{h & 0xFFFFFFFF:08X}"
|
||||
analyze_path = f"/PIONEER/USBANLZ/{bucket}/{analyze_id}/ANLZ0000.DAT"
|
||||
write_anlz(file_path, usb_root / analyze_path.lstrip("/"))
|
||||
# The USBANLZ folder is derived from the USB-relative `file_path` by
|
||||
# rekordbox's bespoke path hash (see `usbanlz_location`). The CDJ/XDJ
|
||||
# firmware RE-DERIVES this same path from `file_path` and reads the
|
||||
# analysis from there — it IGNORES the pdb's `analyze_path` string —
|
||||
# so the folder MUST match rekordbox exactly or the player silently
|
||||
# shows no waveform/beatgrid. Verified 1002/1002 vs a real export and
|
||||
# on XDJ-RX2 hardware. (Earlier crc32 scheme matched 0/984 → grids
|
||||
# missing.)
|
||||
bucket_n, anlz_id = usbanlz_location(file_path)
|
||||
anlz_folder = f"/PIONEER/USBANLZ/P{bucket_n:03X}/{anlz_id:08X}"
|
||||
anlz_index = anlz_folder_seen[anlz_folder] # 0, then 1, 2, … on collision
|
||||
anlz_folder_seen[anlz_folder] += 1
|
||||
analyze_path = f"{anlz_folder}/ANLZ{anlz_index:04d}.DAT"
|
||||
anlz_abs = (usb_root / analyze_path.lstrip("/")).resolve()
|
||||
# `_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(
|
||||
id=track_id,
|
||||
title=meta.title,
|
||||
@@ -110,7 +355,7 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
|
||||
duration=meta.duration,
|
||||
track_number=meta.track_number,
|
||||
year=meta.year,
|
||||
tempo=meta.tempo,
|
||||
tempo=tempo_x100,
|
||||
date_added=today,
|
||||
analyze_path=analyze_path,
|
||||
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
|
||||
|
||||
# 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:
|
||||
return db
|
||||
return db, written, validation_issues
|
||||
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):
|
||||
playlist_id = len(db.playlist_nodes) + 1
|
||||
db.playlist_nodes.append(PlaylistNode(
|
||||
@@ -132,18 +429,58 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> Datab
|
||||
name=name,
|
||||
is_folder=False,
|
||||
))
|
||||
resolved = 0
|
||||
for entry_index, p in enumerate(paths, start=1):
|
||||
tid = track_by_src.get(p.resolve())
|
||||
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
|
||||
db.playlist_entries.append(PlaylistEntry(
|
||||
playlist_id=playlist_id,
|
||||
track_id=tid,
|
||||
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:
|
||||
@@ -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("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-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)
|
||||
|
||||
configure_logging()
|
||||
if not args.input_dir.is_dir():
|
||||
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"
|
||||
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, "
|
||||
f"{len(db.playlist_nodes)} playlists ({len(db.playlist_entries)} entries) to {pdb_path}")
|
||||
if not args.no_prune:
|
||||
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
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Artist/title normalisation + `CollectionIndex` lookup table.
|
||||
|
||||
No I/O. Pure functions plus a small dataclass keyed on normalised
|
||||
(artist, title) tuples so we can answer "is this track already in the
|
||||
collection?" against an in-memory map.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
FUZZY_THRESHOLD = 0.85
|
||||
|
||||
_PARENS = re.compile(r"\s*[\(\[][^)\]]*[\)\]]\s*")
|
||||
_NONWORD = re.compile(r"[^\w\s]+", flags=re.UNICODE)
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
"""Lowercase + NFKC + strip non-word, preserves remix/version suffixes."""
|
||||
s = unicodedata.normalize("NFKC", s or "").lower()
|
||||
s = _NONWORD.sub(" ", s)
|
||||
s = _WS.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def norm_loose(s: str) -> str:
|
||||
"""Same as norm() but with parenthesised suffixes removed."""
|
||||
s = unicodedata.normalize("NFKC", s or "").lower()
|
||||
s = _PARENS.sub(" ", s)
|
||||
s = _NONWORD.sub(" ", s)
|
||||
s = _WS.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def first_artist(artists: str) -> str:
|
||||
"""Split sldl/spotify multi-artist strings on common delimiters."""
|
||||
if not artists:
|
||||
return ""
|
||||
for sep in (",", " feat.", " ft.", " & ", "&", " and ", ";"):
|
||||
if sep in artists:
|
||||
return artists.split(sep, 1)[0].strip()
|
||||
return artists.strip()
|
||||
|
||||
|
||||
def fuzzy_ratio(a: str, b: str) -> float:
|
||||
"""Cheap token-overlap ratio in [0, 1]. No external dep."""
|
||||
ta, tb = set(a.split()), set(b.split())
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
return len(ta & tb) / max(len(ta), len(tb))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectionIndex:
|
||||
"""Maps normalised (artist, title) pairs to file paths in the collection."""
|
||||
|
||||
by_strict: dict[tuple[str, str], Path] = field(default_factory=dict)
|
||||
by_loose: dict[tuple[str, str], Path] = field(default_factory=dict)
|
||||
|
||||
def add(self, artist: str, title: str, path: Path) -> None:
|
||||
a, t = norm(first_artist(artist)), norm(title)
|
||||
if a and t:
|
||||
self.by_strict.setdefault((a, t), path)
|
||||
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
|
||||
if la and lt:
|
||||
self.by_loose.setdefault((la, lt), path)
|
||||
|
||||
def lookup(self, artist: str, title: str) -> Path | None:
|
||||
a, t = norm(first_artist(artist)), norm(title)
|
||||
if (a, t) in self.by_strict:
|
||||
return self.by_strict[(a, t)]
|
||||
la, lt = norm_loose(first_artist(artist)), norm_loose(title)
|
||||
if (la, lt) in self.by_loose:
|
||||
return self.by_loose[(la, lt)]
|
||||
for (ka, kt), p in self.by_loose.items():
|
||||
if ka == la and fuzzy_ratio(kt, lt) >= FUZZY_THRESHOLD:
|
||||
return p
|
||||
return None
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Sockseek subprocess plumbing.
|
||||
|
||||
Owns:
|
||||
* the CLI argument shape (`args_for`) used to run sockseek/sldl,
|
||||
* the temp-CSV writer that feeds tracks in,
|
||||
* the async `download()` entrypoint that streams `--progress-json`
|
||||
events out and forwards sockseek stderr through loguru.
|
||||
|
||||
`Collection.download()` is a thin wrapper around `download()` here so the
|
||||
subprocess concerns stay in one file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
NAME_FORMAT = "{artist(/)album(/)track( )title|(_missing_tags/)slsk-foldername(/)slsk-filename}"
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
_LEVEL_RE = re.compile(
|
||||
r"^\s*\[?(?P<lvl>TRACE|TRC|DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR|FATAL|CRITICAL|CRT)\]?\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LEVEL_MAP = {
|
||||
"TRC": "TRACE", "TRACE": "TRACE",
|
||||
"DBG": "DEBUG", "DEBUG": "DEBUG",
|
||||
"INF": "INFO", "INFO": "INFO",
|
||||
"WRN": "WARNING", "WARN": "WARNING",
|
||||
"ERR": "ERROR", "ERROR": "ERROR",
|
||||
"FATAL": "CRITICAL", "CRT": "CRITICAL", "CRITICAL": "CRITICAL",
|
||||
}
|
||||
|
||||
|
||||
def log_level_for(line: str) -> str:
|
||||
"""Map a sockseek/.NET-style log prefix to a loguru level. Default WARNING."""
|
||||
m = _LEVEL_RE.match(line)
|
||||
if not m:
|
||||
return "WARNING"
|
||||
return _LEVEL_MAP.get(m.group("lvl").upper(), "WARNING")
|
||||
|
||||
|
||||
def args_for(root: Path, index_csv: Path, user: str, password: str) -> list[str]:
|
||||
"""Mirror of src/sockseek.py:config_args(), pinned for FLAC-strict."""
|
||||
return [
|
||||
"--path", str(root),
|
||||
"--index-path", str(index_csv),
|
||||
"--user", user,
|
||||
"--pass", password,
|
||||
"--name-format", NAME_FORMAT,
|
||||
"--format", "flac",
|
||||
"--pref-format", "flac",
|
||||
"--pref-min-bitrate", "200",
|
||||
"--pref-max-bitrate", "2500",
|
||||
"--strict-conditions",
|
||||
"--fast-search",
|
||||
"--concurrent-jobs", "4",
|
||||
]
|
||||
|
||||
|
||||
def write_download_csv(missing: list[dict], fh) -> None:
|
||||
"""sldl-compatible CSV (Artist,Title,Album,Length)."""
|
||||
w = csv.writer(fh)
|
||||
w.writerow(["Artist", "Title", "Album", "Length"])
|
||||
for t in missing:
|
||||
w.writerow([
|
||||
", ".join(t["artists"]),
|
||||
t["title"],
|
||||
t["album"],
|
||||
round(t["duration_ms"] / 1000),
|
||||
])
|
||||
|
||||
|
||||
async def download(
|
||||
root: Path,
|
||||
index_csv: Path,
|
||||
missing: list[dict],
|
||||
on_event: Callable[[dict], Awaitable[None]] | None = None,
|
||||
) -> int:
|
||||
"""Shell out to sockseek for every track in `missing`.
|
||||
|
||||
When `on_event` is provided, sockseek is run with `--progress-json` and
|
||||
every NDJSON envelope on stdout is parsed and awaited through the
|
||||
callback. stderr is always piped and forwarded line-by-line through
|
||||
loguru with a best-effort log-level guess.
|
||||
"""
|
||||
if not missing:
|
||||
return 0
|
||||
binary = shutil.which("sockseek") or shutil.which("sldl")
|
||||
if binary is None:
|
||||
logger.error("sockseek/sldl not on PATH; install slsk-batchdl first")
|
||||
sys.exit(1)
|
||||
user = os.environ.get("SOCKSEEK_USER")
|
||||
password = os.environ.get("SOCKSEEK_PASS")
|
||||
if not (user and password):
|
||||
logger.error("set SOCKSEEK_USER and SOCKSEEK_PASS for downloads")
|
||||
sys.exit(1)
|
||||
index_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".csv", newline="", delete=False) as tf:
|
||||
write_download_csv(missing, tf)
|
||||
tf_path = Path(tf.name)
|
||||
try:
|
||||
args = [binary, str(tf_path), *args_for(root, index_csv, user, password)]
|
||||
if on_event is not None:
|
||||
args.append("--progress-json")
|
||||
masked = ["***" if a == password else a for a in args]
|
||||
logger.info("$ {}", " ".join(masked))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE if on_event is not None else None,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def _forward_stderr() -> None:
|
||||
assert proc.stderr is not None
|
||||
async for raw in proc.stderr:
|
||||
line = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace")).rstrip()
|
||||
if not line:
|
||||
continue
|
||||
logger.log(log_level_for(line), "sockseek: {}", line)
|
||||
|
||||
stderr_task = asyncio.create_task(_forward_stderr())
|
||||
|
||||
if on_event is not None:
|
||||
assert proc.stdout is not None
|
||||
async for raw in proc.stdout:
|
||||
line = raw.decode("utf-8", errors="replace").strip()
|
||||
if not line or not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
evt = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
await on_event(evt)
|
||||
|
||||
rc = await proc.wait()
|
||||
await stderr_task
|
||||
return rc
|
||||
finally:
|
||||
tf_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Per-track validation helpers: ffprobe sanity, duration probe, embedded
|
||||
artwork inspection + embed, async JPEG fetch for missing cover art.
|
||||
|
||||
Pure helpers — no Collection coupling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import APIC, ID3, ID3NoHeaderError
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
|
||||
DURATION_TOLERANCE_S = 5.0
|
||||
ARTWORK_CONCURRENCY = 8
|
||||
|
||||
|
||||
def ffprobe_ok(path: Path) -> bool:
|
||||
if not shutil.which("ffprobe"):
|
||||
return True # ffprobe absent → don't fail the run
|
||||
r = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return r.returncode == 0 and "audio" in r.stdout
|
||||
|
||||
|
||||
def file_duration(path: Path) -> float:
|
||||
audio = MutagenFile(path)
|
||||
info = getattr(audio, "info", None)
|
||||
return float(getattr(info, "length", 0.0) or 0.0)
|
||||
|
||||
|
||||
def has_embedded_art(path: Path) -> bool:
|
||||
suf = path.suffix.lower()
|
||||
if suf == ".flac":
|
||||
try:
|
||||
return bool(FLAC(path).pictures)
|
||||
except Exception:
|
||||
return False
|
||||
if suf == ".mp3":
|
||||
try:
|
||||
return bool(ID3(path).getall("APIC"))
|
||||
except Exception:
|
||||
return False
|
||||
if suf in (".m4a", ".mp4"):
|
||||
try:
|
||||
tags = MP4(path).tags
|
||||
return bool(tags and tags.get("covr"))
|
||||
except Exception:
|
||||
return False
|
||||
return True # unknown format → don't complain
|
||||
|
||||
|
||||
def embed_art(path: Path, jpeg_bytes: bytes) -> None:
|
||||
suf = path.suffix.lower()
|
||||
if suf == ".flac":
|
||||
f = FLAC(path)
|
||||
pic = Picture()
|
||||
pic.data = jpeg_bytes
|
||||
pic.type = 3 # FRONT_COVER
|
||||
pic.mime = "image/jpeg"
|
||||
f.clear_pictures()
|
||||
f.add_picture(pic)
|
||||
f.save()
|
||||
elif suf == ".mp3":
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
tags.delall("APIC")
|
||||
tags.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=jpeg_bytes))
|
||||
tags.save(path)
|
||||
elif suf in (".m4a", ".mp4"):
|
||||
f = MP4(path)
|
||||
f.tags = f.tags or {}
|
||||
f["covr"] = [MP4Cover(jpeg_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
|
||||
f.save()
|
||||
|
||||
|
||||
async def fetch_jpeg(session: aiohttp.ClientSession, url: str) -> bytes | None:
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
except aiohttp.ClientError:
|
||||
return None
|
||||
if data[:3] == b"\xff\xd8\xff":
|
||||
return data
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
# value; override per-install if deck testing shows it matters. Players treat
|
||||
# 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
|
||||
# shape of a real rekordbox Content ID. The real AceMo lo values all fall in this
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from pathlib import Path
|
||||
from struct import pack, unpack_from
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from maple.model import (
|
||||
DEFAULT_COLUMNS,
|
||||
STANDARD_COLORS,
|
||||
@@ -98,11 +100,21 @@ def _next_sequence(path: Path) -> int:
|
||||
|
||||
|
||||
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
|
||||
# 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).
|
||||
fk_errors = validate_referential_integrity(db)
|
||||
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))
|
||||
|
||||
# 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
|
||||
for b in last_builders:
|
||||
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
|
||||
# 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
|
||||
|
||||
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 += pack("<I", 0) # magic
|
||||
@@ -268,3 +285,4 @@ def write_pdb(db: Database, path: str | Path) -> None:
|
||||
page = pages_by_index.get(idx, blank)
|
||||
assert len(page) == PAGE_SIZE
|
||||
f.write(page)
|
||||
logger.info("wrote {} ({} bytes)", out_path, out_path.stat().st_size)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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:]))
|
||||
Generated
+345
@@ -2,6 +2,94 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
{ name = "aiosignal" },
|
||||
{ name = "attrs" },
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.23.1"
|
||||
@@ -29,6 +117,47 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.1"
|
||||
@@ -68,6 +197,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -86,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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maple"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "kaitaistruct" },
|
||||
{ name = "loguru" },
|
||||
{ name = "mixxx-analyzer" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pyrekordbox" },
|
||||
{ name = "scipy" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -104,15 +259,74 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.10" },
|
||||
{ name = "kaitaistruct", specifier = ">=0.11" },
|
||||
{ name = "loguru", specifier = ">=0.7" },
|
||||
{ name = "mixxx-analyzer", specifier = ">=0.1.2" },
|
||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "pyrekordbox", specifier = ">=0.4.4" },
|
||||
{ name = "scipy", specifier = ">=1.10" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
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]]
|
||||
name = "mutagen"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -364,3 +652,60 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
+54
-36
@@ -1,36 +1,54 @@
|
||||
{
|
||||
"folders":
|
||||
[
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
{
|
||||
"path": "h:ome\\ntr\\code\\rekordcrate",
|
||||
},
|
||||
{
|
||||
"path": "U:sers\\nathanrashleigh\\code\\code\\rekordcrate",
|
||||
},
|
||||
{
|
||||
"path": "C:\\Users\\ntr\\code\\rekordcrate"
|
||||
},
|
||||
],
|
||||
"settings":
|
||||
{
|
||||
"cargo_build":
|
||||
{
|
||||
"paths":
|
||||
{
|
||||
"C:\\Users\\ntr\\code\\maple\\maple-rs":
|
||||
{
|
||||
"variants":
|
||||
{
|
||||
"run":
|
||||
{
|
||||
"target": "--bin maple",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
{
|
||||
"folders":
|
||||
[
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
],
|
||||
"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":
|
||||
{
|
||||
"paths":
|
||||
{
|
||||
"$folder/maple-rs":
|
||||
{
|
||||
"variants":
|
||||
{
|
||||
"run":
|
||||
{
|
||||
"target": "--bin maple",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user