kaitai
This commit is contained in:
1 parent
f804c327de
commit
6a32066069
21 files changed
+2028
-1
No files matched your search
@@ -0,0 +1,2 @@
|
||||
def hello() -> str:
|
||||
return "Hello from maple!"
|
||||
@@ -0,0 +1,3 @@
|
||||
from maple.cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""maple CLI: scan a directory of audio + m3u8 playlists and build a rekordbox USB."""
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from maple.copy import stage_audio
|
||||
from maple.meta import read_tags
|
||||
from maple.model import Album, Artist, Database, PlaylistEntry, PlaylistNode, Track
|
||||
from maple.pdb.writer import write_pdb
|
||||
from maple.scan import scan_audio, scan_playlists
|
||||
|
||||
|
||||
def build(input_dir: Path, usb_root: Path) -> Database:
|
||||
db = Database()
|
||||
today = date.today().isoformat()
|
||||
|
||||
artist_index: dict[str, int] = {}
|
||||
album_index: dict[tuple[int, str], int] = {}
|
||||
track_by_src: dict[Path, int] = {}
|
||||
|
||||
audio_files = scan_audio(input_dir)
|
||||
for src in audio_files:
|
||||
meta = read_tags(src)
|
||||
artist_id = artist_index.get(meta.artist)
|
||||
if artist_id is None:
|
||||
artist_id = len(db.artists) + 1
|
||||
artist_index[meta.artist] = artist_id
|
||||
db.artists.append(Artist(id=artist_id, name=meta.artist))
|
||||
|
||||
album_key = (artist_id, meta.album)
|
||||
album_id = album_index.get(album_key)
|
||||
if album_id is None:
|
||||
album_id = len(db.albums) + 1
|
||||
album_index[album_key] = album_id
|
||||
db.albums.append(Album(id=album_id, name=meta.album, artist_id=artist_id))
|
||||
|
||||
_, file_path = stage_audio(src, usb_root, meta.artist, meta.album)
|
||||
track_id = len(db.tracks) + 1
|
||||
db.tracks.append(Track(
|
||||
id=track_id,
|
||||
title=meta.title,
|
||||
artist_id=artist_id,
|
||||
album_id=album_id,
|
||||
filename=Path(file_path).name,
|
||||
file_path=file_path,
|
||||
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=today,
|
||||
source_path=src,
|
||||
))
|
||||
track_by_src[src.resolve()] = track_id
|
||||
|
||||
playlists = scan_playlists(input_dir)
|
||||
for sort_order, (name, paths) in enumerate(playlists.items(), start=1):
|
||||
playlist_id = len(db.playlist_nodes) + 1
|
||||
db.playlist_nodes.append(PlaylistNode(
|
||||
id=playlist_id,
|
||||
parent_id=0,
|
||||
sort_order=sort_order,
|
||||
name=name,
|
||||
is_folder=False,
|
||||
))
|
||||
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)
|
||||
continue
|
||||
db.playlist_entries.append(PlaylistEntry(
|
||||
playlist_id=playlist_id,
|
||||
track_id=tid,
|
||||
entry_index=entry_index,
|
||||
))
|
||||
|
||||
return db
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="maple", description="Build a rekordbox USB from a folder of audio + m3u8 playlists.")
|
||||
parser.add_argument("input_dir", type=Path)
|
||||
parser.add_argument("output_root", nargs="?", type=Path, default=Path("./data/usb"))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
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)
|
||||
|
||||
pdb_path = args.output_root / "PIONEER" / "rekordbox" / "export.pdb"
|
||||
write_pdb(db, pdb_path)
|
||||
|
||||
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}")
|
||||
return 0
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Copy audio files into the USB Contents layout."""
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
_BAD = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
|
||||
|
||||
def sanitise(component: str) -> str:
|
||||
s = _BAD.sub("_", component).strip(" .")
|
||||
return s or "_"
|
||||
|
||||
|
||||
def stage_audio(src: Path, usb_root: Path, artist: str, album: str) -> tuple[Path, str]:
|
||||
artist_dir = sanitise(artist)
|
||||
album_dir = sanitise(album)
|
||||
filename = sanitise(src.name)
|
||||
dest_rel = Path("Contents") / artist_dir / album_dir / filename
|
||||
dest_abs = usb_root / dest_rel
|
||||
dest_abs.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not dest_abs.exists():
|
||||
shutil.copy2(src, dest_abs)
|
||||
file_path = "/" + dest_rel.as_posix()
|
||||
return dest_abs, file_path
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Audio metadata extraction via mutagen."""
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackMeta:
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
track_number: int
|
||||
year: int
|
||||
duration: int
|
||||
bitrate: int
|
||||
sample_rate: int
|
||||
sample_depth: int
|
||||
file_size: int
|
||||
|
||||
|
||||
def _first(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, list):
|
||||
return str(value[0]) if value else ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _int(value) -> int:
|
||||
s = _first(value).strip()
|
||||
if not s:
|
||||
return 0
|
||||
# Handle "3/12" track numbers
|
||||
s = s.split("/")[0]
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def read_tags(path: Path) -> TrackMeta:
|
||||
file_size = path.stat().st_size
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
return TrackMeta(
|
||||
title=path.stem,
|
||||
artist="Unknown Artist",
|
||||
album="Unknown Album",
|
||||
track_number=0,
|
||||
year=0,
|
||||
duration=0,
|
||||
bitrate=0,
|
||||
sample_rate=44100,
|
||||
sample_depth=16,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
tags = audio.tags or {}
|
||||
title = _first(tags.get("title")) or path.stem
|
||||
artist = _first(tags.get("artist")) or "Unknown Artist"
|
||||
album = _first(tags.get("album")) or "Unknown Album"
|
||||
track_number = _int(tags.get("tracknumber"))
|
||||
year = _int(tags.get("date") or tags.get("year"))
|
||||
|
||||
info = getattr(audio, "info", None)
|
||||
duration = int(getattr(info, "length", 0) or 0)
|
||||
bitrate_bps = int(getattr(info, "bitrate", 0) or 0)
|
||||
bitrate = bitrate_bps // 1000 if bitrate_bps > 1000 else bitrate_bps
|
||||
sample_rate = int(getattr(info, "sample_rate", 44100) or 44100)
|
||||
sample_depth = int(getattr(info, "bits_per_sample", 16) or 16)
|
||||
|
||||
return TrackMeta(
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
track_number=track_number,
|
||||
year=year,
|
||||
duration=duration,
|
||||
bitrate=bitrate,
|
||||
sample_rate=sample_rate,
|
||||
sample_depth=sample_depth,
|
||||
file_size=file_size,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Domain model for the in-memory database that becomes a .pdb file."""
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
id: int
|
||||
name: str
|
||||
artist_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
id: int
|
||||
title: str
|
||||
artist_id: int
|
||||
album_id: int
|
||||
filename: str
|
||||
file_path: str
|
||||
file_size: int = 0
|
||||
sample_rate: int = 44100
|
||||
sample_depth: int = 16
|
||||
bitrate: int = 0
|
||||
duration: int = 0
|
||||
track_number: int = 0
|
||||
year: int = 0
|
||||
disc_number: int = 0
|
||||
tempo: int = 0
|
||||
comment: str = ""
|
||||
date_added: str = ""
|
||||
release_date: str = ""
|
||||
mix_name: str = ""
|
||||
analyze_path: str = ""
|
||||
analyze_date: str = ""
|
||||
source_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistNode:
|
||||
id: int
|
||||
parent_id: int
|
||||
sort_order: int
|
||||
name: str
|
||||
is_folder: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistEntry:
|
||||
playlist_id: int
|
||||
track_id: int
|
||||
entry_index: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Database:
|
||||
tracks: list[Track] = field(default_factory=list)
|
||||
artists: list[Artist] = field(default_factory=list)
|
||||
albums: list[Album] = field(default_factory=list)
|
||||
playlist_nodes: list[PlaylistNode] = field(default_factory=list)
|
||||
playlist_entries: list[PlaylistEntry] = field(default_factory=list)
|
||||
Whitespace-only changes.
@@ -0,0 +1,88 @@
|
||||
"""Page builder for the rekordbox .pdb format."""
|
||||
from struct import pack
|
||||
|
||||
PAGE_SIZE = 4096
|
||||
HEADER_SIZE = 40
|
||||
ROW_GROUP_SIZE = 36
|
||||
MAX_ROWS_PER_PAGE = 16 # one row group per page, matching rekordcrate
|
||||
|
||||
|
||||
def _align4(offset: int) -> int:
|
||||
return (offset + 3) & ~3
|
||||
|
||||
|
||||
class PageBuilder:
|
||||
def __init__(self, page_index: int, page_type: int):
|
||||
self.page_index = page_index
|
||||
self.page_type = page_type
|
||||
self.next_page = page_index # caller patches before finalize
|
||||
self.row_bytes: list[bytes] = []
|
||||
self.row_offsets: list[int] = [] # relative to heap_pos
|
||||
self.heap_cursor = 0 # position relative to heap_pos
|
||||
|
||||
def try_add(self, row: bytes) -> bool:
|
||||
if len(self.row_bytes) >= MAX_ROWS_PER_PAGE:
|
||||
return False
|
||||
aligned = _align4(self.heap_cursor)
|
||||
end = aligned + len(row)
|
||||
heap_capacity = PAGE_SIZE - HEADER_SIZE - ROW_GROUP_SIZE
|
||||
if end > heap_capacity:
|
||||
return False
|
||||
self.row_offsets.append(aligned)
|
||||
self.row_bytes.append(row)
|
||||
self.heap_cursor = end
|
||||
return True
|
||||
|
||||
def num_rows(self) -> int:
|
||||
return len(self.row_bytes)
|
||||
|
||||
def finalize(self) -> bytes:
|
||||
n = len(self.row_bytes)
|
||||
if n == 0:
|
||||
used_size = 0
|
||||
heap_padding = PAGE_SIZE - HEADER_SIZE
|
||||
else:
|
||||
used_size = self.heap_cursor
|
||||
heap_padding = PAGE_SIZE - HEADER_SIZE - ROW_GROUP_SIZE
|
||||
free_size = heap_padding - used_size
|
||||
if free_size < 0:
|
||||
raise ValueError("page overflow")
|
||||
|
||||
page_flags = 0x00 if n > 0 else 0x44
|
||||
|
||||
header = b""
|
||||
header += pack("<I", 0) # magic
|
||||
header += pack("<I", self.page_index)
|
||||
header += pack("<I", self.page_type)
|
||||
header += pack("<I", self.next_page)
|
||||
header += pack("<I", 0) # unknown1
|
||||
header += pack("<I", 0) # unknown2
|
||||
header += pack("<BBBB", n & 0xFF, 0, 0, page_flags)
|
||||
header += pack("<HH", free_size, used_size)
|
||||
header += pack("<H", 0) # unknown5
|
||||
header += pack("<H", n) # num_rows_large
|
||||
header += pack("<H", 0) # unknown6
|
||||
header += pack("<H", 0) # unknown7
|
||||
assert len(header) == HEADER_SIZE
|
||||
|
||||
heap = bytearray(heap_padding)
|
||||
if n > 0:
|
||||
for ofs, row in zip(self.row_offsets, self.row_bytes):
|
||||
heap[ofs:ofs + len(row)] = row
|
||||
|
||||
if n == 0:
|
||||
return header + bytes(heap)
|
||||
|
||||
# Row group footer (36 bytes), placed at end of page
|
||||
offsets_padded = self.row_offsets + [0] * (16 - n)
|
||||
presence = 0
|
||||
for i in range(n):
|
||||
presence |= 1 << i
|
||||
rg = b""
|
||||
for ofs in reversed(offsets_padded):
|
||||
rg += pack("<H", ofs)
|
||||
rg += pack("<H", presence)
|
||||
rg += pack("<H", 16) # unknown (rekordcrate default)
|
||||
assert len(rg) == ROW_GROUP_SIZE
|
||||
|
||||
return header + bytes(heap) + rg
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Row serialisers for the rekordbox export.pdb format."""
|
||||
from struct import pack
|
||||
|
||||
from maple.model import Album, Artist, PlaylistEntry, PlaylistNode, Track
|
||||
from maple.pdb.strings import encode_devicesql
|
||||
|
||||
TRACK_FIXED_PREFIX = 0x5E
|
||||
TRACK_OFS_STRINGS_START = TRACK_FIXED_PREFIX
|
||||
TRACK_HEADER_LEN = TRACK_OFS_STRINGS_START + 21 * 2
|
||||
|
||||
|
||||
def _pad2(b: bytes) -> bytes:
|
||||
return b + b"\x00" if len(b) & 1 else b
|
||||
|
||||
|
||||
def serialize_track(t: Track) -> bytes:
|
||||
strings = [
|
||||
"", # 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 date_added
|
||||
t.release_date, # 11 release_date
|
||||
t.mix_name, # 12 mix_name
|
||||
"", # 13 unknown_string_7
|
||||
t.analyze_path, # 14 analyze_path
|
||||
t.analyze_date, # 15 analyze_date
|
||||
t.comment, # 16 comment
|
||||
t.title, # 17 title
|
||||
"", # 18 unknown_string_8
|
||||
t.filename, # 19 filename
|
||||
t.file_path, # 20 file_path
|
||||
]
|
||||
|
||||
encoded = [encode_devicesql(s) for s in strings]
|
||||
|
||||
offsets = []
|
||||
cursor = TRACK_HEADER_LEN
|
||||
blob_parts = []
|
||||
for enc in encoded:
|
||||
offsets.append(cursor)
|
||||
blob_parts.append(enc)
|
||||
cursor += len(enc)
|
||||
blob = b"".join(blob_parts)
|
||||
|
||||
prefix = b""
|
||||
prefix += pack("<HH", 0x24, 0) # subtype, index_shift
|
||||
prefix += pack("<I", 788224) # bitmask
|
||||
prefix += pack("<I", t.sample_rate) # sample_rate
|
||||
prefix += pack("<I", 0) # composer_id
|
||||
prefix += pack("<I", t.file_size) # file_size
|
||||
prefix += pack("<I", 0) # unknown
|
||||
prefix += pack("<H", 19048) # unknown ("always 19048?")
|
||||
prefix += pack("<H", 30967) # unknown ("always 30967?")
|
||||
prefix += pack("<I", 0) # artwork_id
|
||||
prefix += pack("<I", 0) # key_id
|
||||
prefix += pack("<I", 0) # original_artist_id
|
||||
prefix += pack("<I", 0) # label_id
|
||||
prefix += pack("<I", 0) # remixer_id
|
||||
prefix += pack("<I", t.bitrate) # bitrate
|
||||
prefix += pack("<I", t.track_number) # track_number
|
||||
prefix += pack("<I", t.tempo) # tempo
|
||||
prefix += pack("<I", 0) # genre_id
|
||||
prefix += pack("<I", t.album_id) # album_id
|
||||
prefix += pack("<I", t.artist_id) # artist_id
|
||||
prefix += pack("<I", t.id) # id
|
||||
prefix += pack("<H", t.disc_number) # disc_number
|
||||
prefix += pack("<H", 0) # play_count
|
||||
prefix += pack("<H", t.year) # year
|
||||
prefix += pack("<H", t.sample_depth) # sample_depth
|
||||
prefix += pack("<H", t.duration) # duration
|
||||
prefix += pack("<H", 41) # unknown ("always 41?")
|
||||
prefix += pack("<BB", 0, 0) # color_id, rating
|
||||
prefix += pack("<H", 1) # unknown ("always 1?")
|
||||
prefix += pack("<H", 3) # unknown ("alternating 2 or 3")
|
||||
|
||||
assert len(prefix) == TRACK_FIXED_PREFIX, len(prefix)
|
||||
|
||||
ofs_block = b"".join(pack("<H", o) for o in offsets)
|
||||
row = prefix + ofs_block + blob
|
||||
return _pad2(row)
|
||||
|
||||
|
||||
def serialize_artist(a: Artist) -> bytes:
|
||||
name = encode_devicesql(a.name)
|
||||
near_ofs = 0x0A
|
||||
far_threshold = 0x100
|
||||
if near_ofs < far_threshold:
|
||||
subtype = 0x60
|
||||
row = b""
|
||||
row += pack("<HH", subtype, a.id & 0xFFFF) # subtype, index_shift
|
||||
row += pack("<I", a.id) # id
|
||||
row += pack("<B", 0x03) # always 0x03
|
||||
row += pack("<B", near_ofs) # ofs_name_near
|
||||
assert len(row) == near_ofs
|
||||
row += name
|
||||
else:
|
||||
subtype = 0x64
|
||||
# Far form: fixed prefix + u2 ofs_name_far at row+0x0a
|
||||
# Layout: subtype u2, index_shift u2, id u4, 0x03 u1, ofs_name_near u1, ofs_name_far u2 -> name follows at offset 0x0e
|
||||
far = 0x0E
|
||||
row = b""
|
||||
row += pack("<HH", subtype, a.id & 0xFFFF)
|
||||
row += pack("<I", a.id)
|
||||
row += pack("<B", 0x03)
|
||||
row += pack("<B", 0)
|
||||
row += pack("<H", far)
|
||||
row += name
|
||||
return _pad2(row)
|
||||
|
||||
|
||||
def serialize_album(a: Album) -> bytes:
|
||||
name = encode_devicesql(a.name)
|
||||
near_ofs = 0x16
|
||||
subtype = 0x80
|
||||
row = b""
|
||||
row += pack("<HH", subtype, a.id & 0xFFFF) # subtype, index_shift
|
||||
row += pack("<I", 0) # unknown
|
||||
row += pack("<I", a.artist_id) # artist_id
|
||||
row += pack("<I", a.id) # id
|
||||
row += pack("<I", 0) # unknown
|
||||
row += pack("<B", 0x03) # always 0x03
|
||||
row += pack("<B", near_ofs) # ofs_name_near
|
||||
assert len(row) == near_ofs
|
||||
row += name
|
||||
return _pad2(row)
|
||||
|
||||
|
||||
def serialize_playlist_tree(n: PlaylistNode) -> bytes:
|
||||
row = b""
|
||||
row += pack("<I", n.parent_id)
|
||||
row += pack("<I", 0)
|
||||
row += pack("<I", n.sort_order)
|
||||
row += pack("<I", n.id)
|
||||
row += pack("<I", 1 if n.is_folder else 0)
|
||||
row += encode_devicesql(n.name)
|
||||
return _pad2(row)
|
||||
|
||||
|
||||
def serialize_playlist_entry(e: PlaylistEntry) -> bytes:
|
||||
return pack("<III", e.entry_index, e.track_id, e.playlist_id)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""DeviceSQL variable-length string encoding."""
|
||||
|
||||
EMPTY = b"\x03"
|
||||
|
||||
|
||||
def encode_devicesql(s: str) -> bytes:
|
||||
if not s:
|
||||
return EMPTY
|
||||
try:
|
||||
ascii_bytes = s.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
ascii_bytes = None
|
||||
if ascii_bytes is not None and len(ascii_bytes) < 127:
|
||||
length = len(ascii_bytes) + 1
|
||||
length_and_kind = ((length << 1) | 1) & 0xFF
|
||||
return bytes([length_and_kind]) + ascii_bytes
|
||||
payload = s.encode("utf-16-le")
|
||||
total_length = len(payload) + 4
|
||||
return bytes([0x90]) + total_length.to_bytes(2, "little") + b"\x00" + payload
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Top-level rekordbox .pdb file writer."""
|
||||
from pathlib import Path
|
||||
from struct import pack
|
||||
|
||||
from maple.model import Database
|
||||
from maple.pdb.pager import PAGE_SIZE, PageBuilder
|
||||
from maple.pdb.rows import (
|
||||
serialize_album,
|
||||
serialize_artist,
|
||||
serialize_playlist_entry,
|
||||
serialize_playlist_tree,
|
||||
serialize_track,
|
||||
)
|
||||
|
||||
PAGE_TYPE_TRACKS = 0
|
||||
PAGE_TYPE_ARTISTS = 2
|
||||
PAGE_TYPE_ALBUMS = 3
|
||||
PAGE_TYPE_PLAYLIST_TREE = 7
|
||||
PAGE_TYPE_PLAYLIST_ENTRIES = 8
|
||||
|
||||
FILE_HEADER_SIZE = 28 # 0,page_size,num_tables,next_unused,unknown,sequence,gap = 7*u4
|
||||
TABLE_ENTRY_SIZE = 16 # page_type,empty_candidate,first_page,last_page = 4*u4
|
||||
|
||||
|
||||
def _build_chain(page_type: int, row_iter, start_index: int) -> tuple[list[bytes], int, int]:
|
||||
"""Return (page_bytes_list, first_page, last_page)."""
|
||||
pages: list[PageBuilder] = []
|
||||
page_idx = start_index
|
||||
current = PageBuilder(page_idx, page_type)
|
||||
|
||||
for row in row_iter:
|
||||
if not current.try_add(row):
|
||||
pages.append(current)
|
||||
page_idx += 1
|
||||
current = PageBuilder(page_idx, page_type)
|
||||
if not current.try_add(row):
|
||||
raise ValueError(f"row of size {len(row)} too large for an empty page")
|
||||
|
||||
pages.append(current)
|
||||
|
||||
# If the chain has no rows at all, still emit one empty page
|
||||
first_page = start_index
|
||||
last_page = page_idx
|
||||
|
||||
# Patch next_page pointers: each non-last page points to the next; last points to itself
|
||||
out: list[bytes] = []
|
||||
for i, p in enumerate(pages):
|
||||
if i == len(pages) - 1:
|
||||
p.next_page = p.page_index
|
||||
else:
|
||||
p.next_page = p.page_index + 1
|
||||
out.append(p.finalize())
|
||||
|
||||
return out, first_page, last_page
|
||||
|
||||
|
||||
def write_pdb(db: Database, path: str | Path) -> None:
|
||||
# Layout: page 0 is the file header page. Data pages begin at page_index 1.
|
||||
chains: list[tuple[int, list[bytes], int, int]] = [] # (page_type, pages, first, last)
|
||||
next_page_idx = 1
|
||||
|
||||
serialized_tracks = [serialize_track(t) for t in db.tracks]
|
||||
serialized_artists = [serialize_artist(a) for a in db.artists]
|
||||
serialized_albums = [serialize_album(a) for a in db.albums]
|
||||
serialized_pl_tree = [serialize_playlist_tree(n) for n in db.playlist_nodes]
|
||||
serialized_pl_entries = [serialize_playlist_entry(e) for e in db.playlist_entries]
|
||||
|
||||
for ptype, rows in [
|
||||
(PAGE_TYPE_TRACKS, serialized_tracks),
|
||||
(PAGE_TYPE_ARTISTS, serialized_artists),
|
||||
(PAGE_TYPE_ALBUMS, serialized_albums),
|
||||
(PAGE_TYPE_PLAYLIST_TREE, serialized_pl_tree),
|
||||
(PAGE_TYPE_PLAYLIST_ENTRIES, serialized_pl_entries),
|
||||
]:
|
||||
pages, first, last = _build_chain(ptype, rows, next_page_idx)
|
||||
chains.append((ptype, pages, first, last))
|
||||
next_page_idx = last + 1
|
||||
|
||||
num_tables = len(chains)
|
||||
next_unused_page = next_page_idx
|
||||
|
||||
file_header = b""
|
||||
file_header += pack("<I", 0) # magic
|
||||
file_header += pack("<I", PAGE_SIZE)
|
||||
file_header += pack("<I", num_tables)
|
||||
file_header += pack("<I", next_unused_page)
|
||||
file_header += pack("<I", 0) # unknown
|
||||
file_header += pack("<I", 1) # sequence
|
||||
file_header += pack("<I", 0) # gap
|
||||
assert len(file_header) == FILE_HEADER_SIZE
|
||||
|
||||
tables_block = b""
|
||||
for ptype, _pages, first, last in chains:
|
||||
tables_block += pack("<I", ptype)
|
||||
tables_block += pack("<I", 0) # empty_candidate
|
||||
tables_block += pack("<I", first)
|
||||
tables_block += pack("<I", last)
|
||||
assert len(tables_block) == num_tables * TABLE_ENTRY_SIZE
|
||||
|
||||
header_full = file_header + tables_block
|
||||
pad_to_page = PAGE_SIZE - len(header_full)
|
||||
if pad_to_page < 0:
|
||||
raise ValueError("too many tables for one page")
|
||||
page0 = header_full + b"\x00" * pad_to_page
|
||||
|
||||
out_path = Path(path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("wb") as f:
|
||||
f.write(page0)
|
||||
for _ptype, pages, _first, _last in chains:
|
||||
for p in pages:
|
||||
assert len(p) == PAGE_SIZE
|
||||
f.write(p)
|
||||
Whitespace-only changes.
@@ -0,0 +1,34 @@
|
||||
"""Discover audio files and m3u8 playlists in an input directory."""
|
||||
from pathlib import Path
|
||||
|
||||
AUDIO_EXTS = {".mp3", ".flac", ".wav", ".aiff", ".aif", ".m4a"}
|
||||
|
||||
|
||||
def scan_audio(input_dir: Path) -> list[Path]:
|
||||
out = []
|
||||
for p in sorted(input_dir.rglob("*")):
|
||||
if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def parse_m3u8(playlist_path: Path) -> list[Path]:
|
||||
base = playlist_path.parent
|
||||
entries = []
|
||||
with playlist_path.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
p = Path(s)
|
||||
if not p.is_absolute():
|
||||
p = (base / p).resolve()
|
||||
entries.append(p)
|
||||
return entries
|
||||
|
||||
|
||||
def scan_playlists(input_dir: Path) -> dict[str, list[Path]]:
|
||||
out: dict[str, list[Path]] = {}
|
||||
for p in sorted(input_dir.rglob("*.m3u8")):
|
||||
out[p.stem] = parse_m3u8(p)
|
||||
return out
|
||||
Reference in new issue
Block a user