From 71f35c53cb2c766bdeecbf70b7f0ae6354610e3d Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Tue, 2 Jun 2026 09:16:19 +0700 Subject: [PATCH] refactor: vendor iOpenPod core modules instead of pip dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendored 5 packages from iOpenPod (TheRealSavi/iOpenPod): iTunesDB_Writer/ — binary iTunesDB generation iTunesDB_Shared/ — field defs, constants, album identity SQLiteDB_Writer/ — SQLite databases for Nano 6G/7G iTunesDB_Parser/ — play stats reading from iPod ipod_device/ — device detection, FireWire ID, capabilities Rationale: iOpenPod is a standalone iPod manager app, not a library. Pulling it as a pip dependency is heavy and confusing for users. Changes: + src/vendor/ — 77 .py files + calcHashAB.wasm (506 KB) ~ ipod_nano7_db.py — _find_iop_root() now returns src/vendor/ ~ requirements.txt — iopenpod replaced with wasmtime + pycryptodome ~ .gitignore — removed iOpenPod/ exclusion (setup.py reads requirements.txt automatically) --- .gitignore | 1 - requirements.txt | 3 +- src/ipod_nano7_db.py | 32 +- src/vendor/SQLiteDB_Writer/__init__.py | 21 + src/vendor/SQLiteDB_Writer/_helpers.py | 64 + src/vendor/SQLiteDB_Writer/cbk_writer.py | 171 ++ src/vendor/SQLiteDB_Writer/dynamic_writer.py | 124 + src/vendor/SQLiteDB_Writer/extras_writer.py | 82 + src/vendor/SQLiteDB_Writer/genius_writer.py | 56 + src/vendor/SQLiteDB_Writer/library_writer.py | 1189 ++++++++ .../SQLiteDB_Writer/locations_writer.py | 145 + src/vendor/SQLiteDB_Writer/sqlite_writer.py | 211 ++ src/vendor/iTunesDB_Parser/__init__.py | 22 + src/vendor/iTunesDB_Parser/_parsing.py | 69 + src/vendor/iTunesDB_Parser/artwork_links.py | 88 + src/vendor/iTunesDB_Parser/chunk_parser.py | 143 + src/vendor/iTunesDB_Parser/exceptions.py | 59 + src/vendor/iTunesDB_Parser/ipod_library.py | 140 + src/vendor/iTunesDB_Parser/mhbd_parser.py | 39 + src/vendor/iTunesDB_Parser/mhia_parser.py | 27 + src/vendor/iTunesDB_Parser/mhii_parser.py | 30 + src/vendor/iTunesDB_Parser/mhip_parser.py | 27 + src/vendor/iTunesDB_Parser/mhit_parser.py | 30 + src/vendor/iTunesDB_Parser/mhod_parser.py | 613 ++++ src/vendor/iTunesDB_Parser/mhsd_parser.py | 30 + src/vendor/iTunesDB_Parser/mhyp_parser.py | 34 + src/vendor/iTunesDB_Parser/otg.py | 187 ++ src/vendor/iTunesDB_Parser/parser.py | 112 + src/vendor/iTunesDB_Parser/playcounts.py | 260 ++ src/vendor/iTunesDB_Shared/__init__.py | 32 + src/vendor/iTunesDB_Shared/album_identity.py | 93 + src/vendor/iTunesDB_Shared/constants.py | 378 +++ src/vendor/iTunesDB_Shared/extraction.py | 120 + src/vendor/iTunesDB_Shared/field_base.py | 415 +++ src/vendor/iTunesDB_Shared/mhbd_defs.py | 48 + src/vendor/iTunesDB_Shared/mhia_defs.py | 21 + src/vendor/iTunesDB_Shared/mhii_defs.py | 18 + src/vendor/iTunesDB_Shared/mhip_defs.py | 26 + src/vendor/iTunesDB_Shared/mhit_defs.py | 141 + src/vendor/iTunesDB_Shared/mhod_defs.py | 586 ++++ src/vendor/iTunesDB_Shared/mhsd_defs.py | 15 + src/vendor/iTunesDB_Shared/mhyp_defs.py | 35 + src/vendor/iTunesDB_Writer/__init__.py | 148 + src/vendor/iTunesDB_Writer/hash58.py | 280 ++ src/vendor/iTunesDB_Writer/hash72.py | 476 ++++ src/vendor/iTunesDB_Writer/hashab.py | 287 ++ src/vendor/iTunesDB_Writer/mhbd_writer.py | 1140 ++++++++ src/vendor/iTunesDB_Writer/mhip_writer.py | 138 + src/vendor/iTunesDB_Writer/mhit_writer.py | 394 +++ src/vendor/iTunesDB_Writer/mhla_writer.py | 199 ++ src/vendor/iTunesDB_Writer/mhli_writer.py | 136 + src/vendor/iTunesDB_Writer/mhlp_writer.py | 238 ++ src/vendor/iTunesDB_Writer/mhlt_writer.py | 59 + src/vendor/iTunesDB_Writer/mhod52_writer.py | 334 +++ src/vendor/iTunesDB_Writer/mhod_spl_writer.py | 329 +++ src/vendor/iTunesDB_Writer/mhod_writer.py | 434 +++ src/vendor/iTunesDB_Writer/mhsd_writer.py | 101 + src/vendor/iTunesDB_Writer/mhyp_writer.py | 538 ++++ .../iTunesDB_Writer/wasm/calcHashAB.wasm | Bin 0 -> 506338 bytes src/vendor/ipod_device/__init__.py | 148 + src/vendor/ipod_device/artwork.py | 184 ++ src/vendor/ipod_device/artwork_presets.py | 119 + src/vendor/ipod_device/authority.py | 671 +++++ src/vendor/ipod_device/capabilities.py | 641 +++++ src/vendor/ipod_device/checksum.py | 41 + src/vendor/ipod_device/diagnostic_log.py | 177 ++ src/vendor/ipod_device/dump.py | 495 ++++ src/vendor/ipod_device/eject.py | 1022 +++++++ src/vendor/ipod_device/images.py | 408 +++ src/vendor/ipod_device/info.py | 2435 ++++++++++++++++ src/vendor/ipod_device/lookup.py | 170 ++ src/vendor/ipod_device/models.py | 509 ++++ src/vendor/ipod_device/scanner.py | 2524 +++++++++++++++++ src/vendor/ipod_device/sysinfo.py | 528 ++++ src/vendor/ipod_device/usb_backend.py | 132 + src/vendor/ipod_device/virtual.py | 506 ++++ src/vendor/ipod_device/vpd_iokit.py | 617 ++++ src/vendor/ipod_device/vpd_libusb.py | 1353 +++++++++ src/vendor/ipod_device/vpd_linux.py | 266 ++ src/vendor/ipod_device/vpd_usb_control.py | 211 ++ src/vendor/ipod_device/vpd_windows.py | 311 ++ 81 files changed, 24609 insertions(+), 27 deletions(-) create mode 100644 src/vendor/SQLiteDB_Writer/__init__.py create mode 100644 src/vendor/SQLiteDB_Writer/_helpers.py create mode 100644 src/vendor/SQLiteDB_Writer/cbk_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/dynamic_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/extras_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/genius_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/library_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/locations_writer.py create mode 100644 src/vendor/SQLiteDB_Writer/sqlite_writer.py create mode 100644 src/vendor/iTunesDB_Parser/__init__.py create mode 100644 src/vendor/iTunesDB_Parser/_parsing.py create mode 100644 src/vendor/iTunesDB_Parser/artwork_links.py create mode 100644 src/vendor/iTunesDB_Parser/chunk_parser.py create mode 100644 src/vendor/iTunesDB_Parser/exceptions.py create mode 100644 src/vendor/iTunesDB_Parser/ipod_library.py create mode 100644 src/vendor/iTunesDB_Parser/mhbd_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhia_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhii_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhip_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhit_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhod_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhsd_parser.py create mode 100644 src/vendor/iTunesDB_Parser/mhyp_parser.py create mode 100644 src/vendor/iTunesDB_Parser/otg.py create mode 100644 src/vendor/iTunesDB_Parser/parser.py create mode 100644 src/vendor/iTunesDB_Parser/playcounts.py create mode 100644 src/vendor/iTunesDB_Shared/__init__.py create mode 100644 src/vendor/iTunesDB_Shared/album_identity.py create mode 100644 src/vendor/iTunesDB_Shared/constants.py create mode 100644 src/vendor/iTunesDB_Shared/extraction.py create mode 100644 src/vendor/iTunesDB_Shared/field_base.py create mode 100644 src/vendor/iTunesDB_Shared/mhbd_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhia_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhii_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhip_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhit_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhod_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhsd_defs.py create mode 100644 src/vendor/iTunesDB_Shared/mhyp_defs.py create mode 100644 src/vendor/iTunesDB_Writer/__init__.py create mode 100644 src/vendor/iTunesDB_Writer/hash58.py create mode 100644 src/vendor/iTunesDB_Writer/hash72.py create mode 100644 src/vendor/iTunesDB_Writer/hashab.py create mode 100644 src/vendor/iTunesDB_Writer/mhbd_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhip_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhit_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhla_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhli_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhlp_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhlt_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhod52_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhod_spl_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhod_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhsd_writer.py create mode 100644 src/vendor/iTunesDB_Writer/mhyp_writer.py create mode 100644 src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm create mode 100644 src/vendor/ipod_device/__init__.py create mode 100644 src/vendor/ipod_device/artwork.py create mode 100644 src/vendor/ipod_device/artwork_presets.py create mode 100644 src/vendor/ipod_device/authority.py create mode 100644 src/vendor/ipod_device/capabilities.py create mode 100644 src/vendor/ipod_device/checksum.py create mode 100644 src/vendor/ipod_device/diagnostic_log.py create mode 100644 src/vendor/ipod_device/dump.py create mode 100644 src/vendor/ipod_device/eject.py create mode 100644 src/vendor/ipod_device/images.py create mode 100644 src/vendor/ipod_device/info.py create mode 100644 src/vendor/ipod_device/lookup.py create mode 100644 src/vendor/ipod_device/models.py create mode 100644 src/vendor/ipod_device/scanner.py create mode 100644 src/vendor/ipod_device/sysinfo.py create mode 100644 src/vendor/ipod_device/usb_backend.py create mode 100644 src/vendor/ipod_device/virtual.py create mode 100644 src/vendor/ipod_device/vpd_iokit.py create mode 100644 src/vendor/ipod_device/vpd_libusb.py create mode 100644 src/vendor/ipod_device/vpd_linux.py create mode 100644 src/vendor/ipod_device/vpd_usb_control.py create mode 100644 src/vendor/ipod_device/vpd_windows.py diff --git a/.gitignore b/.gitignore index 0c7937b..f2986e0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,3 @@ converted/ specs.md *.AppImage squashfs-root/ -iOpenPod/ diff --git a/requirements.txt b/requirements.txt index 7afb0a3..78b1242 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ pillow>=9.5.0 requests>=2.28.2 PyQt6>=6.5.0 numpy>=1.24.0 -iopenpod @ git+https://github.com/TheRealSavi/iOpenPod.git +wasmtime>=30.0.0 +pycryptodome>=3.20.0 diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index d1b0808..4e2e125 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -40,31 +40,13 @@ from mutagen.easyid3 import EasyID3 from artwork import prepare_artwork_for_track, ArtworkEntry, write_artworkdb def _find_iop_root() -> str: - """Find iOpenPod's installed location. - - Tries in order: - 1. pip-installed package (via importlib.util.find_spec) - 2. /tmp/iOpenPod (backward compat / development) - """ - for pkg in ("iTunesDB_Writer", "SQLiteDB_Writer", "ipod_device"): - spec = importlib.util.find_spec(pkg) - if spec and spec.origin: - parent = os.path.dirname(spec.origin) - if os.path.basename(parent) == pkg: - return os.path.dirname(parent) - - fallbacks = [ - os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "iOpenPod"), - "/tmp/iOpenPod", - os.path.join(os.path.expanduser("~"), "iOpenPod"), - ] - for path in fallbacks: - if os.path.isdir(path): - return path - - raise ImportError( - "iOpenPod not found. Install with: pip install iopenpod" - ) + """Return the vendored iOpenPod packages path.""" + vendor_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor") + if not os.path.isdir(vendor_path): + raise ImportError( + "iOpenPod vendor packages not found at %s" % vendor_path + ) + return vendor_path _IOP_ROOT = _find_iop_root() _IOP_CACHE: dict[str, Any] = {} diff --git a/src/vendor/SQLiteDB_Writer/__init__.py b/src/vendor/SQLiteDB_Writer/__init__.py new file mode 100644 index 0000000..4fb8177 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/__init__.py @@ -0,0 +1,21 @@ +"""SQLiteDB_Writer — Write SQLite databases for iPod Nano 6G/7G. + +These iPods ignore the traditional binary iTunesDB (or iTunesCDB) and read +music metadata from SQLite databases located in: + + /iPod_Control/iTunes/iTunes Library.itlp/ + +The directory contains: + Library.itdb — tracks, albums, artists, composers, playlists, genres + Locations.itdb — iPod file paths for each track + Dynamic.itdb — play counts, ratings, bookmarks + Extras.itdb — lyrics, chapters (optional, can be empty) + Genius.itdb — genius data (optional, can be empty) + Locations.itdb.cbk — HASHAB-signed block checksums of Locations.itdb + +Reference implementation: libgpod itdb_sqlite.c +""" + +from .sqlite_writer import write_sqlite_databases + +__all__ = ["write_sqlite_databases"] diff --git a/src/vendor/SQLiteDB_Writer/_helpers.py b/src/vendor/SQLiteDB_Writer/_helpers.py new file mode 100644 index 0000000..2b9ffbd --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/_helpers.py @@ -0,0 +1,64 @@ +"""Shared helpers for SQLiteDB_Writer modules. + +Centralises utilities that were previously duplicated across +library_writer, locations_writer, dynamic_writer, extras_writer, +and genius_writer. +""" + +import os +import sqlite3 + +# ── Timestamp helpers ────────────────────────────────────────────────── +# SQLite databases use Core Data timestamps: seconds since 2001-01-01 UTC +# (the Cocoa/Core Foundation reference date). +CORE_DATA_EPOCH = 978307200 # Unix timestamp of 2001-01-01 00:00:00 UTC + + +def unix_to_coredata(unix_ts: int, tz_offset: int = 0) -> int: + """Convert Unix timestamp to Core Data timestamp. + + Args: + unix_ts: Unix timestamp (seconds since 1970-01-01) + tz_offset: Timezone offset in seconds (positive = east of UTC) + + Returns: + Core Data timestamp (seconds since 2001-01-01) adjusted for timezone. + Returns 0 if input is 0. + """ + if unix_ts == 0: + return 0 + return unix_ts - CORE_DATA_EPOCH - tz_offset + + +def s64(val: int) -> int: + """Convert unsigned 64-bit int to signed for SQLite INTEGER storage. + + SQLite INTEGER is signed 64-bit (max 2^63-1). iPod db_ids and PIDs + are unsigned 64-bit values that may exceed this limit. + """ + if val >= (1 << 63): + return val - (1 << 64) + return val + + +def open_db(path: str, extra_pragmas: list[str] | None = None) -> tuple[sqlite3.Connection, sqlite3.Cursor]: + """Create a fresh SQLite database at *path*. + + Deletes any existing file, opens a new connection with performance + PRAGMAs (journal_mode=OFF, synchronous=OFF), and returns (conn, cursor). + + Args: + path: Output file path. + extra_pragmas: Additional PRAGMA statements to execute + (e.g. ``["encoding='UTF-8'"]``). + """ + if os.path.exists(path): + os.remove(path) + + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=OFF") + conn.execute("PRAGMA synchronous=OFF") + for pragma in extra_pragmas or []: + conn.execute(f"PRAGMA {pragma}") + + return conn, conn.cursor() diff --git a/src/vendor/SQLiteDB_Writer/cbk_writer.py b/src/vendor/SQLiteDB_Writer/cbk_writer.py new file mode 100644 index 0000000..b1b0953 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/cbk_writer.py @@ -0,0 +1,171 @@ +"""Locations.itdb.cbk writer — HASHAB-signed block checksums. + +The cbk (checksum book) file contains SHA1 checksums of 1024-byte blocks +of Locations.itdb, plus a final SHA1 of all those checksums, signed with +HASHAB. + +File format: + [57 bytes] HASHAB signature of final_sha1 (or 20 bytes for HASH58/72) + [20 bytes] final_sha1 = SHA1(all_block_sha1s concatenated) + [Nx20 bytes] SHA1 of each 1024-byte block of Locations.itdb + +For HASHAB (Nano 6G/7G), the header is 57 bytes. +For HASH58, the header would be 20 bytes. +For HASH72, the header would be 46 bytes. + +Reference: libgpod itdb_sqlite.c mk_Locations_cbk() +""" + +import hashlib +import logging +import os + +from ipod_device import ChecksumType + +logger = logging.getLogger(__name__) + +# Block size for checksumming +BLOCK_SIZE = 1024 + + +def _compute_block_sha1s(data: bytes) -> list[bytes]: + """Compute SHA1 hash of each 1024-byte block. + + The last block may be smaller than 1024 bytes; it's still hashed. + + Args: + data: Raw file contents. + + Returns: + List of 20-byte SHA1 digests, one per block. + """ + block_hashes = [] + offset = 0 + while offset < len(data): + block = data[offset:offset + BLOCK_SIZE] + block_hashes.append(hashlib.sha1(block).digest()) + offset += BLOCK_SIZE + return block_hashes + + +def write_locations_cbk( + cbk_path: str, + locations_itdb_path: str, + checksum_type: ChecksumType, + firewire_id: bytes | None = None, + ipod_path: str | None = None, +) -> None: + """Generate and write the Locations.itdb.cbk checksum file. + + Args: + cbk_path: Output path for the .cbk file. + locations_itdb_path: Path to the Locations.itdb file to checksum. + checksum_type: The device's checksum algorithm (HASHAB, HASH58, etc.). + firewire_id: 8-byte FireWire GUID (required for HASHAB and HASH58). + ipod_path: Mount point of iPod (used for HASH72 HashInfo fallback). + + Raises: + ValueError: If firewire_id is missing when needed. + FileNotFoundError: If Locations.itdb doesn't exist. + """ + with open(locations_itdb_path, 'rb') as f: + locations_data = f.read() + + # Compute block SHA1s + block_sha1s = _compute_block_sha1s(locations_data) + + # Compute final SHA1 = SHA1(concatenation of all block SHA1s) + all_sha1s = b''.join(block_sha1s) + final_sha1 = hashlib.sha1(all_sha1s).digest() + + logger.debug("Locations.itdb: %d bytes, %d blocks, final SHA1: %s", + len(locations_data), len(block_sha1s), final_sha1.hex()) + + # Generate header signature based on checksum type + if checksum_type == ChecksumType.HASHAB: + if not firewire_id or len(firewire_id) < 8: + raise ValueError("FireWire ID required for HASHAB cbk signature") + + from iTunesDB_Writer.hashab import compute_hashab + header = compute_hashab(final_sha1, firewire_id[:8]) + if len(header) != 57: + raise RuntimeError(f"HASHAB returned {len(header)} bytes, expected 57") + logger.debug("CBK header: HASHAB signature (%d bytes)", len(header)) + + elif checksum_type == ChecksumType.HASH58: + if not firewire_id or len(firewire_id) < 8: + raise ValueError("FireWire ID required for HASH58 cbk signature") + + from iTunesDB_Writer.hash58 import compute_hash58 + header = compute_hash58(firewire_id, final_sha1) + logger.debug("CBK header: HASH58 signature (%d bytes)", len(header)) + + elif checksum_type == ChecksumType.HASH72: + from iTunesDB_Writer.hash72 import ( + read_hash_info, extract_hash_info_to_dict, + _hash_generate, HashInfo, + ) + + # Try centralized store first + hash_info = None + try: + from ipod_device import get_current_device + dev = get_current_device() + if dev and dev.hash_info_iv and dev.hash_info_rndpart: + hash_info = HashInfo( + uuid=b'\x00' * 20, + rndpart=dev.hash_info_rndpart, + iv=dev.hash_info_iv, + ) + except Exception: + pass + + if hash_info is None and ipod_path: + try: + hash_info = read_hash_info(ipod_path) + except Exception: + pass + + # Fallback: extract from existing iTunesCDB on device + if hash_info is None and ipod_path: + try: + from ipod_device import resolve_itdb_path + itdb_path = resolve_itdb_path(ipod_path) + if itdb_path: + with open(itdb_path, "rb") as f: + itdb_data = f.read() + hd = extract_hash_info_to_dict(itdb_data) + if hd: + hash_info = HashInfo( + uuid=b'\x00' * 20, + rndpart=hd['rndpart'], + iv=hd['iv'], + ) + logger.debug("CBK: extracted HashInfo from existing %s", + os.path.basename(itdb_path)) + except Exception: + pass + + if hash_info: + header = _hash_generate(final_sha1, hash_info.iv, hash_info.rndpart) + logger.debug("CBK header: HASH72 signature (%d bytes)", len(header)) + else: + logger.warning("No HashInfo available for HASH72 cbk — writing final SHA1 only") + header = final_sha1 + + else: + # No checksum needed — older devices or NONE + # Just write the SHA1 as header (20 bytes) + header = final_sha1 + + # Write the cbk file: header + final_sha1 + block_sha1s + with open(cbk_path, 'wb') as f: + f.write(header) + f.write(final_sha1) + for bsha1 in block_sha1s: + f.write(bsha1) + + total_size = len(header) + 20 + len(block_sha1s) * 20 + logger.info("Wrote Locations.itdb.cbk: %d bytes " + "(%d-byte header + 20-byte final SHA1 + %d×20 block SHA1s)", + total_size, len(header), len(block_sha1s)) diff --git a/src/vendor/SQLiteDB_Writer/dynamic_writer.py b/src/vendor/SQLiteDB_Writer/dynamic_writer.py new file mode 100644 index 0000000..3241e2d --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/dynamic_writer.py @@ -0,0 +1,124 @@ +"""Dynamic.itdb writer — play counts, ratings, and bookmark data. + +Contains item_stats (per-track play/skip counts, ratings, bookmarks) +and container_ui (playlist UI state like play order, repeat, shuffle). + +Reference: libgpod itdb_sqlite.c mk_Dynamic() +""" + +import logging +from typing import Optional + +from iTunesDB_Writer.mhit_writer import TrackInfo +from ._helpers import s64 as _s64, unix_to_coredata, open_db + +logger = logging.getLogger(__name__) + + +_DYNAMIC_SCHEMA = """ +CREATE TABLE IF NOT EXISTS item_stats ( + item_pid INTEGER NOT NULL, + has_been_played INTEGER DEFAULT 0, + date_played INTEGER DEFAULT 0, + play_count_user INTEGER DEFAULT 0, + play_count_recent INTEGER DEFAULT 0, + date_skipped INTEGER DEFAULT 0, + skip_count_user INTEGER DEFAULT 0, + skip_count_recent INTEGER DEFAULT 0, + bookmark_time_ms REAL, + bookmark_time_ms_common REAL, + user_rating INTEGER DEFAULT 0, + user_rating_common INTEGER DEFAULT 0, + rental_expired INTEGER DEFAULT 0, + play_count_user_original INTEGER DEFAULT 0, + skip_count_user_original INTEGER DEFAULT 0, + genius_id INTEGER DEFAULT 0, + PRIMARY KEY (item_pid) +); + +CREATE TABLE IF NOT EXISTS container_ui ( + container_pid INTEGER NOT NULL, + play_order INTEGER DEFAULT 0, + is_reversed INTEGER DEFAULT 0, + album_field_order INTEGER DEFAULT 0, + repeat_mode INTEGER DEFAULT 0, + shuffle_items INTEGER DEFAULT 0, + has_been_shuffled INTEGER DEFAULT 0, + PRIMARY KEY (container_pid) +); + +CREATE TABLE IF NOT EXISTS rental_info ( + item_pid INTEGER NOT NULL, + rental_date_started INTEGER DEFAULT 0, + rental_duration INTEGER DEFAULT 0, + rental_playback_date_started INTEGER DEFAULT 0, + rental_playback_duration INTEGER DEFAULT 0, + is_demo INTEGER DEFAULT 0, + PRIMARY KEY (item_pid) +); +""" + + +def write_dynamic_itdb( + path: str, + tracks: list[TrackInfo], + playlist_pids: Optional[list[int]] = None, + tz_offset: int = 0, +) -> None: + """Write Dynamic.itdb SQLite database. + + Args: + path: Output file path. + tracks: List of TrackInfo objects. + playlist_pids: All playlist PIDs (master + user + smart), as returned + by ``write_library_itdb()``. One ``container_ui`` row + is written per PID. + tz_offset: Timezone offset in seconds. + """ + conn, cur = open_db(path) + + cur.executescript(_DYNAMIC_SCHEMA) + + # ── item_stats ───────────────────────────────────────────────────── + for track in tracks: + has_been_played = 1 if track.play_count > 0 else 0 + + date_played = unix_to_coredata(track.last_played or 0, tz_offset) + date_skipped = unix_to_coredata(track.last_skipped or 0, tz_offset) + + cur.execute( + """INSERT INTO item_stats ( + item_pid, has_been_played, date_played, + play_count_user, play_count_recent, + date_skipped, skip_count_user, skip_count_recent, + bookmark_time_ms, bookmark_time_ms_common, + user_rating, user_rating_common, + rental_expired, + play_count_user_original, skip_count_user_original, + genius_id + ) VALUES (?, ?, ?, ?, 0, ?, ?, 0, ?, ?, ?, ?, 0, ?, ?, 0)""", + ( + _s64(track.db_track_id), has_been_played, date_played, + track.play_count, + date_skipped, track.skip_count, + float(track.bookmark_time), float(track.bookmark_time), + track.rating, track.app_rating, + track.play_count, track.skip_count, + ) + ) + + # ── container_ui ─────────────────────────────────────────────────── + # One row per playlist PID (master + user + smart) + for pid in (playlist_pids or []): + cur.execute( + "INSERT INTO container_ui (container_pid, play_order, is_reversed, " + "album_field_order, repeat_mode, shuffle_items, has_been_shuffled) " + "VALUES (?, 0, 0, 1, 0, 0, 0)", + (_s64(pid),) + ) + + conn.commit() + conn.close() + + logger.info("Wrote Dynamic.itdb: %d item_stats, %d container_ui", + len(tracks), len(playlist_pids or [])) diff --git a/src/vendor/SQLiteDB_Writer/extras_writer.py b/src/vendor/SQLiteDB_Writer/extras_writer.py new file mode 100644 index 0000000..0c140bd --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/extras_writer.py @@ -0,0 +1,82 @@ +"""Extras.itdb writer — lyrics and chapter data. + +Creates the Extras.itdb with empty tables. Lyrics from tracks are +inserted if available. + +Reference: libgpod itdb_sqlite.c mk_Extras() +""" + +import logging + +from iTunesDB_Writer.mhit_writer import TrackInfo +from ._helpers import s64 as _s64, open_db + +logger = logging.getLogger(__name__) + + +_EXTRAS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS chapter ( + item_pid INTEGER NOT NULL, + data BLOB, + PRIMARY KEY (item_pid) +); + +CREATE TABLE IF NOT EXISTS lyrics ( + item_pid INTEGER NOT NULL, + checksum INTEGER, + lyrics TEXT, + PRIMARY KEY (item_pid) +); +""" + + +def write_extras_itdb( + path: str, + tracks: list[TrackInfo], +) -> None: + """Write Extras.itdb SQLite database. + + Args: + path: Output file path. + tracks: List of TrackInfo objects. + """ + conn, cur = open_db(path) + + cur.executescript(_EXTRAS_SCHEMA) + + # Insert lyrics for tracks that have them + lyrics_count = 0 + for track in tracks: + if track.lyrics: + # Simple checksum: sum of bytes mod 2^32 + checksum = sum(track.lyrics.encode('utf-8')) & 0xFFFFFFFF + cur.execute( + "INSERT INTO lyrics (item_pid, checksum, lyrics) VALUES (?, ?, ?)", + (_s64(track.db_track_id), checksum, track.lyrics) + ) + lyrics_count += 1 + + # Insert chapter data for tracks that have chapters + chapter_count = 0 + for track in tracks: + cd = track.chapter_data or {} + chapters = cd.get("chapters") + if chapters: + from iTunesDB_Writer.mhod_writer import build_chapter_blob + blob = build_chapter_blob( + chapters, + unk024=cd.get("unk024", 0), + unk028=cd.get("unk028", 0), + unk032=cd.get("unk032", 0), + ) + if blob: + cur.execute( + "INSERT INTO chapter (item_pid, data) VALUES (?, ?)", + (_s64(track.db_track_id), blob) + ) + chapter_count += 1 + + conn.commit() + conn.close() + + logger.info("Wrote Extras.itdb: %d lyrics entries, %d chapter entries", lyrics_count, chapter_count) diff --git a/src/vendor/SQLiteDB_Writer/genius_writer.py b/src/vendor/SQLiteDB_Writer/genius_writer.py new file mode 100644 index 0000000..5576a07 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/genius_writer.py @@ -0,0 +1,56 @@ +"""Genius.itdb writer — Genius playlists and similarity data. + +Creates a minimal Genius.itdb with empty tables. We don't support Genius +features, but the database file must exist for the firmware. + +Reference: libgpod itdb_sqlite.c mk_Genius() +""" + +import logging + +from ._helpers import open_db + +logger = logging.getLogger(__name__) + + +_GENIUS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS genius_config ( + id INTEGER NOT NULL, + version INTEGER, + default_num_results INTEGER DEFAULT 0, + min_num_results INTEGER DEFAULT 0, + data BLOB, + PRIMARY KEY (id), + UNIQUE (version) +); + +CREATE TABLE IF NOT EXISTS genius_metadata ( + genius_id INTEGER NOT NULL, + version INTEGER, + data BLOB, + PRIMARY KEY (genius_id) +); + +CREATE TABLE IF NOT EXISTS genius_similarities ( + genius_id INTEGER NOT NULL, + version INTEGER, + data BLOB, + PRIMARY KEY (genius_id) +); +""" + + +def write_genius_itdb(path: str) -> None: + """Write Genius.itdb SQLite database (empty tables). + + Args: + path: Output file path. + """ + conn, cur = open_db(path) + + cur.executescript(_GENIUS_SCHEMA) + + conn.commit() + conn.close() + + logger.info("Wrote Genius.itdb (empty)") diff --git a/src/vendor/SQLiteDB_Writer/library_writer.py b/src/vendor/SQLiteDB_Writer/library_writer.py new file mode 100644 index 0000000..b5dcbf7 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/library_writer.py @@ -0,0 +1,1189 @@ +"""Library.itdb writer — main SQLite music library database. + +Writes all track metadata, albums, artists, composers, containers (playlists), +genres, AV format info, and version/db info. + +Schema matches real iTunes-written databases on iPod Nano 6G. +Reference: libgpod itdb_sqlite.c mk_Library() +""" + +import logging +import time + +from iTunesDB_Shared.album_identity import album_identity_from_track +from iTunesDB_Shared.field_base import strip_article +from iTunesDB_Writer.mhit_writer import TrackInfo +from iTunesDB_Writer.mhyp_writer import PlaylistInfo + +from ._helpers import open_db +from ._helpers import s64 as _s64 +from ._helpers import unix_to_coredata as _unix_to_coredata + +logger = logging.getLogger(__name__) + + +# ── Audio format codes ───────────────────────────────────────────────── +# avformat_info.audio_format values observed in real databases +AUDIO_FORMAT_MP3 = 0x012D # 301 +AUDIO_FORMAT_AAC = 0x01F6 # 502 +AUDIO_FORMAT_ALAC = 0x01F7 # 503 +AUDIO_FORMAT_WAV = 0x006E # 110 +AUDIO_FORMAT_AIFF = 0x006F # 111 + +_FILETYPE_TO_AUDIO_FORMAT = { + 'mp3': AUDIO_FORMAT_MP3, + 'aac': AUDIO_FORMAT_AAC, + 'm4a': AUDIO_FORMAT_AAC, + 'm4p': AUDIO_FORMAT_AAC, + 'm4b': AUDIO_FORMAT_AAC, + 'alac': AUDIO_FORMAT_ALAC, + 'wav': AUDIO_FORMAT_WAV, + 'aif': AUDIO_FORMAT_AIFF, + 'aiff': AUDIO_FORMAT_AIFF, +} + +# ── Media kind flags ─────────────────────────────────────────────────── +# item.media_kind in the SQLite database. These differ from the binary +# iTunesDB media_type values. +MEDIA_KIND_SONG = 1 +MEDIA_KIND_AUDIOBOOK = 8 +MEDIA_KIND_MUSIC_VIDEO = 32 +MEDIA_KIND_MOVIE = 2 +MEDIA_KIND_TV_SHOW = 64 +MEDIA_KIND_PODCAST = 4 +MEDIA_KIND_RINGTONE = 0x4000 + +# Map from binary iTunesDB media_type to SQLite media_kind +_ITDB_MEDIATYPE_TO_MEDIA_KIND = { + 0x01: MEDIA_KIND_SONG, # audio + 0x02: MEDIA_KIND_MOVIE, # video/movie + 0x04: MEDIA_KIND_PODCAST, # podcast + 0x06: MEDIA_KIND_PODCAST, # video podcast + 0x08: MEDIA_KIND_AUDIOBOOK, # audiobook + 0x20: MEDIA_KIND_MUSIC_VIDEO, # music video + 0x40: MEDIA_KIND_TV_SHOW, # TV show + 0x4000: MEDIA_KIND_RINGTONE, # ringtone +} + + +def _media_kind(track: TrackInfo) -> int: + """Derive SQLite media_kind from track media_type.""" + return _ITDB_MEDIATYPE_TO_MEDIA_KIND.get(track.media_type, MEDIA_KIND_SONG) + + +# All media_kind values that produce an is_* = 1 flag in the item table. +_MEDIA_KIND_FLAGS = ( + MEDIA_KIND_SONG, MEDIA_KIND_AUDIOBOOK, MEDIA_KIND_MUSIC_VIDEO, + MEDIA_KIND_MOVIE, MEDIA_KIND_TV_SHOW, MEDIA_KIND_RINGTONE, MEDIA_KIND_PODCAST, +) + + +def _media_kind_flags(mk: int) -> tuple[int, ...]: + """Return (is_song, is_audio_book, is_music_video, is_movie, is_tv_show, is_ringtone, is_podcast).""" + return tuple(int(mk == k) for k in _MEDIA_KIND_FLAGS) + + +def _album_identity_fields(track: TrackInfo) -> tuple[str, str, str]: + identity = album_identity_from_track(track) + album_name = identity.album or "" + album_artist = identity.album_artist or identity.artist or "" + show_name = identity.show_name or "" + return album_name, album_artist, show_name + + +_CONTAINER_INSERT_SQL = """\ +INSERT INTO container ( + pid, distinguished_kind, date_created, date_modified, + name, name_order, parent_pid, media_kinds, + workout_template_id, is_hidden, + smart_is_folder, smart_is_dynamic, smart_is_filtered, + smart_is_genius, smart_enabled_only, smart_is_limited, + smart_limit_kind, smart_limit_order, smart_evaluation_order, + smart_limit_value, smart_reverse_limit_order, + smart_criteria, description +) VALUES ( + :pid, :distinguished_kind, :date_created, :date_modified, + :name, :name_order, 0, :media_kinds, + 0, :is_hidden, + :smart_is_folder, :smart_is_dynamic, :smart_is_filtered, + 0, 0, :smart_is_limited, + :smart_limit_kind, :smart_limit_order, :smart_evaluation_order, + :smart_limit_value, :smart_reverse_limit_order, + :smart_criteria, NULL +)""" + + +def _insert_container( + cur, *, pid: int, name: str, name_order: int, + date_created: int, date_modified: int, + distinguished_kind: int = 0, + media_kinds: int = 1, + is_hidden: int = 0, + smart_is_folder: int = 0, + smart_is_dynamic=None, + smart_is_filtered=None, + smart_is_limited=None, + smart_limit_kind=None, + smart_limit_order=None, + smart_evaluation_order=None, + smart_limit_value=None, + smart_reverse_limit_order=None, + smart_criteria=None, +) -> None: + """Insert a single row into the container table.""" + cur.execute(_CONTAINER_INSERT_SQL, { + 'pid': _s64(pid), + 'distinguished_kind': distinguished_kind, + 'date_created': date_created, + 'date_modified': date_modified, + 'name': name, + 'name_order': name_order, + 'media_kinds': media_kinds, + 'is_hidden': is_hidden, + 'smart_is_folder': smart_is_folder, + 'smart_is_dynamic': smart_is_dynamic, + 'smart_is_filtered': smart_is_filtered, + 'smart_is_limited': smart_is_limited, + 'smart_limit_kind': smart_limit_kind, + 'smart_limit_order': smart_limit_order, + 'smart_evaluation_order': smart_evaluation_order, + 'smart_limit_value': smart_limit_value, + 'smart_reverse_limit_order': smart_reverse_limit_order, + 'smart_criteria': smart_criteria, + }) + + +# ── Schema DDL ───────────────────────────────────────────────────────── +# These CREATE TABLE statements match a real Nano 6G Library.itdb exactly. +# We issue them in dependency order. + +_LIBRARY_SCHEMA = """ +CREATE TABLE IF NOT EXISTS version_info ( + id INTEGER PRIMARY KEY, + major INTEGER, + minor INTEGER, + compatibility INTEGER DEFAULT 0, + update_level INTEGER DEFAULT 0, + device_update_level INTEGER DEFAULT 0, + platform INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS db_info ( + pid INTEGER NOT NULL, + primary_container_pid INTEGER, + media_folder_url TEXT, + audio_language INTEGER, + subtitle_language INTEGER, + genius_cuid TEXT, + bib BLOB, + rib BLOB, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS genre_map ( + id INTEGER NOT NULL, + genre TEXT NOT NULL, + genre_order INTEGER DEFAULT 0, + is_unknown INTEGER DEFAULT 0, + has_music INTEGER DEFAULT 0, + artist_count_calc INTEGER DEFAULT 0 NOT NULL, + album_count_calc INTEGER DEFAULT 0 NOT NULL, + compilation_count_calc INTEGER DEFAULT 0 NOT NULL, + PRIMARY KEY (id), + UNIQUE (genre) +); + +CREATE TABLE IF NOT EXISTS location_kind_map ( + id INTEGER NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (kind) +); + +CREATE TABLE IF NOT EXISTS category_map ( + id INTEGER NOT NULL, + category TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (category) +); + +CREATE TABLE IF NOT EXISTS item ( + pid INTEGER NOT NULL, + revision_level INTEGER, + media_kind INTEGER DEFAULT 0, + is_song INTEGER DEFAULT 0, + is_audio_book INTEGER DEFAULT 0, + is_music_video INTEGER DEFAULT 0, + is_movie INTEGER DEFAULT 0, + is_tv_show INTEGER DEFAULT 0, + is_home_video INTEGER DEFAULT 0, + is_ringtone INTEGER DEFAULT 0, + is_tone INTEGER DEFAULT 0, + is_voice_memo INTEGER DEFAULT 0, + is_book INTEGER DEFAULT 0, + is_rental INTEGER DEFAULT 0, + is_itunes_u INTEGER DEFAULT 0, + is_digital_booklet INTEGER DEFAULT 0, + is_podcast INTEGER DEFAULT 0, + date_modified INTEGER DEFAULT 0, + year INTEGER DEFAULT 0, + content_rating INTEGER DEFAULT 0, + content_rating_level INTEGER DEFAULT 0, + is_compilation INTEGER, + is_user_disabled INTEGER DEFAULT 0, + remember_bookmark INTEGER DEFAULT 0, + exclude_from_shuffle INTEGER DEFAULT 0, + part_of_gapless_album INTEGER DEFAULT 0, + chosen_by_auto_fill INTEGER DEFAULT 0, + artwork_status INTEGER, + artwork_cache_id INTEGER DEFAULT 0, + start_time_ms REAL DEFAULT 0, + stop_time_ms REAL DEFAULT 0, + total_time_ms REAL DEFAULT 0, + total_burn_time_ms REAL, + track_number INTEGER DEFAULT 0, + track_count INTEGER DEFAULT 0, + disc_number INTEGER DEFAULT 0, + disc_count INTEGER DEFAULT 0, + bpm INTEGER DEFAULT 0, + relative_volume INTEGER, + eq_preset TEXT, + radio_stream_status TEXT, + genius_id INTEGER DEFAULT 0, + genre_id INTEGER DEFAULT 0, + category_id INTEGER DEFAULT 0, + album_pid INTEGER DEFAULT 0, + artist_pid INTEGER DEFAULT 0, + composer_pid INTEGER DEFAULT 0, + title TEXT, + artist TEXT, + album TEXT, + album_artist TEXT, + composer TEXT, + sort_title TEXT, + sort_artist TEXT, + sort_album TEXT, + sort_album_artist TEXT, + sort_composer TEXT, + title_order INTEGER, + artist_order INTEGER, + album_order INTEGER, + genre_order INTEGER, + composer_order INTEGER, + album_artist_order INTEGER, + album_by_artist_order INTEGER, + series_name_order INTEGER, + comment TEXT, + grouping TEXT, + description TEXT, + description_long TEXT, + collection_description TEXT, + copyright TEXT, + track_artist_pid INTEGER DEFAULT 0, + physical_order INTEGER, + has_lyrics INTEGER DEFAULT 0, + date_released INTEGER DEFAULT 0, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS album ( + pid INTEGER NOT NULL, + kind INTEGER, + artwork_status INTEGER, + artwork_item_pid INTEGER, + artist_pid INTEGER, + user_rating INTEGER, + name TEXT, + name_order INTEGER, + all_compilations INTEGER, + feed_url TEXT, + season_number INTEGER, + is_unknown INTEGER DEFAULT 0, + has_songs INTEGER DEFAULT 0, + has_music_videos INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + artist_order INTEGER DEFAULT 0, + has_any_compilations INTEGER DEFAULT 0, + sort_name TEXT, + artist_count_calc INTEGER DEFAULT 0 NOT NULL, + has_movies INTEGER DEFAULT 0, + item_count INTEGER DEFAULT 0, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS artist ( + pid INTEGER NOT NULL, + kind INTEGER, + artwork_status INTEGER, + artwork_album_pid INTEGER, + name TEXT, + name_order INTEGER, + sort_name TEXT, + is_unknown INTEGER DEFAULT 0, + has_songs INTEGER DEFAULT 0, + has_music_videos INTEGER DEFAULT 0, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS track_artist ( + pid INTEGER NOT NULL, + name TEXT, + name_order INTEGER, + sort_name TEXT, + has_songs INTEGER DEFAULT 0, + has_music_videos INTEGER DEFAULT 0, + has_non_compilation_tracks INTEGER DEFAULT 0, + is_unknown INTEGER DEFAULT 0, + album_count INTEGER DEFAULT 0, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS composer ( + pid INTEGER NOT NULL, + name TEXT, + name_order INTEGER, + sort_name TEXT, + is_unknown INTEGER DEFAULT 0, + has_music INTEGER DEFAULT 0, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS avformat_info ( + item_pid INTEGER NOT NULL, + sub_id INTEGER NOT NULL DEFAULT 0, + audio_format INTEGER, + bit_rate INTEGER DEFAULT 0, + channels INTEGER DEFAULT 0, + sample_rate REAL DEFAULT 0, + duration INTEGER, + gapless_heuristic_info INTEGER, + gapless_encoding_delay INTEGER, + gapless_encoding_drain INTEGER, + gapless_last_frame_resynch INTEGER, + analysis_inhibit_flags INTEGER, + audio_fingerprint INTEGER, + volume_normalization_energy INTEGER, + PRIMARY KEY (item_pid, sub_id) +); + +CREATE TABLE IF NOT EXISTS container ( + pid INTEGER NOT NULL, + distinguished_kind INTEGER, + date_created INTEGER, + date_modified INTEGER, + name TEXT, + name_order INTEGER, + parent_pid INTEGER, + media_kinds INTEGER, + workout_template_id INTEGER, + is_hidden INTEGER, + smart_is_folder INTEGER, + smart_is_dynamic INTEGER, + smart_is_filtered INTEGER, + smart_is_genius INTEGER, + smart_enabled_only INTEGER, + smart_is_limited INTEGER, + smart_limit_kind INTEGER, + smart_limit_order INTEGER, + smart_evaluation_order INTEGER, + smart_limit_value INTEGER, + smart_reverse_limit_order INTEGER, + smart_criteria BLOB, + description TEXT, + PRIMARY KEY (pid) +); + +CREATE TABLE IF NOT EXISTS item_to_container ( + item_pid INTEGER, + container_pid INTEGER, + physical_order INTEGER, + shuffle_order INTEGER +); + +CREATE TABLE IF NOT EXISTS container_seed ( + container_pid INTEGER NOT NULL, + item_pid INTEGER NOT NULL, + seed_order INTEGER DEFAULT 0, + UNIQUE (container_pid, item_pid) +); + +CREATE TABLE IF NOT EXISTS video_info ( + item_pid INTEGER NOT NULL, + has_alternate_audio INTEGER, + has_subtitles INTEGER, + characteristics_valid INTEGER, + has_closed_captions INTEGER, + is_self_contained INTEGER, + is_compressed INTEGER, + is_anamorphic INTEGER, + is_hd INTEGER, + season_number INTEGER, + audio_language INTEGER, + audio_track_index INTEGER, + audio_track_id INTEGER, + subtitle_language INTEGER, + subtitle_track_index INTEGER, + subtitle_track_id INTEGER, + series_name TEXT, + sort_series_name TEXT, + episode_id TEXT, + episode_sort_id INTEGER, + network_name TEXT, + extended_content_rating TEXT, + movie_info TEXT, + PRIMARY KEY (item_pid) +); + +CREATE TABLE IF NOT EXISTS video_characteristics ( + item_pid INTEGER, + sub_id INTEGER DEFAULT 0, + track_id INTEGER, + height INTEGER, + width INTEGER, + depth INTEGER, + codec INTEGER, + frame_rate REAL, + percentage_encrypted REAL, + bit_rate INTEGER, + peak_bit_rate INTEGER, + buffer_size INTEGER, + profile INTEGER, + level INTEGER, + complexity_level INTEGER, + UNIQUE (item_pid, sub_id, track_id) +); + +CREATE TABLE IF NOT EXISTS podcast_info ( + item_pid INTEGER NOT NULL, + date_released INTEGER DEFAULT 0, + external_guid TEXT, + feed_url TEXT, + feed_keywords TEXT, + PRIMARY KEY (item_pid) +); + +CREATE TABLE IF NOT EXISTS store_info ( + item_pid INTEGER NOT NULL, + store_kind INTEGER, + date_purchased INTEGER DEFAULT 0, + date_released INTEGER DEFAULT 0, + account_id INTEGER, + key_versions INTEGER, + key_platform_id INTEGER, + key_id INTEGER, + key_id2 INTEGER, + store_item_id INTEGER, + artist_id INTEGER, + composer_id INTEGER, + genre_id INTEGER, + playlist_id INTEGER, + storefront_id INTEGER, + store_link_id INTEGER, + relevance REAL, + popularity REAL, + xid TEXT, + flavor TEXT, + PRIMARY KEY (item_pid) +); + +CREATE TABLE IF NOT EXISTS store_link ( + id INTEGER NOT NULL, + url TEXT, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS track_size_calc ( + pid INTEGER NOT NULL, + kind TEXT NOT NULL, + size INTEGER NOT NULL, + PRIMARY KEY (pid), + UNIQUE (kind) +); +""" + +# Indexes match those found on a real Nano 6G Library.itdb +_LIBRARY_INDEXES = """ +CREATE INDEX IF NOT EXISTS idx_item_album_pid ON item (album_pid); +CREATE INDEX IF NOT EXISTS idx_item_track_artist_pid ON item (track_artist_pid); +CREATE INDEX IF NOT EXISTS item_album_order_idx ON item (album_order, disc_number, track_number, artist_order, sort_title, physical_order); +CREATE INDEX IF NOT EXISTS item_artist_sort_order_idx ON item (artist_order, album_order, disc_number, track_number, sort_title, physical_order); +CREATE INDEX IF NOT EXISTS item_composer_order_idx ON item (composer_pid, composer_order, media_kind); +CREATE INDEX IF NOT EXISTS item_genre_id_idx ON item (genre_id); +CREATE INDEX IF NOT EXISTS item_title_order_idx ON item (title_order, media_kind); +CREATE INDEX IF NOT EXISTS item_to_container_container_pid_idx ON item_to_container (container_pid, physical_order, item_pid); +CREATE INDEX IF NOT EXISTS item_to_container_physical_order_idx ON item_to_container (physical_order); +""" + + +def _sort_key(name: str | None) -> str: + """Generate a collation key for sorting. + + Uses the article-stripped form, lowercased for case-insensitive sort. + Returns empty string for None/empty so they sort first (matching libgpod + which returns 100 for NULL fields). + """ + if not name: + return "" + return strip_article(name).lower() + + +def _compute_sort_orders(tracks: list[TrackInfo]) -> dict: + """Compute sort orders for all items, replicating libgpod compute_key_orders. + + For each order type (title, artist, album, genre, composer, album_artist), + collect unique sort keys, sort alphabetically, and assign rank = (pos+1)*100. + A NULL/empty key gets rank 100 (matching libgpod's default). + + Returns dict mapping order type → {sort_key → rank}. + """ + ORDER_FIELDS = { + 'title': lambda t: t.sort_name or t.title, + 'artist': lambda t: t.sort_artist or t.artist, + 'album': lambda t: t.sort_album or t.album, + 'genre': lambda t: t.genre, + 'composer': lambda t: t.sort_composer or t.composer, + # libgpod ORDER_ALBUM_ARTIST: sort_artist → artist (simple) + 'album_artist': lambda t: t.sort_artist or t.artist, + # libgpod ORDER_ALBUM_BY_ARTIST: sort_albumartist → albumartist → sort_artist → artist + 'album_by_artist': lambda t: (t.sort_album_artist or t.album_artist + or t.sort_artist or t.artist), + } + + orders: dict[str, dict[str, int]] = {} + for name, field_fn in ORDER_FIELDS.items(): + keys: set[str] = set() + for track in tracks: + val = field_fn(track) + if val: + keys.add(_sort_key(val)) + sorted_keys = sorted(keys) + rank_map = {k: (i + 1) * 100 for i, k in enumerate(sorted_keys)} + orders[name] = rank_map + + return orders + + +def _lookup_order(orders: dict, order_type: str, value: str | None) -> int: + """Look up the sort order rank for a value. Returns 100 if not found.""" + if not value: + return 100 + key = _sort_key(value) + return orders.get(order_type, {}).get(key, 100) + + +def write_library_itdb( + path: str, + tracks: list[TrackInfo], + playlists: list[PlaylistInfo] | None = None, + smart_playlists: list[PlaylistInfo] | None = None, + master_playlist_name: str = "iPod", + db_pid: int = 0, + tz_offset: int = 0, +) -> list[int]: + """Write Library.itdb SQLite database. + + Args: + path: Output file path. + tracks: List of TrackInfo objects (with db_track_id already assigned). + playlists: User playlists (master is auto-generated). + smart_playlists: Smart playlists for dataset 5. + master_playlist_name: Name for the master playlist. + db_pid: Database persistent ID (from mhbd db_id). + tz_offset: Timezone offset in seconds (positive = east of UTC). + + Returns: + List of playlist PIDs in order: [master_pid, *playlist_pids, *smart_playlist_pids]. + """ + conn, cur = open_db(path, extra_pragmas=["encoding='UTF-8'"]) + try: + + # Create schema + cur.executescript(_LIBRARY_SCHEMA) + + # ── version_info ─────────────────────────────────────────────────── + # Values from a real iTunes-synced Nano 6G database: + # major=1, minor=111, device_update_level=1104, platform=2 + cur.execute( + "INSERT INTO version_info (id, major, minor, compatibility, " + "update_level, device_update_level, platform) " + "VALUES (1, 1, 111, 0, 0, 1104, 2)" + ) + + # ── db_info ──────────────────────────────────────────────────────── + # primary_container_pid will be the master playlist pid + # media_folder_url=NULL, audio/subtitle_language=-1: matches iTunes ref + master_pid = db_pid if db_pid else 1 + cur.execute( + "INSERT INTO db_info (pid, primary_container_pid, media_folder_url, " + "audio_language, subtitle_language, genius_cuid, bib, rib) " + "VALUES (?, ?, NULL, -1, -1, NULL, NULL, NULL)", + (_s64(db_pid), _s64(master_pid)) + ) + + # ── location_kind_map ────────────────────────────────────────────── + # IDs and names must match what iTunes writes (from real Nano 6G backup) + cur.execute("INSERT INTO location_kind_map (id, kind) VALUES (1, 'MPEG audio file')") + cur.execute("INSERT INTO location_kind_map (id, kind) VALUES (2, 'Purchased AAC audio file')") + cur.execute("INSERT INTO location_kind_map (id, kind) VALUES (3, 'AAC audio file')") + + # ── Compute sort orders ──────────────────────────────────────────── + # Replicates libgpod's compute_key_orders: for each field type, collect + # all unique sort keys, sort alphabetically, assign rank = (pos+1)*100. + orders = _compute_sort_orders(tracks) + + # ── Collect categories (for podcasts) ───────────────────────────── + category_map: dict[str, int] = {} # category_name → category_id + category_id_counter = 1 + for track in tracks: + cat = track.category or "" + if cat and cat not in category_map: + category_map[cat] = category_id_counter + category_id_counter += 1 + + for cat_name, cat_id in category_map.items(): + cur.execute( + "INSERT INTO category_map (id, category) VALUES (?, ?)", + (cat_id, cat_name) + ) + + # ── Collect genres ───────────────────────────────────────────────── + genre_map: dict[str, int] = {} # genre_name → genre_id + genre_id_counter = 1 + for track in tracks: + g = track.genre or "" + if g and g not in genre_map: + genre_map[g] = genre_id_counter + genre_id_counter += 1 + + # Compute genre_order (alphabetical rank), calc fields + genre_sorted = sorted(genre_map.keys(), key=str.lower) + genre_order_map: dict[str, int] = { + g: i + 1 for i, g in enumerate(genre_sorted) + } + + # Compute genre calc fields: artist_count, album_count, compilation_count + genre_artists: dict[str, set] = {} # genre → set of artist names + genre_albums: dict[str, set] = {} # genre → set of album keys + genre_comp_albums: dict[str, set] = {} # genre → set of compilation album keys + for track in tracks: + g = track.genre or "" + if not g: + continue + album_name, album_artist_name, show_name = _album_identity_fields(track) + album_key = (album_name, album_artist_name, show_name) + genre_artists.setdefault(g, set()).add(album_artist_name) + genre_albums.setdefault(g, set()).add(album_key) + if track.compilation_flag: + genre_comp_albums.setdefault(g, set()).add(album_key) + + for genre_name, gid in genre_map.items(): + g_order = genre_order_map.get(genre_name, 0) + a_count = len(genre_artists.get(genre_name, set())) + al_count = len(genre_albums.get(genre_name, set())) + c_count = len(genre_comp_albums.get(genre_name, set())) + cur.execute( + "INSERT INTO genre_map (id, genre, genre_order, is_unknown, " + "has_music, artist_count_calc, album_count_calc, compilation_count_calc) " + "VALUES (?, ?, ?, 0, 1, ?, ?, ?)", + (gid, genre_name, g_order, a_count, al_count, c_count) + ) + + # ── Collect albums, artists, composers ───────────────────────────── + # We use stable PIDs based on name hashing. + # Album key: (album_name, album_artist or artist, show_name) + album_map: dict[tuple[str, str, str], int] = {} # (album, artist, show) → pid + artist_map: dict[str, int] = {} # artist_name → pid + track_artist_map: dict[str, int] = {} # track_artist_name → pid + composer_map: dict[str, int] = {} # composer_name → pid + pid_counter = 100 # Start above small IDs used for other things + + # db_track_id → track_id map for playlist references + db_track_id_to_track_idx: dict[int, int] = {} + + for idx, track in enumerate(tracks): + db_track_id_to_track_idx[track.db_track_id] = idx + + # Album + album_name, album_artist_name, show_name = _album_identity_fields(track) + album_key = (album_name, album_artist_name, show_name) + if album_key not in album_map: + pid_counter += 1 + album_map[album_key] = pid_counter + + # Artist (album artist) + if album_artist_name and album_artist_name not in artist_map: + pid_counter += 1 + artist_map[album_artist_name] = pid_counter + + # Track artist + ta = track.artist or "" + if ta and ta not in track_artist_map: + pid_counter += 1 + track_artist_map[ta] = pid_counter + + # Composer + comp = track.composer or "" + if comp and comp not in composer_map: + pid_counter += 1 + composer_map[comp] = pid_counter + + # ── Write albums ─────────────────────────────────────────────────── + # Count items per album for item_count, determine if compilation + album_item_counts: dict[tuple[str, str, str], int] = {} + album_has_compilation: dict[tuple[str, str, str], bool] = {} + album_artist_pids: dict[tuple[str, str, str], int] = {} + album_artwork_pids: dict[tuple[str, str, str], int] = {} + album_feed_urls: dict[tuple[str, str, str], str] = {} + artist_artwork_album_pids: dict[str, int] = {} # artist_name → album_pid with art + + for track in tracks: + album_name, album_artist_name, show_name = _album_identity_fields(track) + key = (album_name, album_artist_name, show_name) + album_item_counts[key] = album_item_counts.get(key, 0) + 1 + if track.compilation_flag: + album_has_compilation[key] = True + # Store album artist pid + if album_artist_name and album_artist_name in artist_map: + album_artist_pids[key] = artist_map[album_artist_name] + # Store artwork item pid (first track in album with artwork) + if track.mhii_link and key not in album_artwork_pids: + album_artwork_pids[key] = track.db_track_id + # Store feed_url for podcast albums + if track.podcast_rss_url and key not in album_feed_urls: + album_feed_urls[key] = track.podcast_rss_url + + # Compute album sort orders: name_order = rank by sort_name, sort_order = same + album_sort_names: dict[tuple[str, str, str], str] = {} + for key in album_map: + album_sort_names[key] = strip_article(key[0]) if key[0] else key[0] + album_sorted = sorted(album_map.keys(), + key=lambda k: _sort_key(k[0])) + album_name_orders: dict[tuple[str, str, str], int] = { + k: (i + 1) * 100 for i, k in enumerate(album_sorted) + } + + for (album_name, album_artist_name, show_name), album_pid in album_map.items(): + key = (album_name, album_artist_name, show_name) + is_compilation = 1 if album_has_compilation.get(key, False) else 0 + artwork_pid = album_artwork_pids.get(key, 0) + a_pid = album_artist_pids.get(key, 0) + item_count = album_item_counts.get(key, 0) + is_unknown = 1 if not album_name else 0 + a_name_order = album_name_orders.get(key, 0) + a_sort_name = album_sort_names.get(key) or None + # artist_order for album = the album_artist order rank + a_artist_order = _lookup_order(orders, 'album_artist', album_artist_name) + + a_feed_url = album_feed_urls.get(key) + album_art_status = 1 if artwork_pid else 0 + + # Track the first album with artwork for each artist + if artwork_pid and album_artist_name not in artist_artwork_album_pids: + artist_artwork_album_pids[album_artist_name] = album_pid + + cur.execute( + "INSERT INTO album (pid, kind, artwork_status, artwork_item_pid, " + "artist_pid, user_rating, name, name_order, all_compilations, " + "feed_url, season_number, is_unknown, has_songs, has_music_videos, " + "sort_order, artist_order, has_any_compilations, sort_name, " + "artist_count_calc, has_movies, item_count) " + "VALUES (?, 2, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, 1, 0, ?, ?, ?, ?, 0, 0, ?)", + (album_pid, album_art_status, _s64(artwork_pid), a_pid, + album_name or None, a_name_order, is_compilation, + a_feed_url, is_unknown, + a_name_order, a_artist_order, is_compilation, a_sort_name, + item_count) + ) + + # ── Write artists ────────────────────────────────────────────────── + artist_sorted = sorted(artist_map.keys(), key=_sort_key) + artist_name_orders: dict[str, int] = { + k: (i + 1) * 100 for i, k in enumerate(artist_sorted) + } + for artist_name, a_pid in artist_map.items(): + is_unknown = 1 if not artist_name else 0 + a_name_order = artist_name_orders.get(artist_name, 0) + a_sort_name = strip_article(artist_name) if artist_name else None + art_album_pid = artist_artwork_album_pids.get(artist_name or "", 0) + artist_art_status = 1 if art_album_pid else 0 + cur.execute( + "INSERT INTO artist (pid, kind, artwork_status, artwork_album_pid, " + "name, name_order, sort_name, is_unknown, has_songs, has_music_videos) " + "VALUES (?, 2, ?, ?, ?, ?, ?, ?, 1, 0)", + (a_pid, artist_art_status, _s64(art_album_pid), + artist_name or None, a_name_order, a_sort_name, + is_unknown) + ) + + # ── Write track artists ──────────────────────────────────────────── + ta_sorted = sorted(track_artist_map.keys(), key=_sort_key) + ta_name_orders: dict[str, int] = { + k: (i + 1) * 100 for i, k in enumerate(ta_sorted) + } + for ta_name, ta_pid in track_artist_map.items(): + is_unknown = 1 if not ta_name else 0 + ta_name_order = ta_name_orders.get(ta_name, 0) + ta_sort_name = strip_article(ta_name) if ta_name else None + cur.execute( + "INSERT INTO track_artist (pid, name, name_order, sort_name, " + "has_songs, has_music_videos, has_non_compilation_tracks, " + "is_unknown, album_count) " + "VALUES (?, ?, ?, ?, 1, 0, 1, ?, 0)", + (ta_pid, ta_name or None, ta_name_order, ta_sort_name, + is_unknown) + ) + + # ── Write composers ──────────────────────────────────────────────── + comp_sorted = sorted(composer_map.keys(), key=_sort_key) + comp_name_orders: dict[str, int] = { + k: (i + 1) * 100 for i, k in enumerate(comp_sorted) + } + for comp_name, comp_pid in composer_map.items(): + is_unknown = 1 if not comp_name else 0 + c_name_order = comp_name_orders.get(comp_name, 0) + c_sort_name = strip_article(comp_name) if comp_name else None + cur.execute( + "INSERT INTO composer (pid, name, name_order, sort_name, " + "is_unknown, has_music) " + "VALUES (?, ?, ?, ?, ?, 1)", + (comp_pid, comp_name or None, c_name_order, c_sort_name, + is_unknown) + ) + + # ── Write items (tracks) ────────────────────────────────────────── + now_cd = _unix_to_coredata(int(time.time()), tz_offset) + + total_audio_size = 0 + total_video_size = 0 + total_mv_size = 0 + + for idx, track in enumerate(tracks): + # Resolve foreign keys + album_name, album_artist_name, show_name = _album_identity_fields(track) + album_key = (album_name, album_artist_name, show_name) + album_pid = album_map.get(album_key, 0) + + artist_pid = artist_map.get(album_artist_name, 0) + + ta = track.artist or "" + ta_pid = track_artist_map.get(ta, 0) + + comp = track.composer or "" + composer_pid = composer_map.get(comp, 0) + + genre_id = genre_map.get(track.genre or "", 0) + cat_id = category_map.get(track.category or "", 0) + + media_kind = _media_kind(track) + + # Accumulate track_size_calc + if media_kind == MEDIA_KIND_MUSIC_VIDEO: + total_mv_size += track.size + elif media_kind in (MEDIA_KIND_MOVIE, MEDIA_KIND_TV_SHOW): + total_video_size += track.size + else: + total_audio_size += track.size + + # Timestamps + date_mod = _unix_to_coredata(track.last_modified or track.date_added, tz_offset) if (track.last_modified or track.date_added) else now_cd + date_released = _unix_to_coredata(track.date_released, tz_offset) if track.date_released else 0 + + # Artwork: iTunes uses artwork_status=1 when art is present + art_status = 1 if track.mhii_link else 0 + art_cache_id = track.mhii_link or 0 + + has_lyrics = 1 if (track.has_lyrics or track.lyrics) else 0 + + # Sort fields: fall back to article-stripped name (matches iTunes/libgpod) + sort_title = track.sort_name or strip_article(track.title) if track.title else None + sort_artist = track.sort_artist or strip_article(track.artist) if track.artist else None + sort_album = track.sort_album or strip_article(track.album) if track.album else None + sort_aa = (track.sort_album_artist or strip_article(track.album_artist) + if track.album_artist else + (track.sort_artist or strip_article(track.artist) if track.artist else None)) + sort_composer = track.sort_composer or strip_article(track.composer) if track.composer else None + + # Order ranks from pre-computed sort orders + title_order = _lookup_order(orders, 'title', track.sort_name or track.title) + artist_order = _lookup_order(orders, 'artist', track.sort_artist or track.artist) + album_order = _lookup_order(orders, 'album', track.sort_album or track.album) + genre_order = _lookup_order(orders, 'genre', track.genre) + composer_order = _lookup_order(orders, 'composer', track.sort_composer or track.composer) + aa_order = _lookup_order(orders, 'album_artist', + track.sort_artist or track.artist) + aba_order = _lookup_order(orders, 'album_by_artist', + track.sort_album_artist or track.album_artist + or track.sort_artist or track.artist) + + cur.execute( + """INSERT INTO item ( + pid, revision_level, media_kind, + is_song, is_audio_book, is_music_video, is_movie, + is_tv_show, is_home_video, is_ringtone, is_tone, + is_voice_memo, is_book, is_rental, is_itunes_u, + is_digital_booklet, is_podcast, + date_modified, year, + content_rating, content_rating_level, + is_compilation, is_user_disabled, + remember_bookmark, exclude_from_shuffle, + part_of_gapless_album, chosen_by_auto_fill, + artwork_status, artwork_cache_id, + start_time_ms, stop_time_ms, total_time_ms, total_burn_time_ms, + track_number, track_count, disc_number, disc_count, + bpm, relative_volume, eq_preset, radio_stream_status, + genius_id, genre_id, category_id, + album_pid, artist_pid, composer_pid, + title, artist, album, album_artist, composer, + sort_title, sort_artist, sort_album, + sort_album_artist, sort_composer, + title_order, artist_order, album_order, + genre_order, composer_order, + album_artist_order, album_by_artist_order, + series_name_order, + comment, grouping, + description, description_long, + collection_description, copyright, + track_artist_pid, physical_order, + has_lyrics, date_released + ) VALUES ( + ?, NULL, ?, + ?, ?, ?, ?, + ?, 0, ?, 0, + 0, 0, 0, 0, + 0, ?, + ?, ?, + ?, 0, + ?, ?, + ?, ?, + ?, 0, + ?, ?, + ?, ?, ?, NULL, + ?, ?, ?, ?, + ?, ?, ?, NULL, + 0, ?, ?, + ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, + ?, ?, ?, + ?, ?, + ?, ?, + ?, + ?, ?, + ?, NULL, + NULL, NULL, + ?, ?, + ?, ? + )""", + ( + _s64(track.db_track_id), media_kind, + *_media_kind_flags(media_kind), + date_mod, track.year, + track.explicit_flag, + 1 if track.compilation_flag else 0, 1 if track.checked_flag else 0, + 1 if track.remember_position else 0, 1 if track.skip_when_shuffling else 0, + 1 if track.gapless_album_flag else 0, + art_status, art_cache_id, + float(track.start_time), float(track.stop_time), + float(track.length), + track.track_number, track.total_tracks, track.disc_number, track.total_discs, + track.bpm, track.volume, track.eq_setting, + genre_id, cat_id, + album_pid, artist_pid, composer_pid, + track.title, track.artist, track.album, + track.album_artist, track.composer, + sort_title, sort_artist, sort_album, + sort_aa, sort_composer, + title_order, artist_order, album_order, + genre_order, composer_order, + aa_order, aba_order, + 100, + track.comment, track.grouping, + track.description, + ta_pid, idx, + has_lyrics, date_released, + ) + ) + + # ── avformat_info ────────────────────────────────────────────── + ft = track.filetype.lower() + audio_format = _FILETYPE_TO_AUDIO_FORMAT.get(ft, AUDIO_FORMAT_MP3) + # Detect ALAC: M4A container + high bitrate (ALAC >= ~500 kbps, + # AAC caps at ~330 kbps) + if ft in ('m4a', 'm4b') and track.bitrate > 500: + audio_format = AUDIO_FORMAT_ALAC + # Duration in avformat_info is in SAMPLES, not milliseconds. + # libgpod writes 0 ("iTunes sometimes set it to 0"); we compute + # when sample_rate is available, else 0. + duration_samples = int(track.length * track.sample_rate / 1000) if track.sample_rate else 0 + + cur.execute( + """INSERT INTO avformat_info ( + item_pid, sub_id, audio_format, bit_rate, channels, + sample_rate, duration, + gapless_heuristic_info, gapless_encoding_delay, + gapless_encoding_drain, gapless_last_frame_resynch, + analysis_inhibit_flags, audio_fingerprint, + volume_normalization_energy + ) VALUES (?, 0, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0, 0, ?)""", + ( + _s64(track.db_track_id), audio_format, track.bitrate, + float(track.sample_rate), duration_samples, + track.gapless_track_flag, track.pregap, track.postgap, + track.gapless_data, + track.sound_check, + ) + ) + + # ── podcast_info (podcast tracks only) ──────────────────────── + if media_kind == MEDIA_KIND_PODCAST: + cur.execute( + """INSERT INTO podcast_info ( + item_pid, date_released, external_guid, + feed_url, feed_keywords + ) VALUES (?, ?, NULL, ?, NULL)""", + ( + _s64(track.db_track_id), + date_released, + track.podcast_rss_url, + ) + ) + + # ── track_size_calc ──────────────────────────────────────────────── + # Three rows: audio, video, music_video with total file sizes + cur.execute("INSERT INTO track_size_calc (pid, kind, size) VALUES (1, 'audio', ?)", + (total_audio_size,)) + cur.execute("INSERT INTO track_size_calc (pid, kind, size) VALUES (2, 'video', ?)", + (total_video_size,)) + cur.execute("INSERT INTO track_size_calc (pid, kind, size) VALUES (3, 'music_video', ?)", + (total_mv_size,)) + + # ── Write containers (playlists) ─────────────────────────────────── + # Master playlist: distinguished_kind=0, is_hidden=1 (SQLite schema), smart fields=NULL + # (matches iTunes reference — NOT distinguished_kind=2) + container_pos = 0 + _insert_container( + cur, pid=master_pid, name=master_playlist_name, + name_order=(container_pos + 1) * 100, + date_created=now_cd, date_modified=now_cd, is_hidden=1, + ) + container_pos += 1 + + # Master playlist contains all tracks + for idx, track in enumerate(tracks): + cur.execute( + "INSERT INTO item_to_container (item_pid, container_pid, physical_order, shuffle_order) " + "VALUES (?, ?, ?, NULL)", + (_s64(track.db_track_id), _s64(master_pid), idx) + ) + + # User playlists + all_playlist_pids: list[int] = [master_pid] + playlist_pid_counter = master_pid + 1 + for pl in (playlists or []): + pl_pid = playlist_pid_counter + playlist_pid_counter += 1 + all_playlist_pids.append(pl_pid) + + _insert_container( + cur, pid=pl_pid, name=pl.name, + name_order=(container_pos + 1) * 100, + date_created=now_cd, date_modified=now_cd, + ) + container_pos += 1 + + for order, db_track_id in enumerate(pl.track_ids): + if db_track_id in db_track_id_to_track_idx: + cur.execute( + "INSERT INTO item_to_container (item_pid, container_pid, physical_order, shuffle_order) " + "VALUES (?, ?, ?, NULL)", + (_s64(db_track_id), _s64(pl_pid), order) + ) + + # Smart playlists + for spl in (smart_playlists or []): + spl_pid = playlist_pid_counter + playlist_pid_counter += 1 + all_playlist_pids.append(spl_pid) + + # Determine smart playlist SQLite fields from SmartPlaylistPrefs + spl_is_limited = 0 + spl_limit_kind = 2 # default: MB + spl_limit_order = 2 # default: random + spl_limit_value = 25 + spl_eval_order = 1 # default: 1 (from ref) + spl_reverse = 0 + spl_criteria = None + + if spl.smart_prefs is not None: + spl_is_limited = 1 if spl.smart_prefs.check_limits else 0 + spl_limit_kind = spl.smart_prefs.limit_type + spl_limit_order = spl.smart_prefs.limit_sort + spl_limit_value = spl.smart_prefs.limit_value + + # Build smart_criteria blob from rules (SLst format) + if spl.smart_rules is not None: + from iTunesDB_Writer.mhod_spl_writer import write_mhod51 + # write_mhod51 returns a full MHOD (24-byte header + SLst body). + # smart_criteria in SQLite stores the raw SLst blob (starts with + # b'SLst'), so we strip the 24-byte MHOD header. + mhod51_data = write_mhod51(spl.smart_rules) + if len(mhod51_data) > 24: + spl_criteria = mhod51_data[24:] + + # Distinguished kind for smart playlists (from ref): + # 4 = Music, 5 = Audiobooks + dk = 0 + if spl.mhsd5_type == 4: # music + dk = 4 + elif spl.mhsd5_type == 5: # audiobooks + dk = 5 + + # media_kinds: 1=music, 0=audiobooks (from ref) + spl_media_kinds = 1 if dk == 4 else (0 if dk == 5 else 1) + + # is_hidden maps from PlaylistInfo.master: + # - For ds5 built-in categories (Music, Audiobooks, ...), + # master=True → is_hidden=1. This matches Apple's reference + # SQLite databases where system categories are hidden. + # - For regular user smart playlists, master=False → is_hidden=0. + _insert_container( + cur, pid=spl_pid, name=spl.name, + name_order=(container_pos + 1) * 100, + date_created=now_cd, date_modified=now_cd, + distinguished_kind=dk, media_kinds=spl_media_kinds, + is_hidden=1 if spl.master else 0, + smart_is_dynamic=1, smart_is_filtered=1, + smart_is_limited=spl_is_limited, + smart_limit_kind=spl_limit_kind, + smart_limit_order=spl_limit_order, + smart_evaluation_order=spl_eval_order, + smart_limit_value=spl_limit_value, + smart_reverse_limit_order=spl_reverse, + smart_criteria=spl_criteria, + ) + container_pos += 1 + + # Add evaluated track list for smart playlists + for order, db_track_id in enumerate(spl.track_ids): + if db_track_id in db_track_id_to_track_idx: + cur.execute( + "INSERT INTO item_to_container (item_pid, container_pid, physical_order, shuffle_order) " + "VALUES (?, ?, ?, NULL)", + (_s64(db_track_id), _s64(spl_pid), order) + ) + + # ── Create indexes ───────────────────────────────────────────────── + cur.executescript(_LIBRARY_INDEXES) + + conn.commit() + + logger.info("Wrote Library.itdb: %d tracks, %d albums, %d artists, " + "%d composers, %d genres, %d containers", + len(tracks), len(album_map), len(artist_map), + len(composer_map), len(genre_map), + 1 + len(playlists or []) + len(smart_playlists or [])) + + return all_playlist_pids + finally: + conn.close() diff --git a/src/vendor/SQLiteDB_Writer/locations_writer.py b/src/vendor/SQLiteDB_Writer/locations_writer.py new file mode 100644 index 0000000..5637d77 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/locations_writer.py @@ -0,0 +1,145 @@ +"""Locations.itdb writer — iPod file path mapping database. + +Maps track PIDs to their physical file locations on the iPod filesystem. + +Schema: + base_location: single row with root path "iPod_Control/Music" + location: one row per track, mapping item_pid → "Fxx/ABCD.mp3" + +Reference: libgpod itdb_sqlite.c mk_Locations() +""" + +import time +import logging + +from iTunesDB_Writer.mhit_writer import TrackInfo +from iTunesDB_Shared.constants import FILETYPE_CODES +from ._helpers import s64 as _s64, unix_to_coredata, open_db + +logger = logging.getLogger(__name__) + + +# location_type = 0x46494C45 = "FILE" as big-endian int +LOCATION_TYPE_FILE = 0x46494C45 + +# Extension codes — same as FILETYPE_CODES (big-endian 4-byte ASCII) +_EXTENSION_CODES = FILETYPE_CODES + +# kind_id mapping — matches location_kind_map in Library.itdb +# IDs from real iTunes-written databases on Nano 6G +_KIND_ID = { + 'mp3': 1, # "MPEG audio file" + 'aac': 3, # "AAC audio file" + 'm4a': 3, # "AAC audio file" (or ALAC in M4A container) + 'm4p': 2, # "Purchased AAC audio file" + 'm4b': 3, # "AAC audio file" (audiobook) + 'm4v': 3, # + 'mp4': 3, # + 'wav': 1, # + 'aif': 1, # + 'aiff': 1, # + 'alac': 3, # ALAC is in M4A container +} + + +def _ipod_path_to_location(ipod_path: str) -> str: + """Convert iPod colon-separated path to slash-based location. + + Input: ":iPod_Control:Music:F04:ZEUN.mp3" + Output: "F04/ZEUN.mp3" + + The location field stores the path relative to the base_location + ("iPod_Control/Music"), using forward slashes. + """ + # Strip leading colon and split + parts = ipod_path.strip(':').split(':') + # Skip "iPod_Control" and "Music" prefix + # The path format is :iPod_Control:Music:Fxx:filename + # We want: Fxx/filename + if len(parts) >= 4 and parts[0] == 'iPod_Control' and parts[1] == 'Music': + return '/'.join(parts[2:]) + elif len(parts) >= 2: + # Fallback: just take the last two parts + return '/'.join(parts[-2:]) + else: + return ipod_path.strip(':').replace(':', '/') + + +_LOCATIONS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS base_location ( + id INTEGER NOT NULL, + path TEXT, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS location ( + item_pid INTEGER NOT NULL, + sub_id INTEGER NOT NULL DEFAULT 0, + base_location_id INTEGER DEFAULT 0, + location_type INTEGER, + location TEXT, + extension INTEGER, + kind_id INTEGER DEFAULT 0, + date_created INTEGER DEFAULT 0, + file_size INTEGER DEFAULT 0, + file_creator INTEGER, + file_type INTEGER, + num_dir_levels_file INTEGER, + num_dir_levels_lib INTEGER, + PRIMARY KEY (item_pid, sub_id) +); +""" + + +def write_locations_itdb( + path: str, + tracks: list[TrackInfo], + tz_offset: int = 0, +) -> None: + """Write Locations.itdb SQLite database. + + Args: + path: Output file path. + tracks: List of TrackInfo objects (with db_track_id and location set). + tz_offset: Timezone offset in seconds (positive = east of UTC). + """ + conn, cur = open_db(path) + + cur.executescript(_LOCATIONS_SCHEMA) + + # Single base_location entry + cur.execute( + "INSERT INTO base_location (id, path) VALUES (1, 'iPod_Control/Music')" + ) + + # One location per track + now = int(time.time()) + + for track in tracks: + location = _ipod_path_to_location(track.location) + ft = track.filetype.lower() + extension = _EXTENSION_CODES.get(ft, _EXTENSION_CODES.get('mp3', 0x4D503320)) + kind_id = _KIND_ID.get(ft, 0) + + # date_created: Core Data timestamp of when the file was added + date_added = track.date_added or now + date_cd = unix_to_coredata(date_added, tz_offset) + + cur.execute( + """INSERT INTO location ( + item_pid, sub_id, base_location_id, location_type, + location, extension, kind_id, date_created, file_size, + file_creator, file_type, + num_dir_levels_file, num_dir_levels_lib + ) VALUES (?, 0, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL)""", + ( + _s64(track.db_track_id), LOCATION_TYPE_FILE, + location, extension, kind_id, + date_cd, track.size, + ) + ) + + conn.commit() + conn.close() + + logger.info("Wrote Locations.itdb: %d locations", len(tracks)) diff --git a/src/vendor/SQLiteDB_Writer/sqlite_writer.py b/src/vendor/SQLiteDB_Writer/sqlite_writer.py new file mode 100644 index 0000000..22e8302 --- /dev/null +++ b/src/vendor/SQLiteDB_Writer/sqlite_writer.py @@ -0,0 +1,211 @@ +"""SQLite database writer — orchestrates writing all SQLite databases. + +This is the main entry point for the SQLiteDB_Writer module. It +coordinates writing all five databases plus the checksum file for +iPod Nano 6G/7G. + +The databases are written to: + /iPod_Control/iTunes/iTunes Library.itlp/ + +Usage: + from SQLiteDB_Writer import write_sqlite_databases + + write_sqlite_databases( + ipod_path="/media/ipod", + tracks=tracks, + playlists=playlists, + smart_playlists=smart_playlists, + master_playlist_name="iPod", + ) +""" + +import os +import random +import shutil +import time +import logging +import tempfile +from typing import Optional + +from iTunesDB_Writer.mhit_writer import TrackInfo +from iTunesDB_Writer.mhyp_writer import PlaylistInfo +from ipod_device import ChecksumType, DeviceCapabilities +from ipod_device import detect_checksum_type, get_firewire_id + +from .library_writer import write_library_itdb +from .locations_writer import write_locations_itdb +from .dynamic_writer import write_dynamic_itdb +from .extras_writer import write_extras_itdb +from .genius_writer import write_genius_itdb +from .cbk_writer import write_locations_cbk + +logger = logging.getLogger(__name__) + +# Directory within iPod where SQLite databases live +ITLP_DIR = os.path.join("iPod_Control", "iTunes", "iTunes Library.itlp") + + +def write_sqlite_databases( + ipod_path: str, + tracks: list[TrackInfo], + playlists: Optional[list[PlaylistInfo]] = None, + smart_playlists: Optional[list[PlaylistInfo]] = None, + master_playlist_name: str = "iPod", + db_pid: int = 0, + capabilities: Optional[DeviceCapabilities] = None, + firewire_id: Optional[bytes] = None, + backup: bool = True, +) -> bool: + """Write all SQLite databases for iPod Nano 6G/7G. + + Writes the databases to a temp directory first, then atomically + replaces the files in the iTunes Library.itlp directory. + + Args: + ipod_path: Mount point of iPod (e.g. "E:\\") + tracks: List of TrackInfo objects (db_track_id must already be assigned). + playlists: User playlists (master is auto-generated). + smart_playlists: Smart playlists. + master_playlist_name: Name for the master playlist. + db_pid: Database persistent ID (from mhbd db_id). + capabilities: Device capabilities. + firewire_id: 8-byte FireWire GUID for signing. + backup: Whether to backup existing databases. + + Returns: + True if all databases were written successfully. + """ + itlp_path = os.path.join(ipod_path, ITLP_DIR) + + # Ensure the directory exists + os.makedirs(itlp_path, exist_ok=True) + + # Determine timezone offset + if time.daylight: + tz_offset = -time.altzone + else: + tz_offset = -time.timezone + + # Determine checksum type + checksum_type = ChecksumType.NONE + if capabilities: + checksum_type = capabilities.checksum + else: + checksum_type = detect_checksum_type(ipod_path) + + # Get FireWire ID if needed and not provided + if firewire_id is None and checksum_type in ( + ChecksumType.HASHAB, ChecksumType.HASH58 + ): + try: + firewire_id = get_firewire_id(ipod_path) + except Exception as e: + logger.warning("Could not get FireWire ID for cbk signing: %s", e) + + # Generate db_pid if not provided + if not db_pid: + db_pid = random.getrandbits(64) + + # Backup existing databases + if backup: + for fname in ("Library.itdb", "Locations.itdb", "Dynamic.itdb", + "Extras.itdb", "Genius.itdb", "Locations.itdb.cbk"): + fpath = os.path.join(itlp_path, fname) + if os.path.exists(fpath): + try: + shutil.copy2(fpath, fpath + ".backup") + except Exception as e: + logger.warning("Could not backup %s: %s", fname, e) + + # Write all databases to temp directory first, then move + # This gives us atomicity — if any write fails, the originals are intact. + with tempfile.TemporaryDirectory(prefix="iOpenPod_sqlite_", ignore_cleanup_errors=True) as tmp_dir: + try: + # 1. Library.itdb (tracks, albums, artists, playlists, …) + lib_path = os.path.join(tmp_dir, "Library.itdb") + playlist_pids = write_library_itdb( + path=lib_path, + tracks=tracks, + playlists=playlists, + smart_playlists=smart_playlists, + master_playlist_name=master_playlist_name, + db_pid=db_pid, + tz_offset=tz_offset, + ) + + # 2. Locations.itdb (file path mappings) + loc_path = os.path.join(tmp_dir, "Locations.itdb") + write_locations_itdb( + path=loc_path, + tracks=tracks, + tz_offset=tz_offset, + ) + + # 3. Dynamic.itdb (play counts, ratings, bookmarks) + dyn_path = os.path.join(tmp_dir, "Dynamic.itdb") + write_dynamic_itdb( + path=dyn_path, + tracks=tracks, + playlist_pids=playlist_pids, + tz_offset=tz_offset, + ) + + # 4. Extras.itdb (lyrics, chapters) + extras_path = os.path.join(tmp_dir, "Extras.itdb") + write_extras_itdb( + path=extras_path, + tracks=tracks, + ) + + # 5. Genius.itdb (empty tables) + genius_path = os.path.join(tmp_dir, "Genius.itdb") + write_genius_itdb(path=genius_path) + + # 6. Locations.itdb.cbk (HASHAB-signed block checksums) + cbk_path = os.path.join(tmp_dir, "Locations.itdb.cbk") + try: + write_locations_cbk( + cbk_path=cbk_path, + locations_itdb_path=loc_path, + checksum_type=checksum_type, + firewire_id=firewire_id, + ipod_path=ipod_path, + ) + except Exception as e: + logger.error("Failed to write Locations.itdb.cbk: %s", e) + # CBK is critical for signed devices — fail the whole write + if checksum_type in (ChecksumType.HASHAB, ChecksumType.HASH72): + raise + # For other devices, continue without it + cbk_path = None + + # Move all files to the target directory + files_to_move = [ + ("Library.itdb", lib_path), + ("Locations.itdb", loc_path), + ("Dynamic.itdb", dyn_path), + ("Extras.itdb", extras_path), + ("Genius.itdb", genius_path), + ] + if cbk_path and os.path.exists(cbk_path): + files_to_move.append(("Locations.itdb.cbk", cbk_path)) + + for fname, src_path in files_to_move: + dst_path = os.path.join(itlp_path, fname) + try: + shutil.copyfile(src_path, dst_path) + except Exception as e: + logger.error("Failed to copy %s to iPod: %s", fname, e) + raise + + logger.info("SQLite databases written to %s " + "(%d tracks, %d playlists, %d smart playlists)", + itlp_path, len(tracks), + len(playlists or []), + len(smart_playlists or [])) + return True + + except Exception as e: + logger.error("Failed to write SQLite databases: %s", e, + exc_info=True) + return False diff --git a/src/vendor/iTunesDB_Parser/__init__.py b/src/vendor/iTunesDB_Parser/__init__.py new file mode 100644 index 0000000..5845454 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/__init__.py @@ -0,0 +1,22 @@ +from .exceptions import ( + CorruptHeaderError, + InsufficientDataError, + ITunesDBParseError, + UnknownChunkTypeError, +) +from .parser import decompress_itunescdb, parse_itunesdb +from .playcounts import PlayCountEntry, merge_playcounts, parse_playcounts + +__all__ = [ + # Public parsing API + "parse_itunesdb", + "decompress_itunescdb", + "parse_playcounts", + "merge_playcounts", + "PlayCountEntry", + # Exceptions + "ITunesDBParseError", + "CorruptHeaderError", + "UnknownChunkTypeError", + "InsufficientDataError", +] diff --git a/src/vendor/iTunesDB_Parser/_parsing.py b/src/vendor/iTunesDB_Parser/_parsing.py new file mode 100644 index 0000000..f401aa2 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/_parsing.py @@ -0,0 +1,69 @@ +""" +Internal parsing helpers shared across iTunesDB chunk parsers. + +Provides: +- Pre-compiled ``struct.Struct`` objects for common binary field widths. +- :func:`read_generic_header` — reads the 12-byte generic chunk header. + +Child-iteration helpers live in :mod:`chunk_parser` to avoid circular +imports (they need ``parse_chunk``, which dispatches back to the typed +parsers that use this module's struct helpers). +""" + +from __future__ import annotations + +import struct +from typing import Any + +from .exceptions import CorruptHeaderError, InsufficientDataError + +# ── Pre-compiled struct objects ────────────────────────────────────── +# Used by callers (e.g. mhod_parser) that do inline struct reads. +# The Shared defs module still uses ad-hoc struct.unpack calls; these +# are for Parser-local code. + +UINT16_LE = struct.Struct(" tuple[str, int, int]: + """Read the 12-byte generic chunk header at *offset*. + + Returns: + Tuple of ``(chunk_type, header_length, length_or_child_count)``. + + Raises: + InsufficientDataError: If fewer than 12 bytes remain at *offset*. + CorruptHeaderError: If the chunk type bytes are not valid ASCII. + """ + end = offset + GENERIC_HEADER_SIZE + if end > len(data): + raise InsufficientDataError(offset, GENERIC_HEADER_SIZE, len(data) - offset) + + raw_type, header_length, length_or_children = _GENERIC_HEADER.unpack_from(data, offset) + + try: + chunk_type = raw_type.decode("ascii") + except UnicodeDecodeError as exc: + raise CorruptHeaderError( + offset, + f"chunk type bytes are not valid ASCII: {raw_type!r}", + ) from exc + + return chunk_type, header_length, length_or_children diff --git a/src/vendor/iTunesDB_Parser/artwork_links.py b/src/vendor/iTunesDB_Parser/artwork_links.py new file mode 100644 index 0000000..9255ad2 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/artwork_links.py @@ -0,0 +1,88 @@ +"""Helpers for reconciling track artwork links with ArtworkDB. + +Older iPod database versions can omit the MHIT ``artwork_id_ref`` field even +when the track has album art. In those databases the reliable link lives in +ArtworkDB's MHII ``songId`` field, which equals the track ``db_track_id``. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _artworkdb_path_from_itunesdb(itunesdb_path: str | Path) -> Path: + itunes_path = Path(itunesdb_path) + ipod_control = itunes_path.parent.parent + return ipod_control / "Artwork" / "ArtworkDB" + + +def _build_song_to_artwork_id(artworkdb_path: Path) -> dict[int, int]: + if not artworkdb_path.exists(): + return {} + + try: + from ArtworkDB_Parser.parser import parse_artworkdb + + artworkdb = parse_artworkdb(str(artworkdb_path)) + except Exception as exc: + logger.debug("Could not parse ArtworkDB for artwork links: %s", exc) + return {} + + links: dict[int, int] = {} + for entry in artworkdb.get("mhli", []): + if not isinstance(entry, dict): + continue + try: + song_id = int(entry.get("songId") or entry.get("song_id") or 0) + img_id = int(entry.get("img_id") or 0) + except (TypeError, ValueError): + continue + if song_id and img_id: + links.setdefault(song_id, img_id) + return links + + +def hydrate_track_artwork_refs( + tracks: list[dict[str, Any]], + itunesdb_path: str | Path, +) -> int: + """Fill missing ``artwork_id_ref`` values from ArtworkDB ``songId`` links. + + Returns the number of tracks updated. + """ + if not tracks: + return 0 + + song_to_artwork_id = _build_song_to_artwork_id( + _artworkdb_path_from_itunesdb(itunesdb_path) + ) + if not song_to_artwork_id: + return 0 + + hydrated = 0 + for track in tracks: + if not isinstance(track, dict): + continue + if track.get("artwork_id_ref") not in (None, "", 0): + continue + try: + db_track_id = int(track.get("db_track_id") or track.get("db_id") or 0) + except (TypeError, ValueError): + continue + artwork_id = song_to_artwork_id.get(db_track_id) + if not artwork_id: + continue + track["artwork_id_ref"] = artwork_id + if track.get("mhii_link") in (None, "", 0): + track["mhii_link"] = artwork_id + if not track.get("artwork_count"): + track["artwork_count"] = 1 + hydrated += 1 + + if hydrated: + logger.info("Hydrated %d track artwork refs from ArtworkDB song links", hydrated) + return hydrated diff --git a/src/vendor/iTunesDB_Parser/chunk_parser.py b/src/vendor/iTunesDB_Parser/chunk_parser.py new file mode 100644 index 0000000..aef23e5 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/chunk_parser.py @@ -0,0 +1,143 @@ +""" +Generic chunk dispatcher for iTunesDB chunks. + +Every chunk in the iTunesDB starts with the same 12-byte generic header:: + + +0x00 chunk_type (4 bytes ASCII) e.g. ``mhbd``, ``mhit`` + +0x04 header_length (u32 LE) bytes to end of header + +0x08 length_or_children (u32 LE) total length *or* child count + +This module reads the generic header via :func:`_parsing.read_generic_header`, +then dispatches to the appropriate ``mh*_parser`` module. + +Every parser returns:: + + {"next_offset": int, "data": dict | list} + +Child-iteration helpers (:func:`parse_children`, :func:`parse_child_list`) +also live here so that the recursive ``parse_chunk → parser → parse_children +→ parse_chunk`` loop stays within one module, eliminating the circular import +that previously existed between ``_parsing`` and ``chunk_parser``. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ._parsing import ParseResult, read_generic_header + +logger = logging.getLogger(__name__) + + +# ── Child-iteration helpers ────────────────────────────────────────── + + +def parse_children( + data: bytes | bytearray, + offset: int, + child_count: int, +) -> tuple[list[dict[str, Any]], int]: + """Parse *child_count* consecutive child chunks starting at *offset*. + + Returns: + Tuple of ``(children_list, next_offset)`` where each child is + ``{"chunk_type": str, "data": }``. + """ + children: list[dict[str, Any]] = [] + current = offset + for _ in range(child_count): + parsed, chunk_type = parse_chunk(data, current) + current = parsed["next_offset"] + children.append({"chunk_type": chunk_type, "data": parsed["data"]}) + return children, current + + +def _parse_child_list( + data: bytes | bytearray, + offset: int, + header_length: int, + child_count: int, +) -> ParseResult: + """Parse a pure-list container (mhlt, mhla, mhli, mhlp). + + These chunks consist solely of a thin header followed by *child_count* + sub-chunks with no additional header fields. + + Returns: + ``{"next_offset": int, "data": list[...]}`` + """ + children, next_offset = parse_children(data, offset + header_length, child_count) + return {"next_offset": next_offset, "data": children} + + +# ── Top-level dispatcher ──────────────────────────────────────────── + + +def parse_chunk( + data: bytes | bytearray, + offset: int, +) -> tuple[dict[str, Any], str]: + """Read the generic header at *offset* and delegate to the typed parser. + + Args: + data: Full iTunesDB byte buffer. + offset: Byte position of the chunk to parse. + + Returns: + Tuple of ``(result_dict, chunk_type)`` where *result_dict* contains + ``"next_offset"`` and ``"data"`` keys. + """ + chunk_type, header_length, length_or_children = read_generic_header(data, offset) + + match chunk_type: + case "mhbd": + from .mhbd_parser import parse_db + result = parse_db(data, offset, header_length, length_or_children) + case "mhsd": + from .mhsd_parser import parse_dataset + result = parse_dataset(data, offset, header_length, length_or_children) + + # Pure-list containers — no dedicated parser needed. + case "mhlt" | "mhla" | "mhli" | "mhlp": + result = _parse_child_list(data, offset, header_length, length_or_children) + + case "mhit": + from .mhit_parser import parse_track_item + result = parse_track_item(data, offset, header_length, length_or_children) + case "mhyp": + from .mhyp_parser import parse_playlist + result = parse_playlist(data, offset, header_length, length_or_children) + case "mhip": + from .mhip_parser import parse_playlist_item + result = parse_playlist_item(data, offset, header_length, length_or_children) + case "mhod": + from .mhod_parser import parse_mhod + result = parse_mhod(data, offset, header_length, length_or_children) + case "mhia": + from .mhia_parser import parse_album_item + result = parse_album_item(data, offset, header_length, length_or_children) + case "mhii": + # NOTE: shares the 'mhii' magic with ArtworkDB image items, + # but in iTunesDB context this is an artist item. + from .mhii_parser import parse_artist_item + result = parse_artist_item(data, offset, header_length, length_or_children) + case _: + logger.warning( + "Skipping unknown iTunesDB chunk type %r at offset 0x%X", + chunk_type, offset, + ) + # NOTE: length_or_children may be a child count rather than + # a byte length. For unknown types we naively treat it as a + # length — the worst case is skipping too little, which the + # parent's child loop will catch on the next iteration. + return { + "next_offset": offset + length_or_children, + "data": { + "chunk_type": chunk_type, + "header": bytes(data[offset:offset + header_length]), + "body": bytes(data[offset + header_length:offset + length_or_children]), + }, + }, chunk_type + + return result, chunk_type diff --git a/src/vendor/iTunesDB_Parser/exceptions.py b/src/vendor/iTunesDB_Parser/exceptions.py new file mode 100644 index 0000000..d9a8897 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/exceptions.py @@ -0,0 +1,59 @@ +""" +Custom exception hierarchy for iTunesDB parsing. + +All exceptions inherit from :class:`ITunesDBParseError` so callers can catch +a single base class for any parsing failure. +""" + + +class ITunesDBParseError(Exception): + """Base exception for all iTunesDB parsing failures.""" + + +class CorruptHeaderError(ITunesDBParseError): + """Raised when a chunk header contains invalid or unrecognizable data. + + Attributes: + offset: Byte offset in the data buffer where the header was found. + detail: Human-readable description of what went wrong. + """ + + def __init__(self, offset: int, detail: str) -> None: + self.offset = offset + self.detail = detail + super().__init__(f"Corrupt header at offset 0x{offset:X}: {detail}") + + +class UnknownChunkTypeError(ITunesDBParseError): + """Raised when an unrecognized 4-byte chunk identifier is encountered. + + Attributes: + offset: Byte offset where the unknown chunk starts. + chunk_type: The 4-byte ASCII identifier that was not recognized. + """ + + def __init__(self, offset: int, chunk_type: str) -> None: + self.offset = offset + self.chunk_type = chunk_type + super().__init__( + f"Unknown chunk type {chunk_type!r} at offset 0x{offset:X}" + ) + + +class InsufficientDataError(ITunesDBParseError): + """Raised when the data buffer is too short for the expected read. + + Attributes: + offset: Byte offset where the read was attempted. + needed: Number of bytes required. + available: Number of bytes actually available. + """ + + def __init__(self, offset: int, needed: int, available: int) -> None: + self.offset = offset + self.needed = needed + self.available = available + super().__init__( + f"Insufficient data at offset 0x{offset:X}: " + f"need {needed} bytes, only {available} available" + ) diff --git a/src/vendor/iTunesDB_Parser/ipod_library.py b/src/vendor/iTunesDB_Parser/ipod_library.py new file mode 100644 index 0000000..6262f2c --- /dev/null +++ b/src/vendor/iTunesDB_Parser/ipod_library.py @@ -0,0 +1,140 @@ +""" +iPod Library Loader — standalone parser service, no GUI dependency. + +Parses iTunesDB + Play Counts, inlines MHOD strings, converts timestamps +and field values, and returns a flat dict ready for consumption by any +layer (GUI, CLI, sync engine, tests). + +Usage:: + + from iTunesDB_Parser.ipod_library import load_ipod_library + + data = load_ipod_library("/Volumes/IPOD/iPod_Control/iTunes/iTunesDB") + tracks = data["mhlt"] # list[dict] + albums = data["mhla"] # list[dict] + playlists = data["mhlp"] # list[dict] +""" + +import logging +import os + +from iTunesDB_Shared.extraction import ( + extract_datasets, + extract_mhod_strings, + extract_playlist_extras, + extract_track_extras, +) +from iTunesDB_Shared.field_base import filetype_to_string + +from .parser import parse_itunesdb + +logger = logging.getLogger(__name__) + + +def load_ipod_library(itunesdb_path: str, + merge_playcounts: bool = True) -> dict | None: + """Parse an iTunesDB file and return normalised data. + + Args: + itunesdb_path: Absolute path to the iTunesDB binary file. + merge_playcounts: If True (default), also read the sibling + ``Play Counts`` file and merge deltas into the track dicts. + + Returns: + A dict with keys ``mhlt``, ``mhla``, ``mhlp``, ``mhlp_podcast``, + ``mhlp_smart``, ``mhsd_type_8``, etc. Returns ``None`` when the + file does not exist or cannot be parsed. + """ + if not itunesdb_path or not os.path.exists(itunesdb_path): + return None + + try: + raw = parse_itunesdb(itunesdb_path) + data = extract_datasets(raw) + + _inline_track_strings(data) + from .artwork_links import hydrate_track_artwork_refs + + hydrate_track_artwork_refs(data.get("mhlt", []), itunesdb_path) + _inline_album_strings(data) + _inline_playlist_strings(data) + _inline_artist_strings(data) + + if merge_playcounts: + _merge_play_counts(data, itunesdb_path) + + # Import On-The-Go playlists from OTGPlaylistInfo files. + # These are device-created playlists stored outside the iTunesDB. + from .otg import load_otg_playlists + itunes_dir = os.path.dirname(itunesdb_path) + otg = load_otg_playlists(itunes_dir, data.get("mhlt", [])) + if otg: + data.setdefault("mhlp", []).extend(otg) + + return data + except Exception: + logger.error("Error parsing iTunesDB", exc_info=True) + return None + + +# ── Internal helpers ──────────────────────────────────────────────────────── + + +def _inline_track_strings(data: dict) -> None: + for track in data.get("mhlt", []): + children = track.pop("children", []) + strings = extract_mhod_strings(children) + track.update(strings) + track.update(extract_track_extras(children)) + # filetype u32 → ASCII + ft = track.get("filetype") + if isinstance(ft, int) and ft > 0: + track["filetype"] = filetype_to_string(ft) + # sample_rate_1 is already converted from 16.16 fixed-point to Hz + # by the read_transform (fixed_to_sample_rate) in mhit_defs.py + # sort_mhod_indicators raw bytes → list for JSON serialization + raw = track.get("sort_mhod_indicators", b"") + if isinstance(raw, (bytes, bytearray)): + track["sort_mhod_indicators"] = list(raw) + + +def _inline_album_strings(data: dict) -> None: + for album in data.get("mhla", []): + strings = extract_mhod_strings(album.pop("children", [])) + album.update(strings) + + +def _inline_playlist_strings(data: dict) -> None: + for key in ("mhlp", "mhlp_podcast", "mhlp_smart"): + for pl in data.get(key, []): + mhod_children = pl.pop("mhod_children", []) + strings = extract_mhod_strings(mhod_children) + pl.update(strings) + extras = extract_playlist_extras(mhod_children) + pl.update(extras) + # Flatten MHIP children → items list. + # parse_children always returns {"chunk_type": ..., "data": {...}}. + items = [] + for mhip in pl.pop("mhip_children", []): + items.append({"track_id": mhip["data"].get("track_id", 0)}) + pl["items"] = items + + +def _inline_artist_strings(data: dict) -> None: + for artist in data.get("mhsd_type_8", []): + strings = extract_mhod_strings(artist.pop("children", [])) + artist.update(strings) + + +def _merge_play_counts(data: dict, itunesdb_path: str) -> None: + try: + from .playcounts import merge_playcounts as _merge + from .playcounts import parse_playcounts + + pc_path = os.path.join(os.path.dirname(itunesdb_path), "Play Counts") + entries = parse_playcounts(pc_path) + if entries is not None: + tracks = data.get("mhlt", []) + _merge(tracks, entries) + except Exception: + logger.debug("Play Counts merge skipped", exc_info=True) diff --git a/src/vendor/iTunesDB_Parser/mhbd_parser.py b/src/vendor/iTunesDB_Parser/mhbd_parser.py new file mode 100644 index 0000000..fe78862 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhbd_parser.py @@ -0,0 +1,39 @@ +"""MHBD (Database Header) parser. + +The MHBD chunk is the root of the iTunesDB file. It contains global +metadata (version, hashing scheme, persistent IDs, cryptographic hashes) +followed by one or more MHSD (DataSet) children. + +Binary layout (offsets relative to chunk start):: + + +0x00 'mhbd' magic + +0x04 header_length + +0x08 total_length (entire file size) + +0x0C compressed flag + +0x10 database version + +0x14 child_count (number of MHSD datasets) + +0x18 db_id (u64) + +0x20 platform (u16) -- 1=Mac, 2=Windows + ... (see mhbd_defs.py for complete field map) +""" + +from __future__ import annotations + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_db( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHBD (Database) chunk and its MHSD children.""" + mhbd = idb.read_fields(data, offset, "mhbd", header_length) + mhbd["children"], _ = parse_children( + data, offset + header_length, mhbd["child_count"], + ) + return {"next_offset": offset + chunk_length, "data": mhbd} diff --git a/src/vendor/iTunesDB_Parser/mhia_parser.py b/src/vendor/iTunesDB_Parser/mhia_parser.py new file mode 100644 index 0000000..7bb75c2 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhia_parser.py @@ -0,0 +1,27 @@ +"""MHIA (Album Item) parser. + +Each MHIA lives inside an MHLA (album list) and contains album-level +metadata (album_id, SQL ID, compilation flag) plus MHOD string children +(types 200-204) with album name, artist, etc. +""" + +from __future__ import annotations + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_album_item( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHIA (Album Item) chunk and its MHOD children.""" + mhia = idb.read_fields(data, offset, "mhia", header_length) + mhia["children"], _ = parse_children( + data, offset + header_length, mhia["child_count"], + ) + return {"next_offset": offset + chunk_length, "data": mhia} diff --git a/src/vendor/iTunesDB_Parser/mhii_parser.py b/src/vendor/iTunesDB_Parser/mhii_parser.py new file mode 100644 index 0000000..6e2fa83 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhii_parser.py @@ -0,0 +1,30 @@ +"""MHII (Artist Item) parser for iTunesDB. + +Each MHII lives inside an MHLI (artist list, MHSD type 8) and contains +artist-level metadata (artist_id, SQL ID) plus MHOD type-300 children +with the artist name string. + +NOTE: This chunk shares the ``mhii`` magic with ArtworkDB image items, +but in the iTunesDB context it represents an artist record. +""" + +from __future__ import annotations # noqa: I001 + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_artist_item( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHII (Artist Item) chunk and its MHOD children.""" + mhii = idb.read_fields(data, offset, "mhii", header_length) + mhii["children"], _ = parse_children( + data, offset + header_length, mhii["child_count"], + ) + return {"next_offset": offset + chunk_length, "data": mhii} diff --git a/src/vendor/iTunesDB_Parser/mhip_parser.py b/src/vendor/iTunesDB_Parser/mhip_parser.py new file mode 100644 index 0000000..7f302ea --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhip_parser.py @@ -0,0 +1,27 @@ +"""MHIP (Playlist Item) parser. + +Each MHIP lives inside an MHYP (playlist) and references a track by +``track_id``. It may also carry MHOD type-100 children for position +information. +""" + +from __future__ import annotations + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_playlist_item( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHIP (Playlist Item) chunk and its MHOD children.""" + mhip = idb.read_fields(data, offset, "mhip", header_length) + mhip["children"], _ = parse_children( + data, offset + header_length, mhip["child_count"], + ) + return {"next_offset": offset + chunk_length, "data": mhip} diff --git a/src/vendor/iTunesDB_Parser/mhit_parser.py b/src/vendor/iTunesDB_Parser/mhit_parser.py new file mode 100644 index 0000000..88073ab --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhit_parser.py @@ -0,0 +1,30 @@ +"""MHIT (Track Item) parser. + +Parses a single track record and its MHOD string children. The MHIT +header is the largest in the iTunesDB (up to ~500 bytes in newer +database versions) and contains all numeric track metadata. + +The third generic-header field is ``total_length`` (header + body). +Child count is stored inside the header at offset 0x0C. +""" + +from __future__ import annotations + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_track_item( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHIT (Track Item) chunk and its MHOD children.""" + mhit = idb.read_fields(data, offset, "mhit", header_length) + mhit["children"], _ = parse_children( + data, offset + header_length, mhit["child_count"], + ) + return {"next_offset": offset + chunk_length, "data": mhit} diff --git a/src/vendor/iTunesDB_Parser/mhod_parser.py b/src/vendor/iTunesDB_Parser/mhod_parser.py new file mode 100644 index 0000000..a292e21 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhod_parser.py @@ -0,0 +1,613 @@ +"""MHOD (Data Object) parser. + +MHODs are the most varied chunk type in the iTunesDB. They are always +leaf nodes (no sub-chunks) but have a ``type`` field at offset 0x0C that +determines how the body should be decoded. + +Body layouts by type family: + +- **String types** (1-14, 18-31, 33-44, 200-204, 300): + Standard sub-header at +0x18 with encoding + string_length, then + UTF-16LE or UTF-8 string data starting at +0x28. + +- **Podcast URL types** (15, 16): + UTF-8 string directly after the 24-byte MHOD header (no sub-header). + +- **Chapter data** (17): + Big-endian atom tree (sean → chap → name) after 12-byte preamble. + Contains chapter titles and start positions for audiobooks/podcasts. + +- **Binary blob types** (32=video track data): + Raw binary stored as hex string for JSON round-tripping. + +- **Smart playlist types** (50=SPLPref, 51=SLst rules): + Dedicated binary formats. SLst is the **only** big-endian section + in the entire iTunesDB (besides chapter data atoms). + +- **Index types** (52=sorted index, 53=jump table): + Library playlist indexing data. + +- **Playlist settings** (100=position/preferences, 102=binary prefs): + Context-dependent layout based on parent chunk (MHIP vs MHYP). +""" + +from __future__ import annotations + +import logging +import struct +from typing import Any + +import iTunesDB_Shared as idb + +from ._parsing import UINT16_LE, UINT32_LE, ParseResult + +logger = logging.getLogger(__name__) + +# ── Local layout constants ───────────────────────────────────────── +# Binary offsets not already defined in iTunesDB_Shared.mhod_defs. +# Each is derived from the layout documented in the per-type docstrings. + +# MHOD52 (sorted index): sort_type(4) + count(4) + padding(40) = 48 +_MHOD52_INDICES_OFFSET = 48 + +# MHOD53 (jump table): sort_type(4) + count(4) + padding(8) = 16 +_MHOD53_ENTRIES_OFFSET = 16 +_MHOD53_ENTRY_SIZE = 12 # letter(2) + pad(2) + start(4) + count(4) + +# MHOD100: bodies ≤ 20 bytes are MHIP-context (position); +# larger bodies are MHYP-context (playlist display preferences). +_MHOD100_MHIP_MAX_BODY = 20 + + +# ──────────────────────────────────────────────────────────────────── +# Top-level dispatcher +# ──────────────────────────────────────────────────────────────────── + +def parse_mhod( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse a complete MHOD chunk, dispatching by type to the appropriate decoder.""" + mhod: dict[str, Any] = idb.read_fields(data, offset, "mhod") + mhod_type: int = mhod["mhod_type"] + + if mhod_type in idb.mhod_defs.NON_STRING_MHOD_TYPES: + body_offset = offset + header_length + body_length = chunk_length - header_length + mhod["data"] = _parse_nonstring_mhod(data, body_offset, body_length, mhod_type) + + elif mhod_type in idb.mhod_defs.PODCAST_URL_MHOD_TYPES: + # Podcast URL types (15, 16): UTF-8/ASCII string directly after + # header, with NO sub-header. + url_length = chunk_length - header_length + if url_length > 0: + raw = data[offset + header_length:offset + header_length + url_length] + mhod["string"] = raw.decode("utf-8", errors="replace").rstrip("\x00") + else: + mhod["string"] = "" + + elif mhod_type in idb.mhod_defs.CHAPTER_DATA_MHOD_TYPES: + body_offset = offset + header_length + body_length = chunk_length - header_length + mhod["data"] = _parse_chapter_data(data, body_offset, body_length) + + elif mhod_type in idb.mhod_defs.BINARY_BLOB_MHOD_TYPES: + # Binary blob types (32=video track data). + blob_length = chunk_length - header_length + blob = data[offset + header_length:offset + header_length + blob_length] + mhod["string"] = blob.hex() + + elif mhod_type in idb.mhod_defs.STRING_MHOD_TYPES: + _parse_string_mhod(data, offset, mhod) + + else: + # Unknown MHOD type — return stub. + mhod["string"] = "" + + return {"next_offset": offset + chunk_length, "data": mhod} + + +# ──────────────────────────────────────────────────────────────────── +# String MHOD decoder +# ──────────────────────────────────────────────────────────────────── + +def _parse_string_mhod( + data: bytes | bytearray, + offset: int, + mhod: dict[str, Any], +) -> None: + """Decode a standard string MHOD (sub-header at +0x18) into *mhod* in-place.""" + encoding = idb.mhod_defs.mhod_string_encoding(data, offset) + string_length = idb.mhod_defs.mhod_string_length(data, offset) + mhod["unk_0x20"] = idb.mhod_defs.mhod_string_unk0x20(data, offset) + mhod["unk_0x24"] = idb.mhod_defs.mhod_string_unk0x24(data, offset) + + # String data starts after 24-byte header + 16-byte sub-header. + string_start = offset + idb.mhod_defs.MHOD_STRING_DATA_OFFSET + string_data = data[string_start:string_start + string_length] + + if encoding == 2: + mhod["string"] = string_data.decode("utf-8", errors="replace") + else: + # encoding 0 or 1 = UTF-16LE (most common on iPod). + mhod["string"] = string_data.decode("utf-16-le", errors="replace") + + +# ──────────────────────────────────────────────────────────────────── +# Non-string MHOD dispatcher +# ──────────────────────────────────────────────────────────────────── + +def _parse_nonstring_mhod( + data: bytes | bytearray, + body_offset: int, + body_length: int, + mhod_type: int, +) -> dict[str, Any]: + """Route non-string MHODs to their specific decoders.""" + match mhod_type: + case 50: + return _parse_mhod50(data, body_offset, body_length) + case 51: + return _parse_mhod51(data, body_offset, body_length) + case 52: + return _parse_mhod52(data, body_offset, body_length) + case 53: + return _parse_mhod53(data, body_offset, body_length) + case 100: + return _parse_mhod100(data, body_offset, body_length) + case 102: + return _parse_mhod102(data, body_offset, body_length) + case _: + return {} + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 50 — Smart Playlist Preferences (SPLPref) +# ──────────────────────────────────────────────────────────────────── + +def _parse_mhod50( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse SPLPref (Smart Playlist Preferences) from MHOD type 50. + + Binary layout (relative to body_offset):: + + +0x00 liveUpdate (u8) + +0x01 checkRules (u8) + +0x02 checkLimits (u8) + +0x03 limitType (u8) + +0x04 limitSort (u8) + 3 padding bytes + +0x08 limitValue (u32 LE) + +0x0C matchCheckedOnly (u8) — optional + +0x0D reverseSort (u8) — optional + """ + if body_length < 12: + logger.warning("MHOD50 (SPLPref) body too short: %d bytes", body_length) + return {} + + defs = idb.mhod_defs + result: dict[str, Any] = { + "live_update": defs.mhod_spl_live_update(data, body_offset), + "check_rules": defs.mhod_spl_check_rules(data, body_offset), + "check_limits": defs.mhod_spl_check_limits(data, body_offset), + "limit_type": defs.mhod_spl_limit_type(data, body_offset), + "limit_sort": defs.mhod_spl_limit_sort_raw(data, body_offset), + "limit_value": defs.mhod_spl_limit_value(data, body_offset), + } + + if body_length >= 13: + result["match_checked_only"] = defs.mhod_spl_match_checked_only(data, body_offset) + if body_length >= 14: + result["reverse_sort"] = defs.mhod_spl_reverse_sort(data, body_offset) + + return result + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 51 — Smart Playlist Rules (SLst) +# ──────────────────────────────────────────────────────────────────── + +def _parse_mhod51( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse SPLRules (Smart Playlist Rules) from MHOD type 51. + + CRITICAL: The SLst blob uses BIG-ENDIAN encoding for ALL multi-byte + integers — the only part of the iTunesDB that does so. + """ + if body_length < 16: + logger.warning("MHOD51 (SPLRules) body too short: %d bytes", body_length) + return {} + + slst_magic = idb.mhod_defs.mhod_slst_magic(data, body_offset) + if slst_magic != b'SLst': + logger.warning("MHOD51: expected SLst magic, got %r", slst_magic) + return {} + + defs = idb.mhod_defs + rule_count = defs.mhod_slst_rule_count(data, body_offset) + result: dict[str, Any] = { + "unk004": defs.mhod_slst_unk004(data, body_offset), + "rule_count": rule_count, + "conjunction": defs.mhod_slst_conjunction(data, body_offset), + } + + # Parse individual rules (start after 136-byte SLst header). + rules: list[dict[str, Any]] = [] + rule_offset = body_offset + defs.SLST_HEADER_SIZE + + for _ in range(rule_count): + if rule_offset + defs.SPL_RULE_HEADER_SIZE > body_offset + body_length: + break + rule, rule_total_size = _parse_spl_rule(data, rule_offset) + rules.append(rule) + rule_offset += rule_total_size + + result["rules"] = rules + return result + + +def _parse_spl_rule( + data: bytes | bytearray, + rule_offset: int, +) -> tuple[dict[str, Any], int]: + """Parse a single SPL rule starting at *rule_offset*. + + All multi-byte integers within SLst rules are BIG-ENDIAN. + + Returns: + Tuple of ``(rule_dict, total_rule_size_in_bytes)``. + """ + defs = idb.mhod_defs + rule: dict[str, Any] = {} + + field_id = defs.mhod_spl_rule_field(data, rule_offset) + rule["field_id"] = field_id + rule["action_id"] = defs.mhod_spl_rule_action(data, rule_offset) + + data_length = defs.mhod_spl_rule_data_length(data, rule_offset) + rule["data_length"] = data_length + + data_offset = rule_offset + defs.SPL_RULE_HEADER_SIZE + + field_type = defs.spl_get_field_type(field_id) + + if field_type == defs.SPLFT_STRING: + # SLst strings are UTF-16 BIG-endian. + if data_length > 0: + raw = data[data_offset:data_offset + data_length] + rule["string_value"] = raw.decode("utf-16-be", errors="replace") + else: + rule["string_value"] = "" + else: + # Numeric rule data (INT, DATE, BOOLEAN, PLAYLIST, BINARY_AND). + rule["from_value"] = defs.mhod_spl_rule_from_value(data, data_offset) + rule["from_date"] = defs.mhod_spl_rule_from_date(data, data_offset) + rule["from_units"] = defs.mhod_spl_rule_from_units(data, data_offset) + rule["to_value"] = defs.mhod_spl_rule_to_value(data, data_offset) + rule["to_date"] = defs.mhod_spl_rule_to_date(data, data_offset) + rule["to_units"] = defs.mhod_spl_rule_to_units(data, data_offset) + rule["unk052"] = defs.mhod_spl_rule_unk052(data, data_offset) + rule["unk056"] = defs.mhod_spl_rule_unk056(data, data_offset) + rule["unk060"] = defs.mhod_spl_rule_unk060(data, data_offset) + rule["unk064"] = defs.mhod_spl_rule_unk064(data, data_offset) + rule["unk068"] = defs.mhod_spl_rule_unk068(data, data_offset) + + total_size = defs.SPL_RULE_HEADER_SIZE + data_length + return rule, total_size + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 52 — Library Playlist Index +# ──────────────────────────────────────────────────────────────────── + +def _parse_mhod52( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse library playlist sorted index from MHOD type 52. + + Layout:: + + +0x00 sort_type (u32 LE) + +0x04 count (u32 LE) + +0x08 padding (40 bytes) + +0x30 indices (count x u32 LE) — sorted track positions + """ + if body_length < 8: + logger.warning("MHOD52 (sorted index) body too short: %d bytes", body_length) + return {} + + defs = idb.mhod_defs + count = defs.mhod52_count(data, body_offset) + result: dict[str, Any] = { + "sort_type": defs.mhod52_sort_type(data, body_offset), + "count": count, + } + + indices_start = body_offset + _MHOD52_INDICES_OFFSET + indices: list[int] = [] + for i in range(count): + pos = indices_start + i * 4 + if pos + 4 <= body_offset + body_length: + indices.append(UINT32_LE.unpack_from(data, pos)[0]) + result["indices"] = indices + + return result + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 53 — Library Playlist Jump Table +# ──────────────────────────────────────────────────────────────────── + +def _parse_mhod53( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse library playlist jump table from MHOD type 53. + + Layout:: + + +0x00 sort_type (u32 LE) + +0x04 count (u32 LE) + +0x08 padding (8 bytes) + +0x10 entries (count x 12 bytes each): + letter (u16 LE) + pad(2) + start(u32 LE) + count(u32 LE) + """ + if body_length < 8: + logger.warning("MHOD53 (jump table) body too short: %d bytes", body_length) + return {} + + defs = idb.mhod_defs + count = defs.mhod53_count(data, body_offset) + result: dict[str, Any] = { + "sort_type": defs.mhod53_sort_type(data, body_offset), + "count": count, + } + + entries_start = body_offset + _MHOD53_ENTRIES_OFFSET + entries: list[dict[str, int]] = [] + for i in range(count): + pos = entries_start + i * _MHOD53_ENTRY_SIZE + if pos + _MHOD53_ENTRY_SIZE <= body_offset + body_length: + letter_code = UINT16_LE.unpack_from(data, pos)[0] + start = UINT32_LE.unpack_from(data, pos + 4)[0] + entry_count = UINT32_LE.unpack_from(data, pos + 8)[0] + entries.append({ + "letter_code": letter_code, + "start": start, + "count": entry_count, + }) + result["entries"] = entries + + return result + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 100 — Playlist Position / Preferences +# ──────────────────────────────────────────────────────────────────── +# +# Type 100 appears in two contexts: +# 1. As a child of MHIP: contains track position (small, <=20-byte body) +# 2. As a child of MHYP: contains playlist display preferences (large, ~624-byte body) + +def _parse_mhod100( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse playlist position or preferences from MHOD type 100.""" + result: dict[str, Any] = {} + + if body_length <= _MHOD100_MHIP_MAX_BODY: + # MHIP context: simple position field. + if body_length >= 4: + result["position"] = idb.mhod_defs.mhod100_position(data, body_offset) + else: + # MHYP context: playlist display preferences. + result["fields"] = _scan_nonzero_fields(data, body_offset, body_length) + # Preserve raw bytes for round-trip fidelity. + result["raw_body"] = bytes(data[body_offset:body_offset + body_length]) + + return result + + +def _scan_nonzero_fields( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, int]: + """Scan a binary body for all nonzero bytes, grouped into u32 values. + + Returns a dict mapping hex-offset strings to integer values. + Contiguous nonzero bytes within the same 4-byte-aligned u32 are + merged into a single LE u32 entry. Isolated single bytes are + returned as-is. + """ + fields: dict[str, int] = {} + body = data[body_offset:body_offset + body_length] + visited: set[int] = set() + + for i in range(len(body)): + if body[i] != 0 and i not in visited: + # Try to read as aligned u32 if within bounds. + aligned = (i // 4) * 4 + if aligned + 4 <= len(body): + val = UINT32_LE.unpack_from(body, aligned)[0] + if val != 0: + fields[f"0x{aligned:03X}"] = val + visited.update(range(aligned, aligned + 4)) + continue + # Fallback: single byte. + fields[f"0x{i:03X}"] = body[i] + visited.add(i) + + return fields + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 102 — Playlist Settings (binary, post-iTunes 7) +# ──────────────────────────────────────────────────────────────────── + +def _parse_mhod102( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse MHOD type 102 — playlist settings (opaque binary blob).""" + return { + "fields": _scan_nonzero_fields(data, body_offset, body_length), + # Preserve raw bytes for round-trip fidelity. + "raw_body": bytes(data[body_offset:body_offset + body_length]), + } + + +# ──────────────────────────────────────────────────────────────────── +# MHOD Type 17 — Chapter Data (big-endian atom tree) +# ──────────────────────────────────────────────────────────────────── +# +# Chapter data for audiobooks and enhanced podcasts. The body +# contains a 12-byte preamble (3 × u32 LE unknown fields) followed by +# a big-endian atom tree: ``sean`` → ``chap`` × N → ``name`` + ``hedr``. +# +# This is the ONLY part of the iTunesDB (besides the SLst smart +# playlist rules) that uses big-endian encoding for its atoms. +# +# Layout (from libgpod itdb_itunesdb.c and iPodLinux wiki): +# +# Preamble (LE): +# +0x00 unk024 (u32) +# +0x04 unk028 (u32) +# +0x08 unk032 (u32) +# +# sean atom (BE): +# +0x00 total_size (u32 BE) +# +0x04 "sean" (4 bytes) +# +0x08 unknown (u32 BE, always 1) +# +0x0C child_count (u32 BE, = num_chapters + 1 for hedr) +# +0x10 unknown (u32 BE, always 0) +# +# chap atom (BE), repeated per chapter: +# +0x00 total_size (u32 BE) +# +0x04 "chap" (4 bytes) +# +0x08 startpos (u32 BE, milliseconds) +# +0x0C child_count (u32 BE, = 1 for name) +# +0x10 unknown (u32 BE, always 0) +# +0x14 name atom... +# +# name atom (BE): +# +0x00 total_size (u32 BE) +# +0x04 "name" (4 bytes) +# +0x08 unknown (u32 BE, always 1) +# +0x0C unknown (u32 BE, always 0) +# +0x10 unknown (u32 BE, always 0) +# +0x14 string_length (u16 BE, in UTF-16BE code units) +# +0x16 title (string_length × 2 bytes, UTF-16BE) +# +# hedr atom (BE, 28 bytes): +# +0x00 size=28 (u32 BE) +# +0x04 "hedr" (4 bytes) +# +0x08 unknown (u32 BE, always 1) +# +0x0C child_count=0 (u32 BE) +# +0x10 unknown (u32 BE, always 0) +# +0x14 unknown (u32 BE, always 0) +# +0x18 unknown (u32 BE, always 1) + +_UINT32_BE = struct.Struct(">I") +_UINT16_BE = struct.Struct(">H") + + +def _parse_chapter_data( + data: bytes | bytearray, + body_offset: int, + body_length: int, +) -> dict[str, Any]: + """Parse chapter data atom tree from MHOD type 17. + + Returns a dict with: + - ``unk024``, ``unk028``, ``unk032``: preamble unknowns + - ``chapters``: list of {``startpos``: int, ``title``: str} + """ + defs = idb.mhod_defs + result: dict[str, Any] = {} + + if body_length < defs.CHAPTER_PREAMBLE_SIZE: + logger.warning("MHOD17 (chapter data) too short for preamble: %d bytes", body_length) + result["chapters"] = [] + return result + + # Read 12-byte preamble (little-endian, like the rest of iTunesDB). + result["unk024"] = UINT32_LE.unpack_from(data, body_offset)[0] + result["unk028"] = UINT32_LE.unpack_from(data, body_offset + 4)[0] + result["unk032"] = UINT32_LE.unpack_from(data, body_offset + 8)[0] + + seek = body_offset + defs.CHAPTER_PREAMBLE_SIZE + end = body_offset + body_length + + # Check for "sean" atom. + if seek + 20 > end: + result["chapters"] = [] + return result + + sean_size = _UINT32_BE.unpack_from(data, seek)[0] + if sean_size < 20 or seek + sean_size > end: + logger.warning("Chapter data: invalid 'sean' atom size: %d", sean_size) + result["chapters"] = [] + return result + sean_magic = data[seek + 4:seek + 8] + if sean_magic != defs.SEAN_ATOM: + logger.warning("Chapter data: expected 'sean' atom, got %r", sean_magic) + result["chapters"] = [] + return result + + num_children = _UINT32_BE.unpack_from(data, seek + 12)[0] + num_chapters = max(0, num_children - 1) # subtract 1 for hedr + seek += 20 # skip sean header + + chapters: list[dict[str, Any]] = [] + for _ in range(num_chapters): + if seek + 20 > end: + break + chap_magic = data[seek + 4:seek + 8] + if chap_magic != defs.CHAP_ATOM: + break # unexpected atom, stop parsing + + chap_size = _UINT32_BE.unpack_from(data, seek)[0] + startpos = _UINT32_BE.unpack_from(data, seek + 8)[0] + children = _UINT32_BE.unpack_from(data, seek + 12)[0] + child_seek = seek + 20 + + title = "" + for _ in range(children): + if child_seek + 22 > end: + break + child_size = _UINT32_BE.unpack_from(data, child_seek)[0] + child_magic = data[child_seek + 4:child_seek + 8] + if child_magic == defs.NAME_ATOM: + str_len = _UINT16_BE.unpack_from(data, child_seek + 20)[0] + str_start = child_seek + 22 + str_end = str_start + str_len * 2 + if str_end <= end: + title = data[str_start:str_end].decode("utf-16-be", errors="replace") + child_seek += child_size + + chapters.append({"startpos": startpos, "title": title}) + seek += chap_size + + # Skip hedr atom if present. + if seek + 8 <= end: + hedr_magic = data[seek + 4:seek + 8] + if hedr_magic == defs.HEDR_ATOM: + hedr_size = _UINT32_BE.unpack_from(data, seek)[0] + seek += hedr_size + + result["chapters"] = chapters + return result diff --git a/src/vendor/iTunesDB_Parser/mhsd_parser.py b/src/vendor/iTunesDB_Parser/mhsd_parser.py new file mode 100644 index 0000000..f40e698 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhsd_parser.py @@ -0,0 +1,30 @@ +"""MHSD (DataSet) parser. + +An MHSD contains exactly one child chunk whose type is determined by the +dataset type field at offset 0x0C (see ``constants.chunk_type_map``). + +Dataset types: 1=TrackList, 2=PlaylistList, 3=PodcastList, 4=AlbumList, +5=SmartPlaylistList, 6/10=empty stubs, 8=ArtistList, 9=Genius CUID. +""" + +from __future__ import annotations + +from typing import Any + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_dataset( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHSD (DataSet) chunk and its single child.""" + mhsd: dict[str, Any] = idb.read_fields(data, offset, "mhsd", header_length) + # MHSD always has exactly one child. + mhsd["children"], _ = parse_children(data, offset + header_length, 1) + return {"next_offset": offset + chunk_length, "data": mhsd} diff --git a/src/vendor/iTunesDB_Parser/mhyp_parser.py b/src/vendor/iTunesDB_Parser/mhyp_parser.py new file mode 100644 index 0000000..4514e7b --- /dev/null +++ b/src/vendor/iTunesDB_Parser/mhyp_parser.py @@ -0,0 +1,34 @@ +"""MHYP (Playlist) parser. + +An MHYP represents a single playlist. Its children are split into two +groups parsed sequentially: MHOD metadata objects first, then MHIP +playlist-item entries. The counts are stored separately in the header. +""" + +from __future__ import annotations + +import iTunesDB_Shared as idb + +from ._parsing import ParseResult +from .chunk_parser import parse_children + + +def parse_playlist( + data: bytes | bytearray, + offset: int, + header_length: int, + chunk_length: int, +) -> ParseResult: + """Parse an MHYP (Playlist) chunk with MHOD + MHIP child groups.""" + mhyp = idb.read_fields(data, offset, "mhyp", header_length) + + # MHODs come first, then MHIPs — parsed sequentially with shared offset. + body_start = offset + header_length + mhyp["mhod_children"], mhip_start = parse_children( + data, body_start, mhyp["mhod_child_count"], + ) + mhyp["mhip_children"], _ = parse_children( + data, mhip_start, mhyp["mhip_child_count"], + ) + + return {"next_offset": offset + chunk_length, "data": mhyp} diff --git a/src/vendor/iTunesDB_Parser/otg.py b/src/vendor/iTunesDB_Parser/otg.py new file mode 100644 index 0000000..1ab8f11 --- /dev/null +++ b/src/vendor/iTunesDB_Parser/otg.py @@ -0,0 +1,187 @@ +"""On-The-Go (OTG) playlist parser for iPod devices. + +The iPod firmware stores device-created playlists in a separate MHPO binary +file (``OTGPlaylistInfo``) rather than in the iTunesDB. iTunes and libgpod +both read these files on connect, import the playlists into the database as +regular MHYP entries, and then delete the source file. + +Reference: libgpod ``process_OTG_file`` / ``read_OTG_playlists`` in ``src/itdb_itunesdb.c``. + +MHPO binary layout (little-endian; big-endian devices use magic ``ohpm``): + + +0x00 magic 4 B "mhpo" (LE) or "ohpm" (BE) + +0x04 header_len u32 size of the header block — always 0x14 (20 B) + +0x08 entry_len u32 size of each track entry — always 0x04 (4 B) + +0x0C entry_num u32 number of track entries + +0x10 (reserved) 4 B unknown; ignored + +0x14 entries entry_num x entry_len bytes + each entry: u32 = 0-based index into the iTunesDB track list (mhlt) + +File naming (all in ``iPod_Control/iTunes/``): + OTGPlaylistInfo — first saved OTG playlist + OTGPlaylistInfo_1 — second saved OTG playlist + OTGPlaylistInfo_2 — third, etc. + +The PC sync manager deletes only the base ``OTGPlaylistInfo`` file after +writing the database. The iPod firmware removes the numbered variants itself +once it has processed the updated database. +(libgpod comment: "the iPod will remove the remaining files".) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import struct + +logger = logging.getLogger(__name__) + + +def load_otg_playlists(itunes_dir: str, track_list: list) -> list[dict]: + """Parse OTGPlaylistInfo files and return them as regular playlist dicts. + + Each returned dict has the same shape as a playlist produced by + ``_inline_playlist_strings`` in ``ipod_library.py``: + + ``Title`` — "On-The-Go 1", "On-The-Go 2", … + ``items`` — list of ``{"track_id": }`` dicts + ``playlist_id`` — stable 64-bit ID derived from the file's MD5 so that + repeated rescans without an intervening write don't + introduce duplicates (same file → same ID) + + Args: + itunes_dir: Path to the ``iPod_Control/iTunes`` directory. + track_list: Ordered list of track dicts as returned by the iTunesDB + parser (``data["mhlt"]``). Entry indices in the MHPO + file are 0-based positions into this list. + """ + paths = _collect_otg_paths(itunes_dir) + result: list[dict] = [] + + for pl_num, path_str in enumerate(paths, 1): + playlist = _parse_one_otg_file(path_str, pl_num, track_list) + if playlist is not None: + result.append(playlist) + + return result + + +def delete_otg_files(itunes_dir: str) -> None: + """Delete the base OTGPlaylistInfo file after a successful database write. + + Matches libgpod's ``itdb_rename_files()`` behaviour: only the base + ``OTGPlaylistInfo`` file is removed by the PC sync manager. The iPod + firmware removes the numbered variants (``OTGPlaylistInfo_1``, ``_2``, …) + after it processes the new database. + """ + base = os.path.join(itunes_dir, "OTGPlaylistInfo") + if not os.path.exists(base): + return + try: + os.unlink(base) + logger.info("Deleted OTGPlaylistInfo") + except OSError as exc: + logger.warning("Could not delete OTGPlaylistInfo: %s", exc) + + +# ── Internal helpers ───────────────────────────────────────────────────────── + + +def _collect_otg_paths(itunes_dir: str) -> list[str]: + """Return the ordered list of OTGPlaylistInfo paths that exist. + + Mirrors libgpod's iteration: start with the base file; if it exists, + also collect ``_1``, ``_2``, … stopping at the first missing numbered + file. If the base file is absent, return an empty list (libgpod comment: + "only parse if OTGPlaylistInfo exists"). + """ + base = os.path.join(itunes_dir, "OTGPlaylistInfo") + if not os.path.exists(base): + return [] + + paths = [base] + for i in range(1, 20): + p = os.path.join(itunes_dir, f"OTGPlaylistInfo_{i}") + if not os.path.exists(p): + break + paths.append(p) + return paths + + +def _parse_one_otg_file( + path_str: str, + pl_num: int, + track_list: list, +) -> dict | None: + """Parse a single MHPO file and return a playlist dict, or None on failure.""" + try: + with open(path_str, "rb") as fh: + raw = fh.read() + except OSError as exc: + logger.warning("OTG: could not read %s: %s", path_str, exc) + return None + + if len(raw) < 0x14: + logger.warning("OTG: %s too short (%d B)", os.path.basename(path_str), len(raw)) + return None + + magic = raw[0:4] + if magic == b"mhpo": + fmt = " len(raw): + logger.warning("OTG: %s entry %d extends past EOF — truncating", + os.path.basename(path_str), i) + break + track_index = struct.unpack_from(fmt, raw, offset)[0] + if track_index >= len(track_list): + logger.warning( + "OTG: %s entry %d references track index %d but track list " + "has only %d entries — skipping entry", + os.path.basename(path_str), i, track_index, len(track_list), + ) + continue + tid = track_list[track_index].get("track_id", 0) + if tid: + items.append({"track_id": tid}) + + # Consistent with libgpod: don't create a playlist for an empty file. + if not items: + return None + + # Stable playlist_id derived from file content so that re-scanning the + # same OTG file before a sync produces the same ID each time, preventing + # duplicates in the deduplication step of read_existing_database. + playlist_id = int.from_bytes(hashlib.md5(raw).digest()[:8], "little") + + name = f"On-The-Go {pl_num}" + logger.info("OTG: imported '%s' (%d tracks) from %s", + name, len(items), os.path.basename(path_str)) + return { + "Title": name, + "items": items, + "playlist_id": playlist_id, + } diff --git a/src/vendor/iTunesDB_Parser/parser.py b/src/vendor/iTunesDB_Parser/parser.py new file mode 100644 index 0000000..a2d844e --- /dev/null +++ b/src/vendor/iTunesDB_Parser/parser.py @@ -0,0 +1,112 @@ +""" +iTunesDB / iTunesCDB entry-point parser. + +This module provides the public parsing API for Apple's proprietary +iTunesDB binary database format (and its zlib-compressed variant, +iTunesCDB). It accepts either a file path or a file-like object and +returns a nested dict tree representing the full database hierarchy. + +Typical usage:: + + from iTunesDB_Parser import parse_itunesdb + + db = parse_itunesdb("/media/ipod/iPod_Control/iTunes/iTunesDB") +""" + +from __future__ import annotations + +import logging +import os +import zlib +from typing import Any, BinaryIO + +from ._parsing import UINT32_LE +from .exceptions import CorruptHeaderError + +logger = logging.getLogger(__name__) + +# Recognized mhbd magic at file offset 0. +_MHBD_MAGIC = b"mhbd" + +# Minimum length to contain the mhbd generic header (magic + header_len + total_len + compressed). +_MIN_MHBD_HEADER = 16 + +# iTunesCDB compressed-flag value (mhbd offset 0x0C). +_COMPRESSED_DB_FLAG = 0x02 + + +def decompress_itunescdb(data: bytes | bytearray) -> bytes | bytearray: + """Transparently decompress an iTunesCDB into a standard iTunesDB stream. + + If *data* is already an uncompressed iTunesDB (or is too short / has the + wrong magic), it is returned as-is. + + Detection logic: the mhbd ``compressed`` field at offset 0x0C is ``2`` for + compressed-DB-capable devices, and the payload after the header is a zlib + stream. + + Args: + data: Raw bytes of an iTunesDB or iTunesCDB file. + + Returns: + Decompressed iTunesDB byte stream (original header preserved). + """ + if len(data) < _MIN_MHBD_HEADER or data[:4] != _MHBD_MAGIC: + return data + + header_length = UINT32_LE.unpack_from(data, 0x04)[0] + compressed_flag = UINT32_LE.unpack_from(data, 0x0C)[0] + + if compressed_flag != _COMPRESSED_DB_FLAG: + return data + + try: + decompressed = zlib.decompress(data[header_length:]) + except zlib.error: + return data # not actually compressed — return as-is + + # Reconstruct: original (unmodified) header + decompressed children. + # Header's total_length (offset 8) and compression flag are preserved + # as-is. MHBD children are parsed by child_count so the stale + # total_length is harmless. + logger.debug("iTunesCDB decompressed: %d -> %d payload bytes", + len(data) - header_length, len(decompressed)) + return data[:header_length] + decompressed + + +def parse_itunesdb(file: str | os.PathLike[str] | BinaryIO) -> dict[str, Any]: + """Parse an iTunesDB (or iTunesCDB) file into a nested dict tree. + + Args: + file: A filesystem path (``str`` or ``os.PathLike``) or an open + binary file-like object positioned at the start of the data. + + Returns: + Dict representation of the mhbd root chunk and all children. + + Raises: + TypeError: If *file* is not a path or file-like object. + ITunesDBParseError: If the binary data cannot be parsed. + OSError: If a file path cannot be read. + """ + from .chunk_parser import parse_chunk + + if isinstance(file, (str, os.PathLike)): + with open(file, "rb") as fh: + data: bytes | bytearray = fh.read() + elif hasattr(file, "read"): + data = file.read() + else: + raise TypeError( + f"file must be a path (str/PathLike) or a file-like object, " + f"got {type(file).__name__}" + ) + + if not data: + raise CorruptHeaderError(0, "empty file") + + # Transparently handle iTunesCDB (compressed database) + data = decompress_itunescdb(data) + + parsed, _chunk_type = parse_chunk(data, 0) + return parsed["data"] diff --git a/src/vendor/iTunesDB_Parser/playcounts.py b/src/vendor/iTunesDB_Parser/playcounts.py new file mode 100644 index 0000000..457266a --- /dev/null +++ b/src/vendor/iTunesDB_Parser/playcounts.py @@ -0,0 +1,260 @@ +""" +Play Counts file parser for iPod. + +The iPod firmware does NOT modify the iTunesDB directly. Instead it creates +a separate binary file at ``/iPod_Control/iTunes/Play Counts`` that records +per-track deltas (play count, skip count, rating, timestamps) accumulated +since the last sync. + +File layout (iTunes 7+ / entry_length 0x1C): + + Header (``mhdp``) + ┌─────────────┬────────────────────┐ + │ 0x00 4B │ magic "mhdp" │ + │ 0x04 4B │ header_length │ + │ 0x08 4B │ entry_length │ + │ 0x0C 4B │ entry_count │ + │ 0x10 … │ padding → header_length │ + └─────────────┴────────────────────┘ + + Per-entry (28 bytes for entry_length == 0x1C) + ┌─────────────┬────────────────────┐ + │ 0x00 4B │ play_count │ + │ 0x04 4B │ last_played (Mac) │ + │ 0x08 4B │ bookmark_time │ + │ 0x0C 4B │ rating (0-100) │ + │ 0x10 4B │ unk16 / podcast │ + │ 0x14 4B │ skip_count │ + │ 0x18 4B │ last_skipped (Mac) │ + └─────────────┴────────────────────┘ + +Entries are ordered 1:1 with tracks in the mhlt (matched by index, **not** +by track ID). After a sync tool reads this file and folds the deltas into +the iTunesDB, the file must be **deleted** so the iPod creates a fresh one. + +Reference: libgpod ``itdb_itunesdb.c`` → ``playcounts_read()``. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from iTunesDB_Shared.field_base import MAC_EPOCH_OFFSET + +from ._parsing import UINT32_LE + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class PlayCountEntry: + """Delta values for a single track from the Play Counts file.""" + + play_count: int = 0 + last_played_mac: int = 0 # Mac epoch timestamp (0 = not played) + bookmark_time: int = 0 + rating: int = -1 # -1 = no change; 0-100 = new rating + skip_count: int = 0 + last_skipped_mac: int = 0 # Mac epoch timestamp (0 = not skipped) + + # Convenience: is there any delta data in this entry? + @property + def has_data(self) -> bool: + return ( + self.play_count > 0 + or self.skip_count > 0 + or self.rating >= 0 + ) + + @property + def last_played_unix(self) -> int: + """Last-played as Unix timestamp (0 if never played).""" + if self.last_played_mac == 0: + return 0 + return self.last_played_mac - MAC_EPOCH_OFFSET + + @property + def last_skipped_unix(self) -> int: + """Last-skipped as Unix timestamp (0 if never skipped).""" + if self.last_skipped_mac == 0: + return 0 + return self.last_skipped_mac - MAC_EPOCH_OFFSET + + +def parse_playcounts(path: str | Path) -> list[PlayCountEntry] | None: + """ + Parse an iPod Play Counts file. + + Args: + path: Path to the ``Play Counts`` file. + + Returns: + List of :class:`PlayCountEntry` (one per track, ordered by mhlt + index), or ``None`` if the file doesn't exist or can't be parsed. + """ + path = Path(path) + if not path.exists(): + logger.debug("No Play Counts file at %s", path) + return None + + try: + data = path.read_bytes() + except OSError as exc: + logger.warning("Could not read Play Counts file: %s", exc) + return None + + if len(data) < 16: + logger.warning("Play Counts file too small (%d bytes)", len(data)) + return None + + magic = data[0:4] + if magic != b"mhdp": + logger.warning("Play Counts file bad magic: %r (expected b'mhdp')", magic) + return None + + header_len = UINT32_LE.unpack_from(data, 4)[0] + entry_len = UINT32_LE.unpack_from(data, 8)[0] + entry_count = UINT32_LE.unpack_from(data, 12)[0] + + expected_size = header_len + entry_len * entry_count + if len(data) < expected_size: + logger.warning( + "Play Counts file truncated: %d bytes < expected %d", + len(data), expected_size, + ) + return None + + logger.info( + "Play Counts: header=%d, entry_len=%d, entries=%d", + header_len, entry_len, entry_count, + ) + + entries: list[PlayCountEntry] = [] + for i in range(entry_count): + offset = header_len + i * entry_len + entry = PlayCountEntry() + + # Minimum fields (always present) + entry.play_count = UINT32_LE.unpack_from(data, offset)[0] + + if entry_len >= 8: + entry.last_played_mac = UINT32_LE.unpack_from(data, offset + 4)[0] + + if entry_len >= 12: + entry.bookmark_time = UINT32_LE.unpack_from(data, offset + 8)[0] + + if entry_len >= 16: + raw_rating = UINT32_LE.unpack_from(data, offset + 12)[0] + # Convention: rating=0 in the Play Counts file means "no change" + # when the track had no user interaction. The iPod firmware + # initialises all entries to zero. We treat 0 as "unchanged" + # to avoid accidentally clearing ratings set on the PC. + # + # Ratings 20-100 (1-5 stars) are genuine user-set values. + # A user *removing* a rating on the iPod is indistinguishable + # from "no interaction" — this is a known limitation shared + # with libgpod (which checks ``rating != NO_PLAYCOUNT (-1)`` + # but the firmware never writes -1). + if raw_rating > 0: + entry.rating = raw_rating + # else: stays -1 (no change) + + # entry_len >= 20: unk16 / podcast flag — skipped + + if entry_len >= 24: + entry.skip_count = UINT32_LE.unpack_from(data, offset + 20)[0] + + if entry_len >= 28: + entry.last_skipped_mac = UINT32_LE.unpack_from(data, offset + 24)[0] + + entries.append(entry) + + active = sum(1 for e in entries if e.has_data) + logger.info("Play Counts: %d / %d entries have activity", active, entry_count) + return entries + + +def merge_playcounts( + tracks: list[dict], + entries: list[PlayCountEntry], +) -> None: + """ + Fold Play Counts deltas into parsed track dicts **in place**. + + After calling this: + + - ``track["play_count_1"]`` is the **new cumulative** play count + - ``track["skip_count"]`` is the **new cumulative** skip count + - ``track["recent_playcount"]`` is the delta from this session + - ``track["recent_skipcount"]`` is the delta from this session + - ``track["rating"]`` may be updated if the user rated on the iPod + - ``track["last_played"]`` / ``track["last_skipped"]`` may be updated + + This mirrors libgpod's ``get_mhit()`` merge logic. + """ + count = min(len(tracks), len(entries)) + if len(tracks) != len(entries): + logger.warning( + "Track count (%d) != Play Counts entry count (%d); " + "merging first %d", + len(tracks), len(entries), count, + ) + + merged_plays = 0 + merged_skips = 0 + merged_ratings = 0 + + for i in range(count): + track = tracks[i] + entry = entries[i] + + # --- Play count (additive) --- + track["recent_playcount"] = entry.play_count + track["play_count_1"] = track.get("play_count_1", 0) + entry.play_count + if entry.play_count > 0: + merged_plays += 1 + + # --- Skip count (additive) --- + track["recent_skipcount"] = entry.skip_count + track["skip_count"] = track.get("skip_count", 0) + entry.skip_count + if entry.skip_count > 0: + merged_skips += 1 + + # --- Rating (override if changed) --- + if entry.rating >= 0: # -1 = no change + old_rating = track.get("rating", 0) + if old_rating != entry.rating: + track["app_rating"] = old_rating # backup (libgpod convention) + track["rating"] = entry.rating + merged_ratings += 1 + + # --- Bookmark (override — iPod always has the latest position) --- + if entry.bookmark_time > 0: + track["bookmark_time"] = entry.bookmark_time + + # --- Timestamps (use more-recent value) --- + # track["last_played"] is a Unix timestamp (converted from Mac + # epoch during iTunesDB parsing). entry.last_played_mac is raw + # Mac epoch. Use the .last_played_unix property to compare in + # the same unit and avoid double-conversion downstream. + if entry.last_played_mac > 0: + unix_ts = entry.last_played_unix + if unix_ts > track.get("last_played", 0): + track["last_played"] = unix_ts + + if entry.last_skipped_mac > 0: + unix_ts = entry.last_skipped_unix + if unix_ts > track.get("last_skipped", 0): + track["last_skipped"] = unix_ts + + # Tracks beyond the Play Counts entries get zero deltas + for i in range(count, len(tracks)): + tracks[i]["recent_playcount"] = 0 + tracks[i]["recent_skipcount"] = 0 + + logger.info( + "Merged Play Counts: %d plays, %d skips, %d ratings across %d tracks", + merged_plays, merged_skips, merged_ratings, count, + ) diff --git a/src/vendor/iTunesDB_Shared/__init__.py b/src/vendor/iTunesDB_Shared/__init__.py new file mode 100644 index 0000000..9201e43 --- /dev/null +++ b/src/vendor/iTunesDB_Shared/__init__.py @@ -0,0 +1,32 @@ +from . import field_base as _fb +from .constants import * # noqa: F401, F403 +from .extraction import * # noqa: F401, F403 +from .field_base import * # noqa: F401, F403 +from .mhbd_defs import * # noqa: F401, F403 +from .mhbd_defs import MHBD_FIELDS as _mhbd +from .mhia_defs import * # noqa: F401, F403 +from .mhia_defs import MHIA_FIELDS as _mhia +from .mhii_defs import * # noqa: F401, F403 +from .mhii_defs import MHII_FIELDS as _mhii +from .mhip_defs import * # noqa: F401, F403 +from .mhip_defs import MHIP_FIELDS as _mhip +from .mhit_defs import * # noqa: F401, F403 +from .mhit_defs import MHIT_FIELDS as _mhit +from .mhod_defs import * # noqa: F401, F403 +from .mhod_defs import MHOD_FIELDS as _mhod +from .mhsd_defs import * # noqa: F401, F403 +from .mhsd_defs import MHSD_FIELDS as _mhsd +from .mhyp_defs import * # noqa: F401, F403 +from .mhyp_defs import MHYP_FIELDS as _mhyp + +# ── Build FIELD_REGISTRY from per-chunk defs ──────────────────────── +_fb.FIELD_REGISTRY.update({ + "mhbd": _mhbd, + "mhit": _mhit, + "mhsd": _mhsd, + "mhia": _mhia, + "mhii": _mhii, + "mhip": _mhip, + "mhyp": _mhyp, + "mhod": _mhod, +}) diff --git a/src/vendor/iTunesDB_Shared/album_identity.py b/src/vendor/iTunesDB_Shared/album_identity.py new file mode 100644 index 0000000..3a63aa2 --- /dev/null +++ b/src/vendor/iTunesDB_Shared/album_identity.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") + + +def _clean_text(value: object | None) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +@dataclass(frozen=True) +class AlbumIdentity: + album: str | None + album_artist: str | None + artist: str | None + show_name: str | None + + +def album_identity_from_track(track: object) -> AlbumIdentity: + return AlbumIdentity( + album=_clean_text(getattr(track, "album", None)), + album_artist=_clean_text(getattr(track, "album_artist", None)), + artist=_clean_text(getattr(track, "artist", None)), + show_name=_clean_text(getattr(track, "show_name", None)), + ) + + +def album_identity_from_mapping(track: Mapping[str, object]) -> AlbumIdentity: + return AlbumIdentity( + album=_clean_text(track.get("Album") or track.get("album")), + album_artist=_clean_text( + track.get("Album Artist") or track.get("album_artist") + ), + artist=_clean_text(track.get("Artist") or track.get("artist")), + show_name=_clean_text( + track.get("Show") + or track.get("Show Name") + or track.get("TV Show") + or track.get("show_name") + ), + ) + + +def albums_match(left: AlbumIdentity, right: AlbumIdentity) -> bool: + """Match albums using libgpod's album equality rules.""" + if left.show_name != right.show_name: + return False + if left.album != right.album: + return False + if left.album_artist and right.album_artist: + return left.album_artist == right.album_artist + return left.artist == right.artist + + +@dataclass +class AlbumGroup(Generic[T]): + identity: AlbumIdentity + tracks: list[T] + + +def group_tracks_by_album_identity( + tracks: Iterable[T], + identity_fn: Callable[[T], AlbumIdentity], +) -> list[AlbumGroup]: + """Group tracks into albums using libgpod-compatible matching.""" + groups: list[AlbumGroup] = [] + buckets: dict[tuple[str, str], list[int]] = {} + + for track in tracks: + identity = identity_fn(track) + bucket = (identity.album or "", identity.show_name or "") + candidate_idxs = buckets.get(bucket, []) + + match_idx = None + for idx in candidate_idxs: + if albums_match(identity, groups[idx].identity): + match_idx = idx + break + + if match_idx is None: + match_idx = len(groups) + groups.append(AlbumGroup(identity=identity, tracks=[])) + buckets.setdefault(bucket, []).append(match_idx) + + groups[match_idx].tracks.append(track) + + return groups diff --git a/src/vendor/iTunesDB_Shared/constants.py b/src/vendor/iTunesDB_Shared/constants.py new file mode 100644 index 0000000..2c598e0 --- /dev/null +++ b/src/vendor/iTunesDB_Shared/constants.py @@ -0,0 +1,378 @@ +""" +iTunesDB constants — chunk identifiers, version maps, MHOD type definitions, +media type bitmask, and playlist sort order values. + +Cross-referenced against: + - iPodLinux wiki: https://web.archive.org/web/20081006030946/http://ipodlinux.org/wiki/ITunesDB + - libgpod itdb_itunesdb.c, itdb.h +""" + + +# maps the id used in mhsd to the proper header marker +chunk_type_map = { + 1: "mhlt", # Track list (contains MHIT children) + 2: "mhlp", # Playlist list (contains MHYP children) — regular playlists + 3: "mhlp_podcast", # Podcast list (same MHLP format, different dataset) + # NOTE: Type 3 MHSD MUST come between type 1 and type 2 + # for the iPod to list podcasts correctly. + 4: "mhla", # Album list (iTunes 7.1+; contains MHIA children) + 5: "mhlp_smart", # Smart playlist list (iTunes 7.3+; contains MHYP children) + # Types 6–10 were added in iTunes 9+ for Genius and other features. + # Their child chunk reuses the 'mhli' magic (same as ArtworkDB's image + # list, but here it is a generic item list — different semantics). + # We skip their contents but must recognise them to avoid crashing. + 6: "mhsd_type_6", # Empty mhlt stub (purpose unknown, written by libgpod/iTunes) + 7: "mhsd_type_7", # (reserved, rarely seen) + 8: "mhsd_type_8", # Artist list (mhli with mhii children, MHOD type 300) + 9: "mhsd_type_9", # Genius Chill list + 10: "mhsd_type_10", # Empty mhlt stub (purpose unknown, written by libgpod/iTunes) +} + +# maps the database version to an iTunes version +version_map = { + 0x01: "iTunes 1.0", + 0x02: "iTunes 2.0", + 0x03: "iTunes 3.0", + 0x04: "iTunes 4.0", + 0x05: "iTunes 4.0.1", + 0x06: "iTunes 4.1", + 0x07: "iTunes 4.1.1", + 0x08: "iTunes 4.1.2", + 0x09: "iTunes 4.2", + 0x0a: "iTunes 4.5", + 0x0b: "iTunes 4.7", + 0x0c: "iTunes 4.71/4.8", + 0x0d: "iTunes 4.9", + 0x0e: "iTunes 5", + 0x0f: "iTunes 6", + 0x10: "iTunes 6.0.1", + 0x11: "iTunes 6.0.2-6.0.4", + 0x12: "iTunes 6.0.5", + 0x13: "iTunes 7.0", + 0x14: "iTunes 7.1", + 0x15: "iTunes 7.2", + 0x16: "Unknown (0x16)", + 0x17: "iTunes 7.3.0", + 0x18: "iTunes 7.3.1-7.3.2", + 0x19: "iTunes 7.4", + 0x1a: "iTunes 7.4.1", + 0x1b: "iTunes 7.4.2", + 0x1c: "iTunes 7.5", + 0x1d: "iTunes 7.6", + 0x1e: "iTunes 7.7", + 0x1f: "iTunes 8.0", + 0x20: "iTunes 8.0.1", + 0x21: "iTunes 8.0.2", + 0x22: "iTunes 8.1", + 0x23: "iTunes 8.1.1", + 0x24: "iTunes 8.2", + 0x25: "iTunes 8.2.1", + 0x26: "iTunes 9.0", + 0x27: "iTunes 9.0.1", + 0x28: "iTunes 9.0.2", + 0x29: "iTunes 9.0.3", + 0x2a: "iTunes 9.1", + 0x2b: "iTunes 9.1.1", + 0x2c: "iTunes 9.2", + 0x2d: "iTunes 9.2.1", + 0x30: "iTunes 9.2+", + # Extended versions for newer databases + 0x40: "iTunes 10.x", + 0x50: "iTunes 11.x", + 0x60: "iTunes 12.x", + 0x70: "iTunes 12.5+", + 0x75: "iTunes 12.9+", +} + + +def get_version_name(version_hex: int | str) -> str: + """ + Get iTunes version name from database version number. + + Args: + version_hex: Version as int (0x19) or hex string ('0x19') + + Returns: + Human-readable version string + """ + if isinstance(version_hex, str): + # Remove '0x' prefix if present and convert + version_hex = int(version_hex, 16) if version_hex.startswith('0x') else int(version_hex) + + if version_hex in version_map: + return version_map[version_hex] + + # If not exact match, find closest lower version + lower_versions = [v for v in version_map if v <= version_hex] + if lower_versions: + closest = max(lower_versions) + return f"{version_map[closest]} (or newer)" + + return f"Unknown (version {hex(version_hex)})" + + +# maps the chunk header marker to a readable name +# The identifiers read backwards conceptually — the convention is: +# mhbd = DataBase Header Marker mhsd = DataSet Header Marker +# mhlt = Track List Header Marker mhit = Track Item Header Marker +# mhlp = Playlist List Header Marker mhla = Album List Header Marker +# mhyp = plaYlist Header Marker mhip = playlist Item Header Marker +# mhia = album Item Header Marker mhod = Data Object Header Marker +identifier_readable_map = { + "mhbd": "Database", + "mhsd": "Dataset", + "mhlt": "Track List", + "mhlp": "Playlist or Podcast List", + "mhla": "Album List", + "mhli": "Artist List", + "mhlp_smart": "Smart Playlist List", + "mhia": "Album Item", + "mhii": "Artist Item", + "mhit": "Track Item", + "mhyp": "Playlist", + "mhod": "Data Object", + "mhip": "Playlist Item", +} + +# maps the mhod type to its readable name +# +# Types 1-14: Track string MHODs (standard sub-header at offset 24) +# Types 15-16: Podcast URL MHODs (UTF-8 string at offset 24, NO sub-header) +# Type 17: Chapter data (big-endian atom-based binary blob) +# Types 18-31: Track string MHODs (standard sub-header) +# Type 32: Unknown binary data for video tracks (not a string!) +# Types 33-44: Track string MHODs (standard sub-header) +# Type 50: Smart playlist preferences (SPLPref binary) +# Type 51: Smart playlist rules (SLst — BIG-endian binary) +# Type 52: Library playlist sorted index (binary) +# Type 53: Library playlist jump table (binary) +# Type 100: Playlist column prefs (MHYP child) or position (MHIP child) +# Type 102: Playlist settings (post-iTunes 7, binary blob) +# Types 200-204: Album item string MHODs (standard sub-header) +mhod_type_map = { + 1: "Title", + 2: "Location", + 3: "Album", + 4: "Artist", + 5: "Genre", + 6: "Filetype", + 7: "eq_setting", + 8: "Comment", + 9: "Category", + 10: "Lyrics", + 12: "Composer", + 13: "Grouping", + 14: "Description Text", + 15: "Podcast Enclosure URL", + 16: "Podcast RSS URL", + 17: "Chapter Data", + 18: "Subtitle", + 19: "Show", + 20: "Episode", + 21: "TV Network", + 22: "Album Artist", + 23: "Sort Artist", + 24: "Track Keywords", + 25: "Show Locale", + 26: "iTunes Store Asset Info", + 27: "Sort Title", + 28: "Sort Album", + 29: "Sort Album Artist", + 30: "Sort Composer", + 31: "Sort Show", + 32: "Unknown for Video Track", + 33: "Unknown (33)", + 34: "Unknown (34)", + 35: "Unknown (35)", + 36: "Unknown (36)", + 37: "Content Provider", + 38: "Unknown (38)", + 39: "Copyright", + 40: "Unknown (40)", + 41: "Unknown (41)", + 42: "Encoding Quality Descriptor", + 43: "Purchase Account", + 44: "Purchaser Name", + 50: "Smart Playlist Data", + 51: "Smart Playlist Rules", + 52: "Library Playlist Index", + 53: "Library Playlist Jump Table", + 100: "Column Size or Playlist Order", + 102: "Playlist Settings (binary)", + 200: "Album (Used by Album Item)", + 201: "Artist (Used by Album Item)", + 202: "Sort Artist (Used by Album Item)", + 203: "Podcast URL (Used by Album Item)", + 204: "Show (Used by Album Item)", + # Types 300+: Artist item string MHODs (MHSD type 8) + 300: "Artist (Used by Artist Item)", +} + + +# ============================================================ +# Media Type bitmask values (MHIT offset 208 / 0xD0) +# From libgpod ItdbMediatype enum and iPodLinux wiki. +# ============================================================ +MEDIA_TYPE_MAP = { + 0x00000000: "Audio/Video", # shows in both audio and video menus + 0x00000001: "Audio", + 0x00000002: "Video", # Movie + 0x00000004: "Podcast", + 0x00000006: "Video Podcast", + 0x00000008: "Audiobook", + 0x00000020: "Music Video", + 0x00000040: "TV Show", + 0x00000060: "TV Show (alt)", + 0x00000100: "Ringtone", # libgpod ITDB_MEDIATYPE_RINGTONE (1 << 8) + 0x00000200: "Rental", # iTunes rental movie + 0x00004000: "Ringtone (alt)", # Alternate ringtone value (some firmware) + 0x00040000: "iTunes Pass", + 0x00060000: "Memo / Voice Memo", +} + + +# ============================================================ +# Playlist Sort Order (MHYP offset 44 / 0x2C) +# From iPodLinux wiki "List Sort Order" and libgpod ItdbPlaylistSortOrder. +# ============================================================ +PLAYLIST_SORT_ORDER_MAP = { + 0: "default (unset)", + 1: "playlist order (manual)", + # 2: unknown + 3: "title", + 4: "album", + 5: "artist", + 6: "bitrate", + 7: "genre", + 8: "kind", + 9: "date modified", + 10: "track number", + 11: "size", + 12: "time", + 13: "year", + 14: "sample rate", + 15: "comment", + 16: "date added", + 17: "equalizer", + 18: "composer", + # 19: unknown + 20: "play count", + 21: "last played", + 22: "disc number", + 23: "my rating", + 24: "release date", # used for Podcasts list + 25: "BPM", + 26: "grouping", + 27: "category", + 28: "description", + 29: "show", + 30: "season", + 31: "episode number", +} + + +# ============================================================ +# Explicit / content advisory flag values (MHIT offset 146 / 0x92) +# ============================================================ +EXPLICIT_FLAG_MAP = { + 0: "none", + 1: "explicit", + 2: "clean", +} + + +# ============================================================ +# MHOD Type Integer Constants +# Shared by MHOD parser/writer modules. +# ============================================================ +MHOD_TYPE_TITLE = 1 +MHOD_TYPE_LOCATION = 2 +MHOD_TYPE_ALBUM = 3 +MHOD_TYPE_ARTIST = 4 +MHOD_TYPE_GENRE = 5 +MHOD_TYPE_FILETYPE = 6 +MHOD_TYPE_EQ_SETTING = 7 +MHOD_TYPE_COMMENT = 8 +MHOD_TYPE_CATEGORY = 9 +MHOD_TYPE_LYRICS = 10 +MHOD_TYPE_COMPOSER = 12 +MHOD_TYPE_GROUPING = 13 +MHOD_TYPE_DESCRIPTION = 14 +MHOD_TYPE_PODCAST_ENCLOSURE_URL = 15 +MHOD_TYPE_PODCAST_RSS_URL = 16 +MHOD_TYPE_CHAPTER_DATA = 17 +MHOD_TYPE_SUBTITLE = 18 +MHOD_TYPE_SHOW_NAME = 19 +MHOD_TYPE_EPISODE_ID = 20 +MHOD_TYPE_NETWORK_NAME = 21 +MHOD_TYPE_ALBUM_ARTIST = 22 +MHOD_TYPE_SORT_ARTIST = 23 +MHOD_TYPE_KEYWORDS = 24 +MHOD_TYPE_SHOW_LOCALE = 25 +MHOD_TYPE_SORT_NAME = 27 +MHOD_TYPE_SORT_ALBUM = 28 +MHOD_TYPE_SORT_ALBUM_ARTIST = 29 +MHOD_TYPE_SORT_COMPOSER = 30 +MHOD_TYPE_SORT_SHOW = 31 +MHOD_TYPE_SMART_PLAYLIST_DATA = 50 +MHOD_TYPE_SMART_PLAYLIST_RULES = 51 +MHOD_TYPE_LIBRARY_PLAYLIST_INDEX = 52 +MHOD_TYPE_LIBRARY_PLAYLIST_JUMP_TABLE = 53 +MHOD_TYPE_COLUMN_SIZE_OR_ORDER = 100 +MHOD_TYPE_PLAYLIST_SETTINGS = 102 +# Album item string types +MHOD_TYPE_ALBUM_ALBUM = 200 +MHOD_TYPE_ALBUM_ARTIST_ITEM = 201 +MHOD_TYPE_ALBUM_SORT_ARTIST = 202 +MHOD_TYPE_ALBUM_PODCAST_URL = 203 +MHOD_TYPE_ALBUM_SHOW = 204 +# Artist item string type +MHOD_TYPE_ARTIST_NAME = 300 + + +# ============================================================ +# File Format Codes (big-endian ASCII stored as LE u32) +# Shared by track and locations writers. +# ============================================================ +FILETYPE_CODES: dict[str, int] = { + 'mp3': 0x4D503320, # "MP3 " + 'm4a': 0x4D344120, # "M4A " + 'm4p': 0x4D345020, # "M4P " + 'm4b': 0x4D344220, # "M4B " + 'm4v': 0x4D345620, # "M4V " + 'mp4': 0x4D503420, # "MP4 " + 'wav': 0x57415620, # "WAV " + 'aif': 0x41494646, # "AIFF" + 'aiff': 0x41494646, # "AIFF" + 'aac': 0x41414320, # "AAC " +} + + +# ============================================================ +# Media Type Integer Constants (from libgpod Itdb_Mediatype) +# Shared by track conversion and writer code. +# ============================================================ +MEDIA_TYPE_AUDIO = 0x01 +MEDIA_TYPE_VIDEO = 0x02 +MEDIA_TYPE_PODCAST = 0x04 +MEDIA_TYPE_VIDEO_PODCAST = 0x06 +MEDIA_TYPE_AUDIOBOOK = 0x08 +MEDIA_TYPE_MUSIC_VIDEO = 0x20 +MEDIA_TYPE_TV_SHOW = 0x40 +MEDIA_TYPE_RINGTONE = 0x4000 +MEDIA_TYPE_VIDEO_MASK = MEDIA_TYPE_VIDEO | MEDIA_TYPE_MUSIC_VIDEO | MEDIA_TYPE_TV_SHOW + + +# ============================================================ +# Audio Format Flag map (MHIT offset 0x7E) +# Maps filetype → codec hint value for the audio_format_flag field. +# 0xFFFF = default (MP3/AAC/ALAC), 0x0000 = lossless (WAV/AIFF), +# 0x0001 = Audible (M4B audiobooks). +# ============================================================ +AUDIO_FORMAT_FLAG_MAP: dict[str, int] = { + 'wav': 0x0000, + 'aif': 0x0000, + 'aiff': 0x0000, + 'm4b': 0x0001, +} +AUDIO_FORMAT_FLAG_DEFAULT: int = 0xFFFF diff --git a/src/vendor/iTunesDB_Shared/extraction.py b/src/vendor/iTunesDB_Shared/extraction.py new file mode 100644 index 0000000..7c72914 --- /dev/null +++ b/src/vendor/iTunesDB_Shared/extraction.py @@ -0,0 +1,120 @@ +"""Parser post-processing helpers for flattening parsed iTunesDB dicts. + +These functions walk the nested chunk structures produced by the iTunesDB +parser and extract them into flat, easy-to-consume dictionaries. They are +used by ``iTunesDB_Parser.ipod_library`` and ``SyncEngine.sync_executor``. +""" + +from .constants import chunk_type_map, mhod_type_map + + +def extract_datasets(mhbd: dict) -> dict: + """Walk the MHBD children and extract datasets into a flat dict. + + Returns a dict with: + - All MHBD header fields (excluding 'children') + - "mhlt", "mhlp", "mhlp_podcast", "mhla", "mhlp_smart", etc. + mapped from MHSD dataset_type via chunk_type_map + - Each value is the list of item dicts from the list chunk + """ + result = {} + for key, value in mhbd.items(): + if key != "children": + result[key] = value + + for mhsd_wrapper in mhbd.get("children", []): + mhsd_data = mhsd_wrapper.get("data", {}) + dataset_type = mhsd_data.get("dataset_type") + result_key = chunk_type_map.get(dataset_type) + if result_key is None: + continue + + mhsd_children = mhsd_data.get("children", []) + if not mhsd_children: + result[result_key] = [] + continue + + # The MHSD has one child: the list chunk (mhlt, mhlp, mhla, mhli) + list_chunk = mhsd_children[0] + items = list_chunk.get("data", []) + + # Extract items from their wrapper dicts + flat_items = [] + for item in items: + if isinstance(item, dict) and "data" in item: + flat_items.append(item["data"]) + else: + flat_items.append(item) + result[result_key] = flat_items + + return result + + +def extract_mhod_strings(children: list) -> dict: + """Extract MHOD string values from a chunk's children list. + + Args: + children: The 'children' list from a parsed track/album/artist/playlist. + + Returns: + dict mapping mhod_type_map field keys to string values, + e.g. {"Title": "My Song", "Artist": "Foo"} + """ + strings = {} + for wrapper in children: + mhod_data = wrapper.get("data", {}) + mhod_type = mhod_data.get("mhod_type") + if mhod_type is None: + continue + field_name = mhod_type_map.get(mhod_type) + if field_name and "string" in mhod_data: + strings[field_name] = mhod_data["string"] + return strings + + +def extract_track_extras(mhod_children: list) -> dict: + """Extract non-string MHOD data from track children. + + Returns dict with optional keys: + - "chapter_data": parsed MHOD type 17 data + """ + extras = {} + for wrapper in mhod_children: + mhod_data = wrapper.get("data", {}) + if mhod_data.get("mhod_type") != 17 or "data" not in mhod_data: + continue + + raw_chapter_data = mhod_data["data"] + if not isinstance(raw_chapter_data, dict): + continue + + extras["chapter_data"] = raw_chapter_data + + return extras + + +def extract_playlist_extras(mhod_children: list) -> dict: + """Extract non-string MHOD data from playlist children. + + Returns dict with optional keys: + - "smart_playlist_data": SPL prefs dict (from MHOD type 50) + - "smart_playlist_rules": SPL rules dict (from MHOD type 51) + - "library_indices": sorted index data (from MHOD type 52) + - "playlist_prefs": column prefs (from MHOD type 100) + - "playlist_settings": settings blob (from MHOD type 102) + """ + extras = {} + for wrapper in mhod_children: + mhod_data = wrapper.get("data", {}) + mhod_type = mhod_data.get("mhod_type") + if mhod_type == 50 and "data" in mhod_data: + extras["smart_playlist_data"] = mhod_data["data"] + elif mhod_type == 51 and "data" in mhod_data: + extras["smart_playlist_rules"] = mhod_data["data"] + elif mhod_type == 52 and "data" in mhod_data: + extras.setdefault("library_indices", []).append(mhod_data["data"]) + elif mhod_type == 100 and "data" in mhod_data: + extras["playlist_prefs"] = mhod_data["data"] + elif mhod_type == 102 and "data" in mhod_data: + extras["playlist_settings"] = mhod_data["data"] + return extras diff --git a/src/vendor/iTunesDB_Shared/field_base.py b/src/vendor/iTunesDB_Shared/field_base.py new file mode 100644 index 0000000..aecf51c --- /dev/null +++ b/src/vendor/iTunesDB_Shared/field_base.py @@ -0,0 +1,415 @@ +"""Shared infrastructure for bidirectional iTunesDB field definitions. + +This module provides the :class:`FieldDef` dataclass, factory helpers, +transform / validator functions, exception hierarchy, and the read/write +helpers that all per-chunk ``*_defs.py`` modules build on. + +Per-chunk field lists (``MHBD_FIELDS``, ``MHIT_FIELDS``, …) live in their +own ``*_defs.py`` modules. The :data:`FIELD_REGISTRY` is assembled at +import time by :mod:`iTunesDB_Shared.__init__` from those modules. +""" + +from __future__ import annotations + +import struct +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 1. Exception Hierarchy +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +class WriteError(Exception): + """Base exception for iTunesDB write-time errors.""" + + +class MissingRequiredFieldError(WriteError): + """A field marked ``required=True`` was absent from the values dict.""" + + def __init__(self, section_type: str, field_name: str) -> None: + super().__init__( + f"Required field '{field_name}' missing for section '{section_type}'" + ) + self.section_type = section_type + self.field_name = field_name + + +class InvalidFieldValueError(WriteError): + """A field validator rejected the value.""" + + def __init__(self, section_type: str, field_name: str, detail: str) -> None: + super().__init__( + f"Invalid value for '{field_name}' in section '{section_type}': {detail}" + ) + self.section_type = section_type + self.field_name = field_name + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 2. Transform & Validator Functions +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# Mac HFS+ epoch offset (seconds between 1904-01-01 and 1970-01-01). +MAC_EPOCH_OFFSET: int = 2082844800 + + +def mac_to_unix(mac_ts: int) -> int: + """Convert Mac HFS+ timestamp to Unix epoch.""" + return mac_ts - MAC_EPOCH_OFFSET if mac_ts > 0 else 0 + + +def unix_to_mac(unix_ts: int) -> int: + """Convert Unix epoch timestamp to Mac HFS+ timestamp.""" + return unix_ts + MAC_EPOCH_OFFSET if unix_ts > 0 else 0 + + +def sample_rate_to_fixed(hz: int) -> int: + """Encode sample rate as 16.16 fixed-point for MHIT offset 0x3C.""" + return (hz << 16) & 0xFFFFFFFF + + +def fixed_to_sample_rate(raw: int) -> int: + """Decode 16.16 fixed-point sample rate to integer Hz.""" + return raw >> 16 + + +def validate_rating(value: int) -> None: + """Raise if rating is outside 0-100.""" + if not (0 <= value <= 100): + raise ValueError(f"rating {value} outside 0-100") + + +def clamp_rating(value: int) -> int: + """Clamp rating to 0-100.""" + return max(0, min(100, value)) + + +def validate_volume(value: int) -> None: + """Raise if volume adjustment is outside -255..+255.""" + if not (-255 <= value <= 255): + raise ValueError(f"volume {value} outside -255..+255") + + +def filetype_to_string(val: int) -> str: + """Convert a u32 filetype code to its ASCII representation. + + e.g. 0x4D503320 → "MP3", 0x4D344120 → "M4A" + """ + if not isinstance(val, int) or val <= 0: + return "" + try: + return val.to_bytes(4, "big").decode("ascii").rstrip("\x00").strip() + except (OverflowError, UnicodeDecodeError): + return str(val) + + +def strip_article(name: str) -> str: + """Strip leading English articles (A, An, The) for sort field generation. + + iTunes auto-generates sort_title/sort_album/etc. by stripping common + English leading articles. Used by both the binary iTunesDB writer + (MHOD type 52 jump tables) and the SQLite writer (sort key columns). + """ + if not name: + return name + lower = name.lower() + for article in ('the ', 'a ', 'an '): + if lower.startswith(article): + return name[len(article):] + return name + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 3. FieldDef Dataclass +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +@dataclass(frozen=True, slots=True) +class FieldDef: + """Complete, bidirectional contract for one binary field. + + Attributes: + name: Canonical snake_case identifier shared by parser and writer. + offset: Byte offset within the section header (relative to chunk start). + size: Byte width of the field. + struct_format: :mod:`struct` format string (e.g. ``' FieldDef: + return FieldDef(name=name, offset=offset, size=4, struct_format=" FieldDef: + return FieldDef(name=name, offset=offset, size=4, struct_format=" FieldDef: + return FieldDef(name=name, offset=offset, size=2, struct_format=" FieldDef: + return FieldDef(name=name, offset=offset, size=8, struct_format=" FieldDef: + return FieldDef(name=name, offset=offset, size=1, struct_format="B", **kw) + + +def _f32(name: str, offset: int, **kw: Any) -> FieldDef: + return FieldDef(name=name, offset=offset, size=4, struct_format=" FieldDef: + kw.setdefault("default", b"\x00" * size) + return FieldDef(name=name, offset=offset, size=size, + struct_format=f"<{size}s", **kw) + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 5. FIELD_REGISTRY & lookup helpers +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# Populated by __init__.py after all *_defs modules have been imported. +FIELD_REGISTRY: dict[str, list[FieldDef]] = {} + + +def get_fields( + section_type: str, + header_length: int | None = None, +) -> list[FieldDef]: + """Return field definitions for *section_type*, optionally filtered. + + Args: + section_type: ASCII chunk tag (e.g. ``'mhit'``, ``'mhbd'``). + header_length: If provided, fields whose ``min_header_length`` + exceeds this value are excluded. + + Returns: + List of :class:`FieldDef` in offset order. + """ + fields = FIELD_REGISTRY.get(section_type, []) + if header_length is not None: + return [ + f for f in fields + if f.min_header_length is None or header_length >= f.min_header_length + ] + return list(fields) + + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 6. Read / Write Helpers +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +def read_field( + data: bytes | bytearray, + base_offset: int, + field: FieldDef, + header_length: int | None = None, +) -> Any: + """Read a single field from *data* at *base_offset*. + + Args: + data: Raw byte buffer. + base_offset: Start of the chunk in *data*. + field: Field definition. + header_length: Chunk header length (for version guard). + + Returns: + The unpacked (and optionally transformed) value, or + ``field.default`` if the header is too small. + """ + if field.min_header_length is not None: + if header_length is None or header_length < field.min_header_length: + return field.default + abs_offset = base_offset + field.offset + raw = struct.unpack_from(field.struct_format, data, abs_offset)[0] + if field.read_transform is not None: + return field.read_transform(raw) + return raw + + +def read_fields( + data: bytes | bytearray, + base_offset: int, + section_type: str, + header_length: int | None = None, +) -> dict[str, Any]: + """Read all fields for *section_type* into a dict. + + This is the read-side counterpart to :func:`write_fields`. + + Args: + data: Raw byte buffer. + base_offset: Start of the chunk in *data*. + section_type: Chunk tag (e.g. ``'mhit'``). + header_length: Actual header length from the chunk. Extended + fields not covered by this length are set to their defaults. + + Returns: + Dict mapping field name → value. + """ + result: dict[str, Any] = {} + for field in FIELD_REGISTRY.get(section_type, []): + result[field.name] = read_field(data, base_offset, field, header_length) + return result + + +def write_field( + buffer: bytearray, + base_offset: int, + field: FieldDef, + value: Any, + section_type: str = "", +) -> None: + """Pack a single field value into *buffer*. + + Applies ``write_transform`` then ``validator`` before packing. + + Args: + buffer: Mutable byte buffer to write into. + base_offset: Start of the chunk header in *buffer*. + field: Field definition. + value: The logical value to write. + section_type: For error messages only. + """ + if field.write_transform is not None: + value = field.write_transform(value) + if field.validator is not None: + try: + field.validator(value) + except (ValueError, TypeError) as exc: + raise InvalidFieldValueError( + section_type or field.section_type, field.name, str(exc), + ) from exc + # Coerce to the type required by the format code so that floats or other + # numeric types from metadata sources never reach struct.pack_into as the + # wrong type (e.g. float for an integer field, or int for a float field). + format_code = field.struct_format[-1] + if format_code in 'IiHhQqBbNnP': + if not isinstance(value, int): + value = int(value) + # Clamp to the valid range for the format so bad metadata (e.g. negative + # BPM, oversized play counts) never crashes the packer. + int_ranges = { + 'B': (0, 0xFF), + 'H': (0, 0xFFFF), + 'I': (0, 0xFFFF_FFFF), + 'Q': (0, 0xFFFF_FFFF_FFFF_FFFF), + 'b': (-0x80, 0x7F), + 'h': (-0x8000, 0x7FFF), + 'i': (-0x8000_0000, 0x7FFF_FFFF), + 'q': (-0x8000_0000_0000_0000, 0x7FFF_FFFF_FFFF_FFFF), + } + if format_code in int_ranges: + lower, upper = int_ranges[format_code] + value = max(lower, min(upper, value)) + elif format_code in 'fd': + if not isinstance(value, float): + value = float(value) + try: + struct.pack_into(field.struct_format, buffer, base_offset + field.offset, value) + except struct.error as exc: + raise struct.error( + f"Failed to pack field '{field.name}' (format={field.struct_format!r}, " + f"value={value!r} type={type(value).__name__}): {exc}" + ) from exc + + +def write_fields( + buffer: bytearray, + base_offset: int, + section_type: str, + values: dict[str, Any], + header_length: int, +) -> None: + """Write all applicable fields from *values* into *buffer*. + + This is the single serialization entrypoint used by the writer. + Fields are written in offset order. Fields whose + ``min_header_length`` exceeds *header_length* are skipped. + + Args: + buffer: Pre-allocated mutable buffer (must be ≥ header_length). + base_offset: Chunk start position in *buffer*. + section_type: Chunk tag (e.g. ``'mhit'``). + values: Field name → logical value mapping. + header_length: Target header size. Fields beyond this are skipped. + + Raises: + MissingRequiredFieldError: A ``required`` field is missing from *values*. + InvalidFieldValueError: A validator rejected a value. + """ + for field in FIELD_REGISTRY.get(section_type, []): + # Skip fields outside the target header. + if field.min_header_length is not None and header_length < field.min_header_length: + continue + + if field.name in values: + value = values[field.name] + elif field.required: + raise MissingRequiredFieldError(section_type, field.name) + else: + value = field.default + + write_field(buffer, base_offset, field, value, section_type) + + +def write_generic_header( + buffer: bytearray, + offset: int, + tag: bytes, + header_length: int, + total_length_or_count: int, +) -> None: + """Write the 12-byte generic iTunesDB chunk header. + + Args: + buffer: Target buffer. + offset: Position in buffer. + tag: 4-byte ASCII tag (e.g. ``b'mhit'``). + header_length: Header size to write at +0x04. + total_length_or_count: Value for +0x08 (total_length for item + chunks, child_count for list chunks). + """ + buffer[offset:offset + 4] = tag + struct.pack_into("= 0x34) + _u64("track_persistent_id", 0x2C, section_type=_S, min_header_length=0x34), + # Extended (header_length >= 0x44) — per-track persistent ID, absent in older iTunes + _u64("mhip_persistent_id", 0x3C, section_type=_S, min_header_length=0x44), +] diff --git a/src/vendor/iTunesDB_Shared/mhit_defs.py b/src/vendor/iTunesDB_Shared/mhit_defs.py new file mode 100644 index 0000000..0379f22 --- /dev/null +++ b/src/vendor/iTunesDB_Shared/mhit_defs.py @@ -0,0 +1,141 @@ +"""MHIT (Track Item) field definitions. + +Declarative :class:`FieldDef` list for the MHIT chunk — a single track +record. Both the parser and writer derive their behaviour from these +definitions. +""" + +from .field_base import ( + FieldDef, + _u32, _i32, _u16, _u64, _u8, _f32, _raw, + clamp_rating, validate_rating, validate_volume, + mac_to_unix, unix_to_mac, + fixed_to_sample_rate, sample_rate_to_fixed, +) + +_S = "mhit" + +# Writer header size (iTunes 8+ default). +MHIT_HEADER_SIZE: int = 0x270 # 624 bytes + + +def mhit_header_size_for_version(db_version: int) -> int: + """Return the MHIT header size appropriate for *db_version*. + + Older iPod firmware uses smaller MHIT headers. The writer must pad + to the correct boundary so the firmware can locate child MHODs. + """ + if db_version <= 0x12: + return 0x9C # 156 — pre-iTunes 7 minimum + if db_version <= 0x19: + return 0x148 # 328 — iTunes 7.x + if db_version <= 0x2D: + return 0x1F8 # 504 — iTunes 9.x + return 0x270 # 624 — iTunes 10+ / modern + + +MHIT_FIELDS: list[FieldDef] = [ + # ── Core fields (always present, minimum header ≥ 0x9C) ────── + _u32("child_count", 0x0C, section_type=_S), + _u32("track_id", 0x10, section_type=_S, required=True), + _u32("visible", 0x14, section_type=_S, default=1), + _u32("filetype", 0x18, section_type=_S), + _u8("vbr_flag", 0x1C, section_type=_S), + _u8("mp3_flag", 0x1D, section_type=_S), + _u8("compilation_flag", 0x1E, section_type=_S), + _u8("rating", 0x1F, section_type=_S, + write_transform=clamp_rating, validator=validate_rating), + _u32("last_modified", 0x20, section_type=_S, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u32("size", 0x24, section_type=_S), + _u32("length", 0x28, section_type=_S), + _u32("track_number", 0x2C, section_type=_S), + _u32("total_tracks", 0x30, section_type=_S), + _u32("year", 0x34, section_type=_S), + _u32("bitrate", 0x38, section_type=_S), + _u32("sample_rate_1", 0x3C, section_type=_S, + read_transform=fixed_to_sample_rate, + write_transform=sample_rate_to_fixed), + _i32("volume", 0x40, section_type=_S, validator=validate_volume), + _u32("start_time", 0x44, section_type=_S), + _u32("stop_time", 0x48, section_type=_S), + _u32("sound_check", 0x4C, section_type=_S), + _u32("play_count_1", 0x50, section_type=_S), + _u32("play_count_2", 0x54, section_type=_S), + _u32("last_played", 0x58, section_type=_S, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u32("disc_number", 0x5C, section_type=_S), + _u32("total_discs", 0x60, section_type=_S), + _u32("user_id", 0x64, section_type=_S), + _u32("date_added", 0x68, section_type=_S, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u32("bookmark_time", 0x6C, section_type=_S), + _u64("db_track_id", 0x70, section_type=_S, required=True), + _u8("checked_flag", 0x78, section_type=_S), + _u8("app_rating", 0x79, section_type=_S), + _u16("bpm", 0x7A, section_type=_S), + _u16("artwork_count", 0x7C, section_type=_S), + _u16("audio_format_flag", 0x7E, section_type=_S, default=0xFFFF), + _u32("artwork_size", 0x80, section_type=_S), + _u32("unk0x84", 0x84, section_type=_S), + _f32("sample_rate_2", 0x88, section_type=_S), + _u32("date_released", 0x8C, section_type=_S, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u16("mpeg_audio_type", 0x90, section_type=_S), + _u8("explicit_flag", 0x92, section_type=_S), + _u8("purchased_aac_flag", 0x93, section_type=_S), + _u32("unk0x94", 0x94, section_type=_S), + _u32("genius_category_id", 0x98, section_type=_S), + + # ── Extended fields (guarded by header_length) ─────────────── + _u32("skip_count", 0x9C, section_type=_S, min_header_length=0xA0), + _u32("last_skipped", 0xA0, section_type=_S, min_header_length=0xA4, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u8("has_artwork", 0xA4, section_type=_S, min_header_length=0xA5), + _u8("skip_when_shuffling", 0xA5, section_type=_S, min_header_length=0xA6), + _u8("remember_position", 0xA6, section_type=_S, min_header_length=0xA7), + _u8("use_podcast_now_playing_flag", 0xA7, section_type=_S, min_header_length=0xA8), + _u64("db_track_id_2", 0xA8, section_type=_S, min_header_length=0xB0), + _u8("lyrics_flag", 0xB0, section_type=_S, min_header_length=0xB1), + _u8("movie_flag", 0xB1, section_type=_S, min_header_length=0xB2), + _u8("not_played_flag", 0xB2, section_type=_S, min_header_length=0xB3), + _u8("unk0xB3", 0xB3, section_type=_S, min_header_length=0xB4), + _u32("unk0xB4", 0xB4, section_type=_S, min_header_length=0xB8), + _u32("pregap", 0xB8, section_type=_S, min_header_length=0xBC), + _u64("sample_count", 0xBC, section_type=_S, min_header_length=0xC4), + _u32("unk0xC4", 0xC4, section_type=_S, min_header_length=0xC8), + _u32("postgap", 0xC8, section_type=_S, min_header_length=0xCC), + _u32("encoder", 0xCC, section_type=_S, min_header_length=0xD0), + _u32("media_type", 0xD0, section_type=_S, min_header_length=0xD4, + default=1), + _u32("season_number", 0xD4, section_type=_S, min_header_length=0xD8), + _u32("episode_number", 0xD8, section_type=_S, min_header_length=0xDC), + _u32("date_added_to_itunes", 0xDC, section_type=_S, min_header_length=0xE0, + read_transform=mac_to_unix, write_transform=unix_to_mac), + _u32("store_track_id", 0xE0, section_type=_S, min_header_length=0xE4), + _u32("store_encoder_version", 0xE4, section_type=_S, min_header_length=0xE8), + _u32("store_artist_id", 0xE8, section_type=_S, min_header_length=0xEC), + _u32("unk0xEC", 0xEC, section_type=_S, min_header_length=0xF0), + _u32("store_album_id", 0xF0, section_type=_S, min_header_length=0xF4), + _u32("store_content_flag", 0xF4, section_type=_S, min_header_length=0xF8), + _u32("gapless_audio_payload_size", 0xF8, section_type=_S, min_header_length=0xFC), + _u32("unk0xFC", 0xFC, section_type=_S, min_header_length=0x100), + _u16("gapless_track_flag", 0x100, section_type=_S, min_header_length=0x102), + _u16("gapless_album_flag", 0x102, section_type=_S, min_header_length=0x104), + _raw("hash_0x104", 0x104, 20, section_type=_S, min_header_length=0x118), + _u32("unk0x118", 0x118, section_type=_S, min_header_length=0x11C), + _u32("unk0x11C", 0x11C, section_type=_S, min_header_length=0x120), + _u32("album_id", 0x120, section_type=_S, min_header_length=0x124), + _u64("db_id_2_ref", 0x124, section_type=_S, min_header_length=0x12C), + _u32("size_2", 0x12C, section_type=_S, min_header_length=0x130), + _u32("unk0x130", 0x130, section_type=_S, min_header_length=0x134), + _raw("sort_mhod_indicators", 0x134, 8, section_type=_S, min_header_length=0x13C), + # Gap: 0x13C..0x15F (zero padding / unknown fields) + _u32("artwork_id_ref", 0x160, section_type=_S, min_header_length=0x164), + # 0x168: unknown, libgpod always writes 1 + _u32("unk0x168", 0x168, section_type=_S, min_header_length=0x16C, default=1), + # Gap: 0x16C..0x1DF (zero padding / unknown fields) + _u32("artist_id_ref", 0x1E0, section_type=_S, min_header_length=0x1E4), + # Gap: 0x1E4..0x1F3 (zero padding / unknown fields) + _u32("composer_id", 0x1F4, section_type=_S, min_header_length=0x1F8), +] diff --git a/src/vendor/iTunesDB_Shared/mhod_defs.py b/src/vendor/iTunesDB_Shared/mhod_defs.py new file mode 100644 index 0000000..6dfbfcb --- /dev/null +++ b/src/vendor/iTunesDB_Shared/mhod_defs.py @@ -0,0 +1,586 @@ +"""MHOD (Data Object) field definitions and helpers. + +Contains the :class:`FieldDef` list for the 24-byte common MHOD header, +plus type classification sets, string sub-header accessors, and +SPL / SLst / MHOD-52 / MHOD-53 / MHOD-100 / MHOD-102 parsing helpers. + +The body-level layouts vary widely by MHOD type and do NOT fit the +simple ``FieldDef`` pattern, so they remain as hand-written helpers here. +""" + +import struct + +from .field_base import FieldDef, _u32 + +_S = "mhod" + +MHOD_HEADER_SIZE: int = 24 # common header (body varies by type) + +# ── String MHOD sub-header layout ────────────────────────── +MHOD_STRING_SUBHEADER_OFFSET = 0x18 # sub-header start (relative to chunk start) +MHOD_STRING_SUBHEADER_SIZE = 16 # encoding(4) + length(4) + unk0x20(4) + unk0x24(4) +MHOD_STRING_DATA_OFFSET = 0x28 # string data start (header + sub-header) + +# ── SPLPref body (MHOD type 50) ─────────────────────────── +SPLPREF_BODY_SIZE = 132 + +# ── SLst rule data (MHOD type 51) non-string body size ──── +SPL_RULE_DATA_SIZE = 0x44 # 68 bytes + +# ── MHOD type 52/53 body layout sizes ───────────────────── +MHOD52_BODY_HEADER_SIZE = 48 # sort_type(4) + count(4) + padding(40) +MHOD53_BODY_HEADER_SIZE = 16 # sort_type(4) + count(4) + padding(8) +MHOD53_ENTRY_SIZE = 12 # letter(2) + pad(2) + start(4) + count(4) + +# ── MHOD type 100 body layout ───────────────────────────── +MHOD100_POSITION_BODY_SIZE = 20 # position(4) + padding(16) + +# ── MHOD 52/53 sort type constants (from libgpod MHOD52_SORTTYPE) ── +SORT_TITLE = 0x03 +SORT_ALBUM = 0x04 +SORT_ARTIST = 0x05 +SORT_GENRE = 0x07 +SORT_COMPOSER = 0x12 +SORT_SHOW = 0x1D +SORT_SEASON = 0x1E +SORT_EPISODE = 0x1F +SORT_ALBUM_ARTIST = 0x23 + +# ── Chapter Data atom constants (MHOD type 17, all big-endian) ── +# The chapter data body starts with 12 bytes of unknown data +# (unk024, unk028, unk032 per libgpod), followed by a "sean" atom tree. +CHAPTER_PREAMBLE_SIZE = 12 # 3 × u32 LE before the atom tree +SEAN_ATOM = b'sean' +CHAP_ATOM = b'chap' +NAME_ATOM = b'name' +HEDR_ATOM = b'hedr' +HEDR_SIZE = 28 # hedr atom is always 28 bytes + +MHOD_FIELDS: list[FieldDef] = [ + _u32("mhod_type", 0x0C, section_type=_S, required=True), + _u32("unk0x10", 0x10, section_type=_S), + _u32("unk0x14", 0x14, section_type=_S), +] + + +# ============================================================ +# Type Classification Sets +# ============================================================ + +# String MHOD types that use the standard sub-header at offset 0x18. +# Types 1-14, 18-31, 33-44 are track/item string metadata. +# Types 200-204 are album item strings. +# +# EXCLUDED from this set (handled separately): +# 15-16: Podcast URLs — UTF-8 string with NO sub-header +# 17: Chapter data — big-endian atom blob +# 32: Video track data — binary, not a string +STRING_MHOD_TYPES = ( + set(range(1, 15)) # 1..14 + | set(range(18, 32)) # 18..31 + | set(range(33, 45)) # 33..44 + | set(range(200, 205)) # 200..204 + | {300} # artist item name (MHSD type 8) +) + +# Podcast URL types — UTF-8/ASCII string directly at body start, no sub-header. +PODCAST_URL_MHOD_TYPES = {15, 16} + +# Chapter data MHOD type — big-endian atom tree (sean/chap/name/hedr). +CHAPTER_DATA_MHOD_TYPES = {17} + +# Binary / opaque MHOD types — stored as raw hex for round-tripping. +BINARY_BLOB_MHOD_TYPES = {32} + +# Non-string MHOD types with dedicated binary formats. +NON_STRING_MHOD_TYPES = {50, 51, 52, 53, 100, 102} + + +# ============================================================ +# Common MHOD Header (24 bytes — always 0x18) +# ============================================================ +# The 3 common header fields (type, unk0x10, unk0x14) are defined in +# MHOD_FIELDS above and read via read_fields(). The per-type body +# helpers below still use struct directly since they don't fit FieldDef. + + +def write_mhod_header(mhod_type: int, total_length: int, + unk0x10: int = 0, unk0x14: int = 0) -> bytes: + """Build the 24-byte MHOD common header. + + This is the shared pattern used by every MHOD writer — string, + SPL, index, position, etc. + + Args: + mhod_type: MHOD type ID (e.g. 1, 50, 51, 52, 53, 100, 102). + total_length: Total length of the complete MHOD chunk + (header + body). + unk0x10: Unknown field at offset 0x10 (preserved from parser). + unk0x14: Unknown field at offset 0x14 (preserved from parser). + + Returns: + 24-byte packed header. + """ + return struct.pack( + '<4sIIIII', + b'mhod', + MHOD_HEADER_SIZE, + total_length, + mhod_type, + unk0x10, + unk0x14, + ) + + +# ============================================================ +# String MHOD Sub-Header (starts at 0x18, 16 bytes) +# ============================================================ +# Present on STRING_MHOD_TYPES only. NOT present on podcast URLs (15/16). +# All functions take offset = start of the MHOD chunk. +# +# +0x18: encoding (4 bytes) — 1=UTF-16LE, 2=UTF-8 +# +0x1C: string_length (4 bytes) — byte count of string data +# +0x20: unk0x20 (4 bytes) +# +0x24: unk0x24 (4 bytes) +# +0x28: string data begins (string_length bytes) + +def mhod_string_encoding(data, offset) -> int: + """Position/encoding indicator at 0x18. + 1 (or 0) = UTF-16LE (standard iPod, little-endian strings). + 2 = UTF-8 (mobile-phone iTunesDBs, inversed endian). + libgpod checks this same field to decide encoding.""" + return struct.unpack(" int: + """Byte length of string data at 0x1C.""" + return struct.unpack(" int: + return struct.unpack(" int: + return struct.unpack(" int: + return data[body_offset] + + +def mhod_spl_check_rules(data, body_offset) -> int: + return data[body_offset + 1] + + +def mhod_spl_check_limits(data, body_offset) -> int: + return data[body_offset + 2] + + +def mhod_spl_limit_type(data, body_offset) -> int: + return data[body_offset + 3] + + +def mhod_spl_limit_sort_raw(data, body_offset) -> int: + """Raw limit sort byte at +0x04 (before reverse flag is applied).""" + return data[body_offset + 4] + + +def mhod_spl_limit_value(data, body_offset) -> int: + return struct.unpack(" int: + return data[body_offset + 12] + + +def mhod_spl_reverse_sort(data, body_offset) -> int: + """Reverse flag at +0x0D. If set, limitsort |= 0x80000000.""" + return data[body_offset + 13] + + +# Limit type names (from libgpod ItdbLimitType) +SPL_LIMIT_TYPE_MAP = { + 0x01: "minutes", + 0x02: "MB", + 0x03: "songs", + 0x04: "hours", + 0x05: "GB", +} + +# Limit sort names (from libgpod ItdbLimitSort). +# The 0x80000000 bit is the "reverse" flag, stored separately at SPLPref +13. +SPL_LIMIT_SORT_MAP = { + 0x02: "random", + 0x03: "song_name", + 0x04: "album", + 0x05: "artist", + 0x07: "genre", + 0x10: "most_recently_added", + 0x80000010: "least_recently_added", + 0x14: "most_often_played", + 0x80000014: "least_often_played", + 0x15: "most_recently_played", + 0x80000015: "least_recently_played", + 0x17: "highest_rating", + 0x80000017: "lowest_rating", +} + + +# ============================================================ +# SLst — Smart Playlist Rules (MHOD type 51) +# ============================================================ +# CRITICAL: The SLst blob is the ONLY part of the iTunesDB that uses +# big-endian encoding. All multi-byte integers within SLst use big-endian. +# +# SLst header (136 bytes): +# +0x00: 'SLst' magic (4 bytes) +# +0x04: unk004 (4 bytes BE) — usually 0 +# +0x08: rule_count (4 bytes BE) +# +0x0C: conjunction (4 bytes BE) — 0=AND, 1=OR +# +0x10: padding (120 bytes) +# +# All SLst header functions take body_offset = start of SLst data. + +SLST_HEADER_SIZE = 136 + + +def mhod_slst_magic(data, body_offset) -> bytes: + return data[body_offset:body_offset + 4] + + +def mhod_slst_unk004(data, body_offset) -> int: + return struct.unpack(">I", data[body_offset + 4:body_offset + 8])[0] + + +def mhod_slst_rule_count(data, body_offset) -> int: + return struct.unpack(">I", data[body_offset + 8:body_offset + 12])[0] + + +def mhod_slst_conjunction(data, body_offset) -> int: + """0=AND (match all), 1=OR (match any).""" + return struct.unpack(">I", data[body_offset + 12:body_offset + 16])[0] + + +# SPL Rule header fields. +# Each rule starts at a variable offset within the SLst body. +# Functions take rule_offset = start of the individual rule. +# +# Rule layout: +# +0x00: field (4 bytes BE) — what field to match (see SPL_FIELD_MAP) +# +0x04: action (4 bytes BE) — comparison operator (see SPL_ACTION_MAP) +# +0x08: padding (44 bytes) +# +0x34: data_length (4 bytes BE) — byte length of following data +# +0x38: data (data_length bytes) +# +# Total rule size = 56 + data_length. + +SPL_RULE_HEADER_SIZE = 56 + + +def mhod_spl_rule_field(data, rule_offset) -> int: + return struct.unpack(">I", data[rule_offset:rule_offset + 4])[0] + + +def mhod_spl_rule_action(data, rule_offset) -> int: + return struct.unpack(">I", data[rule_offset + 4:rule_offset + 8])[0] + + +def mhod_spl_rule_data_length(data, rule_offset) -> int: + return struct.unpack(">I", data[rule_offset + 0x34:rule_offset + 0x38])[0] + + +# SPL Rule non-string data fields (0x44 = 68 bytes). +# Functions take data_offset = rule_offset + 0x38. +# +# +0x00: fromValue (8 bytes BE, guint64) +# +0x08: fromDate (8 bytes BE, gint64 — signed) +# +0x10: fromUnits (8 bytes BE, guint64) +# +0x18: toValue (8 bytes BE, guint64) +# +0x20: toDate (8 bytes BE, gint64 — signed) +# +0x28: toUnits (8 bytes BE, guint64) +# +0x30: unk052 (4 bytes BE) +# +0x34: unk056 (4 bytes BE) +# +0x38: unk060 (4 bytes BE) +# +0x3C: unk064 (4 bytes BE) +# +0x40: unk068 (4 bytes BE) + +def mhod_spl_rule_from_value(data, data_offset) -> int: + return struct.unpack(">Q", data[data_offset:data_offset + 8])[0] + + +def mhod_spl_rule_from_date(data, data_offset) -> int: + """Signed 64-bit big-endian.""" + return struct.unpack(">q", data[data_offset + 8:data_offset + 16])[0] + + +def mhod_spl_rule_from_units(data, data_offset) -> int: + return struct.unpack(">Q", data[data_offset + 16:data_offset + 24])[0] + + +def mhod_spl_rule_to_value(data, data_offset) -> int: + return struct.unpack(">Q", data[data_offset + 24:data_offset + 32])[0] + + +def mhod_spl_rule_to_date(data, data_offset) -> int: + """Signed 64-bit big-endian.""" + return struct.unpack(">q", data[data_offset + 32:data_offset + 40])[0] + + +def mhod_spl_rule_to_units(data, data_offset) -> int: + return struct.unpack(">Q", data[data_offset + 40:data_offset + 48])[0] + + +def mhod_spl_rule_unk052(data, data_offset) -> int: + return struct.unpack(">I", data[data_offset + 48:data_offset + 52])[0] + + +def mhod_spl_rule_unk056(data, data_offset) -> int: + return struct.unpack(">I", data[data_offset + 52:data_offset + 56])[0] + + +def mhod_spl_rule_unk060(data, data_offset) -> int: + return struct.unpack(">I", data[data_offset + 56:data_offset + 60])[0] + + +def mhod_spl_rule_unk064(data, data_offset) -> int: + return struct.unpack(">I", data[data_offset + 60:data_offset + 64])[0] + + +def mhod_spl_rule_unk068(data, data_offset) -> int: + return struct.unpack(">I", data[data_offset + 64:data_offset + 68])[0] + + +# Field ID → human-readable name (from libgpod ItdbSPLField enum in itdb.h) +SPL_FIELD_MAP = { + 0x02: "Song Name", + 0x03: "Album", + 0x04: "Artist", + 0x05: "Bitrate", + 0x06: "Sample Rate", + 0x07: "Year", + 0x08: "Genre", + 0x09: "Kind", + 0x0A: "Date Modified", + 0x0B: "Track Number", + 0x0C: "Size", + 0x0D: "Time", + 0x0E: "Comment", + 0x10: "Date Added", + 0x12: "Composer", + 0x16: "Play Count", + 0x17: "Last Played", + 0x18: "Disc Number", + 0x19: "Rating", + 0x1F: "Compilation", + 0x23: "BPM", + 0x27: "Grouping", + 0x28: "Playlist", + 0x29: "Purchased", + 0x36: "Description", + 0x37: "Category", + 0x39: "Podcast", + 0x3C: "Media Type", + 0x3E: "TV Show", + 0x3F: "Season Number", + 0x44: "Skip Count", + 0x45: "Last Skipped", + 0x47: "Album Artist", + 0x4E: "Sort Song Name", + 0x4F: "Sort Album", + 0x50: "Sort Artist", + 0x51: "Sort Album Artist", + 0x52: "Sort Composer", + 0x53: "Sort TV Show", + 0x5A: "Album Rating", +} + +# Action ID → human-readable name (from libgpod ItdbSPLAction enum in itdb.h; +# confirmed against iPodLinux wiki). +# Actions are 32-bit bitmapped values, NOT small sequential integers. +# Byte layout: +# Bits 24-25: 0x00=int/date, 0x01=string, 0x02=negated int, 0x03=negated string +# Bits 0-10: comparison operator flags +SPL_ACTION_MAP = { + # Integer / date comparisons (0x00xxxxxx) + 0x00000001: "is", + 0x00000010: "is greater than", + 0x00000020: "is greater than or equal to", # not in iTunes UI + 0x00000040: "is less than", + 0x00000080: "is less than or equal to", # not in iTunes UI + 0x00000100: "is in the range", + 0x00000200: "is in the last", + 0x00000400: "binary AND", # used for Media Type / Video Kind + 0x00000800: "binary unknown1", + # String comparisons (0x01xxxxxx) + 0x01000001: "is (string)", + 0x01000002: "contains", + 0x01000004: "starts with", + 0x01000008: "ends with", + # Negated integer / date (0x02xxxxxx) + 0x02000001: "is not", + 0x02000010: "is not greater than", # not in iTunes UI + 0x02000020: "is not greater than or equal to", # not in iTunes UI + 0x02000040: "is not less than", # not in iTunes UI + 0x02000080: "is not less than or equal to", # not in iTunes UI + 0x02000100: "is not in the range", # not in iTunes UI + 0x02000200: "is not in the last", + 0x02000400: "not binary AND", + 0x02000800: "binary unknown2", + # Negated string (0x03xxxxxx) + 0x03000001: "is not (string)", + 0x03000002: "does not contain", + 0x03000004: "does not start with", # not in iTunes UI + 0x03000008: "does not end with", # not in iTunes UI +} + +# Field type enum (from libgpod ItdbSPLFieldType — values start at 1) +SPLFT_STRING = 1 +SPLFT_INT = 2 +SPLFT_BOOLEAN = 3 +SPLFT_DATE = 4 +SPLFT_PLAYLIST = 5 +SPLFT_UNKNOWN = 6 +SPLFT_BINARY_AND = 7 + +# Map field ID → field type (equivalent to libgpod's itdb_splr_get_field_type). +# This is how libgpod determines how to parse the rule data — NOT from a binary field. +SPL_FIELD_TYPE_MAP = { + # String fields + 0x02: SPLFT_STRING, # Song Name + 0x03: SPLFT_STRING, # Album + 0x04: SPLFT_STRING, # Artist + 0x08: SPLFT_STRING, # Genre + 0x09: SPLFT_STRING, # Kind + 0x0E: SPLFT_STRING, # Comment + 0x12: SPLFT_STRING, # Composer + 0x27: SPLFT_STRING, # Grouping + 0x36: SPLFT_STRING, # Description + 0x37: SPLFT_STRING, # Category + 0x3E: SPLFT_STRING, # TV Show + 0x47: SPLFT_STRING, # Album Artist + 0x4E: SPLFT_STRING, # Sort Song Name + 0x4F: SPLFT_STRING, # Sort Album + 0x50: SPLFT_STRING, # Sort Artist + 0x51: SPLFT_STRING, # Sort Album Artist + 0x52: SPLFT_STRING, # Sort Composer + 0x53: SPLFT_STRING, # Sort TV Show + # Integer fields + 0x05: SPLFT_INT, # Bitrate + 0x06: SPLFT_INT, # Sample Rate + 0x07: SPLFT_INT, # Year + 0x0B: SPLFT_INT, # Track Number + 0x0C: SPLFT_INT, # Size + 0x0D: SPLFT_INT, # Time + 0x16: SPLFT_INT, # Play Count + 0x18: SPLFT_INT, # Disc Number + 0x19: SPLFT_INT, # Rating + 0x23: SPLFT_INT, # BPM + 0x3F: SPLFT_INT, # Season Number + 0x44: SPLFT_INT, # Skip Count + 0x5A: SPLFT_INT, # Album Rating + # Date fields + 0x0A: SPLFT_DATE, # Date Modified + 0x10: SPLFT_DATE, # Date Added + 0x17: SPLFT_DATE, # Last Played + 0x45: SPLFT_DATE, # Last Skipped + # Boolean fields + 0x1F: SPLFT_BOOLEAN, # Compilation + 0x29: SPLFT_BOOLEAN, # Purchased + 0x39: SPLFT_INT, # Podcast + # Playlist field + 0x28: SPLFT_PLAYLIST, # Playlist + # Binary AND + 0x3C: SPLFT_BINARY_AND, # Video Kind +} + +# Date units for relative date rules +SPL_DATE_UNITS_MAP = { + 1: "seconds", + 60: "minutes", + 3600: "hours", + 86400: "days", + 604800: "weeks", + 2628000: "months", # ~30.4 days +} + + +def spl_get_field_type(field_id: int) -> int: + """Determine SPL field type from field ID (equivalent to libgpod's itdb_splr_get_field_type).""" + return SPL_FIELD_TYPE_MAP.get(field_id, SPLFT_UNKNOWN) + + +# ============================================================ +# MHOD Type 52/53 — Library Playlist Index / Jump Table +# ============================================================ +# Both types share header structure (sort_type + count). +# Functions take body_offset = start of body data (MHOD chunk + header_length). +# +# Type 52 layout: +# +0x00: sort_type (4 bytes LE) — 3=title, 4=album, 5=artist, 7=genre, 18=composer +# +0x04: count (4 bytes LE) — number of index entries +# +0x08: padding (40 bytes) +# +0x30: indices (count × 4 bytes LE) — sorted track positions +# +# Type 53 layout: +# +0x00: sort_type (4 bytes LE) — must match corresponding type 52 +# +0x04: count (4 bytes LE) — number of jump entries +# +0x08: padding (8 bytes) +# +0x10: entries (count × 12 bytes): +# letter (2 bytes UTF-16 LE) + pad (2 bytes) + start (4 bytes) + count (4 bytes) + +SORT_TYPE_MAP = { + 0x03: "title", + 0x04: "album", # then disc/track number, then title + 0x05: "artist", # then album, then disc/track number, then title + 0x07: "genre", # then artist, then album, then disc/track number, then title + 0x12: "composer", # then title + 0x1D: "show", # iTunes 7.2+; secondary sort TBD + 0x1E: "season_number", # iTunes 7.2+; secondary sort TBD + 0x1F: "episode_number", # iTunes 7.2+; secondary sort TBD + 0x23: "album_artist", # then artist (ignoring sort-artist), then album, disc/track, title + 0x24: "artist_nosort", # artist (ignoring sort-artist), then album, disc/track, title +} + + +def mhod52_sort_type(data, body_offset) -> int: + return struct.unpack(" int: + return struct.unpack(" int: + return struct.unpack(" int: + return struct.unpack(" int: + return struct.unpack(" bool: + """ + Write appropriate checksum to iTunesDB based on device type. + + Args: + itdb_data: Mutable bytearray of complete iTunesDB file + ipod_path: Mount point of iPod + + Returns: + True if checksum was written successfully + + Raises: + ValueError: For unsupported devices + """ + checksum_type = detect_checksum_type(ipod_path) + + if checksum_type == ChecksumType.NONE: + # No hash needed + return True + + elif checksum_type == ChecksumType.HASH58: + firewire_id = get_firewire_id(ipod_path) + write_hash58(itdb_data, firewire_id) + return True + + elif checksum_type == ChecksumType.HASH72: + write_hash72(itdb_data, ipod_path) + return True + + elif checksum_type == ChecksumType.HASHAB: + firewire_id = get_firewire_id(ipod_path) + write_hashab(itdb_data, firewire_id) + return True + + else: + raise ValueError( + f"Unsupported checksum type: {checksum_type}." + ) + + +__all__ = [ + 'ChecksumType', + 'detect_checksum_type', + 'get_firewire_id', + 'compute_hash58', + 'write_hash58', + 'compute_hash72', + 'write_hash72', + 'read_hash_info', + 'extract_hash_info', + 'extract_hash_info_to_dict', + 'compute_hashab', + 'write_hashab', + 'write_checksum', + # Writer + 'TrackInfo', + 'write_mhit', + # Media type constants + 'MEDIA_TYPE_AUDIO', + 'MEDIA_TYPE_VIDEO', + 'MEDIA_TYPE_PODCAST', + 'MEDIA_TYPE_VIDEO_PODCAST', + 'MEDIA_TYPE_AUDIOBOOK', + 'MEDIA_TYPE_MUSIC_VIDEO', + 'MEDIA_TYPE_TV_SHOW', + 'MEDIA_TYPE_RINGTONE', + 'write_mhbd', + 'write_itunesdb', + 'extract_db_info', + # Artist list + 'write_mhli', + 'write_mhii_artist', + 'write_mhli_empty', + # Playlists + 'PlaylistInfo', + 'write_playlist', + 'write_mhyp', + 'SmartPlaylistPrefs', + 'SmartPlaylistRules', + 'SmartPlaylistRule', + 'prefs_from_parsed', + 'rules_from_parsed', +] diff --git a/src/vendor/iTunesDB_Writer/hash58.py b/src/vendor/iTunesDB_Writer/hash58.py new file mode 100644 index 0000000..bf62e06 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/hash58.py @@ -0,0 +1,280 @@ +""" +HASH58 implementation for iPod Classic (all gens), Nano 3G, and Nano 4G. +Ported from libgpod's itdb_hash58.c. + +This is the complete, working implementation for signing iTunesDB files +for devices that use hashing_scheme=1 (HASH58). + +Usage: + from hash58 import write_hash58 + + with open("iTunesDB", "rb") as f: + itdb_data = bytearray(f.read()) + + firewire_id = bytes.fromhex("0011223344556677") # From SysInfo + write_hash58(itdb_data, firewire_id) + + with open("iTunesDB", "wb") as f: + f.write(itdb_data) +""" + +import hashlib +from math import gcd + +from iTunesDB_Shared.mhbd_defs import ( + MHBD_OFFSET_DB_ID as OFFSET_DB_ID, + MHBD_OFFSET_HASHING_SCHEME as OFFSET_HASHING_SCHEME, + MHBD_OFFSET_UNK_0x32 as OFFSET_UNK_0x32, + MHBD_OFFSET_HASH58 as OFFSET_HASH58, +) + +# AES S-Box (from libgpod itdb_hash58.c lines 45-76) +TABLE1 = bytes([ + 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, + 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, + 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, + 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, + 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, + 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, + 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75, + 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, + 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, + 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, + 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, + 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8, + 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, + 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, + 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, + 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, + 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB, + 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, + 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, + 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, + 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, + 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A, + 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, + 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, + 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, + 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, + 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16 +]) + +# AES Inverse S-Box (from libgpod itdb_hash58.c lines 78-115) +TABLE2 = bytes([ + 0x52, 0x09, 0x6A, 0xD5, 0x30, 0x36, 0xA5, 0x38, + 0xBF, 0x40, 0xA3, 0x9E, 0x81, 0xF3, 0xD7, 0xFB, + 0x7C, 0xE3, 0x39, 0x82, 0x9B, 0x2F, 0xFF, 0x87, + 0x34, 0x8E, 0x43, 0x44, 0xC4, 0xDE, 0xE9, 0xCB, + 0x54, 0x7B, 0x94, 0x32, 0xA6, 0xC2, 0x23, 0x3D, + 0xEE, 0x4C, 0x95, 0x0B, 0x42, 0xFA, 0xC3, 0x4E, + 0x08, 0x2E, 0xA1, 0x66, 0x28, 0xD9, 0x24, 0xB2, + 0x76, 0x5B, 0xA2, 0x49, 0x6D, 0x8B, 0xD1, 0x25, + 0x72, 0xF8, 0xF6, 0x64, 0x86, 0x68, 0x98, 0x16, + 0xD4, 0xA4, 0x5C, 0xCC, 0x5D, 0x65, 0xB6, 0x92, + 0x6C, 0x70, 0x48, 0x50, 0xFD, 0xED, 0xB9, 0xDA, + 0x5E, 0x15, 0x46, 0x57, 0xA7, 0x8D, 0x9D, 0x84, + 0x90, 0xD8, 0xAB, 0x00, 0x8C, 0xBC, 0xD3, 0x0A, + 0xF7, 0xE4, 0x58, 0x05, 0xB8, 0xB3, 0x45, 0x06, + 0xD0, 0x2C, 0x1E, 0x8F, 0xCA, 0x3F, 0x0F, 0x02, + 0xC1, 0xAF, 0xBD, 0x03, 0x01, 0x13, 0x8A, 0x6B, + 0x3A, 0x91, 0x11, 0x41, 0x4F, 0x67, 0xDC, 0xEA, + 0x97, 0xF2, 0xCF, 0xCE, 0xF0, 0xB4, 0xE6, 0x73, + 0x96, 0xAC, 0x74, 0x22, 0xE7, 0xAD, 0x35, 0x85, + 0xE2, 0xF9, 0x37, 0xE8, 0x1C, 0x75, 0xDF, 0x6E, + 0x47, 0xF1, 0x1A, 0x71, 0x1D, 0x29, 0xC5, 0x89, + 0x6F, 0xB7, 0x62, 0x0E, 0xAA, 0x18, 0xBE, 0x1B, + 0xFC, 0x56, 0x3E, 0x4B, 0xC6, 0xD2, 0x79, 0x20, + 0x9A, 0xDB, 0xC0, 0xFE, 0x78, 0xCD, 0x5A, 0xF4, + 0x1F, 0xDD, 0xA8, 0x33, 0x88, 0x07, 0xC7, 0x31, + 0xB1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xEC, 0x5F, + 0x60, 0x51, 0x7F, 0xA9, 0x19, 0xB5, 0x4A, 0x0D, + 0x2D, 0xE5, 0x7A, 0x9F, 0x93, 0xC9, 0x9C, 0xEF, + 0xA0, 0xE0, 0x3B, 0x4D, 0xAE, 0x2A, 0xF5, 0xB0, + 0xC8, 0xEB, 0xBB, 0x3C, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2B, 0x04, 0x7E, 0xBA, 0x77, 0xD6, 0x26, + 0xE1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0C, 0x7D +]) + +# Fixed bytes for key derivation (from libgpod itdb_hash58.c lines 113-115) +FIXED = bytes([ + 0x67, 0x23, 0xFE, 0x30, 0x45, 0x33, 0xF8, 0x90, 0x99, + 0x21, 0x07, 0xC1, 0xD0, 0x12, 0xB2, 0xA1, 0x07, 0x81 +]) + +# Hash scheme identifier for HASH58 +ITDB_CHECKSUM_HASH58 = 1 + + +def _lcm(a: int, b: int) -> int: + """Least common multiple.""" + if a == 0 or b == 0: + return 1 + return (a * b) // gcd(a, b) + + +def _generate_key(firewire_id: bytes) -> bytes: + """ + Generate a 64-byte HMAC key from the 8-byte FireWire ID. + + Algorithm: + 1. Take LCM of each pair of consecutive bytes in the FireWire ID + 2. Use the high and low bytes of each LCM to index into TABLE1 and TABLE2 + 3. SHA1 hash the FIXED bytes + derived bytes + 4. Pad to 64 bytes + """ + if len(firewire_id) < 8: + raise ValueError(f"FireWire ID must be at least 8 bytes, got {len(firewire_id)}") + + y = bytearray(16) + for i in range(4): + a = firewire_id[i * 2] + b = firewire_id[i * 2 + 1] + cur_lcm = _lcm(a, b) + + hi = (cur_lcm >> 8) & 0xFF + lo = cur_lcm & 0xFF + + y[i * 4] = TABLE1[hi] + y[i * 4 + 1] = TABLE2[hi] + y[i * 4 + 2] = TABLE1[lo] + y[i * 4 + 3] = TABLE2[lo] + + # SHA1(FIXED + y), then pad to 64 bytes + h = hashlib.sha1(FIXED + y).digest() + key = bytearray(64) + key[:len(h)] = h + return bytes(key) + + +def compute_hash58(firewire_id: bytes, itdb_data: bytes) -> bytes: + """ + Compute HMAC-SHA1 of iTunesDB data using FireWire ID-derived key. + + This is a standard HMAC-SHA1 implementation with a custom key derivation. + + Args: + firewire_id: 8-20 byte FireWire GUID from SysInfo + itdb_data: Complete iTunesDB file contents (with hash fields zeroed) + + Returns: + 20-byte SHA1 hash to write at offset 0x58 + """ + key = _generate_key(firewire_id) + + # HMAC-SHA1: H(K XOR opad, H(K XOR ipad, message)) + # Inner hash + inner_key = bytes(b ^ 0x36 for b in key) + inner_hash = hashlib.sha1(inner_key + itdb_data).digest() + + # Outer hash + outer_key = bytes(b ^ 0x5c for b in key) + return hashlib.sha1(outer_key + inner_hash).digest() + + +def write_hash58(itdb_data: bytearray, firewire_id: bytes) -> None: + """ + Compute and write HASH58 checksum to iTunesDB data in-place. + + From libgpod itdb_hash58_write_hash(): + 1. Backs up db_id and unk_0x32 fields + 2. Zeros out db_id, unk_0x32, and hash58 fields (required for hash computation) + 3. Sets hashing_scheme to HASH58 (1) + 4. Computes HMAC-SHA1 hash + 5. Writes hash to offset 0x58 + 6. Restores backed up fields + + NOTE: Unlike HASH72, HASH58 zeros unk_0x32 before computing the hash! + This is a key difference between the two algorithms. + + Args: + itdb_data: Mutable bytearray of complete iTunesDB file + firewire_id: 8-20 byte FireWire GUID from /iPod_Control/Device/SysInfo + + Raises: + ValueError: If iTunesDB is too small or FireWire ID is invalid + """ + if len(itdb_data) < 0x6C: + raise ValueError(f"iTunesDB file too small ({len(itdb_data)} bytes), need at least 0x6C") + + # Verify this is an mhbd header + if itdb_data[:4] != b'mhbd': + raise ValueError("Invalid iTunesDB: expected 'mhbd' header") + + # Backup fields that will be zeroed + backup_db_id = bytes(itdb_data[OFFSET_DB_ID:OFFSET_DB_ID + 8]) + backup_unk32 = bytes(itdb_data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20]) + + # Zero out fields for hash computation + itdb_data[OFFSET_DB_ID:OFFSET_DB_ID + 8] = b'\x00' * 8 + itdb_data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20] = b'\x00' * 20 + itdb_data[OFFSET_HASH58:OFFSET_HASH58 + 20] = b'\x00' * 20 + + # Set hashing scheme to HASH58 + itdb_data[OFFSET_HASHING_SCHEME:OFFSET_HASHING_SCHEME + 2] = \ + ITDB_CHECKSUM_HASH58.to_bytes(2, 'little') + + # Compute and write hash + hash_val = compute_hash58(firewire_id, bytes(itdb_data)) + if len(hash_val) != 20: + raise RuntimeError(f"Hash computation failed: expected 20 bytes, got {len(hash_val)}") + itdb_data[OFFSET_HASH58:OFFSET_HASH58 + 20] = hash_val + + # Restore backed up fields + itdb_data[OFFSET_DB_ID:OFFSET_DB_ID + 8] = backup_db_id + itdb_data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20] = backup_unk32 + + +def read_firewire_id(ipod_path: str) -> bytes: + """Return the FireWire GUID for the connected iPod. + + Reads from the centralised DeviceInfo store. Raises if not available. + """ + from ipod_device import get_current_device + device = get_current_device() + if device is not None: + fwid = device.firewire_id_bytes + if fwid: + return fwid + raise RuntimeError( + "FireWire GUID not available. Device info was not populated " + "by the device scanner." + ) + + +if __name__ == "__main__": + # Example usage + import sys + + if len(sys.argv) < 3: + print("Usage: python hash58.py ") + print("Example: python hash58.py E: E:/iPod_Control/iTunes/iTunesDB") + sys.exit(1) + + ipod_path = sys.argv[1] + itunesdb_path = sys.argv[2] + + try: + firewire_id = read_firewire_id(ipod_path) + print(f"FireWire ID: {firewire_id.hex()}") + + with open(itunesdb_path, 'rb') as f: + itdb_data = bytearray(f.read()) + + print(f"Read {len(itdb_data)} bytes from iTunesDB") + + write_hash58(itdb_data, firewire_id) + print("Hash computed successfully!") + + # Write back (uncomment to actually write) + # with open(itunesdb_path, 'wb') as f: + # f.write(itdb_data) + # print("iTunesDB updated!") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/src/vendor/iTunesDB_Writer/hash72.py b/src/vendor/iTunesDB_Writer/hash72.py new file mode 100644 index 0000000..37862ca --- /dev/null +++ b/src/vendor/iTunesDB_Writer/hash72.py @@ -0,0 +1,476 @@ +""" +HASH72 implementation for iPod Nano 5G. +Ported from libgpod's itdb_hash72.c. + +Note: iTunes also writes a HASH72 signature on iPod Classic devices, but +the Classic firmware only checks HASH58 (scheme=1). We preserve HASH72 +from a reference database when available but do not require it for Classic. + +IMPORTANT: This requires a HashInfo file that must be extracted from a valid +iTunes sync. The HashInfo file contains the IV and random bytes needed to +generate signatures. + +If you don't have a HashInfo file: +1. Sync once with iTunes (creates /iPod_Control/Device/HashInfo) +2. OR use extract_hash_info() with a known-good iTunesDB from iTunes + +Usage: + from hash72 import write_hash72 + + with open("iTunesDB", "rb") as f: + itdb_data = bytearray(f.read()) + + # Requires HashInfo file to exist at /iPod_Control/Device/HashInfo + write_hash72(itdb_data, ipod_path="/media/ipod") + + with open("iTunesDB", "wb") as f: + f.write(itdb_data) +""" + +import hashlib +import os +from typing import Optional + +from iTunesDB_Shared.mhbd_defs import ( + MHBD_OFFSET_DB_ID as OFFSET_DB_ID, + MHBD_OFFSET_HASHING_SCHEME as OFFSET_HASHING_SCHEME, + MHBD_OFFSET_HASH58 as OFFSET_HASH58, + MHBD_OFFSET_HASH72 as OFFSET_HASH72, +) + +# AES-128 key (from libgpod itdb_hash72.c line 40) +AES_KEY = bytes([ + 0x61, 0x8c, 0xa1, 0x0d, 0xc7, 0xf5, 0x7f, 0xd3, + 0xb4, 0x72, 0x3e, 0x08, 0x15, 0x74, 0x63, 0xd7 +]) + +# Hash scheme identifier for HASH72 +ITDB_CHECKSUM_HASH72 = 2 + +# HashInfo file structure +HASHINFO_HEADER = b"HASHv0" +HASHINFO_HEADER_LEN = 6 +HASHINFO_UUID_LEN = 20 +HASHINFO_RNDPART_LEN = 12 +HASHINFO_IV_LEN = 16 + + +class HashInfo: + """Parsed HashInfo file data.""" + + def __init__(self, uuid: bytes, rndpart: bytes, iv: bytes): + self.uuid = uuid + self.rndpart = rndpart + self.iv = iv + + +def _get_hash_info_path(ipod_path: str) -> str: + """Get path to HashInfo file.""" + return os.path.join(ipod_path, "iPod_Control", "Device", "HashInfo") + + +def read_hash_info(ipod_path: str) -> Optional[HashInfo]: + """ + Read and parse HashInfo file from iPod. + + HashInfo structure (54 bytes total): + - header[6]: "HASHv0" + - uuid[20]: Device UUID (should match FirewireGuid) + - rndpart[12]: Random bytes for signature + - iv[16]: AES initialization vector + + Args: + ipod_path: Mount point of iPod + + Returns: + HashInfo object or None if file doesn't exist + """ + # Check centralized device_info store first + try: + from ipod_device import get_current_device + dev = get_current_device() + if dev and dev.hash_info_iv and dev.hash_info_rndpart: + return HashInfo(uuid=b'\x00' * 20, rndpart=dev.hash_info_rndpart, iv=dev.hash_info_iv) + except Exception: + pass + + # Fallback: read from disk + path = _get_hash_info_path(ipod_path) + + if not os.path.exists(path): + return None + + with open(path, 'rb') as f: + data = f.read() + + if len(data) < 54: + return None + + if data[:6] != HASHINFO_HEADER: + return None + + # Parse structure + uuid = data[6:26] + rndpart = data[26:38] + iv = data[38:54] + + return HashInfo(uuid, rndpart, iv) + + +def write_hash_info(ipod_path: str, uuid: bytes, iv: bytes, rndpart: bytes) -> bool: + """ + Write HashInfo file to iPod. + + Args: + ipod_path: Mount point of iPod + uuid: 20-byte device UUID + iv: 16-byte AES IV + rndpart: 12-byte random bytes + + Returns: + True if successful + """ + if len(uuid) != 20 or len(iv) != 16 or len(rndpart) != 12: + return False + + data = HASHINFO_HEADER + uuid + rndpart + iv + + path = _get_hash_info_path(ipod_path) + device_dir = os.path.dirname(path) + os.makedirs(device_dir, exist_ok=True) + + with open(path, 'wb') as f: + f.write(data) + + return True + + +def _compute_itunesdb_sha1(itdb_data: bytearray) -> bytes: + """ + Compute SHA1 of iTunesDB with hash fields zeroed. + + From libgpod itdb_hash72_compute_itunesdb_sha1(): + - db_id (offset 0x18, 8 bytes) is zeroed + - hash58 (offset 0x58, 20 bytes) is zeroed + - hash72 (offset 0x72, 46 bytes) is zeroed + + NOTE: Unlike HASH58, unk_0x32 is NOT zeroed for HASH72! + libgpod backs it up and restores it, but since it's never zeroed, + we don't need to do anything with it. + """ + # Work on a copy to avoid modifying original + data = bytearray(itdb_data) + + # Zero fields for hash computation (same as libgpod) + # hash58 lives at offset 0x58 (20 bytes), hash72 at 0x72 (46 bytes) + data[OFFSET_DB_ID:OFFSET_DB_ID + 8] = b'\x00' * 8 + data[OFFSET_HASH58:OFFSET_HASH58 + 20] = b'\x00' * 20 + data[OFFSET_HASH72:OFFSET_HASH72 + 46] = b'\x00' * 46 + + return hashlib.sha1(bytes(data)).digest() + + +def _hash_generate(sha1: bytes, iv: bytes, rndpart: bytes) -> bytes: + """ + Generate 46-byte signature using AES encryption. + + Signature format: + - bytes 0-1: 0x01 0x00 (prefix) + - bytes 2-13: rndpart (12 bytes) + - bytes 14-45: AES-CBC encrypted (sha1 + rndpart) (32 bytes) + + Args: + sha1: 20-byte SHA1 of iTunesDB + iv: 16-byte initialization vector + rndpart: 12-byte random bytes + + Returns: + 46-byte signature + """ + try: + from Crypto.Cipher import AES + except ImportError: + try: + from Cryptodome.Cipher import AES # type: ignore[import-not-found] + except ImportError: + raise ImportError( + "PyCryptodome is required for HASH72. " + "Install with: pip install pycryptodome" + ) + + # Plaintext: sha1 (20 bytes) + rndpart (12 bytes) = 32 bytes + plaintext = sha1 + rndpart + + # AES-CBC encrypt + cipher = AES.new(AES_KEY, AES.MODE_CBC, iv) + encrypted = cipher.encrypt(plaintext) + + # Build signature + signature = bytearray(46) + signature[0] = 0x01 + signature[1] = 0x00 + signature[2:14] = rndpart + signature[14:46] = encrypted + + return bytes(signature) + + +def _hash_extract(signature: bytes, sha1: bytes) -> Optional[tuple]: + """ + Extract IV and random bytes from a valid signature. + + This can be used to create a HashInfo file from a known-good + iTunes-generated iTunesDB. + + Algorithm from libgpod itdb_hash72.c hash_extract(): + + The signature was created by: + C = AES_encrypt_CBC(plaintext, IV) where plaintext = sha1 + rndpart + + In CBC mode, the first block is: + C_0 = AES_encrypt(P_0 XOR IV) where P_0 = sha1[:16] + + To recover IV, we decrypt C_0 using sha1[:16] as a fake IV: + output = AES_decrypt(C_0) XOR sha1[:16] + = (P_0 XOR IV) XOR sha1[:16] + = (sha1[:16] XOR IV) XOR sha1[:16] + = IV + + The libgpod code also does a sanity check comparing plaintext[16:32] + to output[16:32], but since only the first 16 bytes are decrypted, + output[16:32] is always equal to plaintext[16:32]. We keep this check + for compatibility. + + Args: + signature: 46-byte signature from valid iTunesDB + sha1: 20-byte SHA1 that was used to generate the signature + + Returns: + (iv, rndpart) tuple or None if invalid + """ + try: + from Crypto.Cipher import AES + except ImportError: + try: + from Cryptodome.Cipher import AES # type: ignore[import-not-found] + except ImportError: + raise ImportError( + "PyCryptodome is required for HASH72. " + "Install with: pip install pycryptodome" + ) + + if len(signature) < 46 or signature[0] != 0x01 or signature[1] != 0x00: + return None + + # Build plaintext = sha1 + rndpart (matches libgpod) + rndpart = signature[2:14] + plaintext = bytearray(32) + plaintext[:20] = sha1 + plaintext[20:32] = rndpart + + # Initialize output as copy of plaintext (matches libgpod: memcpy(output, plaintext, 32)) + output = bytearray(plaintext) + + # AES-CBC decrypt first 16 bytes only, using sha1[:16] as IV + # This recovers the real IV through the XOR cancellation described above + cipher = AES.new(AES_KEY, AES.MODE_CBC, bytes(plaintext[:16])) + decrypted_block = cipher.decrypt(bytes(signature[14:30])) + output[:16] = decrypted_block + + # Sanity check from libgpod - always passes since output[16:32] was + # copied from plaintext[16:32] and never modified + if bytes(plaintext[16:32]) != bytes(output[16:32]): + return None + + # The IV is now in output[:16] + iv = bytes(output[:16]) + + return (iv, bytes(rndpart)) + + +def extract_hash_info(ipod_path: str, valid_itdb_data: bytes) -> bool: + """ + Extract HashInfo from a valid iTunes-generated iTunesDB. + + Use this when you have an iTunesDB that was created by iTunes + but you don't have a HashInfo file. + + Args: + ipod_path: Mount point of iPod + valid_itdb_data: Contents of valid iTunes-generated iTunesDB + + Returns: + True if HashInfo was successfully extracted and saved + """ + if len(valid_itdb_data) < 0xA0: + return False + + if valid_itdb_data[:4] != b'mhbd': + return False + + # Get existing hash72 from CORRECT offset (0x72) + hash72 = bytes(valid_itdb_data[OFFSET_HASH72:OFFSET_HASH72 + 46]) + + # Check for valid signature marker + if hash72[0:2] != bytes([0x01, 0x00]): + # Not a valid hash72 signature + return False + + # Compute SHA1 + itdb_copy = bytearray(valid_itdb_data) + sha1 = _compute_itunesdb_sha1(itdb_copy) + + # Extract IV and rndpart + result = _hash_extract(hash72, sha1) + if result is None: + return False + + iv, rndpart = result + + # Get UUID from device (or use zeros if not available) + try: + from .hash58 import read_firewire_id + fw_id = read_firewire_id(ipod_path) + uuid = bytearray(20) + uuid[:len(fw_id)] = fw_id + except Exception: + uuid = bytes(20) + + return write_hash_info(ipod_path, bytes(uuid), iv, rndpart) + + +def extract_hash_info_to_dict(valid_itdb_data: bytes) -> dict | None: + """ + Extract HashInfo from a valid iTunes-generated iTunesDB. + + Returns the extracted info as a dict instead of writing to disk. + + Args: + valid_itdb_data: Contents of valid iTunes-generated iTunesDB + + Returns: + Dict with 'iv' and 'rndpart' keys, or None if extraction failed + """ + if len(valid_itdb_data) < 0xA0: + return None + + if valid_itdb_data[:4] != b'mhbd': + return None + + # Get existing hash72 from CORRECT offset (0x72) + hash72 = bytes(valid_itdb_data[OFFSET_HASH72:OFFSET_HASH72 + 46]) + + # Check for valid signature marker + if hash72[0:2] != bytes([0x01, 0x00]): + return None + + # Compute SHA1 + itdb_copy = bytearray(valid_itdb_data) + sha1 = _compute_itunesdb_sha1(itdb_copy) + + # Extract IV and rndpart + result = _hash_extract(hash72, sha1) + if result is None: + return None + + iv, rndpart = result + return {'iv': iv, 'rndpart': rndpart} + + +def compute_hash72(ipod_path: str, itdb_data: bytes) -> bytes: + """ + Compute HASH72 signature for iTunesDB data. + + Args: + ipod_path: Mount point of iPod (for reading HashInfo) + itdb_data: Complete iTunesDB file contents + + Returns: + 46-byte signature + + Raises: + FileNotFoundError: If HashInfo file doesn't exist + """ + hash_info = read_hash_info(ipod_path) + if hash_info is None: + raise FileNotFoundError( + f"HashInfo file not found at {_get_hash_info_path(ipod_path)}. " + "Sync once with iTunes to create it, or use extract_hash_info() " + "with a valid iTunes-generated iTunesDB." + ) + + sha1 = _compute_itunesdb_sha1(bytearray(itdb_data)) + return _hash_generate(sha1, hash_info.iv, hash_info.rndpart) + + +def write_hash72(itdb_data: bytearray, ipod_path: str) -> None: + """ + Compute and write HASH72 checksum to iTunesDB data in-place. + + Args: + itdb_data: Mutable bytearray of complete iTunesDB file + ipod_path: Mount point of iPod (for reading HashInfo) + + Raises: + ValueError: If iTunesDB is too small + FileNotFoundError: If HashInfo file doesn't exist + """ + if len(itdb_data) < 0x6C: + raise ValueError(f"iTunesDB file too small ({len(itdb_data)} bytes)") + + if itdb_data[:4] != b'mhbd': + raise ValueError("Invalid iTunesDB: expected 'mhbd' header") + + # Set hashing scheme + itdb_data[OFFSET_HASHING_SCHEME:OFFSET_HASHING_SCHEME + 2] = \ + ITDB_CHECKSUM_HASH72.to_bytes(2, 'little') + + # Compute and write signature + signature = compute_hash72(ipod_path, bytes(itdb_data)) + itdb_data[OFFSET_HASH72:OFFSET_HASH72 + 46] = signature + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 3: + print("Usage: python hash72.py ") + print("Example: python hash72.py /media/ipod /media/ipod/iPod_Control/iTunes/iTunesDB") + sys.exit(1) + + ipod_path = sys.argv[1] + itunesdb_path = sys.argv[2] + + try: + # Check for HashInfo + hash_info = read_hash_info(ipod_path) + if hash_info: + print(f"HashInfo found: IV={hash_info.iv.hex()[:16]}...") + else: + print("HashInfo not found. Attempting to extract from iTunesDB...") + with open(itunesdb_path, 'rb') as f: + itdb_data = f.read() + if extract_hash_info(ipod_path, itdb_data): + print("HashInfo extracted and saved successfully!") + else: + print("Failed to extract HashInfo. Sync with iTunes first.") + sys.exit(1) + + with open(itunesdb_path, 'rb') as f: + itdb_data = bytearray(f.read()) + + print(f"Read {len(itdb_data)} bytes from iTunesDB") + + write_hash72(itdb_data, ipod_path) + print("Hash computed successfully!") + + # Write back (uncomment to actually write) + # with open(itunesdb_path, 'wb') as f: + # f.write(itdb_data) + # print("iTunesDB updated!") + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/vendor/iTunesDB_Writer/hashab.py b/src/vendor/iTunesDB_Writer/hashab.py new file mode 100644 index 0000000..c229158 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/hashab.py @@ -0,0 +1,287 @@ +""" +HASHAB implementation for iPod Nano 6G and 7G. + +Uses a WebAssembly module (calcHashAB.wasm) from dstaley/hashab — a clean-room +reimplementation of Apple's white-box AES signing algorithm. The WASM binary +is executed via wasmtime-py, giving cross-platform support without compiling +native code. + +Algorithm overview (4 phases inside the WASM module): + 1. CBC-MAC compression of UUID with AES + 2. Key material expansion (44 → 190 bytes) + 3. Initial buffer generation (190 → 16 bytes) + 4. White-box AES-128 encryption + +The output is a 57-byte signature written at mhbd offset 0xAB. This is +analogous to HASH58 (20 bytes at 0x58) and HASH72 (46 bytes at 0x72). + +Source: https://github.com/dstaley/hashab (The Unlicense) +WASM release: https://github.com/dstaley/hashab/releases/tag/2025-01-04 + +Usage: + from hashab import write_hashab + + with open("iTunesDB", "rb") as f: + itdb_data = bytearray(f.read()) + + firewire_id = bytes.fromhex("0011223344556677") # From SysInfo + write_hashab(itdb_data, firewire_id) + + with open("iTunesDB", "wb") as f: + f.write(itdb_data) +""" + +import hashlib +import logging +from pathlib import Path + +from iTunesDB_Shared.mhbd_defs import ( + MHBD_OFFSET_DB_ID as OFFSET_DB_ID, + MHBD_OFFSET_HASHING_SCHEME as OFFSET_HASHING_SCHEME, + MHBD_OFFSET_UNK_0x32 as OFFSET_UNK_0x32, + MHBD_OFFSET_HASH58 as OFFSET_HASH58, + MHBD_OFFSET_HASH72 as OFFSET_HASH72, + MHBD_OFFSET_HASHAB as OFFSET_HASHAB, +) + +logger = logging.getLogger(__name__) + +HASHAB_SIZE = 57 +ITDB_CHECKSUM_HASHAB = 4 # hashing_scheme value for HASHAB + +# Path to the WASM module (shipped alongside this file) +_WASM_DIR = Path(__file__).parent / "wasm" +_WASM_PATH = _WASM_DIR / "calcHashAB.wasm" + +# Lazy-loaded WASM engine (expensive to create — reuse across calls) +_wasm_instance = None +_wasm_store = None + + +def _get_wasm_instance(): + """Load the WASM module and return (store, instance). + + The module exports: + memory — linear memory + getInputSha1() — returns pointer to 20-byte SHA1 input buffer + getInputUuid() — returns pointer to 8-byte UUID input buffer + getOutput() — returns pointer to 57-byte output buffer + calculateHash()— run the hash computation + """ + global _wasm_instance, _wasm_store + + if _wasm_instance is not None: + return _wasm_store, _wasm_instance + + try: + import wasmtime + except ImportError: + raise ImportError( + "wasmtime is required for HASHAB (iPod Nano 6G/7G). " + "Install with: uv add wasmtime or pip install wasmtime" + ) + + if not _WASM_PATH.exists(): + raise FileNotFoundError( + f"WASM module not found at {_WASM_PATH}. " + "Download calcHashAB.wasm from " + "https://github.com/dstaley/hashab/releases/tag/2025-01-04" + ) + + engine = wasmtime.Engine() + store = wasmtime.Store(engine) + module = wasmtime.Module.from_file(engine, str(_WASM_PATH)) + instance = wasmtime.Instance(store, module, []) + + _wasm_store = store + _wasm_instance = instance + + logger.debug("HASHAB WASM module loaded from %s", _WASM_PATH) + return store, instance + + +def compute_hashab(sha1_digest: bytes, uuid: bytes) -> bytes: + """ + Compute 57-byte HASHAB signature using the WASM module. + + Args: + sha1_digest: 20-byte SHA1 hash of the iTunesDB (with hash fields zeroed) + uuid: 8-byte FireWire GUID / UUID from SysInfo + + Returns: + 57-byte signature to write at mhbd offset 0xAB + """ + if len(sha1_digest) != 20: + raise ValueError(f"SHA1 must be 20 bytes, got {len(sha1_digest)}") + if len(uuid) < 8: + raise ValueError(f"UUID must be at least 8 bytes, got {len(uuid)}") + + store, instance = _get_wasm_instance() + + # Get exported functions and memory + # wasmtime stubs type exports() return as a union; runtime types are correct + exports = instance.exports(store) # type: ignore[arg-type] + memory = exports["memory"] + get_input_sha1 = exports["getInputSha1"] + get_input_uuid = exports["getInputUuid"] + get_output = exports["getOutput"] + calculate_hash = exports["calculateHash"] + + # Get pointers into WASM linear memory + sha1_ptr = get_input_sha1(store) # type: ignore[misc] + uuid_ptr = get_input_uuid(store) # type: ignore[misc] + output_ptr = get_output(store) # type: ignore[misc] + + # Write inputs into WASM memory + mem_data = memory.data_ptr(store) # type: ignore[union-attr] + + # Write SHA1 (20 bytes) + for i in range(20): + mem_data[sha1_ptr + i] = sha1_digest[i] + + # Write UUID (8 bytes) + for i in range(8): + mem_data[uuid_ptr + i] = uuid[i] + + # Execute the hash computation + calculate_hash(store) # type: ignore[misc] + + # Read 57-byte output + result = bytes(mem_data[output_ptr + i] for i in range(HASHAB_SIZE)) + + logger.debug("HASHAB computed: %s…", result[:4].hex()) + return result + + +def _compute_itunesdb_sha1_for_hashab(itdb_data: bytearray) -> bytes: + """ + Compute SHA1 of iTunesDB with all hash fields zeroed for HASHAB. + + Zeroed fields before hashing: + - db_id (offset 0x18, 8 bytes) + - unk_0x32 (offset 0x32, 20 bytes) + - hash58 (offset 0x58, 20 bytes) + - hash72 (offset 0x72, 46 bytes) + - hashAB (offset 0xAB, 57 bytes) + + We zero unk_0x32 (matching HASH58 behavior) because HASHAB devices + (Nano 6G/7G) also maintain hash58 compatibility fields. + """ + data = bytearray(itdb_data) + + data[OFFSET_DB_ID:OFFSET_DB_ID + 8] = b'\x00' * 8 + data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20] = b'\x00' * 20 + data[OFFSET_HASH58:OFFSET_HASH58 + 20] = b'\x00' * 20 + data[OFFSET_HASH72:OFFSET_HASH72 + 46] = b'\x00' * 46 + data[OFFSET_HASHAB:OFFSET_HASHAB + HASHAB_SIZE] = b'\x00' * HASHAB_SIZE + + return hashlib.sha1(bytes(data)).digest() + + +def write_hashab(itdb_data: bytearray, firewire_id: bytes) -> None: + """ + Compute and write HASHAB signature to iTunesDB data in-place. + + Steps: + 1. Zero db_id, unk_0x32, hash58, hash72, hashAB + 2. Set hashing_scheme to 4 (HASHAB) + 3. Compute SHA1 of entire database + 4. Call WASM module with SHA1 + UUID + 5. Write 57-byte result at offset 0xAB + 6. Restore backed-up fields + + Args: + itdb_data: Mutable bytearray of complete iTunesDB file + firewire_id: 8+ byte FireWire GUID from /iPod_Control/Device/SysInfo + + Raises: + ValueError: If iTunesDB is too small or FireWire ID is invalid + """ + min_size = OFFSET_HASHAB + HASHAB_SIZE # 0xAB + 57 = 0xE4 = 228 + if len(itdb_data) < min_size: + raise ValueError( + f"iTunesDB file too small ({len(itdb_data)} bytes), " + f"need at least {min_size} (0x{min_size:X})" + ) + + if itdb_data[:4] != b'mhbd': + raise ValueError("Invalid iTunesDB: expected 'mhbd' header") + + if len(firewire_id) < 8: + raise ValueError( + f"FireWire ID must be at least 8 bytes, got {len(firewire_id)}" + ) + + # Backup fields that will be zeroed for SHA1 computation + backup_db_id = bytes(itdb_data[OFFSET_DB_ID:OFFSET_DB_ID + 8]) + backup_unk32 = bytes(itdb_data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20]) + + # Set hashing scheme to HASHAB (4) + itdb_data[OFFSET_HASHING_SCHEME:OFFSET_HASHING_SCHEME + 2] = \ + ITDB_CHECKSUM_HASHAB.to_bytes(2, 'little') + + # Compute SHA1 with hash fields zeroed + sha1_digest = _compute_itunesdb_sha1_for_hashab(itdb_data) + + # Compute HASHAB via WASM + signature = compute_hashab(sha1_digest, firewire_id[:8]) + + if len(signature) != HASHAB_SIZE: + raise RuntimeError( + f"WASM returned {len(signature)} bytes, expected {HASHAB_SIZE}" + ) + + # Write signature to mhbd header + itdb_data[OFFSET_HASHAB:OFFSET_HASHAB + HASHAB_SIZE] = signature + + # Restore backed-up fields + itdb_data[OFFSET_DB_ID:OFFSET_DB_ID + 8] = backup_db_id + itdb_data[OFFSET_UNK_0x32:OFFSET_UNK_0x32 + 20] = backup_unk32 + + logger.info("HASHAB signature written at offset 0x%X (%d bytes)", + OFFSET_HASHAB, HASHAB_SIZE) + + +def read_firewire_id(ipod_path: str) -> bytes: + """Return the FireWire GUID for the connected iPod. + + Reads from the centralised DeviceInfo store. Raises if not available. + """ + from ipod_device import get_current_device + device = get_current_device() + if device is not None: + fwid = device.firewire_id_bytes + if fwid: + return fwid + raise RuntimeError( + "FireWire GUID not available. Device info was not populated " + "by the device scanner." + ) + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 3: + print("Usage: python hashab.py ") + print("Example: python hashab.py /media/ipod /media/ipod/iPod_Control/iTunes/iTunesDB") + sys.exit(1) + + ipod_path = sys.argv[1] + itunesdb_path = sys.argv[2] + + try: + firewire_id = read_firewire_id(ipod_path) + print(f"FireWire ID: {firewire_id.hex()}") + + with open(itunesdb_path, 'rb') as f: + itdb_data = bytearray(f.read()) + + print(f"Read {len(itdb_data)} bytes from iTunesDB") + + write_hashab(itdb_data, firewire_id) + print("HASHAB computed successfully!") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/src/vendor/iTunesDB_Writer/mhbd_writer.py b/src/vendor/iTunesDB_Writer/mhbd_writer.py new file mode 100644 index 0000000..8f3c3a2 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhbd_writer.py @@ -0,0 +1,1140 @@ +"""MHBD Writer — Write complete iTunesDB database files. + +This is the top-level writer that assembles all components into +a valid iTunesDB (or iTunesCDB for Nano 5G+) file. + +Dataset write order (matches libgpod): + mhbd (database header, 244 bytes) + mhsd type 1 (tracks dataset) + mhlt (track list) + mhit (track) x N + mhod (string) x M + mhsd type 3 (podcasts dataset) — MUST appear between types 1 and 2 + mhlp (playlist list) — same data as type 2 + mhsd type 2 (playlists dataset) + mhlp (playlist list) + mhyp (master playlist) — REQUIRED, always first + mhod types 52/53 (library indices) + mhip (track ref) x N + mhyp (user playlist) x M + mhsd type 4 (albums dataset) + mhla (album list) + mhia (album item) x N + mhsd type 8 (artist list) + mhli (artist list) + mhii (artist item) x N + mhod type 300 (artist name) + mhsd type 6 (empty stub — mhlt with 0 children) + mhsd type 10 (empty stub — mhlt with 0 children) + mhsd type 5 (smart playlists dataset) + mhlp (smart playlist list) + +MHBD header layout (MHBD_HEADER_SIZE = 244 bytes): + +0x00: 'mhbd' magic (4B) + +0x04: header_length (4B) + +0x08: total_length (4B) — entire file size + +0x0C: unk1 (4B) — always 1 + +0x10: version (4B) — 0x4F + +0x14: children_count (4B) — 5 + +0x18: database_id (8B) + +0x20: platform (2B) — 1=Mac, 2=Windows + +0x22: unk_0x22 (2B) — ~611 + +0x24: db_id_2 (8B) — secondary ID (written in every MHIT) + +0x2C: unk_0x2c (4B) + +0x30: hashing_scheme (2B) — 0=none, 1=hash58 + +0x32: unk_0x32 (20B) — zeroed before hash58 + +0x46: language (2B) + +0x48: lib_persistent_id (8B) + +0x50: unk_0x50 (4B) + +0x54: unk_0x54 (4B) + +0x58: hash58 (20B) + +0x6C: timezone_offset (4B signed) + +0x70: unk_0x70 (2B) + +0x72: hash72 (46B) + +0xA0: audio_language (2B) + +0xA2: subtitle_language (2B) + +Cross-referenced against: + - iTunesDB_Parser/mhbd_parser.py parse_db() + - libgpod itdb_itunesdb.c: mk_mhbd() / parse_mhbd() +""" + +import logging +import os +import random +import shutil +import struct +import time +import zlib +from collections.abc import Callable +from dataclasses import replace as _dc_replace + +from ipod_device import ChecksumType, DeviceCapabilities, detect_checksum_type +from iTunesDB_Shared.album_identity import album_identity_from_track +from iTunesDB_Shared.field_base import ( + read_fields, + write_fields, + write_generic_header, +) +from iTunesDB_Shared.mhbd_defs import ( + MHBD_HEADER_SIZE, + MHBD_OFFSET_HASHING_SCHEME, +) + +from .hash58 import write_hash58 +from .hashab import write_hashab +from .mhit_writer import TrackInfo +from .mhla_writer import write_mhla +from .mhli_writer import write_mhli +from .mhlp_writer import write_mhlp_smart, write_mhlp_with_playlists +from .mhlt_writer import write_mhlt +from .mhsd_writer import ( + write_mhsd_empty_stub, + write_mhsd_smart_type5, + write_mhsd_type1, + write_mhsd_type2, + write_mhsd_type3, + write_mhsd_type4, + write_mhsd_type8, +) +from .mhyp_writer import PlaylistInfo, generate_playlist_id + +logger = logging.getLogger(__name__) + +# Default database version — 0x4F (79) works for iPod Classic / Nano 3G+. +# For older devices, callers should pass `db_version` from +# ``ipod_device.DeviceCapabilities.db_version``. +DATABASE_VERSION_DEFAULT = 0x4F + + +def _maybe_decompress_cdb(itdb_data: bytes) -> bytes: + """Decompress an iTunesCDB payload if the compressed indicator is set. + + Returns the full (header + decompressed children) bytes if the data + is a compressed iTunesCDB, or the original bytes unchanged otherwise. + """ + hdr_len = struct.unpack(' hdr_len + 2 + and struct.unpack(' dict: + """ + Extract useful information from an existing iTunesDB. + + This can be used to get: + - db_id: To preserve identity across rewrites + - hashing_scheme: What hash type is used + - hash58/hash72: The actual hash values + + All keys use canonical ``field_defs`` names (e.g. ``'db_id_2'`` not + ``'db_id_2'``, ``'timezone_offset'`` not ``'timezone'``). + + Args: + itdb_path: Path to iTunesDB file + + Returns: + Dictionary with extracted information (field_defs key names) + """ + with open(itdb_path, 'rb') as f: + data = f.read(MHBD_HEADER_SIZE) + + if data[:4] != b'mhbd': + raise ValueError(f"Not an iTunesDB file: {itdb_path}") + + header_length = struct.unpack_from(' list[bytes]: + """Extract raw MHSD blobs for dataset types we don't generate. + + iTunes 9+ writes additional MHSD children for Genius features + (types 6-10). We now generate types 6, 8, and 10 ourselves + (empty stubs for 6/10, artist list for 8), so we only preserve + types we don't generate: 7 and 9 (Genius Chill). + + Args: + itdb_data: Complete original iTunesDB file bytes. + + Returns: + List of raw MHSD byte blobs for dataset types we don't generate, + in the order they appeared in the original database. + """ + if len(itdb_data) < 24 or itdb_data[:4] != b'mhbd': + return [] + + header_length = struct.unpack(' len(itdb_data): + break + magic = itdb_data[offset:offset + 4] + if magic != b'mhsd': + break + mhsd_total = struct.unpack(' int: + """Generate a random 64-bit database ID.""" + return random.getrandbits(64) + + +def write_mhbd( + tracks: list[TrackInfo], + db_id: int | None = None, + language: str = "en", + reference_info: dict | None = None, + playlists_type2: list[PlaylistInfo] | None = None, + playlists_type5: list[PlaylistInfo] | None = None, + preserved_mhsd_blobs: list[bytes] | None = None, + capabilities: DeviceCapabilities | None = None, + master_playlist_name: str = "iPod", +) -> bytes: + """ + Write a complete iTunesDB database. + + Args: + tracks: List of TrackInfo objects to include + db_id: Database ID (generated if not provided) + language: 2-letter language code + reference_info: Dict from extract_db_info() to copy device-specific fields + playlists_type2: List of PlaylistInfo for user playlists (dataset 2). + Master playlist is auto-generated; does NOT belong in this list. + playlists_type5: List of PlaylistInfo for dataset 5 smart playlists + (iPod browsing categories like Music, Movies, etc.) + preserved_mhsd_blobs: Raw MHSD byte blobs (types 6+) extracted from + an existing database via extract_preserved_mhsd_blobs(). + Appended verbatim after the 5 standard datasets to + preserve Genius and other iTunes-generated data. + capabilities: Device capabilities from ``ipod_device``. When provided, + ``db_version`` and ``supports_podcast`` are respected. + master_playlist_name: Display name for the auto-generated master playlist. + + Returns: + Complete iTunesDB file content as bytes + """ + # Determine database ID, passed, preserved, or random + if db_id is None: + if reference_info and 'db_id' in reference_info: + db_id = reference_info['db_id'] + else: + db_id = generate_database_id() + + # Generate db_id_2 early - needed for both the MHBD header AND every MHIT, preserved or random. + # Field is named 'db_id_2' in the shared field definitions (offset 0x24). + if reference_info and 'db_id_2' in reference_info: + db_id_2 = reference_info['db_id_2'] + else: + db_id_2 = random.getrandbits(64) + + # Build album list first to get album IDs for tracks (Type 4 dataset) + global_id_start_index = 1 + + mhla_data, album_map, last_id = write_mhla(tracks, starting_index_for_album_id=global_id_start_index) + mhsd_type4 = write_mhsd_type4(mhla_data) + + # Build artist list to get artist IDs for tracks (Type 8 dataset) + mhli_data, artist_map, last_id = write_mhli(tracks, starting_index_for_artist_id=last_id + 1) + mhsd_type8 = write_mhsd_type8(mhli_data) + + # Build composer ID map (no dataset — composers don't have their own + # MHSD type, but the iPod firmware uses composer_id in mhit for + # grouping and sorting). + composer_map: dict[str, int] = {} # lowercase composer → composer_id + composer_id = last_id + 1 + for track in tracks: + composer_name = track.composer or "" + if not composer_name: + continue + key = composer_name.lower() + if key not in composer_map: + composer_map[key] = composer_id + composer_id += 1 + last_id = composer_id - 1 if composer_map else last_id + + # Assign album_id, artist_id, and composer_id to each track + for track in tracks: + if not track.album_id: + identity = album_identity_from_track(track) + album_name = identity.album or "" + album_artist = identity.album_artist or identity.artist or "" + key = (album_name, album_artist) + track.album_id = album_map.get(key, 0) + + # Artist ID from the artist list (artist_map is keyed by lowercase) + artist_name = track.artist or "" + if artist_name: + track.artist_id = artist_map.get(artist_name.lower(), 0) + + # Composer ID from the composer map + composer_name = track.composer or "" + if composer_name: + track.composer_id = composer_map.get(composer_name.lower(), 0) + + # ── Compute db_version early — needed for MHIT header sizing ──── + ref_version = reference_info.get('version', 0) if reference_info else 0 + cap_version = capabilities.db_version if capabilities else 0 + if cap_version: + # Device identified — use the higher of reference and capability + db_version = max(ref_version, cap_version) + elif ref_version: + # Device unknown — preserve the existing database's version + db_version = ref_version + else: + # No reference, no capabilities — use safe default + db_version = DATABASE_VERSION_DEFAULT + logger.debug("Using db_version=0x%X (ref=0x%X, cap=0x%X, default=0x%X)", + db_version, ref_version, cap_version, DATABASE_VERSION_DEFAULT) + + # Build track list (Type 1 dataset) + # This also returns next_track_id which tells us track IDs used + + mhlt_data, next_track_id = write_mhlt(tracks, db_id_2=db_id_2, capabilities=capabilities, + db_version=db_version, start_track_id=last_id + 1) + mhsd_type1 = write_mhsd_type1(mhlt_data) + + # Collect all track IDs for the master playlist + # Track IDs are sequential starting from 1 + track_ids = list(range(last_id + 1, next_track_id)) + + # Build db_track_id → sequential track_id map so playlists can reference + # tracks by their 32-bit MHIT trackID (not 64-bit db_track_id). + # The sync executor stores db_track_ids in PlaylistInfo.track_ids because + # db_track_ids are the stable identifier, but MHIP entries need 32-bit IDs. + db_track_id_to_track_id: dict[int, int] = {} + for i, track in enumerate(tracks): + if track.db_track_id: + db_track_id_to_track_id[track.db_track_id] = i + last_id + 1 + + # Remap playlist track_ids from 64-bit db_track_id → 32-bit sequential track_id. + # + # PlaylistInfo.track_ids stores db_track_ids (the stable cross-session identifier), + # but MHIP entries in the iTunesDB need sequential track IDs assigned by + # write_mhlt. We build new PlaylistInfo copies with remapped IDs instead + # of mutating the caller's objects — if write_mhbd() were retried (e.g. + # after an I/O error) the original db_track_id-based track_ids must still be intact. + def _remap_playlist(pl: PlaylistInfo) -> PlaylistInfo: + """Return a copy of pl with the db_track_ids translated to track IDs.""" + new_ids: list[int] = [] + new_meta: list | None = [] if pl.item_metadata is not None else None + + meta = pl.item_metadata # capture for type narrowing + for i, db_track_id in enumerate(pl.track_ids): + track_id = db_track_id_to_track_id.get(db_track_id) + if track_id is None: + continue # track not in this database — skip + new_ids.append(track_id) + if new_meta is not None and meta is not None: + new_meta.append(meta[i]) + + return _dc_replace(pl, track_ids=new_ids, item_metadata=new_meta) + + # Build playlist list WITH master playlist (Type 2 dataset) + # The master playlist is REQUIRED and must reference ALL tracks + # Pass tracks so master playlist can generate library index MHODs (type 52/53) + # + # Generate a single master playlist_id shared by DS2 and DS3 so that + # the GUI dedup logic (by playlist_id) correctly collapses the two + # copies of the master playlist into one. + master_playlist_id = generate_playlist_id() + + remapped_playlists_type2 = [_remap_playlist(pl) for pl in (playlists_type2 or [])] + mhsd_type2_data = write_mhlp_with_playlists( + track_ids, playlists=remapped_playlists_type2, + tracks=tracks, db_id_2=db_id_2, capabilities=capabilities, + master_playlist_name=master_playlist_name, + master_playlist_id=master_playlist_id, + ) + mhsd_type2 = write_mhsd_type2(mhsd_type2_data) + + # Build podcast list (Type 3 dataset) + # libgpod writes type 3 with the SAME playlists as type 2, but the + # podcast playlist uses grouped MHIPs where episodes are nested + # under their podcast show (album). Non-podcast playlists are + # written identically to type 2. + + # Pre-podcast devices (iPod 1G-3G, Mini 1G-2G, Shuffle 1G-2G) + # don't understand type 3; skip it when capabilities say so. + include_podcasts = True + if capabilities is not None and not capabilities.supports_podcast: + include_podcasts = False + + if include_podcasts: + # Build track_id → album map for podcast grouping. + # Sequential track IDs start after last_id (same as track_ids range). + track_album_map: dict[int, str] = {} + for i, track in enumerate(tracks): + seq_id = i + last_id + 1 + track_album_map[seq_id] = track.album or "" + + from .mhlp_writer import write_mhlp_with_playlists_type3 + mhsd_type3_data = write_mhlp_with_playlists_type3( + track_ids, playlists=remapped_playlists_type2, + db_id_2=db_id_2, track_album_map=track_album_map, + tracks=tracks, capabilities=capabilities, + master_playlist_name=master_playlist_name, + next_mhip_id_start=next_track_id, + master_playlist_id=master_playlist_id, + ) + mhsd_type3 = write_mhsd_type3(mhsd_type3_data) + else: + mhsd_type3 = b'' + + # Build smart playlist list (Type 5 dataset) — same non-mutating remap + remapped_playlists_type5 = [_remap_playlist(pl) for pl in (playlists_type5 or [])] + mhsd_type5_data = write_mhlp_smart(remapped_playlists_type5, db_id_2=db_id_2) + mhsd_type5 = write_mhsd_smart_type5(mhsd_type5_data) + + mhsd_type6 = write_mhsd_empty_stub(6) + mhsd_type10 = write_mhsd_empty_stub(10) + + # Concatenate all datasets + # + # Default order matches libgpod: Type 1, 3, 2, 4, 8, 6, 10, 5 + # - Type 3 MUST appear between types 1 and 2 for podcast support + # - Type 1 MUST be first — older iPod firmware (Video 5G, Nano 1G-2G) + # may assume dataset[0] is the track list. + # - Types 8, 6, 10 come between albums (4) and smart playlists (5). + # + # When a reference database is available, we match write only those types. + # For example, iTunes on Nano 6G writes only [4,8,1,3,5] + # (no playlist type 2 or empty stubs 6/10). Including types the + # firmware doesn't expect can cause it to reject or mis-parse the + # database. We still keep the libgpod order to stay compatible + # with devices where no reference is available. + + # Determine which MHSD types the reference database uses (if any) + ref_types: set[int] | None = None + ref_order: list[int] | None = None + if reference_info and 'mhsd_types' in reference_info: + rt = reference_info['mhsd_types'] + # Only use ref_types if extraction found meaningful data (at least type 1) + if rt and 1 in rt: + ref_types = rt + ref_order = reference_info.get('mhsd_order') + logger.debug("Reference MHSD types: %s (order: %s)", + sorted(ref_types) if ref_types else "none (fallback to all)", + ref_order if ref_order else "default") + + # Build the candidate datasets in priority order + # Each entry: (type_number, data_bytes, required_flag) + # When ref_types is available, only include types that are present in it. + # Otherwise, include all types (libgpod-compatible default). + + def _include(dtype: int, required: bool = False) -> bool: + if required: + return True + if ref_types is None: + return True # no reference → include everything + return dtype in ref_types + + # Map type numbers to their data blobs + type_to_data: dict[int, bytes] = { + 1: mhsd_type1, + 2: mhsd_type2, + 3: mhsd_type3 if (include_podcasts and mhsd_type3) else b'', + 4: mhsd_type4, + 5: mhsd_type5, + 6: mhsd_type6, + 8: mhsd_type8, + 10: mhsd_type10, + } + + # Assemble datasets — use reference order if available, else libgpod order + dataset_entries: list[tuple[int, bytes]] = [] + if ref_order: + # Follow the exact order from the reference database + for dtype in ref_order: + if dtype not in type_to_data: + continue + # Type 3 (podcasts) requires include_podcasts flag + if dtype == 3 and not include_podcasts: + continue + if _include(dtype, required=(dtype in (1, 4, 8))): + data = type_to_data[dtype] + if data: + dataset_entries.append((dtype, data)) + # Add any required types that weren't in the reference order + for dtype in (1, 4, 8): + if not any(t == dtype for t, _ in dataset_entries): + dataset_entries.append((dtype, type_to_data[dtype])) + else: + # Default libgpod order: 1, 3, 2, 4, 8, 6, 10, 5 + dataset_entries.append((1, mhsd_type1)) # always required + if include_podcasts and _include(3): + dataset_entries.append((3, mhsd_type3)) + if _include(2): + dataset_entries.append((2, mhsd_type2)) + dataset_entries.append((4, mhsd_type4)) # always required + dataset_entries.append((8, mhsd_type8)) # always required + if _include(6): + dataset_entries.append((6, mhsd_type6)) + if _include(10): + dataset_entries.append((10, mhsd_type10)) + if _include(5): + dataset_entries.append((5, mhsd_type5)) + + all_datasets = b''.join(data for _, data in dataset_entries) + child_count = len(dataset_entries) + logger.debug("Writing %d MHSD datasets: %s", child_count, [t for t, _ in dataset_entries]) + + # Append preserved MHSD blobs from original database (Type 7 and 9). + extra_blobs = preserved_mhsd_blobs or [] + for blob in extra_blobs: + all_datasets += blob + child_count += len(extra_blobs) + + # Total file length + total_length = MHBD_HEADER_SIZE + len(all_datasets) + + # ── Compute all field values before writing ────────────────────── + + # +0x0C: compressed — 2 for devices with iTunesCDB, 1 otherwise + compressed = 2 if (capabilities and capabilities.supports_compressed_db) else 1 + + # +0x10: Version — already computed above (before MHLT build) + + # +0x32: unk0x32 — preserve from reference (libgpod does this) + unk0x32 = b'\x00' * 20 + if reference_info and 'unk0x32' in reference_info: + raw = reference_info['unk0x32'] + if isinstance(raw, (bytes, bytearray)) and len(raw) == 20: + unk0x32 = bytes(raw) + + # +0x46: Language ID (2 bytes, e.g. "en") + if reference_info and 'language' in reference_info: + lang_val = reference_info['language'] + if isinstance(lang_val, str): + lang_val = lang_val.encode('utf-8')[:2].ljust(2, b'\x00') + else: + lang_val = language.encode('utf-8')[:2].ljust(2, b'\x00') + + # +0x48: Library Persistent ID — must match iTunesPrefs (macOS protection) + try: + from ipod_device import generate_library_id + lib_pid = struct.unpack(' bool: + """ + Write a complete iTunesDB to an iPod. + + This function: + 1. Optionally writes ArtworkDB + ithmb files from PC embedded art + 2. Builds the database structure + 3. Applies the appropriate checksum/hash for the device + 4. Writes atomically (temp file + rename) + + Args: + ipod_path: Mount point of iPod + tracks: List of TrackInfo objects + db_id: Database ID (uses existing or generates new) + backup: Whether to backup existing iTunesDB + force_checksum: Override auto-detected checksum type (for devices with empty SysInfo) + firewire_id: 8-byte FireWire ID for HASH58 (can be extracted from existing database) + reference_itdb_path: Path to a known-good iTunesDB to extract hash info from + (useful for devices with empty SysInfo) + pc_file_paths: Dict mapping track db_track_id (int) → PC source file path (str) + for extracting embedded album art. If provided, ArtworkDB + and ithmb files will be written and mhii_link set on tracks. + playlists: List of PlaylistInfo for user playlists (dataset 2). + Master playlist is auto-generated; does NOT belong in this list. + smart_playlists: List of PlaylistInfo for dataset 5 smart playlists. + capabilities: Device capabilities from ``ipod_device``. Auto-detected + from the current device if not provided. + master_playlist_name: Display name for the auto-generated master playlist. + + Returns: + True if successful + """ + from ipod_device import itdb_write_filename, resolve_itdb_path + + def _progress(msg: str) -> None: + if progress_callback is not None: + progress_callback(msg) + + _progress("Preparing database") + + # Determine the correct database filename for this device (iTunesDB or iTunesCDB) + db_filename = itdb_write_filename(ipod_path) + itdb_path = os.path.join(ipod_path, "iPod_Control", "iTunes", db_filename) + + # Auto-detect capabilities from the centralized device store + if capabilities is None: + try: + from ipod_device import capabilities_for_family_gen, get_current_device + dev = get_current_device() + if dev and dev.model_family: + capabilities = capabilities_for_family_gen( + dev.model_family, dev.generation or "", + ) + if capabilities: + logger.debug( + "Auto-detected capabilities: %s %s (db_version=0x%X, " + "podcast=%s, gapless=%s, video=%s, music_dirs=%d)", + dev.model_family, dev.generation or "(family fallback)", + capabilities.db_version, + capabilities.supports_podcast, + capabilities.supports_gapless, + capabilities.supports_video, + capabilities.music_dirs, + ) + except Exception as e: + logger.debug("Could not auto-detect capabilities: %s", e) + + # Read existing database for reference (for db_id and hash info extraction) + # Check both iTunesCDB and iTunesDB — the existing database may be under + # either name, and we may be switching filenames (e.g. first iOpenPod write + # to a device that previously only had iTunesCDB from iTunes). + existing_itdb = None + existing_itdb_path = resolve_itdb_path(ipod_path) + if existing_itdb_path: + try: + with open(existing_itdb_path, 'rb') as f: + existing_itdb = f.read() + logger.debug("Read existing database from %s (%d bytes)", + existing_itdb_path, len(existing_itdb)) + except Exception as exc: + logger.warning("Could not read existing database %s: %s", + existing_itdb_path, exc) + + # Also read reference iTunesDB if provided + reference_itdb = None + if reference_itdb_path and os.path.exists(reference_itdb_path): + try: + with open(reference_itdb_path, 'rb') as f: + reference_itdb = f.read() + except Exception: + pass + + # Try to preserve existing db_id if file exists + if db_id is None and existing_itdb and existing_itdb[:4] == b'mhbd' and len(existing_itdb) >= 32: + db_id = struct.unpack('= 244: + # Decompress iTunesCDB payload if needed — the MHSD children + # (needed for type extraction) are in the zlib-compressed payload. + source_itdb_full = _maybe_decompress_cdb(source_itdb) + hdr_len_ref = struct.unpack(' len(source_itdb_full): + break + if source_itdb_full[ref_off:ref_off + 4] != b'mhsd': + break + ref_mhsd_type = struct.unpack(' len(source_itdb_full): + break + mhsd_total = struct.unpack(' 0: + mhit_off = mhlt_off + mhlt_hdr_len + reference_info['mhit_header_size'] = struct.unpack(' %d bytes for iTunesCDB (level 1)", + uncompressed_size, len(cdb_buf)) + # All subsequent checksum code must operate on the compressed buffer + itdb_data = cdb_buf + + _progress("Signing database") + + # Detect checksum type (or use forced type) + # Use reference or existing database as the source for hash extraction + source_itdb = reference_itdb or existing_itdb + hash_error: str | None = None # set on fatal hash failure + + if force_checksum is not None: + checksum_type = force_checksum + logger.debug("Using forced checksum type: %s", checksum_type.name) + else: + checksum_type = detect_checksum_type(ipod_path) + # If detection returned NONE but we have an existing database with hashing, + # infer the checksum type from it + if checksum_type == ChecksumType.NONE and source_itdb and len(source_itdb) >= 0xA0: + existing_scheme = struct.unpack('= 0xA0 and source_itdb[0x72:0x74] == bytes([0x01, 0x00]): + from .hash72 import _compute_itunesdb_sha1, _hash_generate, extract_hash_info_to_dict + hash_dict = extract_hash_info_to_dict(source_itdb) + if hash_dict: + sha1 = _compute_itunesdb_sha1(itdb_data) + signature = _hash_generate(sha1, hash_dict['iv'], hash_dict['rndpart']) + itdb_data[0x72:0x72 + 46] = signature + logger.debug("HASH72 signature written first (hash58 depends on it)") + + # Step 2: Write HASH58 (HMAC-SHA1 using key derived from device FireWire GUID) + # Try to get FireWire ID from parameter, SysInfo, SysInfoExtended, or Windows registry + if firewire_id is None: + try: + from ipod_device import get_firewire_id + firewire_id = get_firewire_id(ipod_path) + except Exception as e: + logger.warning("Could not get FireWire ID: %s", e) + + if firewire_id: + write_hash58(itdb_data, firewire_id) + logger.info("HASH58 signature computed with FireWire ID: %s", firewire_id.hex()) + elif source_itdb and len(source_itdb) >= 0x6C and source_itdb[0x58:0x6C] != bytes(20): + # Last resort: copy hash58 from reference database + # NOTE: This is WRONG if the database content changed! hash58 is content-dependent. + # This fallback only works if the database is byte-identical to the reference. + itdb_data[0x58:0x6C] = source_itdb[0x58:0x6C] + logger.warning("HASH58 copied from reference (content-dependent — may be invalid!)") + logger.warning(" To fix: connect iPod so FireWire GUID can be read from USB serial") + else: + logger.error("No FireWire ID and no reference hash58 — database will be rejected!") + + elif checksum_type == ChecksumType.HASH72: + # Try to get hash info from centralized store first, then fall back to disk + from .hash72 import HashInfo, _compute_itunesdb_sha1, _hash_generate, extract_hash_info_to_dict, read_hash_info + + hash_info = None + try: + from ipod_device import get_current_device + dev = get_current_device() + if dev and dev.hash_info_iv and dev.hash_info_rndpart: + hash_info = HashInfo(uuid=b'\x00' * 20, rndpart=dev.hash_info_rndpart, iv=dev.hash_info_iv) + logger.debug("HashInfo loaded from centralized device store") + except Exception: + pass + + if hash_info is None: + # Fallback: read_hash_info checks the store again (harmless) + # then reads from disk if needed + try: + hash_info = read_hash_info(ipod_path) + except Exception: + pass + + # Set hashing_scheme BEFORE computing hash72 — the SHA1 includes + # this field (it is NOT zeroed), so it must have its final value + # when the hash is computed. libgpod itdb_hash72_write_hash sets + # this to ITDB_CHECKSUM_HASH72 (2), not 1. Using 1 causes the + # Nano 5G firmware to check hash58 instead of hash72. + struct.pack_into(' bytes: + """ + Write an MHIP (playlist item) chunk. + + MHIP entries link tracks to playlists by referencing the track ID. + Each entry also includes an MHOD type 100 with the position. + + Args: + track_id: The track's ID (from MHIT) + position: Position in playlist (0-based) + mhip_id: Unique ID for this MHIP entry (written at offset 0x14) + In libgpod this is called "podcastgroupid" but it's used + for ALL playlists as a unique entry identifier. + timestamp: Mac timestamp (usually 0) + podcast_group_flag: For podcast grouping (usually 0) + podcast_group_ref: For podcast grouping (usually 0) + track_persistent_id: The track's db_track_id (persistent identifier) + mhip_persistent_id: Per-track persistent ID for this playlist item + + Returns: + Complete MHIP chunk bytes + """ + mhod_position = write_mhod_position(position) + total_length = MHIP_HEADER_SIZE + len(mhod_position) + + header = bytearray(MHIP_HEADER_SIZE) + write_generic_header(header, 0, b'mhip', MHIP_HEADER_SIZE, total_length) + write_fields(header, 0, 'mhip', { + 'child_count': 1, + 'podcast_group_flag': podcast_group_flag, + 'group_id': mhip_id, + 'track_id': track_id, + 'timestamp': timestamp, + 'group_id_ref': podcast_group_ref, + 'track_persistent_id': track_persistent_id, + 'mhip_persistent_id': mhip_persistent_id, + }, MHIP_HEADER_SIZE) + + return bytes(header) + mhod_position + + +def write_mhod_position(position: int) -> bytes: + """ + Write an MHOD type 100 (playlist position). + + This MHOD is attached to each MHIP and indicates the track's + position within the playlist. + + Args: + position: Track position in playlist (0-based) + + Returns: + MHOD chunk bytes + """ + total_len = _MHOD_HEADER_SIZE + MHOD100_POSITION_BODY_SIZE + header = write_mhod_header(100, total_len) + + # Data section: position(4) + padding(16) + data = struct.pack(' bytes: + """Write a podcast group header MHIP. + + In the type 3 (podcast) MHSD dataset, episodes are grouped under + their podcast show. Each show gets a "group header" MHIP that + serves as a parent node. Child episode MHIPs reference it via + ``group_id_ref``. + + Group header MHIPs differ from regular MHIPs: + - ``podcast_group_flag`` = 256 (0x100) + - ``track_id`` = 0 (no track reference) + - Contains an MHOD type 1 (title) with the album/show name + instead of an MHOD type 100 (position) + + This matches libgpod's ``write_one_podcast_group()`` in + ``itdb_itunesdb.c``. + + Args: + album_name: Podcast show / album name for the group header + group_id: Unique identifier for this group (child MHIPs + reference this value in their ``group_id_ref`` field) + + Returns: + Complete MHIP chunk bytes (header + MHOD title) + """ + from .mhod_writer import write_mhod_string + + mhod_title = write_mhod_string(MHOD_TYPE_TITLE, album_name) + total_length = MHIP_HEADER_SIZE + len(mhod_title) + + header = bytearray(MHIP_HEADER_SIZE) + write_generic_header(header, 0, b'mhip', MHIP_HEADER_SIZE, total_length) + write_fields(header, 0, 'mhip', { + 'child_count': 1, + 'podcast_group_flag': 256, # 0x100 = podcast group header + 'group_id': group_id, + 'track_id': 0, # group headers don't reference a track + }, MHIP_HEADER_SIZE) + + return bytes(header) + mhod_title diff --git a/src/vendor/iTunesDB_Writer/mhit_writer.py b/src/vendor/iTunesDB_Writer/mhit_writer.py new file mode 100644 index 0000000..e926219 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhit_writer.py @@ -0,0 +1,394 @@ +""" +MHIT Writer — Write track item chunks for iTunesDB. + +MHIT chunks contain all metadata for a single track, plus child MHOD +chunks for strings (title, artist, path, etc.). + +The binary layout of the header is defined declaratively in +``iTunesDB_Shared.field_defs.MHIT_FIELDS``. This writer builds a +values dict and delegates serialization to ``write_fields()``, +guaranteeing that field offsets / sizes / transforms stay in sync +with the parser. + +Cross-referenced against: + - iTunesDB_Shared/field_defs.py (single source of truth for offsets) + - iTunesDB_Parser/mhit_parser.py parse_trackItem() + - libgpod itdb_itunesdb.c: mk_mhit() + - iPodLinux wiki MHIT documentation +""" + +import random +import time +from dataclasses import dataclass + +from iTunesDB_Shared.constants import ( + AUDIO_FORMAT_FLAG_DEFAULT, + AUDIO_FORMAT_FLAG_MAP, + FILETYPE_CODES, + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_MUSIC_VIDEO, + MEDIA_TYPE_PODCAST, + MEDIA_TYPE_TV_SHOW, + MEDIA_TYPE_VIDEO, + MEDIA_TYPE_VIDEO_PODCAST, +) +from iTunesDB_Shared.field_base import write_fields, write_generic_header +from iTunesDB_Shared.mhit_defs import MHIT_HEADER_SIZE, mhit_header_size_for_version + +from .mhod_writer import write_track_mhods + + +def generate_db_track_id() -> int: + """Generate a random 64-bit persistent ID for a track.""" + return random.getrandbits(64) + + +generate_db_id = generate_db_track_id + + +@dataclass +class TrackInfo: + """Track metadata for writing to iTunesDB.""" + + # Required + title: str + location: str # iPod path like ":iPod_Control:Music:F00:ABCD.mp3" + + # File info + size: int = 0 # File size in bytes + length: int = 0 # Duration in milliseconds + filetype: str = 'mp3' # mp3, m4a, m4p, etc. + bitrate: int = 0 # kbps + sample_rate: int = 44100 # Hz + vbr: bool = False + + # Metadata + artist: str | None = None + album: str | None = None + album_artist: str | None = None + genre: str | None = None + composer: str | None = None + comment: str | None = None + year: int = 0 + track_number: int = 0 + total_tracks: int = 0 + disc_number: int = 1 + total_discs: int = 1 + bpm: int = 0 + compilation_flag: bool = False + + # Playback + rating: int = 0 # 0-100 (stars × 20) + play_count: int = 0 + skip_count: int = 0 + volume: int = 0 # -255 to +255 + start_time: int = 0 # ms + stop_time: int = 0 # ms + sound_check: int = 0 # Volume normalization value (from ReplayGain) + bookmark_time: int = 0 # Resume position in ms (audiobooks/podcasts) + checked_flag: int = 0 # 0 = checked/enabled, 1 = unchecked/disabled + + # Gapless playback + gapless_data: int = 0 # Gapless playback encoder delay data + gapless_track_flag: int = 0 # 1 = track has gapless info + gapless_album_flag: int = 0 # 1 = album is gapless + pregap: int = 0 # Encoder pregap samples + postgap: int = 0 # Encoder postgap/padding samples (0xC8) + sample_count: int = 0 # Total decoded sample count (64-bit) + encoder_flag: int = 0 # 0xCC: 0x01=MP3 encoder, 0x00=other + + # Track flags + skip_when_shuffling: bool = False # 1 = skip in shuffle mode + remember_position: bool = False # 1 = resume from bookmark (audiobooks) + podcast_flag: int = 0 # 0xA7: 0x00=normal, 0x01/0x02=podcast + movie_file_flag: int = 0 # 0xB1: 0x01=video/movie file, 0x00=audio + played_mark: int = -1 # 0xB2: -1=auto (derive from play_count), 0x01=played, 0x02=unplayed + explicit_flag: int = 0 # 0=none, 1=explicit, 2=clean + purchased_aac_flag: int = 0 # 0x93: 1 for M4A/iTunes purchases, 0 for most MP3s + has_lyrics: bool = False # True if track has embedded lyrics + lyrics: str | None = None # Full lyrics text (MHOD type 10) + eq_setting: str | None = None # EQ preset name (MHOD type 7), e.g. "Bass Booster" + + # Timestamps (Unix) + date_added: int = 0 # Will be set to now if 0 + date_released: int = 0 + last_modified: int = 0 # 0x20: file modification time (0 = use date_added) + last_played: int = 0 + last_skipped: int = 0 + + # iPod-specific + track_id: int = 0 # Will be assigned during write + db_track_id: int = 0 # Will be generated if 0 + media_type: int = MEDIA_TYPE_AUDIO + season_number: int = 0 # 0xD4: TV show season number + episode_number: int = 0 # 0xD8: TV show episode number + artwork_count: int = 0 + artwork_size: int = 0 + mhii_link: int = 0 # Link to ArtworkDB + album_id: int = 0 # Links to MHIA album entry + source_path: str | None = None # PC source path; internal write-time helper context + source_relative_path: str | None = None # PC path relative to library root, if known + + # Sorting + sort_artist: str | None = None + sort_name: str | None = None + sort_album: str | None = None + sort_album_artist: str | None = None + sort_composer: str | None = None + + # Extra string metadata + grouping: str | None = None + keywords: str | None = None # MHOD type 24 (track keywords) + + # Podcast string metadata (written as MHODs) + podcast_enclosure_url: str | None = None # MHOD type 15 + podcast_rss_url: str | None = None # MHOD type 16 + category: str | None = None # MHOD type 9 + + # Video string metadata (written as MHODs) + description: str | None = None # MHOD type 14 + subtitle: str | None = None # MHOD type 18 + show_name: str | None = None # MHOD type 19 (TV show name) + episode_id: str | None = None # MHOD type 20 (e.g. "S01E05") + network_name: str | None = None # MHOD type 21 (TV network) + sort_show: str | None = None # MHOD type 31 + show_locale: str | None = None # MHOD type 25 (show locale, e.g. "en_US") + + # Filetype description + filetype_desc: str | None = None # e.g., "MPEG audio file" + + # Round-trip fields (preserved from existing iPod database) + user_id: int = 0 # 0x64: DRM user ID (preserved for round-trip) + app_rating: int = 0 # 0x79: Application-computed rating (preserved for round-trip) + mpeg_audio_type: int = 0 # 0x90: MPEG Audio Object Type (12=MP3, 51=AAC, 41=Audible) + + # iTunes Store metadata (round-trip, only for Store purchases) + date_added_to_itunes: int = 0 # 0xDC: Unix ts, original iTunes library add date + store_track_id: int = 0 # 0xE0: iTunes Store per-track content ID + store_encoder_version: int = 0 # 0xE4: iTunes version that encoded the file + store_artist_id: int = 0 # 0xE8: iTunes Store artist/collection ID + store_album_id: int = 0 # 0xF0: iTunes Store album ID + store_content_flag: int = 0 # 0xF4: iTunes Store content type flag + + # Internal IDs (assigned during database write, NOT user-provided) + artist_id: int = 0 # Links to artist entry (assigned by writer) + composer_id: int = 0 # Links to composer entry (assigned by writer) + + # Chapter data (MHOD type 17) lives in iTunesDB, independent of filetype. + chapter_data: dict | None = None + + # Internal sync hint (transient, not written to database) + # Used by ArtworkDB writer to determine preservation strategy: + # "preserve_existing" = use existing art without re-encoding + # "clear_art" = remove art for this track + # "" (empty) = normal processing + _iop_artwork_sync_hint: str = "" + + @property + def db_id(self) -> int: + """Backward-compatible alias for the track persistent ID.""" + return self.db_track_id + + @db_id.setter + def db_id(self, value: int) -> None: + self.db_track_id = value + + +def _compute_sort_indicators(track: TrackInfo) -> bytes: + """Build the 8-byte sort_mhod_indicators field from sort field presence. + + Byte layout (verified via exhaustive bit-correlation across 9 databases): + [0] = sort_title (MHOD 27) + [1] = sort_album (MHOD 28) + [2] = sort_artist (MHOD 23) + [3] = sort_album_artist (MHOD 29) + [4] = sort_composer (MHOD 30) + [5] = sort_show (MHOD 31) + [6..7] = unused (always 0) + + bit 0 = has corresponding sort MHOD override + bit 7 = collation flag (0x80), always set for compatibility + """ + ind = bytearray(8) + ind[0] = 0x81 if track.sort_name else 0x80 + ind[1] = 0x81 if track.sort_album else 0x80 + ind[2] = 0x81 if track.sort_artist else 0x80 + ind[3] = 0x81 if track.sort_album_artist else 0x80 + ind[4] = 0x81 if track.sort_composer else 0x80 + ind[5] = 0x81 if track.sort_show else 0x80 + return bytes(ind) + + +def _resolve_media_type(track: TrackInfo, capabilities) -> int: + """Downgrade media type when the device lacks required capability.""" + media_type = track.media_type + if capabilities is None: + return media_type + if not capabilities.supports_video: + if media_type in (MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC_VIDEO, MEDIA_TYPE_TV_SHOW): + return MEDIA_TYPE_AUDIO + if media_type == MEDIA_TYPE_VIDEO_PODCAST: + return MEDIA_TYPE_PODCAST + if not capabilities.supports_podcast: + if media_type in (MEDIA_TYPE_PODCAST, MEDIA_TYPE_VIDEO_PODCAST): + return MEDIA_TYPE_AUDIO + return media_type + + +def _resolve_movie_flag(track: TrackInfo, media_type: int) -> int: + """Derive movie_flag from media_type when not explicitly set.""" + if track.movie_file_flag != 0: + return track.movie_file_flag + if media_type in (MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC_VIDEO, + MEDIA_TYPE_TV_SHOW, MEDIA_TYPE_VIDEO_PODCAST): + return 1 + return 0 + + +def _resolve_not_played(track: TrackInfo) -> int: + """Resolve the not_played_flag: auto-derive from play_count when -1.""" + if track.played_mark >= 0: + return track.played_mark + return 0x01 if track.play_count > 0 else 0x02 + + +def _gapless_or_zero(value: int, capabilities) -> int: + """Return *value* when the device supports gapless, else 0.""" + if capabilities is not None and not capabilities.supports_gapless: + return 0 + return value + + +def write_mhit(track: TrackInfo, track_id: int, db_id_2: int = 0, + capabilities=None, db_version: int = 0) -> bytes: + """Write a complete MHIT chunk with all child MHODs. + + Args: + track: TrackInfo dataclass with all track metadata. + track_id: Unique track ID within this database. + db_id_2: Database-wide ID from MHBD offset 0x24 (written into every track). + capabilities: Optional DeviceCapabilities for gapless/video filtering. + db_version: Database version — controls the MHIT header size. + Older iPods require smaller headers (e.g. 0x148 for db_version ≤ 0x19). + + Returns: + Complete MHIT chunk bytes (header + MHODs). + """ + if track.db_track_id == 0: + track.db_track_id = generate_db_track_id() + if track.date_added == 0: + track.date_added = int(time.time()) + + ft = track.filetype.lower() + filetype_code = FILETYPE_CODES.get(ft, FILETYPE_CODES['mp3']) + media_type = _resolve_media_type(track, capabilities) + has_lyrics = track.has_lyrics or bool(track.lyrics) + + # Build child MHODs first to know count + size. + mhod_data, mhod_count = write_track_mhods( + title=track.title, location=track.location, + artist=track.artist, album=track.album, genre=track.genre, + album_artist=track.album_artist, composer=track.composer, + comment=track.comment, filetype_desc=track.filetype_desc, + sort_artist=track.sort_artist, sort_name=track.sort_name, + sort_album=track.sort_album, sort_album_artist=track.sort_album_artist, + sort_composer=track.sort_composer, grouping=track.grouping, + keywords=track.keywords, description=track.description, + subtitle=track.subtitle, show_name=track.show_name, + episode_id=track.episode_id, network_name=track.network_name, + sort_show=track.sort_show, show_locale=track.show_locale, + podcast_enclosure_url=track.podcast_enclosure_url, + podcast_rss_url=track.podcast_rss_url, category=track.category, + lyrics=track.lyrics, eq_setting=track.eq_setting, + chapter_data=track.chapter_data, + ) + + # Use device-appropriate header size. Older iPod firmware expects smaller + # MHIT headers; fields beyond the header boundary are automatically skipped + # by write_fields() via each field's min_header_length attribute. + header_size = mhit_header_size_for_version(db_version) if db_version else MHIT_HEADER_SIZE + total_length = header_size + len(mhod_data) + + # Assemble the values dict — write_fields handles transforms & packing. + values: dict = { + 'child_count': mhod_count, + 'track_id': track_id, + 'visible': 1, + 'filetype': filetype_code, + 'vbr_flag': 1 if track.vbr else 0, + 'mp3_flag': 1 if ft == 'mp3' else 0, + 'compilation_flag': 1 if track.compilation_flag else 0, + 'rating': track.rating, + 'last_modified': track.last_modified or track.date_added, + 'size': track.size, + 'length': track.length, + 'track_number': track.track_number, + 'total_tracks': track.total_tracks, + 'year': track.year, + 'bitrate': track.bitrate, + 'sample_rate_1': track.sample_rate, + 'volume': track.volume, + 'start_time': track.start_time, + 'stop_time': track.stop_time, + 'sound_check': track.sound_check, + 'play_count_1': track.play_count, + 'play_count_2': 0, # reset after sync + 'last_played': track.last_played, + 'disc_number': track.disc_number, + 'total_discs': track.total_discs, + 'user_id': track.user_id, + 'date_added': track.date_added, + 'bookmark_time': track.bookmark_time, + 'db_track_id': track.db_track_id, + 'checked_flag': track.checked_flag, + 'app_rating': track.app_rating, + 'bpm': max(0, track.bpm) if track.bpm is not None else 0, + 'artwork_count': track.artwork_count, + 'audio_format_flag': AUDIO_FORMAT_FLAG_MAP.get(ft, AUDIO_FORMAT_FLAG_DEFAULT), + 'artwork_size': track.artwork_size, + 'sample_rate_2': float(track.sample_rate), + 'date_released': track.date_released, + 'mpeg_audio_type': track.mpeg_audio_type, + 'explicit_flag': track.explicit_flag, + 'purchased_aac_flag': track.purchased_aac_flag, + # Extended fields + 'skip_count': track.skip_count, + 'last_skipped': track.last_skipped, + 'has_artwork': 1 if track.artwork_count > 0 else 2, + 'skip_when_shuffling': 1 if track.skip_when_shuffling else 0, + 'remember_position': 1 if track.remember_position else 0, + 'use_podcast_now_playing_flag': track.podcast_flag, + 'db_track_id_2': track.db_track_id, + 'lyrics_flag': 1 if has_lyrics else 0, + 'movie_flag': _resolve_movie_flag(track, media_type), + 'not_played_flag': _resolve_not_played(track), + 'pregap': _gapless_or_zero(track.pregap, capabilities), + 'sample_count': _gapless_or_zero(track.sample_count, capabilities), + 'postgap': _gapless_or_zero(track.postgap, capabilities), + 'encoder': track.encoder_flag, + 'media_type': media_type, + 'season_number': track.season_number, + 'episode_number': track.episode_number, + 'date_added_to_itunes': track.date_added_to_itunes, + 'store_track_id': track.store_track_id, + 'store_encoder_version': track.store_encoder_version, + 'store_artist_id': track.store_artist_id, + 'store_album_id': track.store_album_id, + 'store_content_flag': track.store_content_flag, + 'gapless_audio_payload_size': _gapless_or_zero(track.gapless_data, capabilities), + 'gapless_track_flag': _gapless_or_zero(track.gapless_track_flag, capabilities), + 'gapless_album_flag': _gapless_or_zero(track.gapless_album_flag, capabilities), + 'album_id': track.album_id, + 'db_id_2_ref': db_id_2, + 'size_2': track.size, + 'sort_mhod_indicators': _compute_sort_indicators(track), + 'artwork_id_ref': track.mhii_link, + 'artist_id_ref': track.artist_id, + 'composer_id': track.composer_id, + } + + header = bytearray(header_size) + write_generic_header(header, 0, b'mhit', header_size, total_length) + write_fields(header, 0, 'mhit', values, header_size) + + return bytes(header) + mhod_data diff --git a/src/vendor/iTunesDB_Writer/mhla_writer.py b/src/vendor/iTunesDB_Writer/mhla_writer.py new file mode 100644 index 0000000..b0165e3 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhla_writer.py @@ -0,0 +1,199 @@ +"""MHLA Writer — Write album list chunks for iTunesDB. + +MHLA (album list) contains MHIA (album item) entries that group tracks. +Introduced in iTunes 7.1 (dbversion >= 0x14). + +MHLA header layout (MHLA_HEADER_SIZE = 92 bytes): + +0x00: 'mhla' magic (4B) + +0x04: header_length (4B) + +0x08: album_count (4B) + +MHIA header layout (MHIA_HEADER_SIZE = 88 bytes): + +0x00: 'mhia' magic (4B) + +0x04: header_length (4B) + +0x08: total_length (4B) — header + child MHODs + +0x0C: child_count (4B) + +0x10: album_id (4B) — links to MHIT.albumID + +0x14: sql_id (8B) — internal iPod DB id (must be non-zero) + +0x1C: platform_flag (2B, always 2) + album_compilation_flag (2B, 0=normal, 1=compilation) + + Children: MHOD types 200 (album name), 201 (artist), 202 (sort artist) + +Cross-referenced against: + - iTunesDB_Parser/mhia_parser.py parse_albumItem() + - libgpod itdb_itunesdb.c: mk_mhia() +""" + +import random +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .mhit_writer import TrackInfo + +from iTunesDB_Shared.album_identity import ( + album_identity_from_track, + group_tracks_by_album_identity, +) +from iTunesDB_Shared.constants import ( + MHOD_TYPE_ALBUM_ALBUM, + MHOD_TYPE_ALBUM_ARTIST_ITEM, + MHOD_TYPE_ALBUM_PODCAST_URL, + MHOD_TYPE_ALBUM_SHOW, + MHOD_TYPE_ALBUM_SORT_ARTIST, +) +from iTunesDB_Shared.field_base import ( + MHLA_HEADER_SIZE, + write_fields, + write_generic_header, +) +from iTunesDB_Shared.mhia_defs import MHIA_HEADER_SIZE + +from .mhod_writer import write_mhod_string + + +def write_mhia(album_id: int, album_name: str, album_artist: str, + sort_album_artist: str = "", + podcast_url: str = "", show_name: str = "", + is_compilation: bool = False, + album_track_db_id: int = 0) -> bytes: + """ + Write an MHIA (album item) chunk. + + Args: + album_id: Unique album ID (used to link tracks to albums) + album_name: Album name + album_artist: Album artist + sort_album_artist: Sort album artist (for proper alphabetical sorting) + podcast_url: Podcast RSS URL (MHOD type 203) + show_name: Show/series name (MHOD type 204) + is_compilation: True for Various Artists / compilation albums + album_track_db_id: db_track_id of a representative track in this album + + Returns: + Complete MHIA chunk with MHODs + """ + # Build child MHODs + children = bytearray() + child_count = 0 + + if album_name: + children.extend(write_mhod_string(MHOD_TYPE_ALBUM_ALBUM, album_name)) + child_count += 1 + + if album_artist: + children.extend(write_mhod_string(MHOD_TYPE_ALBUM_ARTIST_ITEM, album_artist)) + child_count += 1 + + if sort_album_artist: + children.extend(write_mhod_string(MHOD_TYPE_ALBUM_SORT_ARTIST, sort_album_artist)) + child_count += 1 + + if podcast_url: + children.extend(write_mhod_string(MHOD_TYPE_ALBUM_PODCAST_URL, podcast_url)) + child_count += 1 + + if show_name: + children.extend(write_mhod_string(MHOD_TYPE_ALBUM_SHOW, show_name)) + child_count += 1 + + # Total chunk length + total_length = MHIA_HEADER_SIZE + len(children) + + # Build header + header = bytearray(MHIA_HEADER_SIZE) + write_generic_header(header, 0, b'mhia', MHIA_HEADER_SIZE, total_length) + + # CRITICAL: sql_id must be non-zero! Clean iTunes DBs have random u64 values here. + sql_id = random.getrandbits(64) + write_fields(header, 0, 'mhia', { + 'child_count': child_count, + 'album_id': album_id, + 'sql_id': sql_id, + 'platform_flag': 2, + 'album_compilation_flag': 1 if is_compilation else 0, + 'album_track_db_id': album_track_db_id, + }, MHIA_HEADER_SIZE) + + return bytes(header) + bytes(children) + + +def _pick_first(tracks: list["TrackInfo"], attr: str) -> str: + for track in tracks: + value = getattr(track, attr, None) or "" + if value: + return value + return "" + + +def write_mhla( + tracks: list["TrackInfo"], + starting_index_for_album_id, +) -> tuple[bytes, dict[tuple[str, str], int], int]: + """ + Write an MHLA (album list) chunk with albums derived from tracks. + + Args: + tracks: List of TrackInfo objects + + Returns: + Tuple of (MHLA chunk bytes, album_map dict mapping (album, artist) to album_id) + """ + groups = group_tracks_by_album_identity(tracks, album_identity_from_track) + + # Build album items + album_items = bytearray() + album_map: dict[tuple[str, str], int] = {} # (album, artist) -> album_id + + def _album_sort_key(group): + identity = group.identity + album_name = identity.album or "" + album_artist = identity.album_artist or identity.artist or "" + show_name = identity.show_name or "" + return (album_name, album_artist, show_name) + + album_id = starting_index_for_album_id + for group in sorted(groups, key=_album_sort_key): + identity = group.identity + album_name = identity.album or "" + album_artist = identity.album_artist or identity.artist or "" + album_map[(album_name, album_artist)] = album_id + # Use sort_albumartist from track first, fall back to sort_artist (per libgpod mk_mhia) + sort_artist = _pick_first(group.tracks, "sort_album_artist") + if not sort_artist: + sort_artist = _pick_first(group.tracks, "sort_artist") + podcast_url = _pick_first(group.tracks, "podcast_rss_url") + show_name = identity.show_name or _pick_first(group.tracks, "show_name") + # Album is a compilation if any track in it has compilation_flag=True + is_compilation = any(t.compilation_flag for t in group.tracks) + # Use first track's db_track_id as the representative track for this album + rep_db_track_id = group.tracks[0].db_track_id if group.tracks else 0 + for track in group.tracks: + track.album_id = album_id + album_items.extend(write_mhia( + album_id, album_name, album_artist, sort_artist, + podcast_url=podcast_url, show_name=show_name, + is_compilation=is_compilation, + album_track_db_id=rep_db_track_id, + )) + album_id += 1 + + album_count = len(album_map) + + # Build header + header = bytearray(MHLA_HEADER_SIZE) + write_generic_header(header, 0, b'mhla', MHLA_HEADER_SIZE, album_count) + + return bytes(header) + bytes(album_items), album_map, album_id + + +def write_mhla_empty() -> bytes: + """ + Write an empty MHLA (album list) chunk. + + Returns: + MHLA header with 0 albums + """ + header = bytearray(MHLA_HEADER_SIZE) + write_generic_header(header, 0, b'mhla', MHLA_HEADER_SIZE, 0) + + return bytes(header) diff --git a/src/vendor/iTunesDB_Writer/mhli_writer.py b/src/vendor/iTunesDB_Writer/mhli_writer.py new file mode 100644 index 0000000..7fe55bd --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhli_writer.py @@ -0,0 +1,136 @@ +"""MHLI Writer — Write artist list chunks for iTunesDB. + +MHSD type 8 contains an artist list using 'mhli' as the list header and +'mhii' as individual artist items. Despite sharing the 'mhii' magic with +ArtworkDB image items, these are structurally different chunks. + +MHLI header layout (MHLI_HEADER_SIZE = 92 bytes): + +0x00: 'mhli' magic (4B) + +0x04: header_length (4B) + +0x08: artist_count (4B) + +MHII header layout (MHII_HEADER_SIZE = 80 bytes, per libgpod mk_mhii): + +0x00: 'mhii' magic (4B) + +0x04: header_length (4B) + +0x08: total_length (4B) — header + child MHODs + +0x0C: child_count (4B) — always 1 (the artist-name MHOD) + +0x10: artist_id (4B) — links to MHIT.artist_id + +0x14: sql_id (8B) — internal iPod DB id (must be non-zero) + +0x1C: platform_flag (4B) — always 2 + + Children: MHOD type 300 (artist name / album-artist name) + +Cross-referenced against: + - libgpod itdb_itunesdb.c: mk_mhii() (artist variant) + - docs/iTunesCDB-internals.md §Type 8 +""" + +import random +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .mhit_writer import TrackInfo + +from iTunesDB_Shared.field_base import ( + MHLI_HEADER_SIZE, + write_fields, + write_generic_header, +) +from iTunesDB_Shared.mhii_defs import MHII_HEADER_SIZE +from iTunesDB_Shared.constants import MHOD_TYPE_ARTIST_NAME +from .mhod_writer import write_mhod_string + + +def write_mhii_artist(artist_id: int, artist_name: str) -> bytes: + """ + Write an MHII (artist item) chunk for the artist list. + + Args: + artist_id: Unique artist ID (used to link tracks to artists) + artist_name: Artist name string + + Returns: + Complete MHII chunk with MHOD type 300 + """ + # Build child MHOD (always exactly 1: the artist name) + children = bytearray() + child_count = 0 + + if artist_name: + children.extend(write_mhod_string(MHOD_TYPE_ARTIST_NAME, artist_name)) + child_count += 1 + + # Total chunk length + total_length = MHII_HEADER_SIZE + len(children) + + # Build header + header = bytearray(MHII_HEADER_SIZE) + write_generic_header(header, 0, b'mhii', MHII_HEADER_SIZE, total_length) + + # CRITICAL: sql_id must be non-zero! Clean iTunes DBs have random u64 values here. + sql_id = random.getrandbits(64) + write_fields(header, 0, 'mhii', { + 'child_count': child_count, + 'artist_id': artist_id, + 'sql_id': sql_id, + 'platform_flag': 2, + }, MHII_HEADER_SIZE) + + return bytes(header) + bytes(children) + + +def write_mhli(tracks: list["TrackInfo"], starting_index_for_artist_id: int) -> tuple[bytes, dict[str, int], int]: + """ + Write an MHLI (artist list) chunk with artists derived from tracks. + + Deduplicates artists using case-insensitive matching (same as album + deduplication in mhla_writer.py). + + Args: + tracks: List of TrackInfo objects + + Returns: + Tuple of (MHLI chunk bytes, artist_map dict mapping artist_name_lower to artist_id) + """ + # Collect unique artists: lowercase artist name → display name + # Use the first occurrence's casing as the canonical display name + artist_display: dict[str, str] = {} + for track in tracks: + artist_name = track.artist or "" + if not artist_name: + continue + key = artist_name.lower() + if key not in artist_display: + artist_display[key] = artist_name + + # Build artist items + artist_items = bytearray() + artist_map: dict[str, int] = {} # lowercase artist → artist_id + + artist_id = starting_index_for_artist_id + for key in sorted(artist_display.keys()): + display_name = artist_display[key] + artist_map[key] = artist_id + artist_items.extend(write_mhii_artist(artist_id, display_name)) + artist_id += 1 + + artist_count = len(artist_map) + + # Build header + header = bytearray(MHLI_HEADER_SIZE) + write_generic_header(header, 0, b'mhli', MHLI_HEADER_SIZE, artist_count) + + return bytes(header) + bytes(artist_items), artist_map, artist_id + + +def write_mhli_empty() -> bytes: + """ + Write an empty MHLI (artist list) chunk. + + Returns: + MHLI header with 0 artists + """ + header = bytearray(MHLI_HEADER_SIZE) + write_generic_header(header, 0, b'mhli', MHLI_HEADER_SIZE, 0) + + return bytes(header) diff --git a/src/vendor/iTunesDB_Writer/mhlp_writer.py b/src/vendor/iTunesDB_Writer/mhlp_writer.py new file mode 100644 index 0000000..f5bbc05 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhlp_writer.py @@ -0,0 +1,238 @@ +"""MHLP Writer — Write playlist list chunks for iTunesDB. + +MHLP (playlist list) wraps all MHYP (playlist) chunks and provides +the playlist count in its header. Every iTunesDB needs at least a +"master playlist" referencing all tracks. + +Header layout (MHLP_HEADER_SIZE = 92 bytes): + +0x00: 'mhlp' magic (4B) + +0x04: header_length (4B) + +0x08: playlist_count (4B) + +Supports: +- Master + user playlists (write_mhlp_with_playlists) +- Dataset 3 podcast playlists (podcast clone of dataset 2) +- Dataset 5 smart playlists (write_mhlp_smart) + +Cross-referenced against: + - iTunesDB_Parser/mhlp_parser.py + - libgpod itdb_itunesdb.c: mk_mhlp() +""" + +from __future__ import annotations + +import logging +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .mhit_writer import TrackInfo + from .mhyp_writer import PlaylistInfo + +from iTunesDB_Shared.field_base import MHLP_HEADER_SIZE, write_generic_header +from .mhyp_writer import write_master_playlist, write_playlist + +logger = logging.getLogger(__name__) + + +def write_mhlp_empty() -> bytes: + """ + Write an empty MHLP (playlist list) chunk. + + Note: An empty MHLP means NO playlists, which may cause issues + on some iPods. Use write_mhlp_with_playlists() for a valid database. + + Returns: + MHLP header with 0 playlists + """ + header = bytearray(MHLP_HEADER_SIZE) + write_generic_header(header, 0, b'mhlp', MHLP_HEADER_SIZE, 0) + + return bytes(header) + + +def write_mhlp(playlist_chunks: List[bytes]) -> bytes: + """ + Write a MHLP chunk with playlists. + + Args: + playlist_chunks: List of MHYP (playlist) chunks + + Returns: + Complete MHLP chunk + """ + # Concatenate all playlist data + playlists_data = b''.join(playlist_chunks) + + header = bytearray(MHLP_HEADER_SIZE) + write_generic_header(header, 0, b'mhlp', MHLP_HEADER_SIZE, len(playlist_chunks)) + + return bytes(header) + playlists_data + + +def write_mhlp_with_playlists( + track_ids: List[int], + playlists: List[PlaylistInfo], + db_id_2, + tracks: Optional[List[TrackInfo]] = None, + capabilities=None, + master_playlist_name: str = "iPod", + master_playlist_id: Optional[int] = None, +) -> bytes: + """ + Write an MHLP chunk with the master playlist + user playlists. + + The master playlist is always first, followed by regular/smart playlists. + This is used for MHSD type 2 (playlists dataset). + + The master playlist is auto-generated from the full track list; its + display name is controlled by *master_playlist_name*. The *playlists* + list should contain only user playlists (no master). + + Args: + track_ids: List of ALL track IDs in the database (for master playlist) + playlists: List of user PlaylistInfo objects (master is NOT included) + tracks: List of ALL TrackInfo objects (needed for library indices) + db_id_2: Database-wide ID from MHBD offset 0x24 + capabilities: Optional DeviceCapabilities for video sort indices. + master_playlist_name: Display name for the auto-generated master playlist. + + Returns: + Complete MHLP chunk + """ + chunks = [] + + # Master playlist MUST be first + master = write_master_playlist( + track_ids, tracks=tracks, db_id_2=db_id_2, + capabilities=capabilities, name=master_playlist_name, + playlist_id=master_playlist_id, + ) + chunks.append(master) + + # Sanity: strip rogue master flags from dataset-2 user playlists. + # Only the auto-generated master above should have master=True. + # Dataset 5 built-in categories (mhsd5_type != 0) legitimately + # need master=True, so we never touch those — even if they end up + # here by accident. + for p in playlists: + if p.master and not p.mhsd5_type: + logger.warning( + "Stripped master flag from user playlist '%s' — " + "master is auto-generated for dataset 2", + p.name, + ) + p.master = False + + # Write all user playlists (regular and smart). + for pl in playlists: + chunks.append(write_playlist(pl, db_id_2=db_id_2)) + + return write_mhlp(chunks) + + +def write_mhlp_with_playlists_type3( + track_ids: List[int], + playlists: List["PlaylistInfo"], + db_id_2: int, + track_album_map: dict[int, str], + tracks: Optional[List["TrackInfo"]] = None, + capabilities=None, + master_playlist_name: str = "iPod", + next_mhip_id_start: int = 1, + master_playlist_id: Optional[int] = None, +) -> bytes: + """Write an MHLP for MHSD type 3 with podcast grouping. + + Identical to :func:`write_mhlp_with_playlists` **except** that playlist + entries marked as podcast (``podcast_flag == 1``) use the grouped + MHIP structure described by libgpod's ``write_podcast_mhips()``. + + In the grouped structure, podcast episodes are nested under their + podcast show (album). Each show gets a group-header MHIP + (``podcast_group_flag=256``, MHOD title = album name) followed by + child episode MHIPs whose ``group_id_ref`` points back to the header. + + Non-podcast playlists are written with the standard flat MHIP layout, + identical to type 2. + + Args: + track_ids: ALL track IDs in the database (for the master playlist) + playlists: User playlist list (same objects as type 2; master is + auto-generated) + db_id_2: Database-wide ID from MHBD offset 0x24 + track_album_map: track_id → album name for podcast grouping + tracks: TrackInfo list (needed for master playlist library indices) + capabilities: DeviceCapabilities (for video sort indices etc.) + master_playlist_name: Display name for the master playlist. + next_mhip_id_start: Starting ID for generated MHIP identifiers. + + Returns: + Complete MHLP chunk bytes. + """ + chunks = [] + + # Master playlist — identical to type 2 + master = write_master_playlist( + track_ids, tracks=tracks, db_id_2=db_id_2, + capabilities=capabilities, name=master_playlist_name, + playlist_id=master_playlist_id, + ) + chunks.append(master) + + for p in playlists: + if p.master and not p.mhsd5_type: + logger.warning( + "Stripped master flag from user playlist '%s' — " + "master is auto-generated for dataset 3", + p.name, + ) + p.master = False + + for pl in playlists: + chunks.append(write_playlist( + pl, db_id_2=db_id_2, + podcast_grouping=True, + track_album_map=track_album_map, + next_mhip_id_start=next_mhip_id_start, + )) + + return write_mhlp(chunks) + + +def write_mhlp_smart( + playlists: List[PlaylistInfo], + db_id_2: int = 0, +) -> bytes: + """ + Write an MHLP chunk for dataset type 5 (smart playlist list). + + These playlists define iPod built-in browse categories (Music, Movies, + TV Shows, Audiobooks, Podcasts, Rentals). Each has a mhsd5_type value + and smart rules that filter by media type. + + **Master flag semantics for dataset 5:** + All built-in categories legitimately have ``master=True`` which writes + ``type=1`` at MHYP offset +0x14. This is the SAME byte used by the + master playlist in dataset 2, but the meaning differs: + + - Dataset 2 ``type=1``: "this is the master playlist" (exactly one) + - Dataset 5 ``type=1``: "this is a built-in system category" (all of them) + + No single-master constraint is enforced here — every ds5 category + needs ``master=True`` for the iPod firmware to recognise it. + + Args: + playlists: List of PlaylistInfo objects (smart playlists only) + db_id_2: Database-wide ID from MHBD offset 0x24 + + Returns: + Complete MHLP chunk, or empty MHLP if no smart playlists + """ + if not playlists: + return write_mhlp_empty() + + chunks = [] + for pl in playlists: + chunks.append(write_playlist(pl, db_id_2=db_id_2)) + + return write_mhlp(chunks) diff --git a/src/vendor/iTunesDB_Writer/mhlt_writer.py b/src/vendor/iTunesDB_Writer/mhlt_writer.py new file mode 100644 index 0000000..cb69d90 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhlt_writer.py @@ -0,0 +1,59 @@ +"""MHLT Writer — Write track list chunks for iTunesDB. + +MHLT (track list) wraps all MHIT (track) chunks and provides +the track count in its header. + +Header layout (MHLT_HEADER_SIZE = 92 bytes): + +0x00: 'mhlt' magic (4B) + +0x04: header_length (4B) + +0x08: track_count (4B) + +Cross-referenced against: + - iTunesDB_Parser/mhlt_parser.py + - libgpod itdb_itunesdb.c: mk_mhlt() +""" + +from typing import List + +from iTunesDB_Shared.field_base import MHLT_HEADER_SIZE, write_generic_header +from .mhit_writer import write_mhit, TrackInfo + + +def write_mhlt(tracks: List[TrackInfo], start_track_id: int, db_id_2: int, + capabilities=None, db_version: int = 0) -> tuple[bytes, int]: + """ + Write a complete MHLT chunk with all tracks. + + Args: + tracks: List of TrackInfo objects + start_track_id: Starting track ID (increments for each track) + db_id_2: Database-wide ID from MHBD (written into every MHIT db_id_2_ref at offset 0x124) + capabilities: Optional DeviceCapabilities for gapless/video filtering + db_version: Database version — forwarded to write_mhit for header sizing + + Returns: + Tuple of (complete MHLT chunk bytes, next available track ID) + """ + + # Build all track chunks first + track_chunks = [] + track_id = start_track_id + + for track in tracks: + try: + mhit_data = write_mhit(track, track_id, db_id_2, capabilities=capabilities, + db_version=db_version) + except Exception as exc: + raise type(exc)( + f"{exc} (track #{track_id}: {track.artist!r} – {track.title!r})" + ) from exc + track_chunks.append(mhit_data) + track_id += 1 + + # Concatenate all track data + all_tracks_data = b''.join(track_chunks) + + header = bytearray(MHLT_HEADER_SIZE) + write_generic_header(header, 0, b'mhlt', MHLT_HEADER_SIZE, len(tracks)) + + return bytes(header) + all_tracks_data, track_id diff --git a/src/vendor/iTunesDB_Writer/mhod52_writer.py b/src/vendor/iTunesDB_Writer/mhod52_writer.py new file mode 100644 index 0000000..6d6e545 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhod52_writer.py @@ -0,0 +1,334 @@ +""" +MHOD Type 52/53 Writer - Library Playlist Index for iTunesDB. + +These MHODs are written ONLY for the Master Playlist and provide +pre-sorted track indices that the iPod uses to build its browsing +views (Songs, Artists, Albums, Genres, Composers). + +Without these indices, the iPod Classic shows "no songs, no albums" +even if tracks exist in the database. + +Based on libgpod's mk_mhod52(), mk_mhod53(), and write_playlist() +in itdb_itunesdb.c. + +Type 52 (MHOD_ID_LIBPLAYLISTINDEX): + Pre-sorted track position arrays for each sort category. + Format: header(24) + sort_type(4) + count(4) + padding(40) + indices(count*4) + Total = 4*count + 72 + +Type 53 (MHOD_ID_LIBPLAYLISTJUMPTABLE): + Letter-jump table for quick scrolling in each category. + Format: header(24) + sort_type(4) + count(4) + padding(8) + entries(count*12) + Total = 12*count + 40 +""" + +import struct +import unicodedata +from typing import TYPE_CHECKING + +from iTunesDB_Shared.field_base import strip_article +from iTunesDB_Shared.mhod_defs import ( + MHOD52_BODY_HEADER_SIZE, + MHOD53_BODY_HEADER_SIZE, + MHOD53_ENTRY_SIZE, + MHOD_HEADER_SIZE, + SORT_ALBUM, + SORT_ALBUM_ARTIST, + SORT_ARTIST, + SORT_COMPOSER, + SORT_EPISODE, + SORT_GENRE, + SORT_SEASON, + SORT_SHOW, + SORT_TITLE, + write_mhod_header, +) + +if TYPE_CHECKING: + from .mhit_writer import TrackInfo + +# Base sort types — always written +BASE_SORT_TYPES = [SORT_TITLE, SORT_ALBUM, SORT_ARTIST, SORT_GENRE, SORT_COMPOSER] + +# Video sort types — only for devices with supports_video +VIDEO_SORT_TYPES = [SORT_SHOW, SORT_SEASON, SORT_EPISODE] + +# Legacy alias for backward compatibility +ALL_SORT_TYPES = BASE_SORT_TYPES + + +def _sort_key(s: str) -> str: + """ + Create a case-insensitive sort key for a string. + + Strips leading articles (A, An, The) for sorting (matching iTunes behavior), + normalizes unicode, and lowercases. + """ + if not s: + return "" + s = strip_article(s) + # Normalize unicode for consistent comparison + return unicodedata.normalize('NFKD', s).casefold() + + +def _jump_table_letter(s: str) -> int: + """ + Get the first alphanumeric character for jump table grouping. + + Returns uppercase letter (A-Z) as Unicode codepoint, or ord('0') + for strings starting with digits. + + Based on libgpod's jump_table_letter(). + """ + if not s: + return ord('0') + + for ch in s: + if ch.isalnum(): + if ch.isdigit(): + return ord('0') + upper = ord(ch.upper()[0]) + if upper > 0xFFFF: + continue # non-BMP char can't fit in UTF-16 jump table + return upper + + return ord('0') + + +def _get_sort_fields(track: "TrackInfo", sort_type: int) -> tuple: + """ + Get sort key fields for a track based on sort type. + + Returns a tuple used for sorting. Multi-field sorts match + libgpod's mhod52_sort_* comparison functions. + + IMPORTANT: For every field, prefer the sort_* variant over the + display variant (e.g. sort_album over album). This matches + libgpod's ``sort_compare(track->sort_X ? track->sort_X : track->X, ...)`` + pattern used in mhod52_sort_album(), mhod52_sort_artist(), etc. + """ + title = _sort_key(track.sort_name or track.title or "") + album = _sort_key(track.sort_album or track.album or "") + artist = _sort_key(track.sort_artist or track.artist or "") + genre = _sort_key(track.genre or "") + composer = _sort_key(track.sort_composer or track.composer or "") + track_nr = track.track_number or 0 + cd_nr = track.disc_number or 0 + + if sort_type == SORT_TITLE: + return (title,) + elif sort_type == SORT_ALBUM: + return (album, cd_nr, track_nr, title) + elif sort_type == SORT_ARTIST: + return (artist, album, cd_nr, track_nr, title) + elif sort_type == SORT_GENRE: + return (genre, artist, album, cd_nr, track_nr, title) + elif sort_type == SORT_COMPOSER: + return (composer, album, cd_nr, track_nr, title) + elif sort_type == SORT_SHOW: + show = _sort_key(track.sort_show or track.show_name or "") + season = track.season_number or 0 + episode = track.episode_number or 0 + return (show, season, episode, title) + elif sort_type == SORT_SEASON: + season = track.season_number or 0 + episode = track.episode_number or 0 + show = _sort_key(track.sort_show or track.show_name or "") + return (season, episode, show, title) + elif sort_type == SORT_EPISODE: + episode = track.episode_number or 0 + season = track.season_number or 0 + show = _sort_key(track.sort_show or track.show_name or "") + return (episode, season, show, title) + elif sort_type == SORT_ALBUM_ARTIST: + album_artist = _sort_key( + track.sort_album_artist + or track.album_artist + or track.sort_artist or track.artist or "" + ) + return (album_artist, album, cd_nr, track_nr, title) + else: + return (title,) + + +def _get_jump_letter(track: "TrackInfo", sort_type: int) -> int: + """Get the letter for jump table grouping based on sort type. + + Uses sort_* field variants for consistency with ``_get_sort_fields``. + """ + if sort_type == SORT_TITLE: + return _jump_table_letter(track.sort_name or track.title or "") + elif sort_type == SORT_ALBUM: + return _jump_table_letter(track.sort_album or track.album or "") + elif sort_type == SORT_ARTIST: + s = track.sort_artist or track.artist or "" + return _jump_table_letter(s) + elif sort_type == SORT_GENRE: + return _jump_table_letter(track.genre or "") + elif sort_type == SORT_COMPOSER: + return _jump_table_letter(track.sort_composer or track.composer or "") + elif sort_type == SORT_SHOW: + return _jump_table_letter(track.sort_show or track.show_name or "") + elif sort_type == SORT_SEASON: + n = track.season_number or 0 + return _jump_table_letter(str(n)) if n else ord('0') + elif sort_type == SORT_EPISODE: + n = track.episode_number or 0 + return _jump_table_letter(str(n)) if n else ord('0') + elif sort_type == SORT_ALBUM_ARTIST: + s = ( + track.sort_album_artist + or track.album_artist + or track.sort_artist or track.artist or "" + ) + return _jump_table_letter(s) + else: + return _jump_table_letter(track.sort_name or track.title or "") + + +def write_mhod_type52(tracks: list["TrackInfo"], sort_type: int) -> tuple[bytes, list[tuple[int, int, int]]]: + """ + Write a Type 52 MHOD (library playlist index) for one sort category. + + Args: + tracks: List of all TrackInfo objects (in original order) + sort_type: Sort category (SORT_TITLE, SORT_ALBUM, etc.) + + Returns: + Tuple of (MHOD bytes, jump_table_entries) where jump_table_entries + is a list of (letter, start, count) tuples for the corresponding + Type 53 MHOD. + """ + num_tracks = len(tracks) + + # Create indexed list: (sort_key, original_index, track) + indexed = [] + for i, track in enumerate(tracks): + sort_key = _get_sort_fields(track, sort_type) + indexed.append((sort_key, i, track)) + + # Sort by the sort key + indexed.sort(key=lambda x: x[0]) + + # Build sorted track indices (original position in track list) + sorted_indices = [idx for _, idx, _ in indexed] + + # Build jump table entries: group by first letter + jump_entries: list[tuple[int, int, int]] = [] + last_letter = -1 + current_entry = None + + for pos, (_, _, track) in enumerate(indexed): + letter = _get_jump_letter(track, sort_type) + if letter != last_letter: + current_entry = (letter, pos, 0) + jump_entries.append(current_entry) + last_letter = letter + # Increment count for current entry + letter_val, start, count = jump_entries[-1] + jump_entries[-1] = (letter_val, start, count + 1) + + # Build MHOD type 52 binary data + # Body: sort_type(4) + count(4) + padding(40) + indices(count*4) + total_len = 4 * num_tracks + MHOD_HEADER_SIZE + MHOD52_BODY_HEADER_SIZE + + header = write_mhod_header(52, total_len) + + # Body header + body_header = bytearray(MHOD52_BODY_HEADER_SIZE) + struct.pack_into(' bytes: + """ + Write a Type 53 MHOD (library playlist jump table) for one sort category. + + Args: + sort_type: Sort category (must match corresponding type 52) + jump_entries: List of (letter, start, count) tuples from write_mhod_type52() + + Returns: + Complete MHOD type 53 bytes + """ + num_entries = len(jump_entries) + + # Build MHOD type 53 binary data + # Body: sort_type(4) + count(4) + padding(8) + entries(count*12) + total_len = MHOD53_ENTRY_SIZE * num_entries + MHOD_HEADER_SIZE + MHOD53_BODY_HEADER_SIZE + + header = write_mhod_header(53, total_len) + + # Body header + body_header = bytearray(MHOD53_BODY_HEADER_SIZE) + struct.pack_into(' tuple[bytes, int]: + """ + Write all library index MHODs (type 52 + type 53 pairs) for the + master playlist. + + Base sort categories (always written): + - Title (0x03), Album (0x04), Artist (0x05), Genre (0x07), Composer (0x12) + + Video sort categories (when capabilities.supports_video is True): + - Show (0x1D), Season (0x1E), Episode (0x1F) + + Album Artist sort (0x23) is written for all devices with capabilities + (i.e. modern iPods that use the capabilities system). + + Args: + tracks: List of all TrackInfo objects + capabilities: Optional DeviceCapabilities for conditional sort types. + + Returns: + Tuple of (concatenated MHOD bytes, count of MHODs written) + """ + if not tracks: + return b'', 0 + + # Build the list of sort types to write + sort_types = list(BASE_SORT_TYPES) + if capabilities is not None: + if capabilities.supports_video: + sort_types.extend(VIDEO_SORT_TYPES) + # Album artist sort for all modern iPods + sort_types.append(SORT_ALBUM_ARTIST) + + result = bytearray() + mhod_count = 0 + + for sort_type in sort_types: + # Write type 52 (sorted index) + mhod52_data, jump_entries = write_mhod_type52(tracks, sort_type) + result.extend(mhod52_data) + mhod_count += 1 + + # Write type 53 (jump table) + mhod53_data = write_mhod_type53(sort_type, jump_entries) + result.extend(mhod53_data) + mhod_count += 1 + + return bytes(result), mhod_count diff --git a/src/vendor/iTunesDB_Writer/mhod_spl_writer.py b/src/vendor/iTunesDB_Writer/mhod_spl_writer.py new file mode 100644 index 0000000..bff5094 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhod_spl_writer.py @@ -0,0 +1,329 @@ +""" +MHOD Type 50/51 Writer — Smart Playlist Preferences & Rules. + +Type 50 (SPLPref): Controls live-update, checked-only, and limit settings. +Type 51 (SPLRules/SLst): The actual filter rules that define the smart playlist. + +The SLst blob is the ONLY part of the iTunesDB that uses big-endian encoding. +All multi-byte integers within SLst must be written as big-endian, and +string values use UTF-16 BE (not LE like the rest of the database). + +Based on libgpod's SPLPref/SPLRules structs in itdb_spl.c / itdb_itunesdb.c +and the parser in iTunesDB_Parser/mhod_parser.py. +""" + +import struct +from dataclasses import dataclass, field + +from iTunesDB_Shared.mhod_defs import ( + MHOD_HEADER_SIZE, + SLST_HEADER_SIZE, + SPL_RULE_DATA_SIZE, + SPL_RULE_HEADER_SIZE, + SPLFT_DATE, + SPLFT_STRING, + SPLPREF_BODY_SIZE, + spl_get_field_type, + write_mhod_header, +) + +DATE_RELATIVE_ACTION_IDS = {0x00000200, 0x02000200} + + +# ──────────────────────────────────────────────────────────── +# Data classes +# ──────────────────────────────────────────────────────────── + +@dataclass +class SmartPlaylistPrefs: + """Smart playlist preferences (MHOD type 50 / SPLPref). + + Mirrors the fields parsed by _parse_mhod50_smart_playlist_data(). + """ + live_update: bool = True + check_rules: bool = True + check_limits: bool = False + limit_type: int = 0x03 # 1=minutes, 2=MB, 3=songs, 4=hours, 5=GB + limit_sort: int = 0x02 # 2=random (low byte); high bit 0x80000000 = reverse + limit_value: int = 25 + match_checked_only: bool = False + + +@dataclass +class SmartPlaylistRule: + """A single smart playlist rule (one entry inside SLst). + + field_id and action_id use the raw integer codes from the parser + constants (SPL_FIELD_MAP, SPL_ACTION_MAP). + """ + field_id: int = 0x02 # e.g. 0x02=Song Name, 0x3C=Media Type + action_id: int = 0x01000002 # e.g. 0x01000002 = "contains" + + # For STRING rules + string_value: str | None = None + + # For non-string rules (INT/DATE/BOOLEAN/PLAYLIST/BINARY_AND) + from_value: int = 0 + from_date: int = 0 + from_units: int = 0 + to_value: int = 0 + to_date: int = 0 + to_units: int = 0 + + # Five unknown trailing 32-bit values (preserved for round-trip) + unk052: int = 0 + unk056: int = 0 + unk060: int = 0 + unk064: int = 0 + unk068: int = 0 + + +@dataclass +class SmartPlaylistRules: + """Full smart playlist rules container (MHOD type 51 / SLst). + + conjunction: "AND" (match all) or "OR" (match any) + """ + conjunction: str = "AND" # "AND" or "OR" + rules: list[SmartPlaylistRule] = field(default_factory=list) + unk004: int = 0 # SLst header +0x04, usually 0 (preserved for round-trip) + + +def _signed_i64(value: int) -> int: + value = int(value or 0) + if value >= (1 << 63): + return value - (1 << 64) + return value + + +def _normalize_relative_date_fields( + from_value: int, + from_date: int, + from_units: int = 0, +) -> tuple[int, int]: + signed_from_value = _signed_i64(from_value) + normalized_from_date = int(from_date or 0) + if normalized_from_date: + normalized_from_date = -abs(normalized_from_date) + elif signed_from_value: + amount = abs(signed_from_value) + units = int(from_units or 0) + if units > 1 and amount >= units and amount % units == 0: + normalized_from_date = -(amount // units) + else: + normalized_from_date = -amount + return 0, normalized_from_date + + +# ──────────────────────────────────────────────────────────── +# MHOD Type 50 — Smart Playlist Preferences +# ──────────────────────────────────────────────────────────── + +def write_mhod50(prefs: SmartPlaylistPrefs) -> bytes: + """Write MHOD type 50 (smart playlist preferences / SPLPref). + + Returns: + Complete MHOD chunk bytes. + """ + body = bytearray(SPLPREF_BODY_SIZE) + + body[0] = 1 if prefs.live_update else 0 + body[1] = 1 if prefs.check_rules else 0 + body[2] = 1 if prefs.check_limits else 0 + body[3] = prefs.limit_type & 0xFF + + # limit_sort: low byte at +4, reverse flag at +13 + low_byte = prefs.limit_sort & 0xFF + reverse = 1 if (prefs.limit_sort & 0x80000000) else 0 + body[4] = low_byte + + # 3 bytes padding (5..7) already zero + + struct.pack_into(' bytes: + """Write a single SLst rule entry (big-endian). + + Rule layout: + +0x00: field (4 BE) + +0x04: action (4 BE) + +0x08: padding (44 bytes) + +0x34: length (4 BE) — byte length of data + +0x38: data (length bytes) + + Total = SPL_RULE_HEADER_SIZE + data_length. + """ + ft = spl_get_field_type(rule.field_id) + + if ft == SPLFT_STRING and rule.string_value is not None: + # String rule: data = UTF-16 BE string + string_bytes = rule.string_value.encode('utf-16-be') + data_length = len(string_bytes) + data_section = string_bytes + else: + # Non-string: fixed SPL_RULE_DATA_SIZE (68) byte data section + data_length = SPL_RULE_DATA_SIZE + data_section = bytearray(SPL_RULE_DATA_SIZE) + from_value = rule.from_value + from_date = rule.from_date + if ft == SPLFT_DATE and rule.action_id in DATE_RELATIVE_ACTION_IDS: + from_value, from_date = _normalize_relative_date_fields( + from_value, + from_date, + rule.from_units, + ) + # from_value, to_value, from_units, to_units use unsigned '>Q' format. + # Mask defensively so legacy in-memory rules with signed values still pack. + _mask = 0xFFFFFFFFFFFFFFFF + struct.pack_into('>Q', data_section, 0x00, from_value & _mask) + struct.pack_into('>q', data_section, 0x08, from_date) + struct.pack_into('>Q', data_section, 0x10, rule.from_units & _mask) + struct.pack_into('>Q', data_section, 0x18, rule.to_value & _mask) + struct.pack_into('>q', data_section, 0x20, rule.to_date) + struct.pack_into('>Q', data_section, 0x28, rule.to_units & _mask) + struct.pack_into('>I', data_section, 0x30, rule.unk052) + struct.pack_into('>I', data_section, 0x34, rule.unk056) + struct.pack_into('>I', data_section, 0x38, rule.unk060) + struct.pack_into('>I', data_section, 0x3C, rule.unk064) + struct.pack_into('>I', data_section, 0x40, rule.unk068) + data_section = bytes(data_section) + + # Build rule header + rule_header = bytearray(SPL_RULE_HEADER_SIZE) + struct.pack_into('>I', rule_header, 0x00, rule.field_id) + struct.pack_into('>I', rule_header, 0x04, rule.action_id) + # 44 bytes padding (0x08..0x33) already zero + struct.pack_into('>I', rule_header, 0x34, data_length) + + return bytes(rule_header) + data_section + + +def write_mhod51(rules_data: SmartPlaylistRules) -> bytes: + """Write MHOD type 51 (smart playlist rules / SLst). + + The entire SLst blob is big-endian. + + Returns: + Complete MHOD chunk bytes. + """ + # Build SLst header + slst_header = bytearray(SLST_HEADER_SIZE) + slst_header[0:4] = b'SLst' + struct.pack_into('>I', slst_header, 4, rules_data.unk004) + struct.pack_into('>I', slst_header, 8, len(rules_data.rules)) + conjunction_val = 1 if rules_data.conjunction.upper() == "OR" else 0 + struct.pack_into('>I', slst_header, 12, conjunction_val) + # 120 bytes padding already zero + + # Build individual rules + rules_bytes = b''.join(_write_spl_rule(r) for r in rules_data.rules) + + slst_body = bytes(slst_header) + rules_bytes + + return write_mhod_header(51, MHOD_HEADER_SIZE + len(slst_body)) + slst_body + + +# ──────────────────────────────────────────────────────────── +# MHOD Type 102 — Playlist Settings (opaque blob passthrough) +# ──────────────────────────────────────────────────────────── + +def write_mhod102(raw_body: bytes) -> bytes: + """Write MHOD type 102 (playlist settings). + + This is an opaque iTunes binary blob. We preserve it verbatim + from the parsed data for round-trip fidelity. + + Args: + raw_body: The raw body bytes (everything after the 24-byte header). + + Returns: + Complete MHOD chunk bytes. + """ + return write_mhod_header(102, MHOD_HEADER_SIZE + len(raw_body)) + raw_body + + +# ──────────────────────────────────────────────────────────── +# Helpers for building from parsed data (round-trip) +# ──────────────────────────────────────────────────────────── + +def prefs_from_parsed(parsed: dict) -> SmartPlaylistPrefs: + """Create SmartPlaylistPrefs from a parsed MHOD type 50 dict. + + This is the inverse of _parse_mhod50_smart_playlist_data(). + """ + # Parser stores limit_sort as the raw low byte and reverse_sort + # separately. Reconstruct the combined value the writer expects. + limit_sort = parsed.get("limit_sort", 0x02) + if parsed.get("reverse_sort", 0): + limit_sort |= 0x80000000 + + return SmartPlaylistPrefs( + live_update=parsed.get("live_update", True), + check_rules=parsed.get("check_rules", True), + check_limits=parsed.get("check_limits", False), + limit_type=parsed.get("limit_type", 0x03), + limit_sort=limit_sort, + limit_value=parsed.get("limit_value", 25), + match_checked_only=parsed.get("match_checked_only", False), + ) + + +def rules_from_parsed(parsed: dict) -> SmartPlaylistRules: + """Create SmartPlaylistRules from a parsed MHOD type 51 dict. + + This is the inverse of _parse_mhod51_smart_playlist_rules(). + """ + rules = [] + for r in parsed.get("rules", []): + field_id = r.get("field_id", 0) + action_id = r.get("action_id", 0) + from_value = r.get("from_value", 0) + from_date = r.get("from_date", 0) + from_units = r.get("from_units", 0) + if spl_get_field_type(field_id) == SPLFT_DATE and action_id in DATE_RELATIVE_ACTION_IDS: + from_value, from_date = _normalize_relative_date_fields( + from_value, + from_date, + from_units, + ) + rule = SmartPlaylistRule( + field_id=field_id, + action_id=action_id, + string_value=r.get("string_value"), + from_value=from_value, + from_date=from_date, + from_units=from_units, + to_value=r.get("to_value", 0), + to_date=r.get("to_date", 0), + to_units=r.get("to_units", 0), + unk052=r.get("unk052", 0), + unk056=r.get("unk056", 0), + unk060=r.get("unk060", 0), + unk064=r.get("unk064", 0), + unk068=r.get("unk068", 0), + ) + rules.append(rule) + + raw_conj = parsed.get("conjunction", "AND") + if isinstance(raw_conj, int): + conj = "OR" if raw_conj == 1 else "AND" + else: + conj = raw_conj + + return SmartPlaylistRules( + conjunction=conj, + rules=rules, + unk004=parsed.get("unk004", 0), + ) diff --git a/src/vendor/iTunesDB_Writer/mhod_writer.py b/src/vendor/iTunesDB_Writer/mhod_writer.py new file mode 100644 index 0000000..13441cb --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhod_writer.py @@ -0,0 +1,434 @@ +""" +MHOD String Writer — Write string, podcast URL, and chapter data MHOD chunks. + +String MHODs (types 1-14, 18-31, 33-44, 200-204, 300) have: + - Common MHOD header (24 bytes) + - String sub-header (16 bytes): encoding + string_length + unk0x20 + unk0x24 + - UTF-16LE encoded string data + +Podcast URL MHODs (types 15, 16) have: + - Common MHOD header (24 bytes) + - UTF-8 encoded string directly (NO sub-header) + +Chapter Data MHOD (type 17) has: + - Common MHOD header (24 bytes) + - 12-byte preamble (3 × u32 LE) + - Big-endian atom tree: sean → chap × N → name + hedr + - Stored in iTunesDB, so it is not tied to an AAC/M4A source file + +Cross-referenced against: + - iTunesDB_Shared/mhod_defs.py (field definitions and constants) + - iTunesDB_Parser/mhod_parser.py _parse_string_mhod(), _parse_chapter_data() + - libgpod itdb_itunesdb.c: mk_mhod(), itdb_chapterdata_build_chapter_blob_internal() +""" + +import struct + +from iTunesDB_Shared.constants import ( + MHOD_TYPE_ALBUM, + MHOD_TYPE_ALBUM_ARTIST, + MHOD_TYPE_ARTIST, + MHOD_TYPE_CATEGORY, + MHOD_TYPE_CHAPTER_DATA, + MHOD_TYPE_COMMENT, + MHOD_TYPE_COMPOSER, + MHOD_TYPE_DESCRIPTION, + MHOD_TYPE_EPISODE_ID, + MHOD_TYPE_EQ_SETTING, + MHOD_TYPE_FILETYPE, + MHOD_TYPE_GENRE, + MHOD_TYPE_GROUPING, + MHOD_TYPE_KEYWORDS, + MHOD_TYPE_LOCATION, + MHOD_TYPE_LYRICS, + MHOD_TYPE_NETWORK_NAME, + MHOD_TYPE_PODCAST_ENCLOSURE_URL, + MHOD_TYPE_PODCAST_RSS_URL, + MHOD_TYPE_SHOW_LOCALE, + MHOD_TYPE_SHOW_NAME, + MHOD_TYPE_SORT_ALBUM, + MHOD_TYPE_SORT_ALBUM_ARTIST, + MHOD_TYPE_SORT_ARTIST, + MHOD_TYPE_SORT_COMPOSER, + MHOD_TYPE_SORT_NAME, + MHOD_TYPE_SORT_SHOW, + MHOD_TYPE_SUBTITLE, + MHOD_TYPE_TITLE, +) +from iTunesDB_Shared.mhod_defs import ( + CHAP_ATOM, + HEDR_ATOM, + HEDR_SIZE, + MHOD_HEADER_SIZE, + MHOD_STRING_SUBHEADER_SIZE, + NAME_ATOM, + SEAN_ATOM, + write_mhod_header, +) + + +def write_mhod_string(mhod_type: int, value: str, + unk_0x20: int = 1, unk_0x24: int = 0) -> bytes: + """ + Write a string MHOD chunk. + + String MHODs have this structure: + - mhod header (24 bytes minimum) + - string data type header (16 bytes) + - UTF-16LE encoded string + + Args: + mhod_type: MHOD type (1=title, 2=location, etc.) + value: String value to encode + unk_0x20: Sub-header unknown at offset 0x20 (preserved from parser). + unk_0x24: Sub-header unknown at offset 0x24 (preserved from parser). + + Returns: + Complete MHOD chunk as bytes + """ + if not value: + return b'' + + string_data = value.encode('utf-16-le') + string_len = len(string_data) + + total_len = MHOD_HEADER_SIZE + MHOD_STRING_SUBHEADER_SIZE + string_len + + header = write_mhod_header(mhod_type, total_len) + + # String sub-header: encoding(4) + string_length(4) + unk0x20(4) + unk0x24(4) + # encoding=1 means UTF-16LE + type_header = struct.pack(' bytes: + """ + Write a location MHOD (type 2) for file path. + + iPod paths use colons as separators: + :iPod_Control:Music:F00:ABCD.mp3 + + Args: + path: iPod-relative path with colon separators + + Returns: + Complete MHOD chunk + """ + return write_mhod_string(MHOD_TYPE_LOCATION, path) + + +def write_mhod_title(title: str) -> bytes: + return write_mhod_string(MHOD_TYPE_TITLE, title) + + +def write_mhod_artist(artist: str) -> bytes: + return write_mhod_string(MHOD_TYPE_ARTIST, artist) + + +def write_mhod_album(album: str) -> bytes: + return write_mhod_string(MHOD_TYPE_ALBUM, album) + + +def write_mhod_genre(genre: str) -> bytes: + return write_mhod_string(MHOD_TYPE_GENRE, genre) + + +def write_mhod_album_artist(album_artist: str) -> bytes: + return write_mhod_string(MHOD_TYPE_ALBUM_ARTIST, album_artist) + + +def write_mhod_composer(composer: str) -> bytes: + return write_mhod_string(MHOD_TYPE_COMPOSER, composer) + + +def write_mhod_comment(comment: str) -> bytes: + return write_mhod_string(MHOD_TYPE_COMMENT, comment) + + +def write_mhod_filetype(filetype: str) -> bytes: + return write_mhod_string(MHOD_TYPE_FILETYPE, filetype) + + +def write_mhod_sort_artist(sort_artist: str) -> bytes: + return write_mhod_string(MHOD_TYPE_SORT_ARTIST, sort_artist) + + +def write_mhod_sort_name(sort_name: str) -> bytes: + return write_mhod_string(MHOD_TYPE_SORT_NAME, sort_name) + + +def write_mhod_sort_album(sort_album: str) -> bytes: + return write_mhod_string(MHOD_TYPE_SORT_ALBUM, sort_album) + + +def write_mhod_podcast_url(mhod_type: int, url: str) -> bytes: + """ + Write a podcast URL MHOD (type 15 or 16). + + Podcast URL MHODs use a DIFFERENT format from standard string MHODs: + - UTF-8 encoded (NOT UTF-16LE) + - NO type sub-header (string follows directly after the 24-byte header) + - Length = total_length − header_length + + Per iPodLinux wiki and parser: types 15 (enclosure URL) and 16 (RSS URL) + have no mhod::length field and use UTF-8/ASCII encoding. + + Args: + mhod_type: Must be 15 (enclosure URL) or 16 (RSS URL) + url: URL string to encode + + Returns: + Complete MHOD chunk as bytes + """ + if not url: + return b'' + if mhod_type not in (MHOD_TYPE_PODCAST_ENCLOSURE_URL, MHOD_TYPE_PODCAST_RSS_URL): + raise ValueError(f"write_mhod_podcast_url only supports types 15 and 16, got {mhod_type}") + + string_data = url.encode('utf-8') + total_len = MHOD_HEADER_SIZE + len(string_data) + + header = write_mhod_header(mhod_type, total_len) + + return header + string_data + + +def write_mhod_chapter_data( + chapters: list[dict], + unk024: int = 0, + unk028: int = 0, + unk032: int = 0, +) -> bytes: + """Write a chapter data MHOD (type 17). + + Chapter data uses big-endian atom tree encoding, matching libgpod's + ``itdb_chapterdata_build_chapter_blob_internal()``. + + Args: + chapters: List of chapter dicts, each with ``startpos`` (int, ms) + and ``title`` (str). + unk024, unk028, unk032: Preamble unknown fields (preserved from + parser, default 0). + + Returns: + Complete MHOD type 17 chunk as bytes, or b'' if chapters is empty. + """ + if not chapters: + return b'' + + # Build the atom tree body (all big-endian). + atoms = bytearray() + + for ch in chapters: + title = ch.get("title", "") + startpos = ch.get("startpos", 0) + title_utf16 = title.encode("utf-16-be") + title_units = len(title_utf16) // 2 + + # name atom: size(4) + "name"(4) + unk=1(4) + unk=0(4) + unk=0(4) + strlen(2) + string + name_size = 22 + len(title_utf16) + name_atom = struct.pack(">I", name_size) + name_atom += NAME_ATOM + name_atom += struct.pack(">III", 1, 0, 0) + name_atom += struct.pack(">H", title_units) + name_atom += title_utf16 + + # chap atom: size(4) + "chap"(4) + startpos(4) + children=1(4) + unk=0(4) + name_atom + chap_size = 20 + name_size + chap_atom = struct.pack(">I", chap_size) + chap_atom += CHAP_ATOM + chap_atom += struct.pack(">III", startpos, 1, 0) + chap_atom += name_atom + + atoms.extend(chap_atom) + + # hedr terminator atom (28 bytes) + hedr_atom = struct.pack(">I", HEDR_SIZE) + hedr_atom += HEDR_ATOM + hedr_atom += struct.pack(">IIIII", 1, 0, 0, 0, 1) + atoms.extend(hedr_atom) + + # sean atom header wraps everything + num_children = len(chapters) + 1 # chapters + hedr + sean_size = 20 + len(atoms) + sean_header = struct.pack(">I", sean_size) + sean_header += SEAN_ATOM + sean_header += struct.pack(">III", 1, num_children, 0) + + # Preamble (little-endian, 12 bytes) + preamble = struct.pack(" bytes: + """Build the chapter atom blob (no MHOD header) for SQLite Extras.itdb. + + Same atom tree format as MHOD type 17 but without the 24-byte MHOD header. + Used by ``SQLiteDB_Writer.extras_writer`` for the ``chapter.data`` BLOB. + + Returns: + Raw chapter blob bytes, or b'' if chapters is empty. + """ + full = write_mhod_chapter_data(chapters, unk024, unk028, unk032) + if not full: + return b'' + # Strip the MHOD header to get just the atom tree + return full[MHOD_HEADER_SIZE:] + + +def write_track_mhods( + title: str, + location: str, + artist: str | None = None, + album: str | None = None, + genre: str | None = None, + album_artist: str | None = None, + composer: str | None = None, + comment: str | None = None, + filetype_desc: str | None = None, + sort_artist: str | None = None, + sort_name: str | None = None, + sort_album: str | None = None, + sort_album_artist: str | None = None, + sort_composer: str | None = None, + grouping: str | None = None, + description: str | None = None, + podcast_enclosure_url: str | None = None, + podcast_rss_url: str | None = None, + subtitle: str | None = None, + show_name: str | None = None, + episode_id: str | None = None, + network_name: str | None = None, + keywords: str | None = None, + sort_show: str | None = None, + category: str | None = None, + lyrics: str | None = None, + eq_setting: str | None = None, + show_locale: str | None = None, + chapter_data: dict | None = None, +) -> tuple[bytes, int]: + """ + Write all MHODs for a track. + + Args: + title: Track title (required) + location: File path on iPod (required) + artist: Artist name + album: Album name + genre: Genre + album_artist: Album artist (for compilations) + composer: Composer + comment: Comment/notes + filetype_desc: File format description (e.g., "MPEG audio file") + sort_artist: Sort artist name + sort_name: Sort title + sort_album: Sort album name + sort_album_artist: Sort album artist name + sort_composer: Sort composer name + grouping: Grouping tag + description: Track description (type 14) + podcast_enclosure_url: Podcast enclosure URL (type 15, UTF-8, no sub-header) + podcast_rss_url: Podcast RSS feed URL (type 16, UTF-8, no sub-header) + subtitle: Subtitle (type 18) + show_name: TV show name (type 19) + episode_id: Episode ID string (type 20) + network_name: TV network name (type 21) + keywords: Keywords (type 24) + sort_show: Sort show name (type 31) + category: Podcast/audiobook category (type 9) + chapter_data: Chapter data dict with ``chapters`` list (type 17) + + Returns: + Tuple of (concatenated MHOD bytes, count of MHODs) + """ + chunks: list[bytes] = [] + + # Required MHODs + chunks.append(write_mhod_title(title)) + chunks.append(write_mhod_location(location)) + + # Optional string MHODs + if artist: + chunks.append(write_mhod_artist(artist)) + if album: + chunks.append(write_mhod_album(album)) + if genre: + chunks.append(write_mhod_genre(genre)) + if album_artist: + chunks.append(write_mhod_album_artist(album_artist)) + if composer: + chunks.append(write_mhod_composer(composer)) + if comment: + chunks.append(write_mhod_comment(comment)) + if filetype_desc: + chunks.append(write_mhod_filetype(filetype_desc)) + if category: + chunks.append(write_mhod_string(MHOD_TYPE_CATEGORY, category)) + if description: + chunks.append(write_mhod_string(MHOD_TYPE_DESCRIPTION, description)) + if subtitle: + chunks.append(write_mhod_string(MHOD_TYPE_SUBTITLE, subtitle)) + if show_name: + chunks.append(write_mhod_string(MHOD_TYPE_SHOW_NAME, show_name)) + if episode_id: + chunks.append(write_mhod_string(MHOD_TYPE_EPISODE_ID, episode_id)) + if network_name: + chunks.append(write_mhod_string(MHOD_TYPE_NETWORK_NAME, network_name)) + if keywords: + chunks.append(write_mhod_string(MHOD_TYPE_KEYWORDS, keywords)) + + # Sort MHODs + if sort_artist: + chunks.append(write_mhod_sort_artist(sort_artist)) + if sort_name: + chunks.append(write_mhod_sort_name(sort_name)) + if sort_album: + chunks.append(write_mhod_sort_album(sort_album)) + if sort_album_artist: + chunks.append(write_mhod_string(MHOD_TYPE_SORT_ALBUM_ARTIST, sort_album_artist)) + if sort_composer: + chunks.append(write_mhod_string(MHOD_TYPE_SORT_COMPOSER, sort_composer)) + if sort_show: + chunks.append(write_mhod_string(MHOD_TYPE_SORT_SHOW, sort_show)) + if show_locale: + chunks.append(write_mhod_string(MHOD_TYPE_SHOW_LOCALE, show_locale)) + if grouping: + chunks.append(write_mhod_string(MHOD_TYPE_GROUPING, grouping)) + + # Podcast URL MHODs (different format: UTF-8, no sub-header) + if podcast_enclosure_url: + chunks.append(write_mhod_podcast_url(MHOD_TYPE_PODCAST_ENCLOSURE_URL, podcast_enclosure_url)) + if podcast_rss_url: + chunks.append(write_mhod_podcast_url(MHOD_TYPE_PODCAST_RSS_URL, podcast_rss_url)) + + # EQ and lyrics + if eq_setting: + chunks.append(write_mhod_string(MHOD_TYPE_EQ_SETTING, eq_setting)) + if lyrics: + chunks.append(write_mhod_string(MHOD_TYPE_LYRICS, lyrics)) + + # Chapter data (type 17, big-endian atom tree) + if chapter_data and chapter_data.get("chapters"): + chunks.append(write_mhod_chapter_data( + chapters=chapter_data["chapters"], + unk024=chapter_data.get("unk024", 0), + unk028=chapter_data.get("unk028", 0), + unk032=chapter_data.get("unk032", 0), + )) + + return b''.join(chunks), len(chunks) diff --git a/src/vendor/iTunesDB_Writer/mhsd_writer.py b/src/vendor/iTunesDB_Writer/mhsd_writer.py new file mode 100644 index 0000000..278a891 --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhsd_writer.py @@ -0,0 +1,101 @@ +"""MHSD Writer — Write dataset chunks for iTunesDB. + +MHSD (dataset) chunks are containers for different types of data. +Each MHSD wraps exactly one child list chunk (mhlt, mhlp, mhla, or mhli). + +Header layout (MHSD_HEADER_SIZE = 96 bytes): + +0x00: 'mhsd' magic (4B) + +0x04: header_length (4B) + +0x08: total_length (4B) — header + child data + +0x0C: dataset_type (4B): + 1 = Track list (mhlt) + 2 = Playlist list (mhlp) + 3 = Podcast list (mhlp) — same content as type 2 + 4 = Album list (mhla) + 5 = Smart playlist list (mhlp) + 6 = Empty stub (mhlt with 0 children) + 8 = Artist list (mhli with mhii children) + 10 = Empty stub (mhlt with 0 children) + +Cross-referenced against: + - iTunesDB_Parser/mhsd_parser.py + - libgpod itdb_itunesdb.c: mk_mhsd() +""" + +from iTunesDB_Shared.field_base import ( + MHLT_HEADER_SIZE, + write_fields, + write_generic_header, +) +from iTunesDB_Shared.mhsd_defs import MHSD_HEADER_SIZE + + +def write_mhsd(dataset_type: int, child_data: bytes) -> bytes: + """ + Write a MHSD (dataset) chunk. + + Args: + dataset_type: Type of dataset + child_data: Child chunk data (mhlt, mhlp, or mhla) + + Returns: + Complete MHSD chunk bytes + """ + # Total length = header + child + total_length = MHSD_HEADER_SIZE + len(child_data) + + # Build header + header = bytearray(MHSD_HEADER_SIZE) + write_generic_header(header, 0, b'mhsd', MHSD_HEADER_SIZE, total_length) + write_fields(header, 0, 'mhsd', {'dataset_type': dataset_type}, MHSD_HEADER_SIZE) + + return bytes(header) + child_data + + +def write_mhsd_type1(track_list_data: bytes) -> bytes: + """Write a Type 1 MHSD containing track list.""" + return write_mhsd(1, track_list_data) + + +def write_mhsd_type2(playlist_list_data: bytes) -> bytes: + """Write a Type 2 MHSD containing playlist list.""" + return write_mhsd(2, playlist_list_data) + + +def write_mhsd_type3(podcast_list_data: bytes) -> bytes: + """Write a Type 3 MHSD containing podcast list.""" + return write_mhsd(3, podcast_list_data) + + +def write_mhsd_type4(album_list_data: bytes) -> bytes: + """Write a Type 4 MHSD containing album list.""" + return write_mhsd(4, album_list_data) + + +def write_mhsd_smart_type5(smart_playlist_data: bytes) -> bytes: + """Write a Type 5 MHSD containing smart playlist list.""" + return write_mhsd(5, smart_playlist_data) + + +def write_mhsd_type8(artist_list_data: bytes) -> bytes: + """Write a Type 8 MHSD containing artist list (mhli).""" + return write_mhsd(8, artist_list_data) + + +def write_mhsd_empty_stub(dataset_type: int) -> bytes: + """Write a stub MHSD containing an empty MHLT (0 children). + + Used for types 6 and 10 which libgpod writes as empty track-list + stubs. The child is a minimal MHLT header with count = 0. + + Args: + dataset_type: The MHSD type (6 or 10). + + Returns: + Complete MHSD + empty MHLT bytes. + """ + # Build an empty MHLT child (92-byte header, 0 tracks) + mhlt = bytearray(MHLT_HEADER_SIZE) + write_generic_header(mhlt, 0, b'mhlt', MHLT_HEADER_SIZE, 0) + + return write_mhsd(dataset_type, bytes(mhlt)) diff --git a/src/vendor/iTunesDB_Writer/mhyp_writer.py b/src/vendor/iTunesDB_Writer/mhyp_writer.py new file mode 100644 index 0000000..098e48d --- /dev/null +++ b/src/vendor/iTunesDB_Writer/mhyp_writer.py @@ -0,0 +1,538 @@ +""" +MHYP Writer — Write playlist chunks for iTunesDB. + +MHYP chunks define playlists. Every iTunesDB MUST have at least one +playlist — the Master Playlist (MPL) which references all tracks. + +Supports three kinds of playlists: +- Master Playlist (master=True): references all tracks, includes library indices +- Regular playlists: user-created playlists with explicit track lists +- Smart playlists: rule-based playlists with MHOD types 50 (prefs) and 51 (rules) + +Header layout (MHYP_HEADER_SIZE = 184 bytes): + +0x00: 'mhyp' magic (4B) + +0x04: header_length (4B) + +0x08: total_length (4B) — header + all children + +0x0C: mhod_count (4B) + +0x10: mhip_count (4B) + +0x14: type (1B) + flag1 (1B) + flag2 (1B) + flag3 (1B) — master playlist flag + +0x18: timestamp (4B Mac) + +0x1C: playlist_id (8B) + +0x24: unk1 (4B) + +0x28: string_mhod_count (2B) + +0x2A: podcast_flag (2B) — 0=normal, 1=podcast playlist (u16, libgpod podcastflag) + +0x2C: sort_order (4B) + +0x3C: db_id_2 (8B) — MHBD database ID reference (non-master) + +0x44: playlist_id_copy (8B) + +0x50: mhsd5_type (2B) — browsing category for dataset 5 + +0x58: timestamp_copy (4B Mac) + +Cross-referenced against: + - iTunesDB_Parser/mhyp_parser.py parse_playlist() + - libgpod itdb_itunesdb.c: write_playlist() / mk_mhyp() + - iPodLinux wiki MHYP documentation +""" + +import random +import struct +import time +from dataclasses import dataclass, field +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .mhit_writer import TrackInfo + +from iTunesDB_Shared.constants import MHOD_TYPE_TITLE +from iTunesDB_Shared.field_base import write_fields, write_generic_header +from iTunesDB_Shared.mhyp_defs import MHYP_HEADER_SIZE +from iTunesDB_Shared.mhod_defs import ( + MHOD_HEADER_SIZE as _MHOD_HEADER_SIZE, + write_mhod_header, +) +from .mhod_writer import write_mhod_string +from .mhip_writer import write_mhip, write_mhip_podcast_group +from .mhod52_writer import write_library_indices +from .mhod_spl_writer import ( + SmartPlaylistPrefs, + SmartPlaylistRules, + write_mhod50, + write_mhod51, + write_mhod102, +) + + +@dataclass +class PlaylistItemMeta: + """Per-item metadata preserved from parsed MHIP entries for round-trip fidelity. + + These fields map directly to MHIP header offsets: + +0x10: podcast_group_flag (4B) + +0x14: group_id (4B) — unique MHIP identifier (libgpod: podcastgroupid) + +0x20: podcast_group_ref (4B) — references another MHIP's group_id + +0x2C: track_persistent_id (8B) — track's db_track_id + +0x3C: mhip_persistent_id (8B) — per-track persistent ID + """ + podcast_group_flag: int = 0 + group_id: int = 0 + podcast_group_ref: int = 0 + track_persistent_id: int = 0 + mhip_persistent_id: int = 0 + + +@dataclass +class PlaylistInfo: + """Structured input for writing a playlist to iTunesDB. + + Covers regular playlists, smart playlists, and the master playlist. + The master playlist is constructed internally by write_master_playlist() + and does not need a PlaylistInfo. + """ + name: str + track_ids: List[int] = field(default_factory=list) + + # Identity + playlist_id: Optional[int] = None # 64-bit; generated if None + master: bool = False # Sets type byte at +0x14 to 1. + # Dataset 2: True for the master playlist only (exactly one). + # Dataset 5: True for ALL built-in categories (Music, Movies, etc.). + # In both cases this controls: (a) the type byte at +0x14, + # (b) whether library indices are generated (only when tracks + # are also provided), and (c) whether db_id_2/playlist_id + # are written at the extended offsets +0x3C/+0x44 (skipped + # when master=True, matching libgpod behaviour). + sortorder: int = 0 # 0=default, 1=manual, 3=title ... + podcast_flag: int = 0 # 0x2A: 0=normal, 1=podcast playlist (u16) + + # Smart playlist fields (both must be set for a smart playlist) + smart_prefs: Optional[SmartPlaylistPrefs] = None + smart_rules: Optional[SmartPlaylistRules] = None + + # mhsd5Type: browsing category for dataset 5 smart playlists + # (per libgpod: 0=None, 2=Movies, 3=TV Shows, 4=Music, 5=Audiobooks, 6=Ringtones, 7=MovieRentals) + mhsd5_type: int = 0 + + # Opaque blobs preserved from parsed data for round-trip fidelity + raw_mhod100: Optional[bytes] = None # Playlist prefs (type 100 body) + raw_mhod102: Optional[bytes] = None # Playlist settings (type 102 body) + + # Per-MHIP metadata preserved from parsed data for round-trip fidelity. + # When provided, must be the same length as track_ids and in the same order. + item_metadata: Optional[List[PlaylistItemMeta]] = None + + @property + def is_smart(self) -> bool: + return self.smart_prefs is not None and self.smart_rules is not None + + +def generate_playlist_id() -> int: + """Generate a random 64-bit playlist ID.""" + return random.getrandbits(64) + + +def write_mhyp( + name: str, + track_ids: List[int], + playlist_id: Optional[int] = None, + master: bool = False, + timestamp: Optional[int] = None, + sortorder: int = 0, + podcast_flag: int = 0, + tracks: Optional[List["TrackInfo"]] = None, + db_id_2: int = 0, + smart_prefs: Optional[SmartPlaylistPrefs] = None, + smart_rules: Optional[SmartPlaylistRules] = None, + mhsd5_type: int = 0, + raw_mhod100: Optional[bytes] = None, + raw_mhod102: Optional[bytes] = None, + item_metadata: Optional[List[PlaylistItemMeta]] = None, + capabilities=None, + podcast_grouping: bool = False, + track_album_map: Optional[dict[int, str]] = None, + next_mhip_id_start: int = 1, +) -> bytes: + """ + Write a complete MHYP (playlist) chunk with MHODs and MHIPs. + + The structure is: + - MHYP header (184 bytes) + - MHOD title (string) + - MHOD playlist data (type 100 preferences) + - [Smart only] MHOD type 50 (smart playlist prefs) + - [Smart only] MHOD type 51 (smart playlist rules / SLst) + - [Smart only] MHOD type 102 (playlist settings, if provided) + - [Master Playlist only] MHOD type 52/53 pairs (library indices) + - MHIP entries (one per track) + + Args: + name: Playlist name + track_ids: List of track IDs to include in this playlist + playlist_id: Playlist ID (generated if not provided) + master: Whether the type byte at +0x14 should be set to 1. + For dataset 2 this means "master playlist" (exactly one). + For dataset 5 this means "built-in system category" (all + categories have master=True). The behavioural effects are: + (a) type byte at +0x14 is written as 1, + (b) library indices are generated IF *tracks* is also + provided (ds5 never passes tracks, so this is safe), + (c) db_id_2 and playlist_id are NOT written at +0x3C/+0x44 + (matches libgpod, which zeros these for type=1). + timestamp: Creation timestamp (now if not provided) + sortorder: Sort order (0 = manual) + podcast_flag: 0x2A — 0=normal playlist, 1=podcast playlist (u16, + matching libgpod podcastflag). + tracks: List of TrackInfo objects (required for Master Playlist to + generate library index MHODs type 52/53) + db_id_2: Database-wide ID from MHBD offset 0x24. Written at MHYP offset + 0x3C for non-master playlists, and used as a validation field. + smart_prefs: Smart playlist preferences (MHOD 50). Both smart_prefs + and smart_rules must be set for a smart playlist. + smart_rules: Smart playlist rules (MHOD 51). + mhsd5_type: Browsing category for dataset 5 smart playlists. + raw_mhod100: If provided, use this raw body for MHOD type 100 instead + of generating a default one. + raw_mhod102: If provided, write an MHOD type 102 with this raw body. + podcast_grouping: When True and this is a podcast playlist, generate + grouped MHIPs (libgpod write_podcast_mhips style) where + episodes are nested under their podcast show by album. + track_album_map: Mapping of track_id → album name. Required when + podcast_grouping is True. + next_mhip_id_start: Starting ID for generated MHIP group_id values + (podcast grouping assigns unique IDs to group headers + and child MHIPs). + + Returns: + Complete MHYP chunk bytes + """ + if playlist_id is None: + playlist_id = generate_playlist_id() + + if timestamp is None: + timestamp = int(time.time()) + + # Build MHOD for title + mhod_title = write_mhod_string(MHOD_TYPE_TITLE, name) + + # Build MHOD for playlist preferences (type 100) + if raw_mhod100 is not None: + mhod_playlist = _write_mhod100_raw(raw_mhod100) + else: + mhod_playlist = write_mhod_playlist_prefs() + + # Smart playlist MHODs (type 50 + 51) + mhod_smart = b'' + smart_mhod_count = 0 + if smart_prefs is not None and smart_rules is not None: + mhod_smart += write_mhod50(smart_prefs) + mhod_smart += write_mhod51(smart_rules) + smart_mhod_count = 2 + + # Optional MHOD type 102 (playlist settings — opaque iTunes blob) + mhod_settings = b'' + settings_count = 0 + if raw_mhod102 is not None: + mhod_settings = write_mhod102(raw_mhod102) + settings_count = 1 + + # Build library index MHODs for master playlist (type 52/53 pairs) + # These are REQUIRED for iPod Classic to build its browsing views + library_indices_data = b'' + library_indices_count = 0 + if master and tracks: + library_indices_data, library_indices_count = write_library_indices(tracks, capabilities=capabilities) + + # Build MHIP entries for each track + mhip_count: int + if podcast_grouping and track_album_map is not None: + # Podcast grouping: group tracks by album (libgpod write_podcast_mhips) + mhip_data, mhip_count = _build_podcast_grouped_mhips( + track_ids, track_album_map, next_mhip_id_start, + ) + else: + # Standard flat MHIP list (write_playlist_mhips) + # When item_metadata is provided (round-trip from parsed data), we + # preserve per-MHIP fields: podcastGroupFlag, groupID, podcastGroupRef. + mhips = [] + for i, track_id in enumerate(track_ids): + meta = item_metadata[i] if item_metadata and i < len(item_metadata) else None + mhip = write_mhip( + track_id, position=i, + mhip_id=meta.group_id if meta else 0, + podcast_group_flag=meta.podcast_group_flag if meta else 0, + podcast_group_ref=meta.podcast_group_ref if meta else 0, + track_persistent_id=meta.track_persistent_id if meta else 0, + mhip_persistent_id=meta.mhip_persistent_id if meta else 0, + ) + mhips.append(mhip) + mhip_data = b''.join(mhips) + mhip_count = len(track_ids) + + # Count MHODs (title + playlist prefs + smart + settings + library indices) + mhod_count = 2 + smart_mhod_count + settings_count + library_indices_count + + # Total chunk length + total_length = ( + MHYP_HEADER_SIZE + len(mhod_title) + len(mhod_playlist) + len(mhod_smart) + len(mhod_settings) + len(library_indices_data) + len(mhip_data) + ) + + # Build MHYP header + header = bytearray(MHYP_HEADER_SIZE) + write_generic_header(header, 0, b'mhyp', MHYP_HEADER_SIZE, total_length) + + # Build values dict for write_fields. + # Timestamps are Unix epoch — write_transform (unix_to_mac) handles conversion. + values: dict[str, int] = { + 'mhod_child_count': mhod_count, + 'mhip_child_count': mhip_count, + 'master_flag': 1 if master else 0, + 'timestamp': timestamp, + 'playlist_id': playlist_id, + 'string_mhod_child_count': 1, + 'podcast_flag': podcast_flag, + 'sort_order': sortorder, + 'timestamp_2': timestamp, + } + + # Non-master playlists write db_id_2 and playlist_id at extended offsets. + # For master=True (ds2 master and ds5 built-in categories), these stay + # zeroed — matching libgpod behaviour. + if not master: + values['db_id_2'] = db_id_2 + values['playlist_id_2'] = playlist_id + + # mhsd5_type — browsing category for dataset 5 smart playlists. + # libgpod writes the same value at +0x50 and +0x52, plus a + # special flag at +0x54 for RINGTONES(6) and MOVIE_RENTALS(7). + if mhsd5_type: + values['mhsd5_type'] = mhsd5_type + values['mhsd5_type_2'] = mhsd5_type + if mhsd5_type in (6, 7): + values['mhsd5_special_flag'] = 0x200 + + write_fields(header, 0, 'mhyp', values, MHYP_HEADER_SIZE) + + return ( + bytes(header) + mhod_title + mhod_playlist + mhod_smart + mhod_settings + library_indices_data + mhip_data + ) + + +def _build_podcast_grouped_mhips( + track_ids: List[int], + track_album_map: dict[int, str], + next_id: int, +) -> tuple[bytes, int]: + """Build podcast-grouped MHIP entries for the type 3 MHSD dataset. + + Groups tracks by album name. For each album group, emits: + 1. A group header MHIP (``podcast_group_flag=256``, ``track_id=0``, + MHOD type 1 with the album name) + 2. One child MHIP per track (``podcast_group_flag=0``, + ``group_id_ref`` pointing to the parent group header's ``group_id``, + MHOD type 100 with the child's own unique ``mhip_id`` as position) + + This matches libgpod's ``write_podcast_mhips()`` + + ``write_one_podcast_group()`` in ``itdb_itunesdb.c``. + + Args: + track_ids: Sequential track IDs for the podcast playlist + track_album_map: track_id → album name ('' if unknown) + next_id: Starting value for unique MHIP group_id / mhip_id + + Returns: + (mhip_bytes, mhip_count) — concatenated MHIPs and the total + MHIP count (= number of tracks + number of album groups). + """ + from collections import OrderedDict + + # Group tracks by album, preserving insertion order + album_groups: OrderedDict[str, list[int]] = OrderedDict() + for tid in track_ids: + album = track_album_map.get(tid, "") + album_groups.setdefault(album, []).append(tid) + + parts: list[bytes] = [] + cur_id = next_id + total_mhip_count = 0 + + for album, tids in album_groups.items(): + # Group header MHIP + group_id = cur_id + cur_id += 1 + parts.append(write_mhip_podcast_group(album or "Unknown", group_id)) + total_mhip_count += 1 + + # Child MHIPs — one per track in this album group + for tid in tids: + mhip_id = cur_id + cur_id += 1 + parts.append(write_mhip( + tid, position=mhip_id, + mhip_id=mhip_id, + podcast_group_flag=0, + podcast_group_ref=group_id, + )) + total_mhip_count += 1 + + return b''.join(parts), total_mhip_count + + +def write_mhod_playlist_prefs() -> bytes: + """ + Write the playlist preferences MHOD (type 100). + + This is a binary blob containing display/sorting preferences. + Based on libgpod's mk_long_mhod_id_playlist(). + + Total size: 0x288 (648) bytes as written by iTunes. + """ + # libgpod mk_long_mhod_id_playlist() writes exactly 0x288 bytes + # This is critical for proper playlist recognition + + total_len = 0x288 # 648 bytes - exactly what libgpod writes + + # Build complete MHOD type 100 + data = bytearray(total_len) + + # Header (24 bytes) — use shared helper, then overlay onto data buffer + hdr = write_mhod_header(100, total_len) + data[:_MHOD_HEADER_SIZE] = hdr + + # Body data - based on libgpod mk_long_mhod_id_playlist() + # Offset 0x18 (after header): + struct.pack_into(' bytes: + """Write an MHOD type 100 from a raw body blob (round-trip passthrough). + + Args: + raw_body: Body bytes (everything after the 24-byte MHOD header). + + Returns: + Complete MHOD type 100 chunk. + """ + total_len = _MHOD_HEADER_SIZE + len(raw_body) + return write_mhod_header(100, total_len) + raw_body + + +def write_playlist( + playlist: "PlaylistInfo", + db_id_2: int = 0, + podcast_grouping: bool = False, + track_album_map: Optional[dict[int, str]] = None, + next_mhip_id_start: int = 1, +) -> bytes: + """Write a playlist from a PlaylistInfo dataclass. + + Handles regular playlists, smart playlists, AND dataset 5 built-in + categories. For dataset 5, PlaylistInfo.master will be True (setting + the type byte at +0x14 to 1) and track_ids will be empty (the iPod + firmware evaluates smart rules at runtime). + + The *master playlist* for dataset 2 is NOT written through this + function — use write_master_playlist() instead. + + Args: + playlist: A PlaylistInfo instance. + db_id_2: Database-wide ID from MHBD offset 0x24. + podcast_grouping: When True and playlist.podcast_flag is set, + generate grouped MHIPs for podcast episodes. + track_album_map: track_id → album name (required when + podcast_grouping applies). + next_mhip_id_start: Starting ID for generated MHIP identifiers + during podcast grouping. + + Returns: + Complete MHYP chunk bytes. + """ + # Only apply podcast grouping to actual podcast playlists + use_grouping = podcast_grouping and bool(playlist.podcast_flag) + return write_mhyp( + name=playlist.name, + track_ids=playlist.track_ids, + playlist_id=playlist.playlist_id, + master=playlist.master, + sortorder=playlist.sortorder, + podcast_flag=playlist.podcast_flag, + db_id_2=db_id_2, + smart_prefs=playlist.smart_prefs, + smart_rules=playlist.smart_rules, + mhsd5_type=playlist.mhsd5_type, + raw_mhod100=playlist.raw_mhod100, + raw_mhod102=playlist.raw_mhod102, + item_metadata=playlist.item_metadata, + podcast_grouping=use_grouping, + track_album_map=track_album_map, + next_mhip_id_start=next_mhip_id_start, + ) + + +def write_master_playlist( + track_ids: List[int], + db_id_2: int, + name: str = "iPod", + tracks: Optional[List["TrackInfo"]] = None, + capabilities=None, + playlist_id: Optional[int] = None, +) -> bytes: + """ + Write the Master Playlist (MPL). + + The master playlist is required and must be the first playlist. + It contains references to ALL tracks in the database. + + Args: + track_ids: List of ALL track IDs in the database + name: Playlist name (usually "iPod" or device name) + tracks: List of ALL TrackInfo objects (needed for library indices) + db_id_2: Database-wide ID from MHBD offset 0x24 + capabilities: Optional DeviceCapabilities for video sort indices. + + Returns: + Complete MHYP chunk for master playlist + """ + # Master playlist MUST have master=True (0x14 field = 1) + # This is how iTunes/iPod identifies the master playlist + return write_mhyp( + name=name, + track_ids=track_ids, + playlist_id=playlist_id, + master=True, # CRITICAL: Master playlist must have type=1 + sortorder=5, # Match iTunes default sort order + tracks=tracks, + db_id_2=db_id_2, + capabilities=capabilities, + ) diff --git a/src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm b/src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm new file mode 100644 index 0000000000000000000000000000000000000000..2ecaf2e69b3e127c5e0488349d99271f07c9914a GIT binary patch literal 506338 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