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 0000000..2ecaf2e Binary files /dev/null and b/src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm differ diff --git a/src/vendor/ipod_device/__init__.py b/src/vendor/ipod_device/__init__.py new file mode 100644 index 0000000..90e2756 --- /dev/null +++ b/src/vendor/ipod_device/__init__.py @@ -0,0 +1,148 @@ +""" +ipod_device — unified iPod device identification & management package. + +Re-exports device-identification and model-capability APIs that were +historically spread across multiple legacy modules. +""" + +# flake8: noqa: F401 + +# ── artwork ────────────────────────────────────────────────────────── +from .artwork import ( + ARTWORK_FORMATS_BY_ID, + ITHMB_FORMAT_MAP, + ITHMB_SIZE_MAP, + cover_art_format_definitions_for_device, + ithmb_formats_for_device, + photo_formats_for_device, + resolve_cover_art_format_definitions, + resolve_cover_art_format_definitions_for_device, +) + +# ── authority ──────────────────────────────────────────────────────── +from .authority import ( + AUTHORITY_FILENAME, + SOURCE_RANK, + SYSINFO_FIELDS, + cache_sysinfo_extended, + check_authority_coverage, + read_authority, + update_sysinfo, +) + +# ── capabilities ───────────────────────────────────────────────────── +from .capabilities import ( + ArtworkFormat, + DeviceCapabilities, + capabilities_for_family_gen, + checksum_type_for_family_gen, + cover_art_formats_for_family_gen, +) +from .checksum import ( + CHECKSUM_MHBD_SCHEME, + MHBD_SCHEME_TO_CHECKSUM, + ChecksumType, +) + +# ── images ─────────────────────────────────────────────────────────── +from .images import ( + COLOR_MAP, + FAMILY_FALLBACK, + GENERIC_IMAGE, + IMAGE_COLORS, + MODEL_IMAGE, + color_for_image, + image_for_model, + resolve_image_filename, +) + +# ── info (device_info) ─────────────────────────────────────────────── +from .info import ( + DeviceInfo, + clear_current_device, + detect_checksum_type, + enrich, + generate_library_id, + get_current_device, + get_firewire_id, + itdb_write_filename, + read_sysinfo, + resolve_itdb_path, + set_current_device, +) + +# ── lookup ─────────────────────────────────────────────────────────── +from .lookup import ( + extract_model_number, + get_friendly_model_name, + get_model_info, + infer_generation, + lookup_by_serial, +) + +# ── models ─────────────────────────────────────────────────────────── +from .models import ( + IPOD_MODELS, + IPOD_USB_PIDS, + SERIAL_LAST3_TO_MODEL, + USB_PID_TO_MODEL, +) + +# ── sysinfo parsing/evidence ───────────────────────────────────────── +from .sysinfo import ( + DeviceEvidence, + EvidenceValue, + ParsedSysInfoExtended, + identity_from_sysinfo, + identity_from_sysinfo_extended, + parse_sysinfo_extended, + parse_sysinfo_text, +) + +# ── virtual iPods ───────────────────────────────────────────────────── +from .virtual import ( + VIRTUAL_IPOD_INFO_FILENAME, + available_virtual_ipod_models, + create_virtual_ipod, + ensure_virtual_itunes_database, + has_virtual_ipod_info, + load_virtual_ipod_info, + virtual_ipod_info_path, +) + +# ── checksum ───────────────────────────────────────────────────────── +from .vpd_libusb import ( + identify_via_vpd, +) +from .vpd_libusb import ( + query_all_ipods as usb_query_all_ipods, +) + +# ── vpd_libusb ─────────────────────────────────────────────────────── +from .vpd_libusb import ( + query_ipod_vpd as usb_query_ipod_vpd, +) +from .vpd_libusb import ( + write_sysinfo as usb_write_sysinfo, +) +from .vpd_usb_control import ( + query_all_ipod_usb_sysinfo_extended, + query_ipod_usb_sysinfo_extended, +) + +try: + from .vpd_linux import query_ipod_vpd_for_path as linux_query_ipod_vpd_for_path +except ImportError: + pass + +try: + from .vpd_windows import query_ipod_vpd_for_path as windows_query_ipod_vpd_for_path +except ImportError: + pass + +# ── vpd_iokit is macOS-only and raises ImportError on other platforms, +# so we don't import it at package level. Import directly: +# from ipod_device.vpd_iokit import query_ipod_vpd + +# ── scanner (GUI/device_scanner) ──────────────────────────────────── +from .scanner import identify_ipod_at_path, scan_for_ipods diff --git a/src/vendor/ipod_device/artwork.py b/src/vendor/ipod_device/artwork.py new file mode 100644 index 0000000..db09242 --- /dev/null +++ b/src/vendor/ipod_device/artwork.py @@ -0,0 +1,184 @@ +"""Artwork lookups backed by the canonical format registry.""" + +from .artwork_presets import ( + ARTWORK_FORMATS_BY_ID, + ArtworkFormat, +) +from .capabilities import capabilities_for_family_gen, cover_art_formats_for_family_gen + +ITHMB_FORMAT_MAP = ARTWORK_FORMATS_BY_ID +"""Primary global lookup of ithmb correlation ID -> ``ArtworkFormat``. + +Most artwork IDs are globally meaningful. Device-aware code can layer a small +override set on top of this table for the few known conflicts, such as Nano +7G's reinterpretation of ``1013``/``1015``/``1016``. +""" + +ITHMB_SIZE_MAP: dict[int, ArtworkFormat] = {} +"""Fallback lookup: byte size -> ``ArtworkFormat``.""" +for _af in ITHMB_FORMAT_MAP.values(): + _byte_size = _af.row_bytes * _af.height + if _byte_size > 0 and _byte_size not in ITHMB_SIZE_MAP: + ITHMB_SIZE_MAP[_byte_size] = _af + + +def ithmb_formats_for_device( + family: str, + generation: str, + *, + capacity: str | None = None, + model_number: str | None = None, +) -> dict[int, tuple[int, int]]: + """Return ``{correlation_id: (width, height)}`` for a device's cover art.""" + definitions = cover_art_format_definitions_for_device( + family, + generation, + capacity=capacity, + model_number=model_number, + ) + return {fid: (af.width, af.height) for fid, af in definitions.items()} + + +def _format_dict(formats: tuple[ArtworkFormat, ...]) -> dict[int, ArtworkFormat]: + return {af.format_id: af for af in formats} + + +def cover_art_format_definitions_for_device( + family: str, + generation: str, + *, + capacity: str | None = None, + model_number: str | None = None, +) -> dict[int, ArtworkFormat]: + """Return the device's required cover-art definitions. + + The normal case is the global registry. Devices with known conflicting IDs + expose a small explicit override set through their capability profile. + """ + + caps = capabilities_for_family_gen( + family, + generation or "", + capacity=capacity, + model_number=model_number, + ) + if caps is None: + return _format_dict( + cover_art_formats_for_family_gen( + family, + generation, + capacity=capacity, + model_number=model_number, + ) + ) + if not caps.supports_artwork: + return {} + return _format_dict(caps.cover_art_formats) + + +def _resolve_observed_format( + format_id: int, + width: int, + height: int, + preferred_defs: dict[int, ArtworkFormat], +) -> ArtworkFormat: + """Resolve an observed ``id -> dimensions`` using overrides first, then global defaults. + + If neither source matches the observed dimensions, fall back to a generic + RGB565 cover-art definition for that observed shape. + """ + for candidate in ( + preferred_defs.get(format_id), + ARTWORK_FORMATS_BY_ID.get(format_id), + ): + if candidate is None: + continue + if int(candidate.width) == int(width) and int(candidate.height) == int(height): + return candidate + + return ArtworkFormat( + int(format_id), + int(width), + int(height), + int(width) * 2, + "RGB565_LE", + "cover", + f"Device artwork format {format_id}", + ) + + +def resolve_cover_art_format_definitions( + family: str = "", + generation: str = "", + *, + capacity: str | None = None, + model_number: str | None = None, + observed_formats: dict[int, tuple[int, int]] | None = None, +) -> dict[int, ArtworkFormat]: + """Resolve the authoritative cover-art definitions for a device. + + ``observed_formats`` usually comes from SysInfoExtended or an existing + ArtworkDB. When present, its ID list is authoritative, but each entry still + resolves through device overrides first and the global registry second. Only + unmatched dimensions fall back to a generic inferred definition. + """ + preferred_defs = cover_art_format_definitions_for_device( + family, + generation, + capacity=capacity, + model_number=model_number, + ) + + if observed_formats: + resolved: dict[int, ArtworkFormat] = {} + for fid, dims in observed_formats.items(): + width, height = dims + resolved[int(fid)] = _resolve_observed_format( + int(fid), + int(width), + int(height), + preferred_defs, + ) + return resolved + + return preferred_defs + + +def resolve_cover_art_format_definitions_for_device(device) -> dict[int, ArtworkFormat]: + """Resolve cover-art definitions from a ``DeviceInfo``-like object.""" + if device is None: + return {} + + return resolve_cover_art_format_definitions( + getattr(device, "model_family", "") or "", + getattr(device, "generation", "") or "", + capacity=getattr(device, "capacity", ""), + model_number=getattr(device, "model_number", ""), + observed_formats=getattr(device, "artwork_formats", None) or None, + ) + + +def photo_formats_for_device( + family: str, + generation: str, + *, + capacity: str | None = None, + model_number: str | None = None, +) -> dict[int, ArtworkFormat]: + """Return device-specific photo ithmb formats. + + This is separate from cover-art formats because iPods keep slide-show/photo + caches in the ``Photos`` hierarchy rather than ``ArtworkDB``. The per-device + formats are sourced from ``DeviceCapabilities.photo_formats``. + """ + + caps = capabilities_for_family_gen( + family, + generation or "", + capacity=capacity, + model_number=model_number, + ) + formats = caps.photo_formats if caps is not None else () + if not formats: + return {} + return {af.format_id: af for af in formats} diff --git a/src/vendor/ipod_device/artwork_presets.py b/src/vendor/ipod_device/artwork_presets.py new file mode 100644 index 0000000..72985a5 --- /dev/null +++ b/src/vendor/ipod_device/artwork_presets.py @@ -0,0 +1,119 @@ +"""Canonical ithmb artwork format definitions. + +Sources: + - libgpod ``src/itdb_device.c`` fallback artwork tables + - Keith's iPod Photo Reader README (model/prefix cross-checks) + - cyianor/ithmbrdr README (1067 photo payload confirmation) + - local iTunes-authored Nano 7G artwork dump (F1010/F1013/F1015/F1016) + +The global registry below is the default source of truth for artwork IDs. +Only a very small number of device families are known to reinterpret IDs, +so those conflicts are modeled as explicit overrides rather than treating +the whole ID space as device-specific. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ArtworkFormat: + """One ithmb artwork format definition.""" + + format_id: int + width: int + height: int + row_bytes: int + pixel_format: str = "RGB565_LE" + role: str = "cover" + description: str = "" + + +ARTWORK_FORMATS_BY_ID: dict[int, ArtworkFormat] = { + # iPod Photo / Video era + 1005: ArtworkFormat(1005, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 7G photo thumbnail"), + 1007: ArtworkFormat(1007, 480, 864, 960, "RGB565_LE", "photo_full", "Nano 7G photo full screen"), + 1009: ArtworkFormat(1009, 42, 30, 84, "RGB565_LE", "photo_list", "Photo list thumbnail"), + 1010: ArtworkFormat(1010, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 7G album art large"), + 1013: ArtworkFormat(1013, 220, 176, 440, "RGB565_BE_90", "photo_full", "Photo full screen (rotated)"), + 1015: ArtworkFormat(1015, 130, 88, 260, "RGB565_LE", "photo_preview", "Photo/Video preview"), + 1016: ArtworkFormat(1016, 140, 140, 280, "RGB565_LE", "cover_large", "Photo album art large"), + 1017: ArtworkFormat(1017, 56, 56, 112, "RGB565_LE", "cover_small", "Photo album art small"), + 1019: ArtworkFormat(1019, 720, 480, 1440, "UYVY", "tv_out", "Photo/Video NTSC TV output"), + # Compatibility alias preserved from existing Apple databases. + 1020: ArtworkFormat(1020, 220, 176, 440, "RGB565_BE_90", "photo_full", "Photo full screen (alt rotated)"), + 1023: ArtworkFormat(1023, 176, 132, 352, "RGB565_BE", "photo_full", "Nano full screen"), + 1024: ArtworkFormat(1024, 320, 240, 640, "RGB565_LE", "photo_full", "320x240 photo full screen"), + 1027: ArtworkFormat(1027, 100, 100, 200, "RGB565_LE", "cover_large", "Nano album art large"), + 1028: ArtworkFormat(1028, 100, 100, 200, "RGB565_LE", "cover_small", "Video album art small"), + 1029: ArtworkFormat(1029, 200, 200, 400, "RGB565_LE", "cover_large", "Video album art large"), + 1031: ArtworkFormat(1031, 42, 42, 84, "RGB565_LE", "cover_small", "Nano album art small"), + 1032: ArtworkFormat(1032, 42, 37, 84, "RGB565_LE", "photo_list", "Nano list thumbnail"), + 1036: ArtworkFormat(1036, 50, 41, 100, "RGB565_LE", "photo_list", "Video list thumbnail"), + # Classic / later click-wheel iPods + # Compatibility alias preserved from existing Apple databases. + 1044: ArtworkFormat(1044, 128, 128, 256, "RGB565_LE", "cover_medium", "Classic album art medium"), + 1055: ArtworkFormat(1055, 128, 128, 256, "RGB565_LE", "cover_medium", "Classic album art medium"), + 1056: ArtworkFormat(1056, 128, 128, 256, "RGB565_LE", "cover_medium_alt", "128x128 cover art (alternate)"), + 1060: ArtworkFormat(1060, 320, 320, 640, "RGB565_LE", "cover_large", "Classic album art large"), + 1061: ArtworkFormat(1061, 56, 56, 112, "RGB565_LE", "cover_small", "Classic album art small"), + 1066: ArtworkFormat(1066, 64, 64, 128, "RGB565_LE", "photo_thumb", "Classic photo thumbnail"), + 1067: ArtworkFormat(1067, 720, 480, 1080, "I420_LE", "tv_out", "Classic TV output (YUV)"), + 1068: ArtworkFormat(1068, 128, 128, 256, "RGB565_LE", "cover_medium_alt", "Classic album art medium (alt 2)"), + 1071: ArtworkFormat(1071, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 4G album art large"), + 1073: ArtworkFormat(1073, 240, 240, 480, "RGB565_LE", "cover_large", "Nano 5G/6G album art large"), + 1074: ArtworkFormat(1074, 50, 50, 100, "RGB565_LE", "cover_xsmall", "Nano album art tiny"), + 1078: ArtworkFormat(1078, 80, 80, 160, "RGB565_LE", "cover_small", "Nano 4G/5G album art small"), + 1079: ArtworkFormat(1079, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 4G/5G photo thumbnail"), + 1081: ArtworkFormat(1081, 640, 480, 0, "JPEG", "photo_full", "JPEG photo format (experimental/legacy)"), + 1083: ArtworkFormat(1083, 240, 320, 480, "RGB565_LE", "photo_full", "Nano 4G photo full screen (portrait)"), + 1084: ArtworkFormat(1084, 240, 240, 480, "RGB565_LE", "cover_large_alt", "Nano 4G album art (alt)"), + # Newer iPod-only formats beyond libgpod's older hardcoded tables. + 1085: ArtworkFormat(1085, 88, 88, 176, "RGB565_LE", "cover_medium", "Nano 6G album art medium"), + 1087: ArtworkFormat(1087, 384, 384, 768, "RGB565_LE", "photo_large", "Nano 5G photo large"), + 1089: ArtworkFormat(1089, 58, 58, 116, "RGB565_LE", "cover_small", "Nano 6G album art small"), + 1092: ArtworkFormat(1092, 80, 80, 160, "RGB565_LE", "photo_thumb", "Nano 6G photo thumbnail"), + 1093: ArtworkFormat(1093, 512, 512, 1024, "RGB565_LE", "photo_full", "Nano 6G photo full screen"), + # Mobile / touch-era formats + 2002: ArtworkFormat(2002, 50, 50, 100, "RGB565_BE", "cover_small", "iPod Mobile cover art small"), + 2003: ArtworkFormat(2003, 150, 150, 300, "RGB565_BE", "cover_large", "iPod Mobile cover art large"), + 3001: ArtworkFormat(3001, 256, 256, 512, "REC_RGB555_LE", "cover_large", "iPod touch cover art large"), + 3002: ArtworkFormat(3002, 128, 128, 256, "REC_RGB555_LE", "cover_medium", "iPod touch cover art medium"), + 3003: ArtworkFormat(3003, 64, 64, 128, "REC_RGB555_LE", "cover_small", "iPod touch cover art small"), + 3005: ArtworkFormat(3005, 320, 320, 640, "RGB555_LE", "cover_xlarge", "iPod touch cover art xlarge"), +} + + +CLASSIC_COVER_ART_FORMATS = ( + ARTWORK_FORMATS_BY_ID[1055], + ARTWORK_FORMATS_BY_ID[1060], + ARTWORK_FORMATS_BY_ID[1061], + ARTWORK_FORMATS_BY_ID[1068], +) +"""Cover-art formats used by click-wheel iPod Classic generations.""" + + +NANO_7G_COVER_ART_OVERRIDES = ( + ARTWORK_FORMATS_BY_ID[1010], + ArtworkFormat(1013, 50, 50, 100, "RGB565_LE", "cover_xsmall", "Nano 7G album art tiny"), + ArtworkFormat(1015, 58, 58, 116, "RGB565_LE", "cover_small", "Nano 7G album art small"), + ArtworkFormat(1016, 57, 57, 116, "RGB565_LE", "cover_small_alt", "Nano 7G album art small (aligned)"), +) +"""Known Nano 7G overrides for a few globally-defined artwork IDs.""" + + +# Backward-compatible alias used by capability tables and existing imports. +NANO_7G_COVER_ART_FORMATS = NANO_7G_COVER_ART_OVERRIDES + + +def artwork_format_candidates() -> tuple[ArtworkFormat, ...]: + """Return the global registry plus the small set of known override variants.""" + candidates = [ + *ARTWORK_FORMATS_BY_ID.values(), + *CLASSIC_COVER_ART_FORMATS, + *NANO_7G_COVER_ART_OVERRIDES, + ] + unique: dict[tuple[int, int, int, str], ArtworkFormat] = {} + for fmt in candidates: + key = (fmt.format_id, fmt.width, fmt.height, fmt.pixel_format) + unique.setdefault(key, fmt) + return tuple(unique.values()) diff --git a/src/vendor/ipod_device/authority.py b/src/vendor/ipod_device/authority.py new file mode 100644 index 0000000..23abe86 --- /dev/null +++ b/src/vendor/ipod_device/authority.py @@ -0,0 +1,671 @@ +""" +iOpenPod SysInfo Authority — manages authoritative SysInfo writing. + +After device identification is complete, reconciles gathered data with +the existing SysInfo file on the iPod, using per-field provenance tracking +to keep the most reliable value for each field. + +The authority file at ``/iPod_Control/Device/iOpenPodSysInfoAuthority`` +(JSON) records which data source was used to populate each SysInfo field. +On subsequent runs, if a field's value has changed, the authority +determines whether the new or existing value is more trustworthy. + +Source reliability (most → least):: + + Sure (live hardware): + scsi_vpd / windows_scsi / linux_scsi / usb_vendor > vpd > iokit > ioctl + > device_tree / ioreg / sysfs > wmi + Guesses (files / lookups / derivations): + sysinfo_extended > sysinfo > itunes > serial_lookup + > usb_pid > hashing > unknown +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .info import DeviceInfo + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────── +# Source reliability ranking — lower index = more reliable +# ────────────────────────────────────────────────────────────────────── + +_SOURCE_ORDER: list[str] = [ + # ── Sure: live hardware probes ────────────────────────────────── + "scsi_vpd", # Live SCSI INQUIRY VPD plist + "windows_scsi", # Windows SCSI pass-through VPD + "linux_scsi", # Linux SG_IO SCSI pass-through VPD + "usb_vendor", # Live Apple USB vendor-control plist + "vpd", # SCSI Vital Product Data — gold standard + "iokit", # macOS IOKit SCSI (effectively VPD, no unmount) + "ioctl", # Windows direct SCSI inquiry + "device_tree", # Windows PnP device tree (live hardware) + "ioreg", # macOS ioreg (live hardware) + "sysfs", # Linux sysfs (live hardware) + "wmi", # Windows WMI (live hardware query) + # ── Guesses: lookups, derivations, files ──────────────────────── + "itunes", # Pre-existing value assumed to be from iTunes + "serial_lookup", # Derived from serial last-3 chars + "usb_pid", # Coarse USB PID mapping + "disk_size", # Live disk-size based capacity estimate + "model_table", # Deterministic inference from known model tuples + "inferred", # Other deterministic inference from known fields + "sysinfo_extended", # SysInfoExtended XML plist — on-disk, stale-prone + "sysinfo", # SysInfo plain text — on-disk, stale-prone + "hashing", # Inferred from hashing scheme + "unknown", # Source not tracked +] + +SOURCE_RANK: dict[str, int] = {src: i for i, src in enumerate(_SOURCE_ORDER)} +"""Map source name → rank (lower = more reliable).""" + +_WORST_RANK: int = len(_SOURCE_ORDER) + +# Anything with rank < _SURE_THRESHOLD is a "sure" (live hardware) source. +# Anything >= is a "guess" (file / lookup / derivation). +_SURE_THRESHOLD: int = SOURCE_RANK["itunes"] # first guess source + + +# ────────────────────────────────────────────────────────────────────── +# SysInfo key ↔ DeviceInfo field mapping +# ────────────────────────────────────────────────────────────────────── + +SYSINFO_FIELDS: list[tuple[str, str]] = [ + # ── Core identifiers (written by iTunes / hardware probes) ──────── + ("pszSerialNumber", "serial"), + ("FirewireGuid", "firewire_guid"), + ("visibleBuildID", "firmware"), + ("BoardHwName", "board"), + ("ModelNumStr", "model_number"), + ("FamilyID", "family_id"), + ("UpdaterFamilyID", "updater_family_id"), + # ── Derived / resolved by iOpenPod for full device granularity ──── + # These are deterministically derived from model_number (via + # IPOD_MODELS or serial-last-3 lookup), but caching them in SysInfo + # avoids re-derivation and lets the authority system track provenance. + ("ModelFamily", "model_family"), + ("Generation", "generation"), + ("Capacity", "capacity"), + ("Color", "color"), + ("USBProductID", "usb_pid"), +] + +# Fields whose default DeviceInfo value is a non-empty sentinel that should +# NOT be treated as "already populated" when reading from SysInfo. +# model_family defaults to "iPod" (generic, unresolved). +_SENTINEL_DEFAULTS: dict[str, str] = { + "model_family": "iPod", +} + +_DERIVED_SYSINFO_KEYS: frozenset[str] = frozenset({ + "ModelFamily", + "Generation", + "Capacity", + "Color", + "USBProductID", +}) + +# Core identification fields — these drive the "all sure" determination for +# the HIGH authority path. If ALL core fields have sure (live hardware) +# provenance, the expensive hardware and VPD probes are skipped. +# +# Only the essential identification trio is included: +# - Serial number (needed for serial-last-3 exact model resolution) +# - FireWire GUID (needed for database signing) +# - Model number (needed for family/gen/capacity/color derivation) +# +# Other fields (firmware, board) are informational — their absence from +# live hardware probes should NOT force re-probing. Derived fields +# (ModelFamily, Generation, etc.) are excluded because they inherit trust +# from a core field and don't require independent hardware probing. +_CORE_FIELDS: frozenset[str] = frozenset({ + "pszSerialNumber", + "FirewireGuid", + "ModelNumStr", +}) + +AUTHORITY_FILENAME = "iOpenPodSysInfoAuthority" + + +# ────────────────────────────────────────────────────────────────────── +# Authority coverage check +# ────────────────────────────────────────────────────────────────────── + +def check_authority_coverage( + ipod_path: str, +) -> tuple[bool, dict[str, str]]: + """Check whether the authority file indicates core fields are all tracked. + + Returns ``(all_tracked, field_sources)`` where: + + * *all_tracked* is ``True`` when every **core** SysInfo field has an + authority entry (i.e., iOpenPod has previously identified this device + and cached the results). The SysInfo is trusted as high-authority + because either (a) it was written by iTunes, or (b) iOpenPod wrote + it after probing hardware. The only things that invalidate trust + are external modification (detected via SHA-256 hashes) or missing + authority file (first run). + * *field_sources* maps DeviceInfo field names to their authority source + strings (e.g. ``{"serial": "vpd", "firewire_guid": "ioctl", ...}``). + + If the authority file is missing or empty, returns ``(False, {})`` so + that the caller runs the full probe pipeline. + """ + authority = read_authority(ipod_path) + fields = authority.get("fields", {}) + if not fields: + return False, {} + + # Tamper detection — if SysInfo/SysInfoExtended were modified externally + # (by iTunes or another tool), we can't trust the cached provenance. + stored_hashes = authority.get("file_hashes", {}) + if stored_hashes: + tampered = False + for label, path in [ + ("SysInfo", _sysinfo_path(ipod_path)), + ("SysInfoExtended", _sysinfo_extended_path(ipod_path)), + ]: + stored = stored_hashes.get(label) + if stored is not None: + current = _hash_file(path) + if current != stored: + tampered = True + break + if tampered: + logger.info( + "Authority coverage: external modification detected, " + "treating all sources as low-authority", + ) + return False, {} + + field_sources: dict[str, str] = {} + all_tracked = True + for sysinfo_key, device_field in SYSINFO_FIELDS: + entry = fields.get(sysinfo_key) + if entry is None: + # Field not in authority at all. Only core fields affect + # the "all tracked" flag — missing derived fields are fine + # (they'll be re-derived cheaply from model lookup). + if sysinfo_key in _CORE_FIELDS: + all_tracked = False + continue + source = entry.get("source", "unknown") + field_sources[device_field] = source + + return all_tracked, field_sources + + +# ────────────────────────────────────────────────────────────────────── +# Formatting helpers +# ────────────────────────────────────────────────────────────────────── + +def _format_for_sysinfo(sysinfo_key: str, device_value) -> str: + """Convert a DeviceInfo field value to the SysInfo on-disk format. + + Handles both string and non-string field types (e.g. usb_pid is int). + """ + if sysinfo_key == "USBProductID": + # usb_pid is an int on DeviceInfo + if isinstance(device_value, int): + return f"0x{device_value:04X}" if device_value else "" + # String passthrough (shouldn't happen, but safe) + return str(device_value) if device_value else "" + if not device_value: + return "" + device_value = str(device_value) + if sysinfo_key == "FirewireGuid": + clean = device_value + if clean.startswith(("0x", "0X")): + return f"0x{clean[2:].upper()}" + return f"0x{clean.upper()}" + if sysinfo_key == "ModelNumStr": + # SysInfo stores "xA623" — the 'M' prefix becomes 'x' + if device_value.startswith("M"): + return f"x{device_value[1:]}" + return device_value + return device_value + + +def _normalise_sysinfo_value(sysinfo_key: str, raw_value) -> str: + """Normalise a raw SysInfo value to a comparable canonical form. + + Strips null padding, ``0x`` prefixes, and whitespace so that + comparisons are not tripped up by trivial formatting differences. + """ + val = str(raw_value).strip().rstrip("\x00") + if sysinfo_key == "FirewireGuid": + if val.startswith(("0x", "0X")): + val = val[2:] + return val.upper() + if sysinfo_key == "ModelNumStr": + if val.startswith("x"): + val = "M" + val[1:] + return val.upper().rstrip("\x00") + if sysinfo_key == "USBProductID": + if val.upper().startswith("0X"): + val = val[2:] + return val.upper().lstrip("0") or "0" + return val + + +# ────────────────────────────────────────────────────────────────────── +# File I/O +# ────────────────────────────────────────────────────────────────────── + +def _authority_path(ipod_path: str) -> str: + return os.path.join( + ipod_path, "iPod_Control", "Device", AUTHORITY_FILENAME, + ) + + +def _sysinfo_path(ipod_path: str) -> str: + return os.path.join(ipod_path, "iPod_Control", "Device", "SysInfo") + + +def _sysinfo_extended_path(ipod_path: str) -> str: + return os.path.join(ipod_path, "iPod_Control", "Device", "SysInfoExtended") + + +def _hash_file(path: str) -> str | None: + """Return hex SHA-256 of a file, or ``None`` if the file is missing.""" + if not os.path.exists(path): + return None + try: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + except Exception as exc: + logger.warning("Failed to hash %s: %s", path, exc) + return None + + +def read_authority(ipod_path: str) -> dict: + """Read the authority file. Returns ``{}`` if missing or corrupt.""" + path = _authority_path(ipod_path) + if not os.path.exists(path): + return {} + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception as exc: + logger.warning("Authority file read failed: %s", exc) + return {} + + +def _write_authority(ipod_path: str, authority: dict) -> None: + path = _authority_path(ipod_path) + device_dir = os.path.dirname(path) + os.makedirs(device_dir, exist_ok=True) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(authority, f, indent=2, ensure_ascii=False) + logger.debug("Wrote authority file to %s", path) + except Exception as exc: + logger.warning("Failed to write authority file: %s", exc) + + +def _read_sysinfo_raw(ipod_path: str) -> dict[str, str]: + """Read all key:value pairs from SysInfo, preserving raw values.""" + path = _sysinfo_path(ipod_path) + result: dict[str, str] = {} + if not os.path.exists(path): + return result + try: + with open(path, errors="replace") as f: + for line in f: + if ":" in line: + key, val = line.split(":", 1) + result[key.strip()] = val.strip() + except Exception as exc: + logger.warning("SysInfo read failed: %s", exc) + return result + + +def _write_sysinfo_file(ipod_path: str, fields: dict[str, str]) -> None: + """Write all fields to the SysInfo file.""" + path = _sysinfo_path(ipod_path) + device_dir = os.path.dirname(path) + os.makedirs(device_dir, exist_ok=True) + lines = [f"{k}: {v}" for k, v in fields.items() if v] + try: + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + logger.info("Wrote SysInfo (%d fields) to %s", len(lines), path) + except Exception as exc: + logger.warning("Failed to write SysInfo: %s", exc) + + +def _normalise_sysinfo_extended(raw_xml: bytes | str) -> bytes: + """Return canonical SysInfoExtended plist bytes suitable for caching.""" + if isinstance(raw_xml, str): + raw = raw_xml.encode("utf-8", errors="replace") + else: + raw = bytes(raw_xml or b"") + if not raw: + return b"" + + try: + from .sysinfo import parse_sysinfo_extended + + parsed = parse_sysinfo_extended(raw) + if parsed.plist and parsed.raw_xml: + return parsed.raw_xml + except Exception: + pass + + for marker in (b"= 0: + raw = raw[idx:] + break + raw = raw.strip(b"\x00\r\n\t ") + if raw and b"" not in raw: + raw += b"\n\n" + return raw + + +def cache_sysinfo_extended( + ipod_path: str, + raw_xml: bytes | str, + *, + source: str = "unknown", + metadata: dict | None = None, +) -> bool: + """Cache a live SysInfoExtended payload and refresh authority hashes.""" + if not ipod_path or not raw_xml: + return False + + device_dir = os.path.join(ipod_path, "iPod_Control", "Device") + if not os.path.isdir(device_dir): + return False + + data = _normalise_sysinfo_extended(raw_xml) + if not data: + return False + + path = _sysinfo_extended_path(ipod_path) + try: + with open(path, "wb") as f: + f.write(data) + except Exception as exc: + logger.warning("Failed to cache SysInfoExtended: %s", exc) + return False + + authority = read_authority(ipod_path) + now = datetime.now(UTC).isoformat() + files = authority.setdefault("files", {}) + files["SysInfoExtended"] = { + "source": source, + "updated": now, + "bytes": len(data), + } + if metadata: + files["SysInfoExtended"]["metadata"] = { + str(k): v + for k, v in metadata.items() + if isinstance(v, (str, int, float, bool)) and v not in ("", None) + } + authority["version"] = 1 + authority["last_updated"] = now + _store_file_hashes(ipod_path, authority) + _write_authority(ipod_path, authority) + logger.debug( + "Cached SysInfoExtended (%d bytes, source=%s) to %s", + len(data), + source, + path, + ) + return True + + +# ────────────────────────────────────────────────────────────────────── +# Tamper detection +# ────────────────────────────────────────────────────────────────────── + +def _detect_external_modification( + ipod_path: str, + authority: dict, + fields: dict[str, dict], + now: str, +) -> None: + """Check whether SysInfo or SysInfoExtended were modified externally. + + Compares the stored SHA-256 hashes in the authority file against the + current on-disk files. If either file has been changed (e.g. by + iTunes or another tool), **all** authority source levels are reset to + ``"sysinfo"`` because we can no longer trust the provenance — the + external software may have overwritten fields with different values. + """ + stored_hashes: dict[str, str] = authority.get("file_hashes", {}) + if not stored_hashes: + # First run or no hashes recorded — nothing to compare. + return + + tampered: list[str] = [] + + for label, path in [ + ("SysInfo", _sysinfo_path(ipod_path)), + ("SysInfoExtended", _sysinfo_extended_path(ipod_path)), + ]: + stored = stored_hashes.get(label) + if stored is None: + # We never recorded a hash for this file — skip. + continue + current = _hash_file(path) + if current is None: + # File was deleted externally — that counts as a change. + tampered.append(f"{label} (deleted)") + elif current != stored: + tampered.append(label) + + if not tampered: + return + + logger.warning( + "External modification detected in %s — resetting all " + "authority sources to 'sysinfo'", + ", ".join(tampered), + ) + for _sysinfo_key, entry in fields.items(): + if isinstance(entry, dict) and entry.get("source") != "sysinfo": + entry["source"] = "sysinfo" + entry["updated"] = now + + +def _store_file_hashes(ipod_path: str, authority: dict) -> None: + """Compute and store SHA-256 hashes of SysInfo and SysInfoExtended.""" + hashes: dict[str, str] = {} + for label, path in [ + ("SysInfo", _sysinfo_path(ipod_path)), + ("SysInfoExtended", _sysinfo_extended_path(ipod_path)), + ]: + h = _hash_file(path) + if h is not None: + hashes[label] = h + authority["file_hashes"] = hashes + + +# ────────────────────────────────────────────────────────────────────── +# Authority-aware SysInfo update +# ────────────────────────────────────────────────────────────────────── + +def _rank(source: str) -> int: + """Lower = more reliable. Unknown sources get worst rank.""" + return SOURCE_RANK.get(source, _WORST_RANK) + + +def _default_existing_source(sysinfo_key: str, old_raw: str) -> str: + """Return provenance for an existing SysInfo value with no authority entry.""" + if not old_raw: + return "unknown" + if sysinfo_key in _DERIVED_SYSINFO_KEYS: + return "sysinfo" + return "itunes" + + +def update_sysinfo(info: DeviceInfo) -> None: + """Reconcile gathered DeviceInfo with the on-disk SysInfo. + + Called **after** all identification and enrichment is complete. + + For each SysInfo-mappable field: + + * Missing from SysInfo → add it. + * Same value → refresh authority timestamp (upgrade source if better). + * Different value → keep the one from the more reliable source. + + Also writes/updates the ``iOpenPodSysInfoAuthority`` JSON alongside + SysInfo so future runs can make informed decisions. + """ + if not info.path: + return + + ipod_path = info.path + device_dir = os.path.join(ipod_path, "iPod_Control", "Device") + if not os.path.isdir(device_dir): + return + + existing_sysinfo = _read_sysinfo_raw(ipod_path) + authority = read_authority(ipod_path) + fields = authority.get("fields", {}) + now = datetime.now(UTC).isoformat() + + # ── Tamper detection: hash SysInfo / SysInfoExtended ────────── + _detect_external_modification(ipod_path, authority, fields, now) + + # Start with all existing SysInfo fields so we preserve any we don't map + updated_sysinfo: dict[str, str] = dict(existing_sysinfo) + sysinfo_changed = False + + for sysinfo_key, device_field in SYSINFO_FIELDS: + new_value = getattr(info, device_field, "") + + # Skip empty / default-sentinel values — these haven't been + # resolved to anything useful yet. + sentinel = _SENTINEL_DEFAULTS.get(device_field) + if sentinel is not None and new_value == sentinel: + continue + if not new_value: + continue + + new_source: str = info._field_sources.get(device_field, "unknown") + new_formatted = _format_for_sysinfo(sysinfo_key, new_value) + + old_raw = existing_sysinfo.get(sysinfo_key, "") + old_source: str = fields.get(sysinfo_key, {}).get( + "source", _default_existing_source(sysinfo_key, old_raw), + ) + + # ── Field missing from SysInfo → add it ────────────────────── + if not old_raw: + updated_sysinfo[sysinfo_key] = new_formatted + fields[sysinfo_key] = { + "value": new_formatted, + "source": new_source, + "updated": now, + } + sysinfo_changed = True + logger.debug( + "SysInfo: adding %s = %s (source: %s)", + sysinfo_key, new_formatted, new_source, + ) + continue + + # ── Compare normalised values ───────────────────────────────── + old_normalised = _normalise_sysinfo_value(sysinfo_key, old_raw) + new_normalised = _normalise_sysinfo_value(sysinfo_key, new_formatted) + + if old_normalised == new_normalised: + # Same effective value — upgrade authority source if we're + # more (or equally) reliable, otherwise ensure the existing + # best source is still recorded (so authority coverage check + # sees the field as tracked). + best_source = ( + new_source + if _rank(new_source) <= _rank(old_source) + else old_source + ) + if sysinfo_key not in fields or _rank(best_source) <= _rank( + fields[sysinfo_key].get("source", "unknown"), + ): + fields[sysinfo_key] = { + "value": old_raw, # keep existing formatting + "source": best_source, + "updated": now, + } + continue + + # iOpenPod-derived fields are cache material, not immutable iTunes + # facts. If our current resolver produces a different derived label + # after a table/provenance fix, let it refresh stale authority entries + # even when the old cached source had a better historical rank. + if sysinfo_key in _DERIVED_SYSINFO_KEYS: + updated_sysinfo[sysinfo_key] = new_formatted + fields[sysinfo_key] = { + "value": new_formatted, + "source": new_source, + "updated": now, + } + sysinfo_changed = True + logger.debug( + "SysInfo: refreshing derived %s: %r → %r (source: %s)", + sysinfo_key, + old_raw, + new_formatted, + new_source, + ) + continue + + # ── Values differ — use the more reliable source ────────────── + if _rank(new_source) <= _rank(old_source): + # New source is at least as reliable → overwrite + updated_sysinfo[sysinfo_key] = new_formatted + fields[sysinfo_key] = { + "value": new_formatted, + "source": new_source, + "updated": now, + } + sysinfo_changed = True + + logger.debug( + "SysInfo: updating %s: %r → %r (source %s [rank %d] " + "beats %s [rank %d])", + sysinfo_key, old_raw, new_formatted, + new_source, _rank(new_source), + old_source, _rank(old_source), + ) + else: + # Existing value from a more reliable source → keep it + logger.debug( + "SysInfo: keeping %s = %r (source %s [rank %d] beats " + "new %s [rank %d] with %r)", + sysinfo_key, old_raw, + old_source, _rank(old_source), + new_source, _rank(new_source), new_formatted, + ) + + # ── Persist ─────────────────────────────────────────────────────── + if sysinfo_changed: + _write_sysinfo_file(ipod_path, updated_sysinfo) + + # Always ensure the authority dict is well-formed before writing. + authority["version"] = 1 + authority["fields"] = fields + authority["last_updated"] = now + + # Always refresh file hashes so the next run can detect tampering. + _store_file_hashes(ipod_path, authority) + _write_authority(ipod_path, authority) diff --git a/src/vendor/ipod_device/capabilities.py b/src/vendor/ipod_device/capabilities.py new file mode 100644 index 0000000..60a6b05 --- /dev/null +++ b/src/vendor/ipod_device/capabilities.py @@ -0,0 +1,641 @@ +"""Device capabilities — per-generation feature map backed by canonical artwork formats. + +Sources: + - libgpod ``itdb_device.c`` — itdb_device_supports_*() functions, + ipod_info_table, artwork format tables + - libgpod ``itdb_itunesdb.c`` — iTunesSD writer, mhbd version handling + - Empirical: iPod Classic 2G, Nano 3G confirmed + +This table captures every capability dimension that affects database +writing, artwork generation, or sync behaviour. It is the single +authority for "what does this device support?" questions. +""" + +from dataclasses import dataclass + +from .artwork_presets import ( + ARTWORK_FORMATS_BY_ID, + CLASSIC_COVER_ART_FORMATS, + NANO_7G_COVER_ART_FORMATS, + ArtworkFormat, +) +from .checksum import ChecksumType + + +@dataclass(frozen=True) +class DeviceCapabilities: + """Per-generation device capability flags. + + Every (family, generation) pair maps to exactly one of these. The + flags drive decisions in the sync engine, iTunesDB writer, and + ArtworkDB writer. + + All flags default to the *most common* value so that only deviations + need to be specified in the lookup table. + """ + + # ── Database format ──────────────────────────────────────────────── + checksum: ChecksumType = ChecksumType.NONE + is_shuffle: bool = False + """If True, device uses iTunesSD (flat binary) instead of / in addition + to iTunesDB. Shadow DB version determines the iTunesSD format.""" + shadow_db_version: int = 0 + """0 = not a shuffle. 1 = iTunesSD v1 (Shuffle 1G/2G, 18-byte header, + 558-byte entries, big-endian). 2 = iTunesSD v2 (Shuffle 3G/4G, + bdhs/hths/hphs chunk format, little-endian).""" + supports_compressed_db: bool = False + """If True, device expects iTunesCDB (zlib-compressed iTunesDB) and will + generate an empty iTunesDB alongside it. Nano 5G/6G/7G only.""" + + # ── Media type support ───────────────────────────────────────────── + supports_video: bool = False + """Device can play video files (mediatype & VIDEO != 0).""" + supports_podcast: bool = True + """Device supports podcast mhsd types (type 3). False only for + very early iPods (1G–3G) and iPod Mobile.""" + supports_gapless: bool = False + """Device honours gapless playback fields (pregap, postgap, + samplecount, gapless_data, gapless_track_flag). Introduced with + iPod Video 5.5G (Late 2006).""" + + # ── Artwork ──────────────────────────────────────────────────────── + supports_artwork: bool = True + """Device has an ArtworkDB and .ithmb files for album art.""" + supports_photo: bool = False + """Device has additional photo artwork formats (for photo viewer).""" + photo_formats: tuple[ArtworkFormat, ...] = () + """Photo/slideshow ithmb formats used by the Photos database pipeline.""" + supports_chapter_image: bool = False + """Device has chapter image artwork formats (for enhanced podcasts).""" + supports_sparse_artwork: bool = False + """Artwork can be written in sparse mode (Nano 3G+, Classic, Touch).""" + supports_alac: bool = True + """Device supports Apple Lossless (ALAC) audio playback. + False for iPod 1G–3G and Mini 1G (pre-firmware-update era hardware that + received ALAC support only from 4th Gen / Photo / Mini 2G onwards).""" + cover_art_formats: tuple[ArtworkFormat, ...] = () + """Supported cover-art thumbnail sizes. Empty means no artwork.""" + + # ── Storage layout ───────────────────────────────────────────────── + music_dirs: int = 20 + """Number of ``Fxx`` directories under ``iPod_Control/Music/``. + Varies 0–50 depending on model and storage capacity.""" + + # ── SQLite database ──────────────────────────────────────────────── + uses_sqlite_db: bool = False + """If True, device uses SQLite databases in + ``iTunes Library.itlp/`` instead of (or alongside) binary + iTunesDB/iTunesCDB. The firmware on Nano 6G/7G reads the SQLite + databases and ignores iTunesCDB completely.""" + + # ── Writer parameters ────────────────────────────────────────────── + db_version: int = 0x30 + """iTunesDB version to write in mhbd header. Older iPods need + lower values (0x0c for Shuffle 1G/2G, 0x13 for pre-Classic).""" + byte_order: str = "le" + """Byte order for database writing. ``"le"`` for almost all models. + ``"be"`` for iPod Mobile (Motorola ROKR/SLVR/RAZR).""" + + # ── Screen / display ─────────────────────────────────────────────── + has_screen: bool = True + """Device has a display. Shuffles have no screen.""" + + # ── Video encoding limits ────────────────────────────────────────── + max_video_width: int = 0 + """Maximum H.264 decode width (pixels). 0 = no video support. + This is the firmware decode ceiling, not the screen resolution — + the device downscales to fit its screen.""" + max_video_height: int = 0 + """Maximum H.264 decode height (pixels). 0 = no video support.""" + max_video_fps: int = 30 + """Maximum frame rate for H.264 decode (fps). All video-capable iPods + support 30 fps; PAL-resolution Nano 7G content is typically 25 fps but + 30 fps playback is still supported.""" + max_video_bitrate: int = 0 + """Hard bitrate ceiling for H.264 decode (kbps). 0 = no explicit cap + (quality-controlled by CRF only). Non-zero values enforce a -maxrate + flag in ffmpeg. + Nano 3G/4G use Baseline Profile Level 1.3, capped at 768 kbps by spec.""" + h264_level: str = "3.0" + """H.264 Baseline Profile level to target when encoding video. + Most iPods support Level 3.0. iPod Classic supports 3.1. + Nano 3G/4G are limited to Level 1.3 by their hardware decoder.""" + + +# ────────────────────────────────────────────────────────────────────────── +# The master capabilities table +# ────────────────────────────────────────────────────────────────────────── + +_FAMILY_GEN_CAPABILITIES: dict[tuple[str, str], DeviceCapabilities] = { + + # ── iPod 1G–3G: earliest models, no podcast, no gapless ─────────── + ("iPod", "1st Gen"): DeviceCapabilities( + supports_podcast=False, + supports_artwork=False, + supports_alac=True, + has_screen=True, + music_dirs=20, + db_version=0x13, + ), + ("iPod", "2nd Gen"): DeviceCapabilities( + supports_podcast=False, + supports_artwork=False, + supports_alac=True, + has_screen=True, + music_dirs=20, + db_version=0x13, + ), + ("iPod", "3rd Gen"): DeviceCapabilities( + supports_podcast=False, + supports_artwork=False, + supports_alac=True, + has_screen=True, + music_dirs=20, + db_version=0x13, + ), + + # ── iPod 4G (Click Wheel): first with podcast support ───────────── + ("iPod", "4th Gen"): DeviceCapabilities( + supports_artwork=False, + music_dirs=20, + db_version=0x13, + ), + + # ── iPod U2 Special Edition (4th Gen hardware) ──────────────────── + ("iPod U2", "4th Gen"): DeviceCapabilities( + supports_artwork=False, + music_dirs=20, + db_version=0x13, + ), + + # ── iPod Photo (Color Display) ──────────────────────────────────── + ("iPod Photo", "4th Gen"): DeviceCapabilities( + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1009], + ARTWORK_FORMATS_BY_ID[1013], + ARTWORK_FORMATS_BY_ID[1015], + ARTWORK_FORMATS_BY_ID[1019], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1017], + ARTWORK_FORMATS_BY_ID[1016], + ), + music_dirs=20, + db_version=0x13, + ), + + # ── iPod Video 5th Gen ──────────────────────────────────────────── + ("iPod Video", "5th Gen"): DeviceCapabilities( + supports_video=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1036], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1015], + ARTWORK_FORMATS_BY_ID[1019], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1028], + ARTWORK_FORMATS_BY_ID[1029], + ), + music_dirs=20, + db_version=0x19, + max_video_width=640, + max_video_height=480, + ), + + # ── iPod Video 5.5th Gen — first with gapless playback ─────────── + ("iPod Video", "5.5th Gen"): DeviceCapabilities( + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1036], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1015], + ARTWORK_FORMATS_BY_ID[1019], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1028], + ARTWORK_FORMATS_BY_ID[1029], + ), + music_dirs=20, + db_version=0x19, + max_video_width=640, + max_video_height=480, + ), + + # ── iPod Video U2 editions ──────────────────────────────────────── + ("iPod Video U2", "5th Gen"): DeviceCapabilities( + supports_video=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1036], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1015], + ARTWORK_FORMATS_BY_ID[1019], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1028], + ARTWORK_FORMATS_BY_ID[1029], + ), + music_dirs=20, + db_version=0x19, + max_video_width=640, + max_video_height=480, + ), + ("iPod Video U2", "5.5th Gen"): DeviceCapabilities( + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1036], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1015], + ARTWORK_FORMATS_BY_ID[1019], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1028], + ARTWORK_FORMATS_BY_ID[1029], + ), + music_dirs=20, + db_version=0x19, + max_video_width=640, + max_video_height=480, + ), + + # ── iPod Classic (all gens): HASH58, gapless, video ─────────────── + ("iPod Classic", "1st Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH58, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1067], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1066], + ), + supports_chapter_image=True, + supports_sparse_artwork=True, + cover_art_formats=CLASSIC_COVER_ART_FORMATS, + music_dirs=50, + db_version=0x30, + max_video_width=640, + max_video_height=480, + ), + ("iPod Classic", "2nd Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH58, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1067], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1066], + ), + supports_chapter_image=True, + supports_sparse_artwork=True, + cover_art_formats=CLASSIC_COVER_ART_FORMATS, + music_dirs=50, + db_version=0x30, + max_video_width=640, + max_video_height=480, + ), + ("iPod Classic", "3rd Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH58, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1067], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1066], + ), + supports_chapter_image=True, + supports_sparse_artwork=True, + cover_art_formats=CLASSIC_COVER_ART_FORMATS, + music_dirs=50, + db_version=0x30, + max_video_width=640, + max_video_height=480, + ), + + # ── iPod Mini ───────────────────────────────────────────────────── + ("iPod Mini", "1st Gen"): DeviceCapabilities( + supports_artwork=False, + supports_alac=True, + music_dirs=6, + db_version=0x13, + ), + ("iPod Mini", "2nd Gen"): DeviceCapabilities( + supports_artwork=False, + music_dirs=6, + db_version=0x13, + ), + + # ── iPod Nano 1G/2G ────────────────────────────────────────────── + ("iPod Nano", "1st Gen"): DeviceCapabilities( + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1032], + ARTWORK_FORMATS_BY_ID[1023], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1031], + ARTWORK_FORMATS_BY_ID[1027], + ), + music_dirs=14, + db_version=0x13, + ), + ("iPod Nano", "2nd Gen"): DeviceCapabilities( + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1032], + ARTWORK_FORMATS_BY_ID[1023], + ), + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1031], + ARTWORK_FORMATS_BY_ID[1027], + ), + music_dirs=14, + db_version=0x13, + ), + + # ── iPod Nano 3G ("Fat"): first Nano with video, HASH58 ────────── + ("iPod Nano", "3rd Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH58, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1067], + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1066], + ), + supports_sparse_artwork=True, + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1061], + ARTWORK_FORMATS_BY_ID[1055], + ARTWORK_FORMATS_BY_ID[1068], + ARTWORK_FORMATS_BY_ID[1060], + ), + music_dirs=20, + db_version=0x30, + max_video_width=320, + max_video_height=240, + max_video_bitrate=768, + h264_level="1.3", + ), + + # ── iPod Nano 4G: HASH58 ───────────────────────────────────────── + ("iPod Nano", "4th Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH58, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1024], + ARTWORK_FORMATS_BY_ID[1066], + ARTWORK_FORMATS_BY_ID[1079], + ARTWORK_FORMATS_BY_ID[1083], + ), + supports_chapter_image=True, + supports_sparse_artwork=True, + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1055], + ARTWORK_FORMATS_BY_ID[1068], + ARTWORK_FORMATS_BY_ID[1071], + ARTWORK_FORMATS_BY_ID[1074], + ARTWORK_FORMATS_BY_ID[1078], + ARTWORK_FORMATS_BY_ID[1084], + ), + music_dirs=20, + db_version=0x30, + max_video_width=480, + max_video_height=320, + max_video_bitrate=768, + h264_level="1.3", + ), + + # ── iPod Nano 5G: HASH72, compressed DB + SQLite ───────────────── + ("iPod Nano", "5th Gen"): DeviceCapabilities( + checksum=ChecksumType.HASH72, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1087], + ARTWORK_FORMATS_BY_ID[1079], + ARTWORK_FORMATS_BY_ID[1066], + ), + supports_sparse_artwork=True, + supports_compressed_db=True, + uses_sqlite_db=True, + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1056], + ARTWORK_FORMATS_BY_ID[1078], + ARTWORK_FORMATS_BY_ID[1073], + ARTWORK_FORMATS_BY_ID[1074], + ), + music_dirs=14, + db_version=0x30, + max_video_width=640, + max_video_height=480, + ), + + # ── iPod Nano 6G: HASHAB, no video ─────────────────────────────── + ("iPod Nano", "6th Gen"): DeviceCapabilities( + checksum=ChecksumType.HASHAB, + supports_video=False, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1092], + ARTWORK_FORMATS_BY_ID[1093], + ), + supports_sparse_artwork=True, + supports_compressed_db=True, + uses_sqlite_db=True, + cover_art_formats=( + ARTWORK_FORMATS_BY_ID[1073], + ARTWORK_FORMATS_BY_ID[1085], + ARTWORK_FORMATS_BY_ID[1089], + ARTWORK_FORMATS_BY_ID[1074], + ), + music_dirs=20, + db_version=0x30, + ), + + # ── iPod Nano 7G: HASHAB, video returns ────────────────────────── + ("iPod Nano", "7th Gen"): DeviceCapabilities( + checksum=ChecksumType.HASHAB, + supports_video=True, + supports_gapless=True, + supports_artwork=True, + supports_photo=True, + photo_formats=( + ARTWORK_FORMATS_BY_ID[1007], + ARTWORK_FORMATS_BY_ID[1005], + ), + supports_sparse_artwork=True, + supports_compressed_db=True, + uses_sqlite_db=True, + cover_art_formats=NANO_7G_COVER_ART_FORMATS, + music_dirs=20, + db_version=0x30, + max_video_width=720, + max_video_height=576, + ), + + # ── iPod Shuffle 1G ────────────────────────────────────────────── + ("iPod Shuffle", "1st Gen"): DeviceCapabilities( + is_shuffle=True, + shadow_db_version=1, + supports_podcast=True, + supports_artwork=False, + has_screen=False, + music_dirs=3, + db_version=0x0c, + ), + + # ── iPod Shuffle 2G ────────────────────────────────────────────── + ("iPod Shuffle", "2nd Gen"): DeviceCapabilities( + is_shuffle=True, + shadow_db_version=1, + supports_podcast=True, + supports_artwork=False, + has_screen=False, + music_dirs=3, + db_version=0x13, + ), + + # ── iPod Shuffle 3G ────────────────────────────────────────────── + ("iPod Shuffle", "3rd Gen"): DeviceCapabilities( + is_shuffle=True, + shadow_db_version=2, + supports_podcast=True, + supports_artwork=False, + has_screen=False, + music_dirs=3, + db_version=0x19, + ), + + # ── iPod Shuffle 4G ────────────────────────────────────────────── + ("iPod Shuffle", "4th Gen"): DeviceCapabilities( + is_shuffle=True, + shadow_db_version=2, + supports_podcast=True, + supports_artwork=False, + has_screen=False, + music_dirs=3, + db_version=0x19, + ), +} + + +def capabilities_for_family_gen( + family: str, + generation: str, + *, + capacity: str | None = None, + model_number: str | None = None, +) -> DeviceCapabilities | None: + """Return the device capabilities for a (family, generation) pair. + + If the exact pair is not found but *generation* is empty/unknown, + checks whether all known generations of *family* share identical + capabilities and returns those. + + Returns ``None`` if the pair is not in the lookup table and the + family-level fallback is ambiguous. + """ + caps = _FAMILY_GEN_CAPABILITIES.get((family, generation)) + if caps is not None: + return caps + + if family and not generation: + family_caps = [ + c for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items() + if f == family + ] + if family_caps and all(c == family_caps[0] for c in family_caps): + return family_caps[0] + + return None + + +def cover_art_formats_for_family_gen( + family: str, + generation: str, + *, + capacity: str | None = None, + model_number: str | None = None, +) -> tuple[ArtworkFormat, ...]: + """Return cover-art formats for a family/generation pair. + + This is intentionally narrower than ``capabilities_for_family_gen``. Some + families have generations with different playback capabilities but the same + ArtworkDB cover formats, so a full capability fallback would be ambiguous + while artwork generation is still safe. + """ + _ = capacity, model_number + + caps = _FAMILY_GEN_CAPABILITIES.get((family, generation)) + if caps is not None: + return caps.cover_art_formats if caps.supports_artwork else () + + if family and not generation: + family_formats = [ + c.cover_art_formats if c.supports_artwork else () + for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items() + if f == family + ] + if family_formats and all(formats == family_formats[0] for formats in family_formats): + return family_formats[0] + + return () + + +def checksum_type_for_family_gen( + family: str, + generation: str, +) -> ChecksumType | None: + """Return the checksum type for a (family, generation) pair. + + Derives the answer from ``_FAMILY_GEN_CAPABILITIES``. If the exact + (family, generation) pair is not found but *generation* is empty/unknown, + checks whether all known generations of *family* share the same checksum + type and returns it. + + Returns ``None`` if the pair is not in the lookup table and the family- + level fallback is ambiguous. + """ + caps = _FAMILY_GEN_CAPABILITIES.get((family, generation)) + if caps is not None: + return caps.checksum + + if family and not generation: + family_checksums = { + c.checksum + for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items() + if f == family + } + if len(family_checksums) == 1: + return family_checksums.pop() + + return None diff --git a/src/vendor/ipod_device/checksum.py b/src/vendor/ipod_device/checksum.py new file mode 100644 index 0000000..71cc327 --- /dev/null +++ b/src/vendor/ipod_device/checksum.py @@ -0,0 +1,41 @@ +"""Checksum type enumeration and MHBD hashing-scheme mappings.""" + +from enum import IntEnum + + +class ChecksumType(IntEnum): + """Checksum types for different iPod generations. + + NONE — Pre-2007 iPods (1G–5G, Photo, Video, Mini, Nano 1G–2G, Shuffle) + HASH58 — iPod Classic (all gens), Nano 3G, Nano 4G + HASH72 — Nano 5G + HASHAB — Nano 6G, Nano 7G (white-box AES, via WASM module) + UNSUPPORTED — Reserved for any future unsupported scheme + UNKNOWN — Device not yet identified + """ + NONE = 0 + HASH58 = 1 + HASH72 = 2 + HASHAB = 3 + UNSUPPORTED = 98 + UNKNOWN = 99 + + +# ── MHBD hashing scheme ↔ ChecksumType mapping ────────────────────────── +# +# The mhbd header at offset 0x30 stores a 16-bit ``hashing_scheme`` value. +# These constants map between our ``ChecksumType`` enum and the raw wire +# values. Note: HASHAB is enum 3 but wire 4. + +CHECKSUM_MHBD_SCHEME: dict[ChecksumType, int] = { + ChecksumType.NONE: 0, + ChecksumType.HASH58: 1, + ChecksumType.HASH72: 2, + ChecksumType.HASHAB: 4, +} +"""Map ``ChecksumType`` → raw ``hashing_scheme`` field in mhbd header.""" + +MHBD_SCHEME_TO_CHECKSUM: dict[int, ChecksumType] = { + v: k for k, v in CHECKSUM_MHBD_SCHEME.items() +} +"""Map raw ``hashing_scheme`` field in mhbd header → ``ChecksumType``.""" diff --git a/src/vendor/ipod_device/diagnostic_log.py b/src/vendor/ipod_device/diagnostic_log.py new file mode 100644 index 0000000..1d23558 --- /dev/null +++ b/src/vendor/ipod_device/diagnostic_log.py @@ -0,0 +1,177 @@ +"""Compact log formatting helpers for device-identification diagnostics.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +FieldSpec = tuple[str, str] + + +IDENTITY_FIELDS: tuple[FieldSpec, ...] = ( + ("model_number", "model"), + ("model_family", "family"), + ("generation", "gen"), + ("capacity", "capacity"), + ("color", "color"), + ("serial", "serial"), + ("firewire_guid", "fwguid"), + ("firmware", "fw"), + ("usb_vid", "vid"), + ("usb_pid", "pid"), + ("usb_serial", "usb_serial"), + ("scsi_vendor", "scsi_vendor"), + ("scsi_product", "scsi_product"), + ("scsi_revision", "scsi_rev"), +) + +CAPABILITY_FIELDS: tuple[FieldSpec, ...] = ( + ("family_id", "family_id"), + ("updater_family_id", "updater_id"), + ("product_type", "product"), + ("db_version", "db_version"), + ("shadow_db_version", "shadow_db"), + ("uses_sqlite_db", "sqlite"), + ("supports_sparse_artwork", "sparse_art"), + ("max_tracks", "max_tracks"), + ("max_file_size_gb", "max_file_gb"), + ("max_transfer_speed", "max_transfer"), + ("podcasts_supported", "podcasts"), + ("voice_memos_supported", "voice_memos"), + ("artwork_formats", "art_ids"), + ("photo_formats", "photo_ids"), + ("chapter_image_formats", "chapter_ids"), +) + +SOURCE_FIELDS: tuple[FieldSpec, ...] = ( + ("serial", "serial"), + ("firewire_guid", "fwguid"), + ("model_number", "model"), + ("model_family", "family"), + ("generation", "gen"), + ("capacity", "capacity"), + ("color", "color"), + ("usb_pid", "pid"), + ("firmware", "fw"), +) + +_HEX_WIDTHS: dict[str, int] = { + "usb_vid": 4, + "usb_pid": 4, + "db_version": 0, + "shadow_db_version": 0, +} + + +def is_missing(value: Any) -> bool: + return value is None or value == "" or value == b"" or value == {} or value == [] + + +def compact(value: Any, *, max_chars: int = 96) -> str: + text = str(value) + if len(text) <= max_chars: + return text + head = max_chars // 2 - 2 + tail = max_chars - head - 3 + return f"{text[:head]}...{text[-tail:]}" + + +def format_value(field: str, value: Any) -> str: + if isinstance(value, bytes): + return f"<{len(value)} bytes>" + if isinstance(value, bool): + return "yes" if value else "no" + if field in _HEX_WIDTHS: + try: + number = int(value) + if not number: + return "0" + width = _HEX_WIDTHS[field] + return f"0x{number:0{width}X}" if width else f"0x{number:X}" + except (TypeError, ValueError): + return compact(value) + if isinstance(value, Mapping): + if field.endswith("_formats") or field in { + "artwork_formats", + "photo_formats", + "chapter_image_formats", + }: + ids = ", ".join(str(k) for k in sorted(value)[:12]) + suffix = "..." if len(value) > 12 else "" + return f"{len(value)}[{ids}{suffix}]" + keys = ", ".join(str(k) for k in sorted(value, key=str)[:8]) + suffix = "..." if len(value) > 8 else "" + return f"{len(value)} keys[{keys}{suffix}]" + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + items = list(value) + shown = ", ".join(str(item) for item in items[:12]) + suffix = "..." if len(items) > 12 else "" + return f"{len(items)}[{shown}{suffix}]" + return compact(value) + + +def format_fields( + data: Mapping[str, Any], + fields: Iterable[FieldSpec] = IDENTITY_FIELDS, + *, + include_false: bool = False, +) -> str: + parts: list[str] = [] + for field, label in fields: + if field not in data: + continue + value = data.get(field) + if is_missing(value): + continue + if value == 0 and not isinstance(value, bool): + continue + if value is False and not include_false: + continue + parts.append(f"{label}={format_value(field, value)}") + return ", ".join(parts) if parts else "none" + + +def format_sources( + sources: Mapping[str, Any] | None, + fields: Iterable[FieldSpec] = SOURCE_FIELDS, +) -> str: + if not sources: + return "none" + parts = [] + for field, label in fields: + source = sources.get(field) + if source: + parts.append(f"{label}:{source}") + return ", ".join(parts) if parts else "none" + + +def format_conflicts(conflicts: Any) -> str: + if not conflicts: + return "none" + if not isinstance(conflicts, list): + return compact(conflicts) + + parts: list[str] = [] + for conflict in conflicts[:6]: + if isinstance(conflict, Mapping): + field = conflict.get("field", "?") + winner = conflict.get("winner", "") + rejected_source = conflict.get("rejected_source", "") + rejected_value = conflict.get("rejected_value", "") + reason = conflict.get("reason", "") + detail = f"{field}" + if winner: + detail += f" winner={winner}" + if rejected_source or rejected_value: + detail += ( + f" rejected={rejected_source or '?'}:" + f"{compact(rejected_value, max_chars=36)}" + ) + if reason: + detail += f" reason={compact(reason, max_chars=64)}" + parts.append(detail) + else: + parts.append(compact(conflict, max_chars=96)) + if len(conflicts) > 6: + parts.append(f"+{len(conflicts) - 6} more") + return "; ".join(parts) diff --git a/src/vendor/ipod_device/dump.py b/src/vendor/ipod_device/dump.py new file mode 100644 index 0000000..8dab118 --- /dev/null +++ b/src/vendor/ipod_device/dump.py @@ -0,0 +1,495 @@ +"""Read-only diagnostic dump for connected iPods. + +Usage: + uv run python -m ipod_device.dump D:\ + uv run python -m ipod_device.dump --all +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path +from typing import Any + +IDENTITY_FIELDS: tuple[str, ...] = ( + "serial", + "firewire_guid", + "model_number", + "model_family", + "generation", + "capacity", + "color", + "firmware", + "board", + "family_id", + "updater_family_id", + "product_type", + "usb_vid", + "usb_pid", + "usb_serial", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "connected_bus", + "volume_format", + "db_version", + "shadow_db_version", + "uses_sqlite_db", + "supports_sparse_artwork", + "max_tracks", + "max_file_size_gb", + "max_transfer_speed", + "podcasts_supported", + "voice_memos_supported", +) + + +def _device_dir(mount_path: str) -> Path: + return Path(mount_path) / "iPod_Control" / "Device" + + +def _mount_name(mount_path: str) -> str: + if sys.platform == "win32": + drive, _tail = os.path.splitdrive(mount_path) + if drive: + return drive + if mount_path and mount_path[0].isalpha(): + return f"{mount_path[0].upper()}:" + return os.path.basename(os.path.normpath(mount_path)) or mount_path + + +def _normalise_mount_path(mount_path: str) -> str: + if sys.platform == "win32": + stripped = mount_path.strip().strip('"') + if len(stripped) == 2 and stripped[0].isalpha() and stripped[1] == ":": + return stripped + "\\" + return stripped + return mount_path + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _json_safe(value: Any, *, include_raw: bool = False) -> Any: + if isinstance(value, bytes): + result: dict[str, Any] = { + "bytes": len(value), + "sha256": _sha256(value), + } + if include_raw: + result["text"] = value.decode("utf-8", errors="replace") + return result + if isinstance(value, dict): + return { + str(k): _json_safe(v, include_raw=include_raw) + for k, v in sorted(value.items(), key=lambda item: str(item[0])) + } + if isinstance(value, (list, tuple)): + return [_json_safe(v, include_raw=include_raw) for v in value] + return value + + +def _normalise_for_compare(field: str, value: Any) -> str: + if value in (None, ""): + return "" + if field == "firewire_guid": + try: + from .sysinfo import normalize_guid + + return normalize_guid(value) + except Exception: + pass + if field == "usb_pid": + try: + return f"0x{int(value):04X}" + except Exception: + return str(value).strip().upper() + return str(value).strip() + + +def _sysinfo_dump(mount_path: str) -> dict[str, Any]: + path = _device_dir(mount_path) / "SysInfo" + if not path.exists(): + return {"present": False} + + raw = path.read_text(errors="replace") + from .sysinfo import identity_from_sysinfo, parse_sysinfo_text + + parsed = parse_sysinfo_text(raw) + return { + "present": True, + "path": str(path), + "sha256": _sha256(raw.encode("utf-8", errors="replace")), + "fields": parsed, + "identity": identity_from_sysinfo(parsed, "sysinfo"), + } + + +def _sysinfo_extended_dump( + mount_path: str, + *, + include_raw: bool, +) -> dict[str, Any]: + path = _device_dir(mount_path) / "SysInfoExtended" + if not path.exists(): + return {"present": False} + + raw = path.read_bytes() + from .sysinfo import parse_sysinfo_extended + + parsed = parse_sysinfo_extended(raw, source="sysinfo_extended") + result: dict[str, Any] = { + "present": True, + "path": str(path), + "bytes": len(raw), + "sha256": _sha256(raw), + "used_regex_fallback": parsed.used_regex_fallback, + "keys": sorted(parsed.plist.keys()), + "identity": parsed.identity, + "cover_art_formats": parsed.cover_art_formats, + "photo_formats": parsed.photo_formats, + "chapter_image_formats": parsed.chapter_image_formats, + } + if include_raw: + result["raw"] = raw + result["plist"] = parsed.plist + return result + + +def _identity_from_live_vpd(vpd: dict[str, Any], source: str) -> dict[str, Any]: + from .sysinfo import ( + ParsedSysInfoExtended, + identity_from_sysinfo_extended, + parse_sysinfo_extended, + ) + + raw = vpd.get("vpd_raw_xml") + if raw: + identity = parse_sysinfo_extended(raw, source=source, live=True).identity + else: + parsed = ParsedSysInfoExtended(plist=vpd, source=source, live=True) + identity = identity_from_sysinfo_extended(parsed, source, live=True) + sources = identity.setdefault("_sources", {}) + for field in ( + "usb_pid", + "usb_vid", + "usb_serial", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "block_device", + ): + value = vpd.get(field) + if value not in (None, "", b""): + identity[field] = value + sources[field] = source + return identity + + +def _live_scsi_dump( + mount_path: str, + final_identity: dict[str, Any], +) -> dict[str, Any]: + try: + if sys.platform == "win32": + from .vpd_windows import query_ipod_vpd_for_path + + result = query_ipod_vpd_for_path( + mount_path, + usb_pid=int(final_identity.get("usb_pid") or 0), + serial_filter=str(final_identity.get("firewire_guid") or ""), + ) + elif sys.platform == "linux": + from .vpd_linux import query_ipod_vpd_for_path + + result = query_ipod_vpd_for_path( + mount_path, + usb_pid=int(final_identity.get("usb_pid") or 0), + serial_filter=str(final_identity.get("firewire_guid") or ""), + ) + elif sys.platform == "darwin": + from .vpd_iokit import query_ipod_vpd + + result = query_ipod_vpd( + usb_pid=int(final_identity.get("usb_pid") or 0), + serial_filter=str(final_identity.get("firewire_guid") or ""), + ) + else: + return {"available": False, "reason": f"unsupported_{sys.platform}"} + if not result: + return {"available": True, "result": None, "error": "no_result"} + source = str(result.get("_source") or { + "win32": "windows_scsi", + "linux": "linux_scsi", + "darwin": "scsi_vpd", + }.get(sys.platform, "scsi_vpd")) + return { + "available": True, + "result": result, + "identity": _identity_from_live_vpd(result, source), + "standard_inquiry": { + "vendor": result.get("scsi_vendor", ""), + "product": result.get("scsi_product", ""), + "revision": result.get("scsi_revision", ""), + }, + } + except Exception as exc: + return {"available": True, "result": None, "error": repr(exc)} + + +def _live_windows_scsi_dump( + mount_path: str, + final_identity: dict[str, Any], +) -> dict[str, Any]: + if sys.platform != "win32": + return {"available": False, "reason": "not_windows"} + return _live_scsi_dump(mount_path, final_identity) + + +def _live_usb_vendor_dump( + final_identity: dict[str, Any], +) -> dict[str, Any]: + try: + from .usb_backend import backend_diagnostic + from .vpd_usb_control import query_ipod_usb_sysinfo_extended + + result = query_ipod_usb_sysinfo_extended( + usb_pid=int(final_identity.get("usb_pid") or 0), + serial_filter=str(final_identity.get("firewire_guid") or ""), + ) + if not result: + return { + "available": True, + "result": None, + "error": "no_result", + "backend": backend_diagnostic(), + } + source = str(result.get("_source") or "usb_vendor") + return { + "available": True, + "result": result, + "identity": _identity_from_live_vpd(result, source), + "backend": backend_diagnostic(), + } + except Exception as exc: + return {"available": False, "result": None, "error": repr(exc)} + + +def _disk_size_gb(mount_path: str) -> float: + try: + import shutil + + return round(shutil.disk_usage(mount_path).total / 1e9, 1) + except Exception: + return 0.0 + + +def _final_identity_snapshot(mount_path: str) -> dict[str, Any]: + from .scanner import _probe_filesystem, _probe_hardware, _resolve_model + + mount_name = _mount_name(mount_path) + hardware = _probe_hardware(mount_path, mount_name) + filesystem = _probe_filesystem(mount_path) + resolved = _resolve_model(hardware, filesystem, _disk_size_gb(mount_path)) + return { + "hardware": hardware, + "filesystem": filesystem, + "resolved": resolved, + } + + +def _append_identity_evidence( + evidence: dict[str, list[dict[str, Any]]], + source: str, + identity: dict[str, Any] | None, +) -> None: + if not identity: + return + sources = identity.get("_sources", {}) + for field in IDENTITY_FIELDS: + value = identity.get(field) + if value in (None, ""): + continue + evidence.setdefault(field, []).append({ + "source": sources.get(field, source), + "value": value, + }) + + +def _append_plain_evidence( + evidence: dict[str, list[dict[str, Any]]], + source: str, + data: dict[str, Any] | None, +) -> None: + if not data: + return + sources = data.get("_sources", {}) + for field in IDENTITY_FIELDS: + value = data.get(field) + if value in (None, ""): + continue + evidence.setdefault(field, []).append({ + "source": sources.get(field, source), + "value": value, + }) + + +def _rejected_conflicts( + final_identity: dict[str, Any], + evidence: dict[str, list[dict[str, Any]]], +) -> list[dict[str, Any]]: + rejected: list[dict[str, Any]] = [] + for field, entries in sorted(evidence.items()): + final_value = final_identity.get(field) + final_norm = _normalise_for_compare(field, final_value) + if not final_norm: + continue + for entry in entries: + value_norm = _normalise_for_compare(field, entry.get("value")) + if value_norm and value_norm != final_norm: + rejected.append({ + "field": field, + "final_value": final_value, + "rejected_value": entry.get("value"), + "rejected_source": entry.get("source"), + }) + return rejected + + +def dump_device_info( + mount_path: str, + *, + include_raw: bool = False, + probe_usb_vendor: bool = True, +) -> dict[str, Any]: + """Build a read-only device diagnostic report.""" + mount_path = os.path.abspath(_normalise_mount_path(mount_path)) + snapshot = _final_identity_snapshot(mount_path) + final_identity = snapshot["resolved"] + + sysinfo = _sysinfo_dump(mount_path) + sysinfo_extended = _sysinfo_extended_dump( + mount_path, + include_raw=include_raw, + ) + live_scsi = _live_scsi_dump(mount_path, final_identity) + live_windows_scsi = ( + live_scsi + if sys.platform == "win32" + else {"available": False, "reason": "not_windows"} + ) + live_usb_vendor = ( + _live_usb_vendor_dump(final_identity) + if probe_usb_vendor + else {"available": False, "reason": "disabled"} + ) + + evidence: dict[str, list[dict[str, Any]]] = {} + _append_identity_evidence(evidence, "sysinfo", sysinfo.get("identity")) + _append_identity_evidence( + evidence, + "sysinfo_extended", + sysinfo_extended.get("identity"), + ) + _append_plain_evidence(evidence, "hardware", snapshot.get("hardware")) + _append_identity_evidence( + evidence, + "live_scsi", + live_scsi.get("identity"), + ) + _append_identity_evidence( + evidence, + "usb_vendor", + live_usb_vendor.get("identity"), + ) + + usb_details = { + key: snapshot["hardware"].get(key) + for key in ( + "usb_vid", + "usb_pid", + "firewire_guid", + "usbstor_instance_id", + "usb_parent_instance_id", + "usb_grandparent_instance_id", + ) + if snapshot["hardware"].get(key) not in (None, "") + } + + report = { + "mount_path": mount_path, + "mount_name": _mount_name(mount_path), + "sysinfo": sysinfo, + "disk_sysinfo_extended": sysinfo_extended, + "live_scsi_vpd": live_scsi, + "live_windows_scsi_vpd": live_windows_scsi, + "live_usb_vendor": live_usb_vendor, + "standard_inquiry": live_scsi.get("standard_inquiry", {}), + "usb_details": usb_details, + "final_resolved_identity": final_identity, + "resolver_conflicts": final_identity.get("_conflicts", []), + "all_identity_evidence": evidence, + "rejected_conflicting_evidence": _rejected_conflicts( + final_identity, + evidence, + ), + } + return _json_safe(report, include_raw=include_raw) + + +def _default_paths() -> list[str]: + from .scanner import _find_ipod_volumes + + return [mount for mount, _display in _find_ipod_volumes()] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Dump read-only iPod identity evidence for debugging.", + ) + parser.add_argument("paths", nargs="*", help="Mounted iPod path(s)") + parser.add_argument( + "--all", + action="store_true", + help="Dump every mounted volume containing iPod_Control", + ) + parser.add_argument( + "--include-raw", + action="store_true", + help="Include raw SysInfoExtended text in the JSON output", + ) + parser.add_argument( + "--no-usb-vendor", + action="store_true", + help="Skip the USB vendor-control diagnostic probe", + ) + args = parser.parse_args(argv) + + paths = list(args.paths) + if args.all or not paths: + paths.extend(path for path in _default_paths() if path not in paths) + + if not paths: + print("No iPod volumes found.", file=sys.stderr) + return 1 + + reports = [ + dump_device_info( + path, + include_raw=args.include_raw, + probe_usb_vendor=not args.no_usb_vendor, + ) + for path in paths + ] + payload: Any = reports[0] if len(reports) == 1 else reports + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/vendor/ipod_device/eject.py b/src/vendor/ipod_device/eject.py new file mode 100644 index 0000000..911a16a --- /dev/null +++ b/src/vendor/ipod_device/eject.py @@ -0,0 +1,1022 @@ +"""Cross-platform safe-eject helper for iPods. + +Provides a single entry point, :func:`eject_ipod`, that unmounts and +(where applicable) powers down the device behind a given mount path. + +Strategies per platform: + * **Windows** — Windows device eject request, Shell "Eject" fallback, + and drive-removal verification. + * **macOS** — ``diskutil eject`` first, then force unmount/eject fallbacks. + * **Linux** — ``udisksctl`` unmount/power-off first, then ``eject``, + then force/lazy ``umount`` fallbacks. +""" + +from __future__ import annotations + +import json +import logging +import plistlib +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +_TIMEOUT_SECS = 30 +_WINDOWS_VERIFY_SECS = 20 +_WINDOWS_LOCK_RETRY_SECS = 10 +_ALREADY_UNMOUNTED_HINTS = ( + "not mounted", + "not currently mounted", + "already unmounted", +) +_MISSING_TARGET_HINTS = ( + "no such file or directory", + "not found", + "no object", + "error looking up object", + "does not exist", +) + + +def eject_ipod(mount_path: str) -> tuple[bool, str]: + """Safely eject / unmount an iPod at *mount_path*. + + Returns ``(success, message)``. The *message* is suitable for + display in a dialog or log entry. + """ + if not mount_path: + return False, "No device path supplied." + + path = Path(mount_path) + try: + if sys.platform == "win32": + return _eject_windows(path) + if sys.platform == "darwin": + return _eject_macos(path) + return _eject_linux(path) + except Exception as exc: # last-ditch safety net + logger.exception("eject_ipod: unexpected failure") + return False, f"Unexpected error: {exc}" + + +# ────────────────────────────────────────────────────────────────────── +# Windows +# ────────────────────────────────────────────────────────────────────── + +def _eject_windows(path: Path) -> tuple[bool, str]: + """Ask Windows to safely remove the drive and verify it disappeared. + + Explorer's COM ``Eject`` verb returns before Windows confirms the + device was actually removed, and it can fail silently when another + process has an open handle. Prefer the Configuration Manager eject + API, fall back to the shell verb, and only report success once the + drive letter is no longer mounted. + """ + drive = _windows_drive_from_path(path) + if not drive: + return False, f"Cannot determine drive letter from {path}." + + if not _windows_drive_is_mounted(drive): + return True, f"{drive} is already ejected." + + pnp_device_id, pnp_msg = _get_windows_disk_pnp_id(drive) + prep_ok, prep_msg = _prepare_windows_volume_for_eject(drive) + if prep_ok and _wait_for_windows_drive_removed(drive): + return True, prep_msg or f"Ejected {drive}" + + cfg_ok, cfg_msg = _run_windows_cfgmgr_eject(drive, pnp_device_id) + if cfg_ok and _wait_for_windows_drive_removed(drive): + return True, cfg_msg or f"Ejected {drive}" + + shell_ok, shell_msg = _run_windows_shell_eject(drive) + if shell_ok and _wait_for_windows_drive_removed(drive): + return True, shell_msg or f"Ejected {drive}" + + return False, _windows_eject_failure_message( + drive=drive, + prep_msg=prep_msg, + pnp_msg=pnp_msg if not pnp_device_id else "", + cfg_msg=cfg_msg, + shell_msg=shell_msg, + ) + + +def _windows_drive_from_path(path: Path) -> str: + """Return a normalized Windows drive string (``E:``) for *path*.""" + raw = str(path) + m = re.match(r"^([a-zA-Z]):", raw) + if m: + return f"{m.group(1).upper()}:" + + drive = path.drive + m = re.match(r"^([a-zA-Z]):", drive) + if m: + return f"{m.group(1).upper()}:" + return "" + + +def _prepare_windows_volume_for_eject(drive: str) -> tuple[bool, str]: + """Flush, lock, and dismount the drive volume before PnP eject. + + This is the part Explorer hides behind "Safely Remove Hardware". If + Windows cannot lock the volume, some process still has an open handle + to the iPod and the later device eject request will usually be vetoed. + """ + import ctypes + from ctypes import wintypes + + # Prefer WinDLL when available (gives use_last_error semantics), + # otherwise fall back to windll.kernel32. Use getattr to avoid + # static-analysis attribute complaints in type checkers. + WinDLL = getattr(ctypes, "WinDLL", None) + if WinDLL: + kernel32 = WinDLL("kernel32", use_last_error=True) + else: + windll = getattr(ctypes, "windll", None) + kernel32 = getattr(windll, "kernel32", None) + + if kernel32 is None: + # This function should only be called on Windows where kernel32 is + # available; fail fast to satisfy static analysis and avoid None + # attribute access below. + raise RuntimeError("Windows kernel32 API not available on this platform") + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.DeviceIoControl.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + wintypes.LPVOID, + ] + kernel32.DeviceIoControl.restype = wintypes.BOOL + kernel32.FlushFileBuffers.argtypes = [wintypes.HANDLE] + kernel32.FlushFileBuffers.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + generic_read = 0x80000000 + generic_write = 0x40000000 + file_share_read = 0x00000001 + file_share_write = 0x00000002 + open_existing = 3 + invalid_handle_value = wintypes.HANDLE(-1).value + + fsctl_lock_volume = 0x00090018 + fsctl_dismount_volume = 0x00090020 + ioctl_storage_media_removal = 0x002D4804 + ioctl_storage_eject_media = 0x002D4808 + + volume_name = f"\\\\.\\{drive}" + handle = kernel32.CreateFileW( + volume_name, + generic_read | generic_write, + file_share_read | file_share_write, + None, + open_existing, + 0, + None, + ) + if handle == invalid_handle_value: + err = _windows_last_error_message() + return False, f"Could not open {drive} for eject ({err})." + + try: + kernel32.FlushFileBuffers(handle) + + deadline = time.monotonic() + _WINDOWS_LOCK_RETRY_SECS + last_error = "" + while True: + ok, msg = _windows_device_io_control( + kernel32, + handle, + fsctl_lock_volume, + ) + if ok: + break + last_error = msg + if time.monotonic() >= deadline: + return ( + False, + f"Could not lock {drive} for eject ({last_error}). " + "Another process still has the iPod volume open.", + ) + time.sleep(0.25) + + ok, msg = _windows_device_io_control( + kernel32, + handle, + fsctl_dismount_volume, + ) + if not ok: + return False, f"Locked {drive}, but Windows refused to dismount it ({msg})." + + prevent_removal = ctypes.c_ubyte(0) + _windows_device_io_control( + kernel32, + handle, + ioctl_storage_media_removal, + ctypes.byref(prevent_removal), + ctypes.sizeof(prevent_removal), + ) + _windows_device_io_control(kernel32, handle, ioctl_storage_eject_media) + + return True, f"Locked and dismounted {drive}." + finally: + kernel32.CloseHandle(handle) + + +def _windows_device_io_control( + kernel32, + handle, + ioctl: int, + in_buffer=None, + in_size: int = 0, +) -> tuple[bool, str]: + import ctypes + from ctypes import wintypes + + bytes_returned = wintypes.DWORD(0) + ok = kernel32.DeviceIoControl( + handle, + ioctl, + in_buffer, + in_size, + None, + 0, + ctypes.byref(bytes_returned), + None, + ) + if ok: + return True, "" + return False, _windows_last_error_message() + + +def _windows_last_error_message() -> str: + import ctypes + + # Use getattr to guard against static-analysis missing attributes. + code = getattr(ctypes, "get_last_error", lambda: 0)() + try: + text = getattr(ctypes, "FormatError", lambda c: "Unknown Windows error")(code).strip() + except Exception: + text = "Unknown Windows error" + return f"{text} (Win32 error {code})" + + +def _get_windows_disk_pnp_id(drive: str) -> tuple[str | None, str]: + """Return the Win32_DiskDrive PNPDeviceID backing *drive*.""" + ps_cmd = ( + "$ErrorActionPreference = 'Stop'\n" + f"$drive = {_ps_single_quote(drive)}\n" + r""" +function Write-IopResult { + param([string]$Status, [string]$Message) + [Console]::Out.WriteLine($Status + "`t" + $Message) +} + +$logical = Get-CimInstance -ClassName Win32_LogicalDisk -Filter ("DeviceID='" + $drive + "'") -ErrorAction Stop +if ($null -eq $logical) { + Write-IopResult "ERROR" ($drive + " is not mounted.") + exit 2 +} + +$partition = Get-CimAssociatedInstance -InputObject $logical -ResultClassName Win32_DiskPartition -ErrorAction Stop | Select-Object -First 1 +if ($null -eq $partition) { + Write-IopResult "ERROR" ("Could not find the disk partition for " + $drive + ".") + exit 2 +} + +$disk = Get-CimAssociatedInstance -InputObject $partition -ResultClassName Win32_DiskDrive -ErrorAction Stop | Select-Object -First 1 +if ($null -eq $disk -or [string]::IsNullOrWhiteSpace([string]$disk.PNPDeviceID)) { + Write-IopResult "ERROR" ("Could not find the physical disk for " + $drive + ".") + exit 2 +} + +Write-IopResult "OK" ([string]$disk.PNPDeviceID) +exit 0 +""" + ) + ok, message = _run_windows_powershell_eject( + ps_cmd, + "Could not resolve the Windows disk device.", + ) + return (message if ok else None), ("" if ok else message) + + +def _run_windows_cfgmgr_eject( + drive: str, + pnp_device_id: str | None = None, +) -> tuple[bool, str]: + """Request safe device removal via ``CM_Request_Device_EjectW``.""" + ps_cmd = ( + "$ErrorActionPreference = 'Stop'\n" + f"$drive = {_ps_single_quote(drive)}\n" + f"$pnpDeviceId = {_ps_single_quote(pnp_device_id or '')}\n" + r""" +function Write-IopResult { + param([string]$Status, [string]$Message) + [Console]::Out.WriteLine($Status + "`t" + $Message) +} + +if ([string]::IsNullOrWhiteSpace($pnpDeviceId)) { + $logical = Get-CimInstance -ClassName Win32_LogicalDisk -Filter ("DeviceID='" + $drive + "'") -ErrorAction Stop + if ($null -eq $logical) { + Write-IopResult "MISSING" ($drive + " is not mounted.") + exit 0 + } + + $partition = Get-CimAssociatedInstance -InputObject $logical -ResultClassName Win32_DiskPartition -ErrorAction Stop | Select-Object -First 1 + if ($null -eq $partition) { + Write-IopResult "ERROR" ("Could not find the disk partition for " + $drive + ".") + exit 2 + } + + $disk = Get-CimAssociatedInstance -InputObject $partition -ResultClassName Win32_DiskDrive -ErrorAction Stop | Select-Object -First 1 + if ($null -eq $disk -or [string]::IsNullOrWhiteSpace([string]$disk.PNPDeviceID)) { + Write-IopResult "ERROR" ("Could not find the physical disk for " + $drive + ".") + exit 2 + } + $pnpDeviceId = [string]$disk.PNPDeviceID +} + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class IopCfgMgr +{ + [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)] + public static extern int CM_Locate_DevNodeW(out UInt32 pdnDevInst, string pDeviceID, UInt32 ulFlags); + + [DllImport("cfgmgr32.dll", CharSet = CharSet.Unicode)] + public static extern int CM_Request_Device_EjectW(UInt32 dnDevInst, out UInt32 pVetoType, StringBuilder pszVetoName, Int32 ulNameLength, UInt32 ulFlags); +} +"@ + +[UInt32]$devInst = 0 +$locate = [IopCfgMgr]::CM_Locate_DevNodeW([ref]$devInst, [string]$pnpDeviceId, 0) +if ($locate -ne 0) { + Write-IopResult "ERROR" ("Windows could not locate the device node for " + $drive + " (CM error " + $locate + ").") + exit 3 +} + +[UInt32]$vetoType = 0 +$vetoName = New-Object System.Text.StringBuilder 512 +$result = [IopCfgMgr]::CM_Request_Device_EjectW($devInst, [ref]$vetoType, $vetoName, $vetoName.Capacity, 0) +if ($result -ne 0 -or $vetoType -ne 0) { + $detail = "Windows vetoed eject for " + $drive + " (CM error " + $result + ", veto " + $vetoType + $name = $vetoName.ToString() + if (-not [string]::IsNullOrWhiteSpace($name)) { + $detail += ", " + $name + } + $detail += ")." + Write-IopResult "ERROR" $detail + exit 4 +} + +Write-IopResult "OK" ("Windows accepted the eject request for " + $drive + ".") +exit 0 +""" + ) + return _run_windows_powershell_eject(ps_cmd, "Windows device eject request failed.") + + +def _run_windows_shell_eject(drive: str) -> tuple[bool, str]: + """Invoke Explorer's shell ``Eject`` verb as a compatibility fallback.""" + ps_cmd = ( + "$ErrorActionPreference = 'Stop'\n" + f"$drive = {_ps_single_quote(drive)}\n" + r""" +function Write-IopResult { + param([string]$Status, [string]$Message) + [Console]::Out.WriteLine($Status + "`t" + $Message) +} + +$shell = New-Object -ComObject Shell.Application +$computer = $shell.Namespace(17) +if ($null -eq $computer) { + Write-IopResult "ERROR" "Explorer did not expose the Computer namespace." + exit 2 +} + +$item = $computer.ParseName($drive) +if ($null -eq $item) { + Write-IopResult "MISSING" ($drive + " is not mounted.") + exit 0 +} + +$item.InvokeVerb("Eject") +Write-IopResult "OK" ("Explorer accepted the eject request for " + $drive + ".") +exit 0 +""" + ) + return _run_windows_powershell_eject(ps_cmd, "Explorer eject request failed.") + + +def _run_windows_powershell_eject(ps_cmd: str, default_error: str) -> tuple[bool, str]: + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=_TIMEOUT_SECS, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except FileNotFoundError: + return False, "PowerShell is not available on this system." + except subprocess.TimeoutExpired: + return False, "Eject timed out." + + status, message = _parse_windows_eject_result(proc.stdout) + if status in {"OK", "MISSING"} and proc.returncode == 0: + return True, message + if status == "ERROR": + return False, message + + err = (proc.stderr or proc.stdout or "").strip() + if proc.returncode != 0: + return False, err or f"{default_error} PowerShell exited with code {proc.returncode}." + return True, err or default_error + + +def _parse_windows_eject_result(output: str) -> tuple[str, str]: + for line in output.splitlines(): + status, sep, message = line.partition("\t") + if sep and status in {"OK", "MISSING", "ERROR"}: + return status, message.strip() + return "", output.strip() + + +def _wait_for_windows_drive_removed(drive: str) -> bool: + deadline = time.monotonic() + _WINDOWS_VERIFY_SECS + while time.monotonic() < deadline: + if not _windows_drive_is_mounted(drive): + return True + time.sleep(0.25) + return not _windows_drive_is_mounted(drive) + + +def _windows_drive_is_mounted(drive: str) -> bool: + if sys.platform != "win32": + return Path(_windows_drive_root(drive)).exists() + + import ctypes + + drive_type = ctypes.windll.kernel32.GetDriveTypeW(_windows_drive_root(drive)) + return drive_type != 1 # DRIVE_NO_ROOT_DIR + + +def _windows_drive_root(drive: str) -> str: + return f"{drive[0].upper()}:\\" + + +def _ps_single_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _windows_eject_failure_message( + drive: str, + prep_msg: str, + pnp_msg: str, + cfg_msg: str, + shell_msg: str, +) -> str: + details = [] + for msg in (pnp_msg, prep_msg, cfg_msg, shell_msg): + msg = msg.strip() + if msg and msg not in details: + details.append(msg) + + message = ( + f"Windows did not eject {drive}; the drive is still mounted. " + "Close any File Explorer windows or apps using the iPod, then try again." + ) + if details: + message += "\n\nDetails: " + " | ".join(details) + return message + + +# ────────────────────────────────────────────────────────────────────── +# macOS +# ────────────────────────────────────────────────────────────────────── + +def _eject_macos(path: Path) -> tuple[bool, str]: + """Eject via ``diskutil`` with forced unmount fallbacks.""" + if not shutil.which("diskutil"): + return False, "diskutil is not available." + + mount_path = str(path) + info, info_msg = _macos_disk_info(mount_path) + device_id = str(info.get("DeviceIdentifier") or "") + parent = str(info.get("ParentWholeDisk") or "") + whole_disk = device_id if info.get("WholeDisk") else parent + disk_target = whole_disk or device_id or mount_path + volume_target = device_id or mount_path + mount_point = str(info.get("MountPoint") or mount_path) + + if not info and not _macos_mount_is_present(mount_point, device_id): + return True, "Device already unmounted." + + _run_sync() + attempts: list[str] = [] + + ok, msg = _run_command(["diskutil", "eject", disk_target]) + attempts.append(msg) + if ok and _wait_for_macos_mount_gone(mount_point, device_id): + return True, f"Ejected {disk_target}" + + ok, msg = _run_command(["diskutil", "unmount", volume_target]) + attempts.append(msg) + if ok: + ok_eject, eject_msg = _run_command(["diskutil", "eject", disk_target]) + attempts.append(eject_msg) + if (ok_eject or _is_benign_absence(eject_msg)) and _wait_for_macos_mount_gone( + mount_point, + device_id, + ): + return True, f"Unmounted and ejected {disk_target}" + + ok, msg = _run_command(["diskutil", "unmount", "force", volume_target]) + attempts.append(msg) + if ok: + ok_eject, eject_msg = _run_command(["diskutil", "eject", disk_target]) + attempts.append(eject_msg) + if (ok_eject or _is_benign_absence(eject_msg)) and _wait_for_macos_mount_gone( + mount_point, + device_id, + ): + return True, f"Force-unmounted and ejected {disk_target}" + + ok, msg = _run_command(["diskutil", "unmountDisk", "force", disk_target]) + attempts.append(msg) + if ok: + ok_eject, eject_msg = _run_command(["diskutil", "eject", disk_target]) + attempts.append(eject_msg) + if _wait_for_macos_mount_gone(mount_point, device_id): + if ok_eject or _is_benign_absence(eject_msg): + return True, f"Force-unmounted and ejected {disk_target}" + return True, f"Force-unmounted {disk_target}; eject reported: {eject_msg}" + + if _wait_for_macos_mount_gone(mount_point, device_id): + return True, f"Device unmounted: {disk_target}" + + details = _join_unique_messages([info_msg, *attempts]) + return False, details or f"diskutil could not eject {disk_target}." + + +def _macos_disk_info(target: str) -> tuple[dict, str]: + """Return ``diskutil info -plist`` data for *target*.""" + try: + proc = subprocess.run( + ["diskutil", "info", "-plist", target], + capture_output=True, + timeout=10, + ) + except FileNotFoundError: + return {}, "diskutil is not available." + except subprocess.TimeoutExpired: + return {}, "diskutil info timed out." + + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip() + return {}, err or "diskutil info failed." + + try: + data = plistlib.loads(proc.stdout) + except Exception as exc: + return {}, f"Could not parse diskutil info: {exc}" + if isinstance(data, dict): + return data, "" + return {}, "diskutil info returned an unexpected response." + + +def _wait_for_macos_mount_gone(mount_point: str, device_id: str = "") -> bool: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if not _macos_mount_is_present(mount_point, device_id): + return True + time.sleep(0.25) + return not _macos_mount_is_present(mount_point, device_id) + + +def _macos_mount_is_present(mount_point: str, device_id: str = "") -> bool: + try: + proc = subprocess.run( + ["mount"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return True + except FileNotFoundError: + return Path(mount_point).exists() + + if proc.returncode != 0: + return Path(mount_point).exists() + + wanted_mount = _normalize_posix_path(mount_point) + wanted_device = f"/dev/{device_id}" if device_id else "" + for line in proc.stdout.splitlines(): + m = re.match(r"^(.+) on (.+) \(.+\)$", line) + if not m: + continue + dev, mounted_at = m.group(1), m.group(2) + if _normalize_posix_path(mounted_at) == wanted_mount: + return True + if wanted_device and dev == wanted_device: + return True + return False + + +# ────────────────────────────────────────────────────────────────────── +# Linux +# ────────────────────────────────────────────────────────────────────── + +def _eject_linux(path: Path) -> tuple[bool, str]: + """Try udisksctl, eject, then force/lazy umount, verifying each step.""" + mount_path = str(path) + device = _find_block_device(mount_path) + detach_target = (_parent_block_device(device) or device) if device else None + if not _linux_path_is_mounted(mount_path, device): + return True, "Device already unmounted." + + _run_sync() + errors: list[str] = [] + last_error: str | None = None + + if device and shutil.which("udisksctl"): + ok, msg = _udisks_eject(device, mount_path) + if ok and _wait_for_linux_mount_gone(mount_path, device): + return True, msg + last_error = msg + errors.append(msg) + logger.debug("udisksctl eject failed, falling back: %s", msg) + + if detach_target and shutil.which("eject"): + ok, msg = _run_eject_command(detach_target) + if ok and _wait_for_linux_mount_gone(mount_path, device): + return True, msg + if _is_benign_absence(msg): + return True, f"Device already detached: {detach_target}" + last_error = msg + errors.append(msg) + logger.debug("eject command failed for %s: %s", detach_target, msg) + + if shutil.which("umount"): + umount_targets: list[str] = [] + if device: + umount_targets.append(device) + if path.exists(): + umount_targets.append(mount_path) + umount_targets = list(dict.fromkeys(umount_targets)) + + modes = ("normal", "force", "lazy", "force_lazy") + for target in umount_targets: + for mode in modes: + ok, msg, already_unmounted = _run_umount_command(target, mode=mode) + if ok or already_unmounted: + if _wait_for_linux_mount_gone(mount_path, device): + detach_msg = _detach_linux_after_unmount(detach_target) + if detach_msg: + return True, f"{msg}; {detach_msg}" + return True, msg + last_error = msg + errors.append(msg) + + if _wait_for_linux_mount_gone(mount_path, device): + return True, "Device already unmounted." + + details = _join_unique_messages(errors) + return False, details or last_error or ( + "No suitable unmount utility found " + "(tried udisksctl, eject, force/lazy umount)." + ) + + +def _find_block_device(mount_path: str) -> str | None: + """Return the block device backing *mount_path* (e.g. ``/dev/sdb1``).""" + if shutil.which("findmnt"): + try: + proc = subprocess.run( + ["findmnt", "-n", "-o", "TARGET,SOURCE", "--target", mount_path], + capture_output=True, + text=True, + timeout=5, + ) + if proc.returncode == 0: + line = proc.stdout.strip().splitlines()[0].strip() if proc.stdout.strip() else "" + parts = line.split(None, 1) + target = _decode_mount_field(parts[0]) if parts else "" + source = parts[1].strip() if len(parts) > 1 else "" + if ( + source.startswith("/dev/") + and target != "/" + and ( + _normalize_posix_path(mount_path) == _normalize_posix_path(target) + or _normalize_posix_path(mount_path).startswith( + _normalize_posix_path(target).rstrip("/") + "/" + ) + ) + ): + return source + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # Fallback: scan /proc/mounts for the longest matching mountpoint. + best: str | None = None + try: + with open("/proc/mounts", encoding="utf-8", errors="replace") as f: + best_len = -1 + for line in f: + parts = line.split() + if len(parts) < 2: + continue + dev, mp = parts[0], _decode_mount_field(parts[1]) + if mp == "/": + continue + if mount_path == mp or mount_path.startswith(mp.rstrip("/") + "/"): + if dev.startswith("/dev/") and len(mp) > best_len: + best, best_len = dev, len(mp) + except OSError: + pass + + if shutil.which("lsblk"): + try: + proc = subprocess.run( + ["lsblk", "-J", "-o", "NAME,MOUNTPOINT"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + if proc.returncode == 0: + data = json.loads(proc.stdout) + for dev_entry in data.get("blockdevices", []): + for child in dev_entry.get("children", []): + mountpoint = child.get("mountpoint") or "" + if mountpoint == mount_path: + name = child.get("name", "") + if name: + return f"/dev/{name}" + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + pass + + return best + + +def _udisks_eject(device: str, mount_path: str) -> tuple[bool, str]: + """Unmount then power off the parent disk via ``udisksctl``.""" + ok, msg = _run_udisks_unmount(device, force=False) + if not ok and not _is_benign_absence(msg): + ok, msg = _run_udisks_unmount(device, force=True) + if not ok and not _is_benign_absence(msg): + return False, msg + + if not _wait_for_linux_mount_gone(mount_path, device): + return False, msg or f"udisksctl did not unmount {device}." + + parent = _parent_block_device(device) + if not parent: + return True, f"Unmounted {device}" + + return _run_udisks_poweroff(parent) + + +def _run_udisks_unmount(device: str, force: bool) -> tuple[bool, str]: + args = [ + "udisksctl", "unmount", + "--block-device", device, + "--no-user-interaction", + ] + if force: + args.append("--force") + + ok, msg = _run_command(args) + if ok: + label = "force-unmounted" if force else "unmounted" + return True, f"udisksctl {label} {device}." + if _is_benign_absence(msg): + return True, "Device already unmounted." + prefix = "udisksctl force unmount failed" if force else "udisksctl unmount failed" + return False, msg or f"{prefix}." + + +def _run_udisks_poweroff(parent: str) -> tuple[bool, str]: + ok, msg = _run_command( + [ + "udisksctl", "power-off", + "--block-device", parent, + "--no-user-interaction", + ] + ) + if ok: + return True, f"Ejected {parent}" + if _is_benign_absence(msg): + return True, f"Device already detached: {parent}" + return False, msg or "udisksctl power-off failed." + + +def _run_eject_command(target: str) -> tuple[bool, str]: + try: + proc = subprocess.run( + ["eject", target], + capture_output=True, + text=True, + timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return False, "eject timed out." + + if proc.returncode == 0: + return True, f"Ejected {target}" + err = (proc.stderr or proc.stdout).strip() + return False, err or "eject failed." + + +def _run_umount_command(target: str, mode: str = "normal") -> tuple[bool, str, bool]: + args = ["umount"] + label = "umount" + if mode == "force": + args.append("-f") + label = "force umount" + elif mode == "lazy": + args.append("-l") + label = "lazy umount" + elif mode == "force_lazy": + args.append("-fl") + label = "force/lazy umount" + args.append(target) + + try: + proc = subprocess.run( + args, + capture_output=True, + text=True, + timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return False, f"{label} timed out.", False + + if proc.returncode == 0: + return True, f"{label} succeeded for {target}", False + + err = (proc.stderr or proc.stdout).strip() + if _is_benign_absence(err): + return False, err or "Device already unmounted.", True + return False, err or f"{label} failed.", False + + +def _detach_linux_after_unmount(detach_target: str | None) -> str: + if not detach_target: + return "" + + if shutil.which("udisksctl"): + ok, msg = _run_udisks_poweroff(detach_target) + if ok or _is_benign_absence(msg): + return msg + logger.debug("udisksctl power-off after umount failed: %s", msg) + + if shutil.which("eject"): + ok, msg = _run_eject_command(detach_target) + if ok or _is_benign_absence(msg): + return msg + logger.debug("eject after umount failed: %s", msg) + + return "" + + +def _wait_for_linux_mount_gone(mount_path: str, device: str | None = None) -> bool: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if not _linux_path_is_mounted(mount_path, device): + return True + time.sleep(0.25) + return not _linux_path_is_mounted(mount_path, device) + + +def _linux_path_is_mounted(mount_path: str, device: str | None = None) -> bool: + wanted_mount = _normalize_posix_path(mount_path) + wanted_device = device or "" + + for dev, mp in _linux_mount_entries(): + if wanted_device and dev == wanted_device: + return True + normalized_mp = _normalize_posix_path(mp) + if wanted_mount == normalized_mp: + return True + if ( + normalized_mp != "/" + and wanted_mount.startswith(normalized_mp.rstrip("/") + "/") + and dev.startswith("/dev/") + ): + if not wanted_device or dev == wanted_device: + return True + return False + + +def _linux_mount_entries() -> list[tuple[str, str]]: + entries: list[tuple[str, str]] = [] + try: + with open("/proc/mounts", encoding="utf-8", errors="replace") as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + entries.append((parts[0], _decode_mount_field(parts[1]))) + except OSError: + pass + return entries + + +def _run_sync() -> None: + if not shutil.which("sync"): + return + try: + subprocess.run( + ["sync"], + capture_output=True, + text=True, + timeout=15, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + +def _run_command(args: list[str], timeout: int = _TIMEOUT_SECS) -> tuple[bool, str]: + try: + proc = subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + except FileNotFoundError: + return False, f"{args[0]} is not available." + except subprocess.TimeoutExpired: + return False, f"{' '.join(args)} timed out." + + output = (proc.stderr or proc.stdout or "").strip() + if proc.returncode == 0: + return True, output or f"{' '.join(args)} succeeded." + return False, output or f"{' '.join(args)} failed with code {proc.returncode}." + + +def _join_unique_messages(messages: list[str]) -> str: + unique: list[str] = [] + for msg in messages: + msg = (msg or "").strip() + if msg and msg not in unique: + unique.append(msg) + return " | ".join(unique) + + +def _normalize_posix_path(path: str) -> str: + try: + return str(Path(path).resolve(strict=False)) + except OSError: + return path.rstrip("/") or "/" + + +def _decode_mount_field(field: str) -> str: + """Decode octal escapes from ``/proc/mounts`` mountpoint fields.""" + return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), field) + + +def _is_benign_absence(message: str) -> bool: + lower = message.lower() + return any(hint in lower for hint in _ALREADY_UNMOUNTED_HINTS + _MISSING_TARGET_HINTS) + + +def _parent_block_device(device: str) -> str | None: + """Given ``/dev/sdb1`` return ``/dev/sdb`` (``nvme0n1p1`` → ``nvme0n1``).""" + name = device.rsplit("/", 1)[-1] + m = re.match(r"^(nvme\d+n\d+)p\d+$", name) + if m: + return "/dev/" + m.group(1) + m = re.match(r"^(mmcblk\d+)p\d+$", name) + if m: + return "/dev/" + m.group(1) + m = re.match(r"^([a-z]+)\d+$", name) + if m: + return "/dev/" + m.group(1) + return None diff --git a/src/vendor/ipod_device/images.py b/src/vendor/ipod_device/images.py new file mode 100644 index 0000000..78822dd --- /dev/null +++ b/src/vendor/ipod_device/images.py @@ -0,0 +1,408 @@ +"""iPod product image mapping and accent color extraction. + +Maps iPod model families, generations, and colors to official Apple device +icons stored in assets/ipod_images/. Also maps each icon to an (R,G,B) +accent color for the "Match iPod" theme setting. +""" + +from .models import IPOD_MODELS + +# Key: (model_family_lower, generation_lower, color_lower) → filename +COLOR_MAP: dict[tuple[str, str, str], str] = { + # ── iPod (1G–4G) ──────────────────────────────────────── + ("ipod", "1st gen", "white"): "iPod1.png", + ("ipod", "2nd gen", "white"): "iPod1.png", + ("ipod", "3rd gen", "white"): "iPod2.png", + ("ipod", "4th gen", "white"): "iPod4-White.png", + ("ipod u2", "4th gen", "black"): "iPod4-BlackRed.png", + + # ── iPod Photo / iPod with Color Display (4th gen)────── + ("ipod photo", "4th gen", "white"): "iPod5-White.png", + ("ipod photo u2", "4th gen", "black"): "iPod5-BlackRed.png", + + # ── iPod with Video (5th Gen / 5.5th Gen) ──────────── + ("ipod video", "5th gen", "white"): "iPod6-White.png", + ("ipod video", "5th gen", "black"): "iPod6-Black.png", + ("ipod video", "5.5th gen", "white"): "iPod6-White.png", + ("ipod video", "5.5th gen", "black"): "iPod6-Black.png", + ("ipod video u2", "5th gen", "black"): "iPod6-BlackRed.png", + ("ipod video u2", "5.5th gen", "black"): "iPod6-BlackRed.png", + + # ── iPod Classic (1st–3rd Gen) ─────────────────────── + ("ipod classic", "1st gen", "silver"): "iPod11-Silver.png", + ("ipod classic", "1st gen", "black"): "iPod11-Black.png", + ("ipod classic", "2nd gen", "silver"): "iPod11-Silver.png", + ("ipod classic", "2nd gen", "black"): "iPod11B-Black.png", + ("ipod classic", "3rd gen", "silver"): "iPod11-Silver.png", + ("ipod classic", "3rd gen", "black"): "iPod11B-Black.png", + + # ── iPod Mini 1st Gen ──────────── + ("ipod mini", "1st gen", "silver"): "iPod3-Silver.png", + ("ipod mini", "1st gen", "blue"): "iPod3-Blue.png", + ("ipod mini", "1st gen", "gold"): "iPod3-Gold.png", + ("ipod mini", "1st gen", "green"): "iPod3-Green.png", + ("ipod mini", "1st gen", "pink"): "iPod3-Pink.png", + + # ── iPod Mini 2nd Gen ─── + ("ipod mini", "2nd gen", "silver"): "iPod3-Silver.png", + ("ipod mini", "2nd gen", "blue"): "iPod3B-Blue.png", + ("ipod mini", "2nd gen", "green"): "iPod3B-Green.png", + ("ipod mini", "2nd gen", "pink"): "iPod3B-Pink.png", + + # ── iPod Nano 1st Gen ────────────────────────────── + ("ipod nano", "1st gen", "white"): "iPod7-White.png", + ("ipod nano", "1st gen", "black"): "iPod7-Black.png", + + # ── iPod Nano 2nd Gen ───── + ("ipod nano", "2nd gen", "silver"): "iPod9-Silver.png", + ("ipod nano", "2nd gen", "black"): "iPod9-Black.png", + ("ipod nano", "2nd gen", "blue"): "iPod9-Blue.png", + ("ipod nano", "2nd gen", "green"): "iPod9-Green.png", + ("ipod nano", "2nd gen", "pink"): "iPod9-Pink.png", + ("ipod nano", "2nd gen", "red"): "iPod9-Red.png", + + # ── iPod Nano 3rd Gen ───── + ("ipod nano", "3rd gen", "silver"): "iPod12-Silver.png", + ("ipod nano", "3rd gen", "black"): "iPod12-Black.png", + ("ipod nano", "3rd gen", "blue"): "iPod12-Blue.png", + ("ipod nano", "3rd gen", "green"): "iPod12-Green.png", + ("ipod nano", "3rd gen", "pink"): "iPod12-Pink.png", + ("ipod nano", "3rd gen", "red"): "iPod12-Red.png", + + # ── iPod Nano 4th Gen ───────────────────────────────── + ("ipod nano", "4th gen", "silver"): "iPod15-Silver.png", + ("ipod nano", "4th gen", "black"): "iPod15-Black.png", + ("ipod nano", "4th gen", "blue"): "iPod15-Blue.png", + ("ipod nano", "4th gen", "green"): "iPod15-Green.png", + ("ipod nano", "4th gen", "orange"): "iPod15-Orange.png", + ("ipod nano", "4th gen", "pink"): "iPod15-Pink.png", + ("ipod nano", "4th gen", "purple"): "iPod15-Purple.png", + ("ipod nano", "4th gen", "red"): "iPod15-Red.png", + ("ipod nano", "4th gen", "yellow"): "iPod15-Yellow.png", + + # ── iPod Nano 5th Gen ───────────────────────────────── + ("ipod nano", "5th gen", "silver"): "iPod16-Silver.png", + ("ipod nano", "5th gen", "black"): "iPod16-Black.png", + ("ipod nano", "5th gen", "blue"): "iPod16-Blue.png", + ("ipod nano", "5th gen", "green"): "iPod16-Green.png", + ("ipod nano", "5th gen", "orange"): "iPod16-Orange.png", + ("ipod nano", "5th gen", "pink"): "iPod16-Pink.png", + ("ipod nano", "5th gen", "purple"): "iPod16-Purple.png", + ("ipod nano", "5th gen", "red"): "iPod16-Red.png", + ("ipod nano", "5th gen", "yellow"): "iPod16-Yellow.png", + + # ── iPod Nano 6th Gen ───────────────────────────────── + ("ipod nano", "6th gen", "silver"): "iPod17-Silver.png", + ("ipod nano", "6th gen", "graphite"): "iPod17-DarkGray.png", + ("ipod nano", "6th gen", "blue"): "iPod17-Blue.png", + ("ipod nano", "6th gen", "green"): "iPod17-Green.png", + ("ipod nano", "6th gen", "orange"): "iPod17-Orange.png", + ("ipod nano", "6th gen", "pink"): "iPod17-Pink.png", + ("ipod nano", "6th gen", "red"): "iPod17-Red.png", + + # ── iPod Nano 7th Gen ───────────────────────────────────────────── + ("ipod nano", "7th gen", "silver"): "iPod18A-Silver.png", + ("ipod nano", "7th gen", "space gray"): "iPod18A-SpaceGray.png", + ("ipod nano", "7th gen", "blue"): "iPod18A-Blue.png", + ("ipod nano", "7th gen", "pink"): "iPod18A-Pink.png", + ("ipod nano", "7th gen", "red"): "iPod18A-Red.png", + ("ipod nano", "7th gen", "gold"): "iPod18A-Gold.png", + ("ipod nano", "7th gen", "slate"): "iPod18-DarkGray.png", + ("ipod nano", "7th gen", "green"): "iPod18-Green.png", + ("ipod nano", "7th gen", "purple"): "iPod18-Purple.png", + ("ipod nano", "7th gen", "yellow"): "iPod18-Yellow.png", + + # ── iPod Shuffle 1st Gen ─────────────────────── + ("ipod shuffle", "1st gen", "white"): "iPod128.png", + + # ── iPod Shuffle 2nd Gen ────────────────────────────────────────── + ("ipod shuffle", "2nd gen", "silver"): "iPod130-Silver.png", + ("ipod shuffle", "2nd gen", "blue"): "iPod130-Blue.png", + ("ipod shuffle", "2nd gen", "green"): "iPod130-Green.png", + ("ipod shuffle", "2nd gen", "pink"): "iPod130-Pink.png", + ("ipod shuffle", "2nd gen", "orange"): "iPod130-Orange.png", + ("ipod shuffle", "2nd gen", "purple"): "iPod130C-Purple.png", + ("ipod shuffle", "2nd gen", "red"): "iPod130C-Red.png", + ("ipod shuffle", "2nd gen", "gold"): "iPod130F-Gold.png", + + # ── iPod Shuffle 3rd Gen ─────────────────────────────────── + ("ipod shuffle", "3rd gen", "silver"): "iPod132-Silver.png", + ("ipod shuffle", "3rd gen", "black"): "iPod132-DarkGray.png", + ("ipod shuffle", "3rd gen", "blue"): "iPod132-Blue.png", + ("ipod shuffle", "3rd gen", "green"): "iPod132-Green.png", + ("ipod shuffle", "3rd gen", "pink"): "iPod132-Pink.png", + ("ipod shuffle", "3rd gen", "stainless steel"): "iPod132B-Silver.png", + + # ── iPod Shuffle 4th Gen (2010–2017) ─────────────────────────────── + ("ipod shuffle", "4th gen", "silver"): "iPod133D-Silver.png", + ("ipod shuffle", "4th gen", "space gray"): "iPod133D-SpaceGray.png", + ("ipod shuffle", "4th gen", "blue"): "iPod133D-Blue.png", + ("ipod shuffle", "4th gen", "pink"): "iPod133D-Pink.png", + ("ipod shuffle", "4th gen", "red"): "iPod133D-Red.png", + ("ipod shuffle", "4th gen", "gold"): "iPod133D-Gold.png", + ("ipod shuffle", "4th gen", "slate"): "iPod133B-DarkGray.png", + ("ipod shuffle", "4th gen", "green"): "iPod133B-Green.png", + ("ipod shuffle", "4th gen", "purple"): "iPod133B-Purple.png", + ("ipod shuffle", "4th gen", "yellow"): "iPod133B-Yellow.png", + ("ipod shuffle", "4th gen", "orange"): "iPod133-Orange.png", +} + +MODEL_IMAGE: dict[str, str] = { + # ── iPod Nano 7th Gen (2012 original → iPod18) ───────────────────── + 'MD475': 'iPod18-Pink.png', + 'MD476': 'iPod18-Yellow.png', + 'MD477': 'iPod18-Blue.png', + 'MD478': 'iPod18-Green.png', + 'MD479': 'iPod18-Purple.png', + 'MD480': 'iPod18-Silver.png', + 'MD481': 'iPod18-DarkGray.png', + 'MD744': 'iPod18-Red.png', + 'ME971': 'iPod18-SpaceGray.png', + # ── iPod Nano 7th Gen (2015 refresh → iPod18A) ───────────────────── + 'MKMV2': 'iPod18A-Pink.png', + 'MKMX2': 'iPod18A-Gold.png', + 'MKN02': 'iPod18A-Blue.png', + 'MKN22': 'iPod18A-Silver.png', + 'MKN52': 'iPod18A-SpaceGray.png', + 'MKN72': 'iPod18A-Red.png', + # ── iPod Shuffle 2nd Gen — Sept 2007 Rev A (iPod130C) ───────────── + 'MB227': 'iPod130C-Blue.png', + 'MB228': 'iPod130C-Blue.png', + 'MB229': 'iPod130C-Green.png', + 'MB520': 'iPod130C-Blue.png', + 'MB522': 'iPod130C-Green.png', + # ── iPod Shuffle 2nd Gen — 2008 Rev B (iPod130F) ────────────────── + 'MB811': 'iPod130F-Pink.png', + 'MB813': 'iPod130F-Blue.png', + 'MB815': 'iPod130F-Green.png', + 'MB817': 'iPod130F-Red.png', + 'MB681': 'iPod130F-Pink.png', + 'MB683': 'iPod130F-Blue.png', + 'MB685': 'iPod130F-Green.png', + 'MB779': 'iPod130F-Red.png', + # ── iPod Shuffle 4th Gen — 2010 original (iPod133) ──────────────── + 'MC584': 'iPod133-Silver.png', + 'MC585': 'iPod133-Pink.png', + 'MC750': 'iPod133-Green.png', + 'MC751': 'iPod133-Blue.png', + # ── iPod Shuffle 4th Gen — Late 2012 Rev A (iPod133B) ───────────── + 'MD773': 'iPod133B-Pink.png', + 'MD775': 'iPod133B-Blue.png', + 'MD778': 'iPod133B-Silver.png', + 'MD780': 'iPod133B-Red.png', + 'ME949': 'iPod133B-SpaceGray.png', +} + +FAMILY_FALLBACK: dict[str, str] = { + "ipod": "iPod4-White.png", + "ipod u2": "iPod4-BlackRed.png", + "ipod photo": "iPod5-White.png", + "ipod photo u2": "iPod5-BlackRed.png", + "ipod video": "iPod6-White.png", + "ipod video u2": "iPod6-BlackRed.png", + "ipod classic": "iPod11-Silver.png", + "ipod mini": "iPod3-Silver.png", + "ipod nano": "iPod15-Silver.png", + "ipod shuffle": "iPod133D-Silver.png", +} + +GENERIC_IMAGE = "iPodGeneric.png" + + +# ── Image → accent color (R, G, B) ─────────────────────────────────────────── +# Maps image filename (case-insensitive, without extension) to the dominant +# body color of that iPod model. Used by the "Match iPod" accent color +# setting. White/silver models use a generic silver; black/gray use a +# generic dark gray; colorful models use their actual body tint. +_SILVER = (223, 224, 223) +_GRAY = (44, 44, 49) + +IMAGE_COLORS: dict[str, tuple[int, int, int]] = { + # ── iPod (original / Photo / Video / Classic) ───────────────────── + "ipod1": _SILVER, + "ipod2": _SILVER, + "ipod4-white": _SILVER, + "ipod4-blackred": (163, 36, 24), + "ipod5-white": _SILVER, + "ipod5-blackred": (163, 36, 24), + "ipod6-white": _SILVER, + "ipod6-black": _GRAY, + "ipod6-blackred": (233, 51, 35), + "ipod11-silver": _SILVER, + "ipod11-black": _GRAY, + "ipod11b-black": _GRAY, + # ── iPod Mini 1st Gen ───────────────────────────────────────────── + "ipod3-silver": _SILVER, + "ipod3-blue": (137, 178, 204), + "ipod3-gold": (217, 201, 140), + "ipod3-green": (196, 208, 139), + "ipod3-pink": (216, 173, 201), + # ── iPod Mini 2nd Gen ───────────────────────────────────────────── + "ipod3b-blue": (121, 184, 229), + "ipod3b-green": (211, 230, 120), + "ipod3b-pink": (225, 156, 203), + # ── iPod Nano 1st Gen ───────────────────────────────────────────── + "ipod7-white": _SILVER, + "ipod7-black": _GRAY, + # ── iPod Nano 2nd Gen ───────────────────────────────────────────── + "ipod9-silver": _SILVER, + "ipod9-black": _GRAY, + "ipod9-blue": (94, 194, 210), + "ipod9-green": (172, 199, 84), + "ipod9-pink": (209, 61, 139), + "ipod9-red": (206, 67, 66), + # ── iPod Nano 3rd Gen ───────────────────────────────────────────── + "ipod12-silver": _SILVER, + "ipod12-black": _GRAY, + "ipod12-blue": (206, 67, 66), + "ipod12-green": (170, 220, 168), + "ipod12-pink": (200, 80, 146), + "ipod12-red": (154, 63, 81), + # ── iPod Nano 4th Gen ───────────────────────────────────────────── + "ipod15-silver": _SILVER, + "ipod15-black": _GRAY, + "ipod15-blue": (62, 127, 180), + "ipod15-green": (131, 173, 68), + "ipod15-orange": (208, 131, 57), + "ipod15-pink": (227, 67, 133), + "ipod15-purple": (126, 45, 199), + "ipod15-red": (209, 62, 66), + "ipod15-yellow": (239, 230, 109), + # ── iPod Nano 5th Gen ───────────────────────────────────────────── + "ipod16-silver": _SILVER, + "ipod16-black": _GRAY, + "ipod16-blue": (26, 67, 145), + "ipod16-green": (52, 119, 61), + "ipod16-orange": (215, 102, 43), + "ipod16-pink": (217, 49, 103), + "ipod16-purple": (65, 9, 127), + "ipod16-red": (146, 28, 45), + "ipod16-yellow": (236, 209, 78), + # ── iPod Nano 6th Gen ───────────────────────────────────────────── + "ipod17-silver": _SILVER, + "ipod17-darkgray": _GRAY, + "ipod17-blue": (105, 128, 168), + "ipod17-green": (135, 151, 69), + "ipod17-orange": (178, 131, 57), + "ipod17-pink": (182, 91, 125), + "ipod17-red": (186, 50, 48), + # ── iPod Nano 7th Gen (2012 iPod18) ─────────────────────────────── + "ipod18-silver": _SILVER, + "ipod18-darkgray": _GRAY, + "ipod18-blue": (91, 187, 212), + "ipod18-green": (146, 224, 163), + "ipod18-pink": (222, 132, 128), + "ipod18-purple": (222, 152, 208), + "ipod18-red": (216, 68, 61), + "ipod18-yellow": (217, 218, 91), + "ipod18-spacegray": _GRAY, + # ── iPod Nano 7th Gen (2015 iPod18A) ────────────────────────────── + "ipod18a-silver": _SILVER, + "ipod18a-spacegray": _GRAY, + "ipod18a-blue": (109, 165, 229), + "ipod18a-gold": (216, 204, 185), + "ipod18a-pink": (236, 115, 167), + "ipod18a-red": (232, 105, 97), + # ── iPod Shuffle 1st Gen ────────────────────────────────────────── + "ipod128": _SILVER, + # ── iPod Shuffle 2nd Gen (iPod130) ──────────────────────────────── + "ipod130-silver": _SILVER, + "ipod130-blue": (81, 169, 195), + "ipod130-green": (165, 198, 75), + "ipod130-orange": (230, 107, 44), + "ipod130-pink": (198, 52, 129), + # ── iPod Shuffle 2nd Gen Rev A (iPod130C) ───────────────────────── + "ipod130c-blue": (152, 205, 206), + "ipod130c-green": (167, 217, 164), + "ipod130c-purple": (131, 131, 201), + "ipod130c-red": (150, 59, 77), + # ── iPod Shuffle 2nd Gen Rev B (iPod130F) ───────────────────────── + "ipod130f-blue": (50, 110, 179), + "ipod130f-gold": (208, 189, 129), + "ipod130f-green": (128, 178, 63), + "ipod130f-pink": (205, 58, 115), + "ipod130f-red": (179, 42, 40), + # ── iPod Shuffle 3rd Gen (iPod132) ──────────────────────────────── + "ipod132-silver": _SILVER, + "ipod132-darkgray": _GRAY, + "ipod132-blue": (73, 156, 177), + "ipod132-green": (147, 189, 77), + "ipod132-pink": (204, 75, 117), + "ipod132b-silver": _SILVER, + # ── iPod Shuffle 4th Gen (2010 iPod133) ─────────────────────────── + "ipod133-silver": _SILVER, + "ipod133-blue": (139, 175, 212), + "ipod133-green": (181, 221, 105), + "ipod133-orange": (224, 186, 109), + "ipod133-pink": (220, 134, 179), + # ── iPod Shuffle 4th Gen (2012 iPod133B) ────────────────────────── + "ipod133b-silver": _SILVER, + "ipod133b-darkgray": _GRAY, + "ipod133b-blue": (89, 194, 217), + "ipod133b-green": (146, 219, 162), + "ipod133b-pink": (219, 122, 118), + "ipod133b-purple": (212, 143, 199), + "ipod133b-red": (216, 69, 62), + "ipod133b-yellow": (213, 213, 89), + # ── iPod Shuffle 4th Gen (2015 iPod133D) ────────────────────────── + "ipod133d-silver": _SILVER, + "ipod133d-spacegray": _GRAY, + "ipod133d-blue": (67, 129, 202), + "ipod133d-gold": (244, 233, 215), + "ipod133d-pink": (237, 115, 167), + "ipod133d-red": (223, 85, 76), +} + + +def color_for_image(image_filename: str) -> tuple[int, int, int] | None: + """Return the (R, G, B) accent color for an iPod image filename. + + Returns None if the image is not in the mapping (e.g. iPodGeneric). + """ + key = image_filename.rsplit(".", 1)[0].lower() + return IMAGE_COLORS.get(key) + + +_DEFAULT_COLOR_PREFERENCE = ("silver", "white") + + +def resolve_image_filename( + family: str, + generation: str, + color: str = "", +) -> str: + """Resolve an image filename through a tiered lookup. + + 1. Exact (family, generation, color) + 2. Inferred default — try "silver" then "white" for (family, generation) + 3. Family-level fallback + 4. ``iPodGeneric.png`` + """ + fam = family.lower() + gen = generation.lower() + col = color.lower().strip() + + if col: + filename = COLOR_MAP.get((fam, gen, col)) + if filename: + return filename + + for default_col in _DEFAULT_COLOR_PREFERENCE: + filename = COLOR_MAP.get((fam, gen, default_col)) + if filename: + return filename + + return FAMILY_FALLBACK.get(fam, GENERIC_IMAGE) + + +def image_for_model(model_number: str) -> str: + """Return the exact image filename for a known model number.""" + override = MODEL_IMAGE.get(model_number) + if override: + return override + + info = IPOD_MODELS.get(model_number) + if info: + return resolve_image_filename(info[0], info[1], info[3]) + + return GENERIC_IMAGE diff --git a/src/vendor/ipod_device/info.py b/src/vendor/ipod_device/info.py new file mode 100644 index 0000000..9dcc989 --- /dev/null +++ b/src/vendor/ipod_device/info.py @@ -0,0 +1,2435 @@ +""" +Centralised device information store for iOpenPod. + +When an iPod is selected, every knowable detail about it is gathered **once** +by the device scanner / loader and stored here. Every other module — GUI, +writer, sync engine — accesses device info exclusively through this store. +**No consumer should ever probe hardware, read SysInfo, or query the registry +on its own.** If the store is empty the consumer uses a safe default. + +Typical flow +~~~~~~~~~~~~ +1. Device scanner discovers iPod → ``DeviceInfo`` +2. User picks one → ``DeviceManager`` calls ``set_current_device(info)`` +3. Any backend module: ``device = get_current_device()`` + +For headless (non-GUI) use:: + + from ipod_device import DeviceInfo, set_current_device, enrich + info = DeviceInfo(path="/media/ipod") + enrich(info) # reads SysInfo once, computes everything + set_current_device(info) +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import socket +import sys +import threading +from dataclasses import dataclass, field, replace +from typing import Any + +from .diagnostic_log import ( + CAPABILITY_FIELDS, + IDENTITY_FIELDS, + SOURCE_FIELDS, + format_fields, + format_sources, +) + +logger = logging.getLogger(__name__) + + +_LIVE_VALIDATION_LOCK = threading.Lock() +_LIVE_VALIDATION_INFLIGHT: set[str] = set() + + +def _source_rank(source: str) -> int: + try: + from .authority import _WORST_RANK, SOURCE_RANK + + return SOURCE_RANK.get(source, _WORST_RANK) + except Exception: + return 999 + + +def _values_match(field: str, left, right) -> bool: + if field == "firewire_guid": + try: + from .sysinfo import normalize_guid + + return normalize_guid(left) == normalize_guid(right) + except Exception: + pass + return str(left or "").strip() == str(right or "").strip() + + +def _set_field_from_source( + info: DeviceInfo, + field: str, + value, + source: str, + *, + label: str = "live probe", +) -> None: + """Set or provenance-upgrade a DeviceInfo field from a ranked source.""" + if value in (None, "", b""): + return + + current = getattr(info, field, None) + current_source = info._field_sources.get(field, "unknown") + new_rank = _source_rank(source) + current_rank = _source_rank(current_source) + + if not current: + setattr(info, field, value) + info._field_sources[field] = source + logger.debug("enrich: %s from %s: %s", field, label, value) + return + + if _values_match(field, current, value): + if new_rank <= current_rank: + info._field_sources[field] = source + return + + if new_rank <= current_rank: + logger.warning( + "enrich: %s from %s overrides %r from %s with %r", + field, + label, + current, + current_source, + value, + ) + setattr(info, field, value) + info._field_sources[field] = source + + +# ────────────────────────────────────────────────────────────────────── +# Data model +# ────────────────────────────────────────────────────────────────────── + +@dataclass +class DeviceInfo: + """Comprehensive iPod device information, gathered once and reused everywhere. + + All fields that could not be determined are left at their defaults (empty + string, 0, empty dict, etc.). Consumers should always check before using. + """ + + # ── Identity ────────────────────────────────────────────────────── + path: str = "" # Mount root (e.g. "D:\\" or "/Volumes/iPod") + mount_name: str = "" # Volume display name (e.g. "D:", "IPOD") + ipod_name: str = "" # User-assigned name from master playlist Title + model_number: str = "" # Normalised (e.g. "MC297", never "xA623") + model_family: str = "iPod" # e.g. "iPod Classic", "iPod Nano" + generation: str = "" # e.g. "3rd Gen" + capacity: str = "" # e.g. "160GB" + color: str = "" # e.g. "Black" + + # ── Hardware / Identifiers ──────────────────────────────────────── + firewire_guid: str = "" # 16 hex chars (8 bytes), used for hash signing + serial: str = "" # Apple serial (e.g. "YM0350TRVQ5"), NOT the FW GUID + firmware: str = "" + board: str = "" # BoardHwName from SysInfo + family_id: int | str = 0 + updater_family_id: int | str = 0 + product_type: str = "" + usb_pid: int = 0 + usb_vid: int = 0 + usb_serial: str = "" # Usually the FireWire GUID on iPods + usbstor_instance_id: str = "" + usb_parent_instance_id: str = "" + usb_grandparent_instance_id: str = "" + scsi_vendor: str = "" + scsi_product: str = "" + scsi_revision: str = "" + connected_bus: str = "" + volume_format: str = "" + + # ── Device capabilities from SysInfoExtended / VPD ──────────────── + db_version: int = 0 + shadow_db_version: int = 0 + uses_sqlite_db: bool = False + supports_sparse_artwork: bool = False + max_tracks: int = 0 + max_file_size_gb: int | float = 0 + max_transfer_speed: int = 0 + podcasts_supported: bool = False + voice_memos_supported: bool = False + audio_codecs: dict[str, Any] = field(default_factory=dict) + power_information: dict[str, Any] = field(default_factory=dict) + apple_drm_version: dict[str, Any] = field(default_factory=dict) + + # ── Hashing / Security ──────────────────────────────────────────── + checksum_type: int = 99 # ChecksumType value (99 = UNKNOWN) + hashing_scheme: int = -1 # From iTunesDB header offset 0x30 + hash_info_iv: bytes = b"" # AES IV from HashInfo (16 bytes if present) + hash_info_rndpart: bytes = b"" # Random bytes from HashInfo (12 bytes) + + # ── Storage ─────────────────────────────────────────────────────── + disk_size_gb: float = 0.0 + free_space_gb: float = 0.0 + + # ── Artwork ─────────────────────────────────────────────────────── + artwork_formats: dict[int, tuple[int, int]] = field(default_factory=dict) + photo_formats: dict[int, tuple[int, int]] = field(default_factory=dict) + chapter_image_formats: dict[int, tuple[int, int]] = field(default_factory=dict) + + # ── Raw SysInfo cache (so nobody ever has to re-read the file) ──── + sysinfo: dict[str, str] = field(default_factory=dict) + raw_identity_evidence: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + identity_conflicts: list[dict[str, Any]] = field(default_factory=list) + + # ── Provenance ──────────────────────────────────────────────────── + identification_method: str = "unknown" + _field_sources: dict[str, str] = field(default_factory=dict, init=False, repr=False) + + # ── Computed helpers ────────────────────────────────────────────── + + @property + def firewire_id_bytes(self) -> bytes | None: + """FireWire GUID as raw bytes, or *None* if unavailable / all-zero.""" + if not self.firewire_guid: + return None + guid = self.firewire_guid + if guid.startswith(("0x", "0X")): + guid = guid[2:] + try: + result = bytes.fromhex(guid) + return None if result == b"\x00" * len(result) else result + except ValueError: + return None + + @property + def drive_letter(self) -> str: + """Windows drive letter from *path*, or empty string.""" + import sys as _sys + if _sys.platform == "win32" and self.path and self.path[0].isalpha(): + return self.path[0] + return "" + + @property + def display_name(self) -> str: + """User-friendly one-line description.""" + parts = [self.model_family] + if self.generation: + parts.append(self.generation) + if self.capacity: + parts.append(self.capacity) + if self.color: + parts.append(self.color) + return " ".join(parts) + + @property + def subtitle(self) -> str: + """Secondary line (mount name + free space).""" + parts = [self.mount_name] if self.mount_name else [] + if self.disk_size_gb > 0: + parts.append(f"{self.free_space_gb:.1f} of {self.disk_size_gb:.1f} GB free") + return " — ".join(parts) if parts else "" + + @property + def icon(self) -> str: + """Emoji icon based on model family.""" + family = self.model_family.lower() + if "classic" in family or "video" in family or "photo" in family: + return "📱" + elif "nano" in family: + return "🎵" + elif "shuffle" in family: + return "🔀" + elif "mini" in family: + return "🎶" + return "🎵" + + @property + def capabilities(self): + """Return the DeviceCapabilities for this device, or defaults. + + Uses family-level fallback when generation is unknown but all + generations of the family share identical capabilities. + """ + from .capabilities import DeviceCapabilities, capabilities_for_family_gen + caps = None + if self.model_family: + caps = capabilities_for_family_gen( + self.model_family, self.generation or "", + ) + if caps is None: + caps = DeviceCapabilities() + + overrides: dict[str, Any] = {} + if self.db_version: + overrides["db_version"] = int(self.db_version) + if self.shadow_db_version: + overrides["shadow_db_version"] = int(self.shadow_db_version) + if "uses_sqlite_db" in self._field_sources: + overrides["uses_sqlite_db"] = bool(self.uses_sqlite_db) + if "supports_sparse_artwork" in self._field_sources: + overrides["supports_sparse_artwork"] = bool(self.supports_sparse_artwork) + if "podcasts_supported" in self._field_sources: + overrides["supports_podcast"] = bool(self.podcasts_supported) + return replace(caps, **overrides) if overrides else caps + + +# ────────────────────────────────────────────────────────────────────── +# Utility functions (used by multiple modules) +# ────────────────────────────────────────────────────────────────────── + +def resolve_itdb_path(ipod_path: str) -> str | None: + """Return the path to the iTunesDB (or iTunesCDB) on the iPod. + + Newer iPods (Nano 5G+) use ``iTunesCDB`` instead of ``iTunesDB``. + iTunesCDB is **zlib-compressed**: the mhbd header is stored + uncompressed, followed by a zlib stream containing all mhsd children. + The parser transparently decompresses it; the writer compresses when + ``DeviceCapabilities.supports_compressed_db`` is True. The firmware + on those devices reads ``iTunesCDB`` and ignores ``iTunesDB``. + + Check order: + + 1. ``iTunesCDB`` — used by devices with ``supports_compressed_db`` + 2. ``iTunesDB`` — used by all other devices + + Returns the path to whichever file exists, or ``None`` if neither is + present. + """ + itunes_dir = os.path.join(ipod_path, "iPod_Control", "iTunes") + cdb = os.path.join(itunes_dir, "iTunesCDB") + if os.path.exists(cdb): + return cdb + db = os.path.join(itunes_dir, "iTunesDB") + if os.path.exists(db): + return db + return None + + +def itdb_write_filename(ipod_path: str) -> str: + """Return the filename to use when **writing** the iTunesDB. + + Uses the device capabilities (``supports_compressed_db``) when + available. Falls back to whichever file already exists on disk, and + finally defaults to ``"iTunesDB"``. + """ + # 1. Ask the device store (capabilities handles family-level fallback) + dev = get_current_device() + if dev and dev.model_family: + from .capabilities import capabilities_for_family_gen + caps = capabilities_for_family_gen( + dev.model_family, dev.generation or "", + ) + if caps and caps.supports_compressed_db: + return "iTunesCDB" + + # 2. If an iTunesCDB already exists on disk, keep using it + cdb = os.path.join(ipod_path, "iPod_Control", "iTunes", "iTunesCDB") + if os.path.exists(cdb): + return "iTunesCDB" + + return "iTunesDB" + + +def read_sysinfo(ipod_path: str) -> dict: + """Parse the SysInfo file from an iPod. + + The SysInfo file at ``/iPod_Control/Device/SysInfo`` contains device + identification info as ``key: value`` pairs (one per line): + + - ``ModelNumStr`` — device model (e.g. ``"xA623"``) + - ``FirewireGuid`` — device GUID for hash computation + - ``pszSerialNumber`` — Apple serial number + - ``BoardHwName`` — hardware identifier + - ``visibleBuildID`` — firmware version + + Returns: + Dictionary of SysInfo key→value pairs. + + Raises: + FileNotFoundError: If SysInfo doesn't exist. + """ + sysinfo_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfo") + + if not os.path.exists(sysinfo_path): + raise FileNotFoundError(f"SysInfo not found at {sysinfo_path}") + + with open(sysinfo_path, errors="ignore") as f: + content = f.read() + + from .sysinfo import parse_sysinfo_text + return parse_sysinfo_text(content) + + +def _estimate_capacity_from_disk_size(disk_gb: float) -> str: + """Map raw disk size (GB) to a marketed capacity string. + + iPod capacities are advertised in base-10, but actual formatted space + is lower due to filesystem overhead and base-2/base-10 conversion. + This uses generous thresholds to handle both. + """ + thresholds = [ + (140, "160GB"), (100, "120GB"), (65, "80GB"), + (50, "60GB"), (35, "40GB"), (25, "30GB"), + (17, "20GB"), (14, "16GB"), (12, "15GB"), + (8.5, "10GB"), (6.5, "8GB"), (5.2, "6GB"), + (4.2, "5GB"), (3, "4GB"), (1.5, "2GB"), + (0.7, "1GB"), (0.3, "512MB"), + ] + for threshold, label in thresholds: + if disk_gb >= threshold: + return label + return "" + + +def _normalise_identity_text(value: str | None) -> str: + return " ".join(str(value or "").strip().casefold().split()) + + +def _normalise_identity_capacity(value: str | None) -> str: + return str(value or "").replace(" ", "").strip().casefold() + + +def _matching_model_variants( + family: str, + generation: str, + *, + capacity: str = "", + color: str = "", +) -> list[tuple[str, str, str, str]]: + """Return known model-table tuples matching the supplied identity pieces.""" + if not family or not generation: + return [] + + family_norm = _normalise_identity_text(family) + generation_norm = _normalise_identity_text(generation) + capacity_norm = _normalise_identity_capacity(capacity) + color_norm = _normalise_identity_text(color) + + from .models import IPOD_MODELS + + matches: list[tuple[str, str, str, str]] = [] + for model_info in IPOD_MODELS.values(): + model_family, model_generation, model_capacity, model_color = model_info + if _normalise_identity_text(model_family) != family_norm: + continue + if _normalise_identity_text(model_generation) != generation_norm: + continue + if ( + capacity_norm + and _normalise_identity_capacity(model_capacity) != capacity_norm + ): + continue + if color_norm and _normalise_identity_text(model_color) != color_norm: + continue + matches.append(model_info) + return matches + + +def _clear_variant_field(info: DeviceInfo, field: str, reason: str) -> None: + old_value = getattr(info, field, "") + if not old_value: + return + logger.warning( + "enrich: dropping inconsistent %s=%r for %s %s (%s)", + field, + old_value, + info.model_family or "unknown family", + info.generation or "unknown generation", + reason, + ) + setattr(info, field, "") + info._field_sources.pop(field, None) + + +def _restore_usb_pid_identity_if_needed(info: DeviceInfo) -> None: + """Use the live USB PID as an anchor when only cached identity is present.""" + if not info.usb_pid: + return + + try: + from .models import USB_PID_TO_MODEL + except ImportError: + return + + pid_info = USB_PID_TO_MODEL.get(info.usb_pid) + if not pid_info: + return + + pid_family, pid_generation = pid_info + if info.model_number: + model_info = None + usb_pid_identity_conflicts = None + try: + from .lookup import get_model_info + from .lookup import usb_pid_identity_conflicts as _usb_pid_identity_conflicts + model_info = get_model_info(info.model_number) + usb_pid_identity_conflicts = _usb_pid_identity_conflicts + except Exception: + model_info = None + if model_info and usb_pid_identity_conflicts and not usb_pid_identity_conflicts( + model_info[0], + model_info[1], + pid_family, + pid_generation, + ): + return + if model_info: + logger.warning( + "enrich: clearing stale model_number %s (%s %s) because " + "live USB PID 0x%04X identifies %s %s", + info.model_number, + model_info[0], + model_info[1], + info.usb_pid, + pid_family, + pid_generation, + ) + info.model_number = "" + info._field_sources.pop("model_number", None) + + if pid_family and info.model_family: + if ( + _normalise_identity_text(info.model_family) + != _normalise_identity_text(pid_family) + ): + logger.warning( + "enrich: cached family %r conflicts with live USB PID " + "0x%04X family %r; using live USB identity", + info.model_family, + info.usb_pid, + pid_family, + ) + info.model_family = pid_family + info._field_sources["model_family"] = "usb_pid" + + if pid_generation and info.generation: + if ( + _normalise_identity_text(info.generation) + != _normalise_identity_text(pid_generation) + ): + source = info._field_sources.get("generation", "unknown") + if source in { + "usb_pid", + "sysinfo", + "sysinfo_extended", + "hashing", + "unknown", + }: + logger.warning( + "enrich: cached generation %r conflicts with live USB " + "PID 0x%04X generation %r; using live USB identity", + info.generation, + info.usb_pid, + pid_generation, + ) + info.generation = pid_generation + info._field_sources["generation"] = "usb_pid" + + +def _sanitize_variant_fields(info: DeviceInfo) -> None: + """Drop impossible capacity/color pairings before they reach the UI/SysInfo. + + Family/generation often come from live USB PID data, while capacity/color can + come from cached SysInfo fields written by a previous run. If those pieces + do not exist together in the model table, prefer a partial, honest identity + over a stitched-together impossible one. + """ + if not info.model_family or not info.generation: + return + if not info.capacity and not info.color: + return + + _restore_usb_pid_identity_if_needed(info) + + base_matches = _matching_model_variants(info.model_family, info.generation) + if not base_matches: + return + + if _matching_model_variants( + info.model_family, + info.generation, + capacity=info.capacity, + color=info.color, + ): + return + + capacity_valid = ( + not info.capacity + or bool(_matching_model_variants( + info.model_family, + info.generation, + capacity=info.capacity, + )) + ) + color_valid = ( + not info.color + or bool(_matching_model_variants( + info.model_family, + info.generation, + color=info.color, + )) + ) + + if not capacity_valid: + _clear_variant_field(info, "capacity", "no matching model capacity") + if not color_valid: + _clear_variant_field(info, "color", "no matching model color") + + if capacity_valid and color_valid and info.capacity and info.color: + try: + from .authority import _WORST_RANK, SOURCE_RANK + cap_rank = SOURCE_RANK.get( + info._field_sources.get("capacity", "unknown"), + _WORST_RANK, + ) + color_rank = SOURCE_RANK.get( + info._field_sources.get("color", "unknown"), + _WORST_RANK, + ) + except Exception: + cap_rank = color_rank = 0 + + if cap_rank < color_rank: + _clear_variant_field(info, "color", "capacity/color combo is invalid") + elif color_rank < cap_rank: + _clear_variant_field(info, "capacity", "capacity/color combo is invalid") + else: + _clear_variant_field(info, "capacity", "capacity/color combo is invalid") + _clear_variant_field(info, "color", "capacity/color combo is invalid") + + +def _infer_color_from_variant_table(info: DeviceInfo) -> None: + """Fill color when all known variants for the identity share one color.""" + if info.color or not info.model_family or not info.generation: + return + + matches = _matching_model_variants( + info.model_family, + info.generation, + capacity=info.capacity, + ) + colors = sorted({variant[3] for variant in matches if variant[3]}) + if len(colors) != 1: + return + + info.color = colors[0] + info._field_sources["color"] = "model_table" + logger.debug( + "enrich: inferred color %s from %s %s%s", + info.color, + info.model_family, + info.generation, + f" {info.capacity}" if info.capacity else "", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Thread-safe singleton store +# ────────────────────────────────────────────────────────────────────── + +class _Store: + """Holds the *active* DeviceInfo for the running session. + + Thread safety: singleton creation is protected by a lock. The ``current`` + property is set only from the main thread (via ``set_current_device``), + so no additional synchronisation is needed for reads from worker threads + that happen *after* the device is stored. + """ + + _instance: _Store | None = None + _lock = threading.Lock() + + def __init__(self) -> None: + self._info: DeviceInfo | None = None + + @classmethod + def _get(cls) -> _Store: + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @property + def current(self) -> DeviceInfo | None: + return self._info + + @current.setter + def current(self, info: DeviceInfo | None) -> None: + self._info = info + + +# ────────────────────────────────────────────────────────────────────── +# Public API +# ────────────────────────────────────────────────────────────────────── + +def get_current_device() -> DeviceInfo | None: + """Return the active DeviceInfo, or *None* if no device is selected.""" + return _Store._get().current + + +def set_current_device(info: DeviceInfo | None) -> None: + """Store *info* as the active device (called once during selection).""" + _Store._get().current = info + if info is not None: + logger.info( + "Device stored: %s %s (%s) serial=…%s fwguid=%s " + "checksum=%s method=%s capacity=%s formats=%s", + info.model_family, info.generation, info.model_number, + info.serial[-3:] if info.serial else "none", + info.firewire_guid or "none", + info.checksum_type, + info.identification_method, + info.capacity or "unknown", + list(info.artwork_formats.keys()) if info.artwork_formats else "none", + ) + else: + logger.info("Device cleared") + + +def clear_current_device() -> None: + """Clear the stored device info (device disconnected / deselected).""" + set_current_device(None) + + +def detect_checksum_type(ipod_path: str): + """Detect which checksum type an iPod requires. + + Reads from the centralised store first; falls back to SysInfo probing. + Returns a :class:`ipod_device.ChecksumType` enum value. + """ + from .capabilities import checksum_type_for_family_gen + from .checksum import ChecksumType + from .lookup import extract_model_number, get_model_info + + # Fast path: centralised store + device = get_current_device() + if device is not None and device.checksum_type != 99: + return ChecksumType(device.checksum_type) + + try: + from .virtual import has_virtual_ipod_info, load_virtual_ipod_info + + if has_virtual_ipod_info(ipod_path): + virtual = load_virtual_ipod_info(ipod_path) + if virtual.checksum_type != 99: + return ChecksumType(virtual.checksum_type) + except Exception: + pass + + # Fallback: probe from scratch + try: + sysinfo = read_sysinfo(ipod_path) + except FileNotFoundError: + return ChecksumType.NONE + + model_str = sysinfo.get("ModelNumStr", "") + model_num = extract_model_number(model_str) + + if model_num: + mi = get_model_info(model_num) + if mi: + ct = checksum_type_for_family_gen(mi[0], mi[1]) + if ct is not None: + return ct + + hi_path = os.path.join(ipod_path, "iPod_Control", "Device", "HashInfo") + if os.path.exists(hi_path): + return ChecksumType.HASH72 + + firmware = sysinfo.get("visibleBuildID", "") + if firmware: + try: + version = int(firmware.split(".")[0]) + if version >= 2: + return ChecksumType.UNKNOWN + except (ValueError, IndexError): + pass + + if "FirewireGuid" in sysinfo: + return ChecksumType.UNKNOWN + + return ChecksumType.NONE + + +def get_firewire_id(ipod_path: str, *, known_guid: str | None = None) -> bytes: + """Get the FireWire GUID for an iPod, trying multiple sources. + + Sources (in priority order): + 0. ``known_guid`` parameter + 1. Centralised DeviceInfo store + 2. SysInfo file + 3. SysInfoExtended plist + + Returns: + FireWire GUID as raw bytes (typically 8 bytes). + + Raises: + RuntimeError: If the GUID cannot be found from any source. + """ + # Source 0: caller-supplied + if known_guid: + try: + guid_bytes = bytes.fromhex(known_guid) + if guid_bytes != b"\x00" * len(guid_bytes): + return guid_bytes + except ValueError: + pass + + # Source 1: centralised store + device = get_current_device() + if device is not None: + fwid = device.firewire_id_bytes + if fwid: + return fwid + + # Source 1b: virtual iPod metadata + try: + from .virtual import has_virtual_ipod_info, load_virtual_ipod_info + + if has_virtual_ipod_info(ipod_path): + virtual = load_virtual_ipod_info(ipod_path) + fwid = virtual.firewire_id_bytes + if fwid: + return fwid + except Exception: + pass + + # Source 2: SysInfo + try: + sysinfo = read_sysinfo(ipod_path) + guid = sysinfo.get("FirewireGuid", "") + if guid: + if guid.startswith(("0x", "0X")): + guid = guid[2:] + result = bytes.fromhex(guid) + if result != b"\x00" * len(result): + return result + except (FileNotFoundError, ValueError): + pass + + # Source 3: SysInfoExtended + sysinfo_ex_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfoExtended") + if os.path.exists(sysinfo_ex_path): + try: + with open(sysinfo_ex_path, errors="ignore") as f: + content = f.read() + import re as _re + m = _re.search( + r"FireWireGUID\s*([0-9A-Fa-f]+)", + content, + ) + if m: + guid_hex = m.group(1) + if guid_hex.startswith(("0x", "0X")): + guid_hex = guid_hex[2:] + result = bytes.fromhex(guid_hex) + if result != b"\x00" * len(result): + return result + except Exception: + pass + + raise RuntimeError( + "Could not find iPod FireWire GUID. Tried:\n" + " 0. known_guid parameter\n" + " 1. Centralised device info store\n" + " 2. SysInfo file\n" + " 3. SysInfoExtended plist\n" + "\n" + "Connect the iPod and try again." + ) + + +# ────────────────────────────────────────────────────────────────────── +# Enrichment — fills derived fields from the ones already known +# ────────────────────────────────────────────────────────────────────── + +def enrich(info: DeviceInfo) -> None: + """Fill in derived fields by probing sources in authority order. + + This is the ONE place in the entire codebase that touches hardware, + reads files from the device, queries the OS, etc. + + The authority file determines the strategy: + + * **HIGH authority** (all fields sourced from live hardware on a + previous run) → trust SysInfo / SysInfoExtended values, skip + expensive hardware and VPD probes. + * **LOW authority** (any field sourced from a guess, or no authority + file yet) → probe from highest authority to lowest, filling gaps + as each source is tried: + + 1. Hardware probe (IOCTL / IOKit / sysfs) + 2. USB VPD query (SCSI inquiry — gets Apple serial + model) + 3. SysInfoExtended XML plist + 4. SysInfo text file + 5. Windows registry fallback + + After all identification, ``update_sysinfo()`` writes the gathered + data back to SysInfo and updates the authority file + hashes. + """ + logger.debug( + "Device enrich start: mount=%s identity=[%s] sources=[%s]", + info.path or "unknown", + format_fields(info.__dict__, IDENTITY_FIELDS), + format_sources(info._field_sources, SOURCE_FIELDS), + ) + + # ── 0. Load SysInfo dict (always — needed for reference) ────────── + if info.path and not info.sysinfo: + try: + info.sysinfo = read_sysinfo(info.path) + logger.debug("enrich: SysInfo loaded (%d keys)", len(info.sysinfo)) + except FileNotFoundError: + logger.debug("enrich: no SysInfo at %s", info.path) + except Exception as exc: + logger.debug("enrich: SysInfo read failed: %s", exc) + + # ── 1. Authority coverage check ─────────────────────────────────── + _authority_is_high = False + _run_background_live_validation = False + if info.path: + try: + from .authority import check_authority_coverage + _all_tracked, _auth_sources = check_authority_coverage(info.path) + if _all_tracked: + _authority_is_high = True + # Pre-populate _field_sources from authority so the rest + # of the pipeline sees the correct provenance. + for _field, _source in _auth_sources.items(): + if _field not in info._field_sources: + info._field_sources[_field] = _source + logger.debug( + "enrich: authority covers all core fields — " + "trusting SysInfo, skipping hardware/VPD probes; " + "sources=[%s]", + format_sources(_auth_sources, SOURCE_FIELDS), + ) + elif _auth_sources: + logger.debug( + "enrich: authority has untracked core fields — " + "probing highest to lowest authority; sources=[%s]", + format_sources(_auth_sources, SOURCE_FIELDS), + ) + else: + logger.debug( + "enrich: no authority coverage — probing highest to " + "lowest authority", + ) + except Exception as exc: + logger.debug("enrich: authority check failed: %s", exc) + + if _authority_is_high: + # ── HIGH authority path: SysInfo is trustworthy ─────────────── + _populate_fields_from_sysinfo(info) + if info.path: + _enrich_from_sysinfo_extended(info) + _run_background_live_validation = True + else: + # ── LOW authority path: probe highest → lowest ──────────────── + # Each source fills only gaps (if not info.X guards), so the + # first source to provide a value wins — which is the highest + # authority source. + + # 2a. Hardware probe (IOCTL + device tree + USB PID) + _enrich_from_hardware_probe(info) + + # 2b. Live VPD query (highest authority on supported non-Windows + # platforms — Apple serial + model). Runs even if SysInfo exists, + # because low-authority SysInfo values should be upgraded with + # live data when possible. + if info.path: + _enrich_from_usb_vpd(info) + + # 2c. SysInfoExtended (fills gaps) + if info.path: + _enrich_from_sysinfo_extended(info) + + # 2d. SysInfo (fills remaining gaps — lowest useful authority) + _populate_fields_from_sysinfo(info) + + # 2e. Windows registry fallback for FW GUID + if not info.firewire_guid: + _enrich_from_windows_registry(info) + + # ── 3. Model lookup (map model_number → family/gen/capacity/color) ─ + # This is a cheap dict lookup — always run it to fill derived fields. + # Uses the model_number's source as provenance for derived fields, + # since they are deterministically derived from it. + if info.model_number and info.model_family in ("iPod", ""): + try: + from .lookup import get_model_info + mi = get_model_info(info.model_number) + if mi: + _mn_source = info._field_sources.get("model_number", "unknown") + info.model_family = mi[0] + info._field_sources.setdefault("model_family", _mn_source) + info.generation = mi[1] + info._field_sources.setdefault("generation", _mn_source) + if not info.capacity: + info.capacity = mi[2] + info._field_sources.setdefault("capacity", _mn_source) + if not info.color: + info.color = mi[3] + info._field_sources.setdefault("color", _mn_source) + logger.debug("enrich: model DB -> %s %s %s %s", + mi[0], mi[1], mi[2], mi[3]) + except ImportError: + pass + + # ── 3b. Serial-last-3 model lookup ──────────────────────────────── + # Very reliable — the last 3 chars of the serial encode the exact + # model (incl. capacity and color). Run whenever the serial is + # available: the lookup is cheap and _enrich_from_serial_lookup + # uses authority-rank comparison, so it only overwrites fields + # whose current source is less reliable than the serial. This + # catches devices where ModelNumStr is wrong (e.g. corrupted NVRAM + # or logic-board swap) even when all fields appear "populated". + if info.serial: + _enrich_from_serial_lookup(info) + + # ── 3c. USB PID-based family/generation (if nothing else worked) ── + if info.usb_pid and info.model_family in ("iPod", ""): + try: + from .models import USB_PID_TO_MODEL + pid_info = USB_PID_TO_MODEL.get(info.usb_pid) + if pid_info: + info.model_family = pid_info[0] + info._field_sources.setdefault("model_family", "usb_pid") + if not info.generation and pid_info[1]: + info.generation = pid_info[1] + info._field_sources.setdefault("generation", "usb_pid") + logger.debug( + "enrich: USB PID 0x%04X -> %s %s", + info.usb_pid, pid_info[0], pid_info[1], + ) + except ImportError: + pass + + # ── 3d. Generation inference from family + capacity ─────────────── + # When we know the family (e.g. from USB PID) but not the generation, + # use capacity to narrow it down. For example, only iPod Classic + # 2nd Gen came in 120GB. Disk-size-based capacity estimation + # (stage 8/9) hasn't run yet, so this only works if capacity was + # already resolved from serial, model number, or SysInfo. + if info.model_family and not info.generation: + _cap = info.capacity + if not _cap and info.disk_size_gb > 0: + _cap = _estimate_capacity_from_disk_size(info.disk_size_gb) + if not _cap and info.path: + try: + import shutil + total, _used, free = shutil.disk_usage(info.path) + _disk_gb = round(total / 1e9, 1) + _cap = _estimate_capacity_from_disk_size(_disk_gb) + except Exception: + pass + if _cap: + try: + from .lookup import infer_generation + _gen = infer_generation(info.model_family, _cap) + if _gen: + info.generation = _gen + info._field_sources.setdefault("generation", "inferred") + logger.debug( + "enrich: inferred generation %s from %s + %s", + _gen, info.model_family, _cap, + ) + except ImportError: + pass + + # Cached derived SysInfo fields are useful, but they must not be allowed + # to combine with live USB identity into an impossible model label. + _restore_usb_pid_identity_if_needed(info) + _sanitize_variant_fields(info) + + # ── 4. iTunesDB header (hashing scheme, version) ───────────────── + if info.path and info.hashing_scheme == -1: + _enrich_from_itunesdb_header(info) + + # ── 5. Checksum type ────────────────────────────────────────────── + if info.checksum_type == 99: + _resolve_checksum_type(info) + + # ── 6. HashInfo (cryptographic material for HASH72 signing) ─────── + if not info.hash_info_iv and info.path: + # Try HashInfo file first + hi_path = os.path.join( + info.path, "iPod_Control", "Device", "HashInfo", + ) + try: + if os.path.exists(hi_path): + with open(hi_path, "rb") as f: + hi_data = f.read() + if len(hi_data) >= 54 and hi_data[:6] == b"HASHv0": + info.hash_info_iv = hi_data[38:54] + info.hash_info_rndpart = hi_data[26:38] + logger.debug( + "enrich: cached HashInfo present (iv=%d, rndpart=%d)", + len(info.hash_info_iv), len(info.hash_info_rndpart), + ) + except Exception as exc: + logger.debug("enrich: HashInfo read failed: %s", exc) + + # Fallback: extract IV/rndpart from existing iTunesCDB hash72 signature + if not info.hash_info_iv: + try: + itdb_path = resolve_itdb_path(info.path) + if itdb_path: + with open(itdb_path, "rb") as f: + itdb_data = f.read() + if (len(itdb_data) >= 0xA0 + and itdb_data[:4] == b"mhbd" + and itdb_data[0x72:0x74] == b"\x01\x00"): + from iTunesDB_Writer.hash72 import extract_hash_info_to_dict + hd = extract_hash_info_to_dict(itdb_data) + if hd: + info.hash_info_iv = hd["iv"] + info.hash_info_rndpart = hd["rndpart"] + logger.debug( + "enrich: extracted HashInfo from existing %s", + os.path.basename(itdb_path), + ) + except Exception as exc: + logger.debug("enrich: HashInfo extraction from CDB failed: %s", exc) + + # ── 7. Artwork formats ──────────────────────────────────────────── + # Try model-based lookup first (ithmb_formats_for_device handles + # family-level fallback when generation is unknown). + if not info.artwork_formats and info.model_family: + try: + from .artwork import ithmb_formats_for_device + table = ithmb_formats_for_device( + info.model_family, + info.generation, + capacity=info.capacity, + model_number=info.model_number, + ) + if table: + info.artwork_formats = dict(table) + logger.debug( + "enrich: artwork formats from model: %s", + list(info.artwork_formats.keys()), + ) + except ImportError: + pass + + # Fallback: scan ArtworkDB for format IDs + if not info.artwork_formats and info.path: + _enrich_artwork_from_artworkdb(info) + + # ── 8. Disk size ───────────────────────────────────────────────── + if info.disk_size_gb == 0.0 and info.path: + try: + import shutil + total, _used, free = shutil.disk_usage(info.path) + info.disk_size_gb = round(total / 1e9, 1) + info.free_space_gb = round(free / 1e9, 1) + logger.debug( + "enrich: disk %.1f GB, free %.1f GB", + info.disk_size_gb, info.free_space_gb, + ) + except Exception as exc: + logger.debug("enrich: disk_usage failed: %s", exc) + + # ── 9. Capacity from disk size (if still unknown) ──────────────── + if not info.capacity and info.disk_size_gb > 0: + info.capacity = _estimate_capacity_from_disk_size(info.disk_size_gb) + if info.capacity: + info._field_sources["capacity"] = "disk_size" + logger.debug("enrich: capacity from disk size: %s", info.capacity) + + _restore_usb_pid_identity_if_needed(info) + _sanitize_variant_fields(info) + _infer_color_from_variant_table(info) + + # ── 10. Backfill _field_sources for derived fields ─────────────────── + # The scanner's _resolve_model may have set model_family/generation/ + # capacity/color/usb_pid without tracking sources. Before writing + # authority, ensure every populated field has a source entry. + # Derived fields inherit from the identification method that resolved + # them (model_number's source, or the identification_method itself). + _derived_fields = ( + "model_family", + "generation", + "capacity", + "color", + "usb_pid", + ) + _backfill_src = info._field_sources.get( + "model_number", + ( + info.identification_method + if info.identification_method != "unknown" + else "unknown" + ), + ) + for _df in _derived_fields: + if getattr(info, _df, None) and _df not in info._field_sources: + info._field_sources[_df] = _backfill_src + + # ── 11. SysInfo authority update ─────────────────────────────────────── + # After all identification and enrichment is complete, reconcile our + # gathered data with the on-disk SysInfo file via the authority system. + if info.path: + try: + from .authority import update_sysinfo as _update_sysinfo + _update_sysinfo(info) + except Exception as exc: + logger.warning("enrich: SysInfo authority update failed: %s", exc) + + if _run_background_live_validation: + _start_live_identity_validation(info) + + logger.debug( + "DeviceInfo enriched: mount=%s identity=[%s] caps=[%s] checksum=%s " + "hash_scheme=%s method=%s disk=%.1fGB free=%.1fGB sources=[%s]", + info.path or "unknown", + format_fields(info.__dict__, IDENTITY_FIELDS), + format_fields(info.__dict__, CAPABILITY_FIELDS, include_false=True), + info.checksum_type, + info.hashing_scheme, + info.identification_method, + info.disk_size_gb, + info.free_space_gb, + format_sources(info._field_sources, SOURCE_FIELDS), + ) + + +def _live_validation_key(info: DeviceInfo) -> str: + path = os.path.abspath(info.path) if info.path else "" + return "|".join([ + path, + info.firewire_guid.upper(), + f"0x{info.usb_pid:04X}" if info.usb_pid else "", + ]) + + +def _cache_live_sysinfo_extended( + ipod_path: str, + vpd_raw: dict, + source: str, +) -> None: + raw_xml = vpd_raw.get("vpd_raw_xml") if isinstance(vpd_raw, dict) else b"" + if not raw_xml: + return + + live_sources = { + "windows_scsi", + "scsi_vpd", + "iokit", + "usb_vendor", + "vpd", + } + if source not in live_sources: + return + + try: + from .authority import cache_sysinfo_extended + + metadata = { + key: vpd_raw.get(key) + for key in ( + "_transport", + "usb_vid", + "usb_pid", + "usb_serial", + "vpd_serial", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "block_device", + ) + if vpd_raw.get(key) not in (None, "", b"") + } + cache_sysinfo_extended( + ipod_path, + raw_xml, + source=source, + metadata=metadata, + ) + except Exception as exc: + logger.debug("enrich: live SysInfoExtended cache failed: %s", exc) + + +def _sysinfo_extended_cache_metadata(ipod_path: str) -> dict[str, Any]: + try: + from .authority import read_authority + + authority = read_authority(ipod_path) + file_entry = authority.get("files", {}).get("SysInfoExtended", {}) + metadata = file_entry.get("metadata", {}) + if not isinstance(metadata, dict): + return {} + result = dict(metadata) + if file_entry.get("source"): + result["_source"] = file_entry["source"] + return result + except Exception: + return {} + + +def _log_live_validation_differences( + cached: DeviceInfo, + live_result: dict, + live_source: str, +) -> None: + checks = { + "serial": live_result.get("serial", ""), + "firewire_guid": live_result.get("firewire_guid", ""), + "model_number": live_result.get("model_number", ""), + "model_family": live_result.get("model_family", ""), + "generation": live_result.get("generation", ""), + "capacity": live_result.get("capacity", ""), + "color": live_result.get("color", ""), + } + mismatches = [] + for fld, live_value in checks.items(): + cached_value = getattr(cached, fld, "") + if ( + cached_value + and live_value + and not _values_match(fld, cached_value, live_value) + ): + mismatches.append(f"{fld}: cached={cached_value!r}, live={live_value!r}") + if mismatches: + logger.warning( + "Live identity validation from %s disagreed with cache for %s: %s", + live_source, + cached.path, + "; ".join(mismatches), + ) + + +def _apply_live_result_to_cache( + path: str, + mount_name: str, + usb_pid: int, + usb_pid_source: str, + live_result: dict, + live_source: str, +) -> None: + validated = DeviceInfo(path=path, mount_name=mount_name) + validated.usb_pid = usb_pid + if usb_pid: + validated._field_sources["usb_pid"] = usb_pid_source or "unknown" + + for fld in ( + "serial", + "firewire_guid", + "firmware", + "model_number", + "model_family", + "generation", + "capacity", + "color", + ): + value = live_result.get(fld, "") + if value: + setattr(validated, fld, value) + validated._field_sources[fld] = live_source + + vpd_info = live_result.get("vpd_info") or {} + for vpd_key, fld in ( + ("FamilyID", "family_id"), + ("UpdaterFamilyID", "updater_family_id"), + ): + value = vpd_info.get(vpd_key) + if value not in (None, ""): + setattr(validated, fld, value) + validated._field_sources[fld] = live_source + + try: + from .authority import update_sysinfo as _update_sysinfo + + _update_sysinfo(validated) + logger.debug( + "live validation: refreshed SysInfo authority cache for %s " + "identity=[%s] sources=[%s]", + path, + format_fields(validated.__dict__, IDENTITY_FIELDS), + format_sources(validated._field_sources, SOURCE_FIELDS), + ) + except Exception as exc: + logger.debug("live validation: SysInfo authority update failed: %s", exc) + + +def _start_live_identity_validation(info: DeviceInfo) -> None: + """Validate high-authority cached identity with live SCSI in the background.""" + if not info.path: + return + if sys.platform == "win32": + logger.debug( + "Live identity validation skipped on Windows: mount=%s", + info.path, + ) + return + + key = _live_validation_key(info) + with _LIVE_VALIDATION_LOCK: + if key in _LIVE_VALIDATION_INFLIGHT: + logger.debug( + "Live identity validation already running: mount=%s key=%s", + info.path, + key, + ) + return + _LIVE_VALIDATION_INFLIGHT.add(key) + + logger.debug( + "Live identity validation scheduled: mount=%s identity=[%s] sources=[%s]", + info.path, + format_fields(info.__dict__, IDENTITY_FIELDS), + format_sources(info._field_sources, SOURCE_FIELDS), + ) + + cached = DeviceInfo( + path=info.path, + mount_name=info.mount_name, + model_number=info.model_number, + model_family=info.model_family, + generation=info.generation, + capacity=info.capacity, + color=info.color, + firewire_guid=info.firewire_guid, + serial=info.serial, + firmware=info.firmware, + usb_pid=info.usb_pid, + ) + cached._field_sources.update(info._field_sources) + + def _run() -> None: + try: + from .vpd_libusb import identify_via_vpd + + logger.debug( + "Live identity validation start: mount=%s pid=%s fwguid=%s", + cached.path, + f"0x{cached.usb_pid:04X}" if cached.usb_pid else "unknown", + cached.firewire_guid or "unknown", + ) + result = identify_via_vpd( + mount_path=cached.path, + usb_pid=cached.usb_pid or 0, + firewire_guid=cached.firewire_guid or "", + write_sysinfo_to_device=False, + ) + if not result: + logger.debug( + "Live identity validation returned no VPD data: mount=%s", + cached.path, + ) + return + + vpd_raw = result.get("vpd_info") or {} + live_source = str(vpd_raw.get("_source") or result.get("source") or "vpd") + logger.debug( + "Live identity validation result: mount=%s source=%s " + "identity=[%s] caps=[%s]", + cached.path, + live_source, + format_fields(result, IDENTITY_FIELDS), + format_fields(vpd_raw, CAPABILITY_FIELDS, include_false=True), + ) + _log_live_validation_differences(cached, result, live_source) + _cache_live_sysinfo_extended(cached.path, vpd_raw, live_source) + for fld in ( + "serial", + "firewire_guid", + "firmware", + "model_number", + "model_family", + "generation", + "capacity", + "color", + ): + _set_field_from_source( + info, + fld, + result.get(fld), + live_source, + label=f"{live_source} validation", + ) + try: + from .sysinfo import ( + ParsedSysInfoExtended, + identity_from_sysinfo_extended, + parse_sysinfo_extended, + ) + + parsed = ( + parse_sysinfo_extended( + vpd_raw["vpd_raw_xml"], + source=live_source, + live=True, + ) + if vpd_raw.get("vpd_raw_xml") + else ParsedSysInfoExtended( + plist=vpd_raw, + source=live_source, + live=True, + ) + ) + identity = identity_from_sysinfo_extended( + parsed, + live_source, + live=True, + ) + identity_sources = identity.setdefault("_sources", {}) + for fld in ( + "usb_pid", + "usb_vid", + "usb_serial", + "scsi_vendor", + "scsi_product", + "scsi_revision", + ): + value = vpd_raw.get(fld) + if value not in (None, "", b""): + identity[fld] = value + identity_sources[fld] = live_source + _apply_sysinfo_extended_identity(info, identity, live_source) + except Exception as exc: + logger.debug("live validation: applying rich fields failed: %s", exc) + _apply_live_result_to_cache( + cached.path, + cached.mount_name, + cached.usb_pid, + cached._field_sources.get("usb_pid", ""), + result, + live_source, + ) + except Exception as exc: + logger.debug("Live identity validation failed for %s: %s", cached.path, exc) + finally: + with _LIVE_VALIDATION_LOCK: + _LIVE_VALIDATION_INFLIGHT.discard(key) + logger.debug("Live identity validation finished: mount=%s", cached.path) + + threading.Thread( + target=_run, + name=f"iPodLiveValidation-{os.path.basename(info.path.rstrip(os.sep))}", + daemon=True, + ).start() + + +# ────────────────────────────────────────────────────────────────────── +# Private enrichment helpers — each probes ONE source +# ────────────────────────────────────────────────────────────────────── + +def _populate_fields_from_sysinfo(info: DeviceInfo) -> None: + """Fill empty DeviceInfo fields from the cached SysInfo dict. + + Only fills fields that are **not already populated**, and uses + ``setdefault`` for ``_field_sources`` so higher-authority source + annotations from earlier probes are preserved. + + Called at different points depending on authority level: + + * **HIGH** authority → called early (before probes), so all fields + are still empty and get filled from the trusted SysInfo. + * **LOW** authority → called late (after probes), so only the gaps + that the hardware/VPD probes couldn't fill get patched from SysInfo. + """ + if not info.sysinfo: + return + + if not info.board: + _board = info.sysinfo.get("BoardHwName", "") + if _board: + info.board = _board + info._field_sources.setdefault("board", "sysinfo") + + if not info.serial: + apple_serial = info.sysinfo.get("pszSerialNumber", "") + if apple_serial and apple_serial != info.firewire_guid: + info.serial = apple_serial + info._field_sources.setdefault("serial", "sysinfo") + logger.debug("enrich: serial (Apple) from SysInfo: %s", apple_serial) + elif apple_serial and apple_serial == info.firewire_guid: + logger.warning( + "enrich: SysInfo pszSerialNumber equals FW GUID (%s) " + "— not a real Apple serial, skipping", + apple_serial, + ) + + if not info.firmware: + fw_ver = info.sysinfo.get("visibleBuildID", "") + if fw_ver: + info.firmware = fw_ver + info._field_sources.setdefault("firmware", "sysinfo") + logger.debug("enrich: firmware from SysInfo: %s", fw_ver) + + if not info.firewire_guid: + guid = info.sysinfo.get("FirewireGuid", "") + if guid: + if guid.startswith(("0x", "0X")): + guid = guid[2:] + if guid and guid != "0" * len(guid): + info.firewire_guid = guid + info._field_sources.setdefault("firewire_guid", "sysinfo") + logger.debug("enrich: FW GUID from SysInfo: %s", guid) + + if not info.model_number: + try: + from .lookup import extract_model_number + raw = info.sysinfo.get("ModelNumStr", "") + if raw: + mn = extract_model_number(raw) + if mn: + info.model_number = mn + info._field_sources.setdefault("model_number", "sysinfo") + logger.debug("enrich: model from SysInfo: %s", mn) + except ImportError: + pass + + # ── Derived / resolved fields (written by iOpenPod) ─────────────── + # These are only present if a previous iOpenPod run cached them. + # model_family default is "iPod" (sentinel), so only replace it with + # a more specific value. + _mf = info.sysinfo.get("ModelFamily", "") + if _mf and _mf != "iPod" and info.model_family in ("iPod", ""): + current_source = info._field_sources.get("model_family", "unknown") + if current_source == "usb_pid" and info.generation: + logger.warning( + "enrich: ignoring cached SysInfo ModelFamily %r because " + "live USB PID already identified %s %s", + _mf, + info.model_family, + info.generation, + ) + else: + info.model_family = _mf + info._field_sources.setdefault("model_family", "sysinfo") + logger.debug("enrich: model_family from SysInfo: %s", _mf) + + if not info.generation: + _gen = info.sysinfo.get("Generation", "") + if _gen: + info.generation = _gen + info._field_sources.setdefault("generation", "sysinfo") + logger.debug("enrich: generation from SysInfo: %s", _gen) + + if not info.capacity: + _cap = info.sysinfo.get("Capacity", "") + if _cap: + info.capacity = _cap + info._field_sources.setdefault("capacity", "sysinfo") + logger.debug("enrich: capacity from SysInfo: %s", _cap) + + if not info.color: + _col = info.sysinfo.get("Color", "") + if _col: + info.color = _col + info._field_sources.setdefault("color", "sysinfo") + logger.debug("enrich: color from SysInfo: %s", _col) + + if not info.usb_pid: + _pid_str = info.sysinfo.get("USBProductID", "") + if _pid_str: + try: + info.usb_pid = int(_pid_str, 0) # handles "0x1261" and "4705" + info._field_sources.setdefault("usb_pid", "sysinfo") + logger.debug("enrich: usb_pid from SysInfo: 0x%04X", info.usb_pid) + except ValueError: + pass + + try: + from .sysinfo import identity_from_sysinfo + + identity = identity_from_sysinfo(info.sysinfo, "sysinfo") + for fld in ("family_id", "updater_family_id"): + if fld in identity: + _set_field_from_source( + info, + fld, + identity[fld], + identity.get("_sources", {}).get(fld, "sysinfo"), + label="SysInfo", + ) + except Exception: + pass + + +def _enrich_from_sysinfo_extended(info: DeviceInfo) -> None: + """Read SysInfoExtended XML plist for identity and artwork capabilities.""" + sysinfo_ex_path = os.path.join( + info.path, "iPod_Control", "Device", "SysInfoExtended", + ) + if not os.path.exists(sysinfo_ex_path): + logger.debug("enrich: SysInfoExtended not present at %s", sysinfo_ex_path) + return + + try: + with open(sysinfo_ex_path, "rb") as f: + content = f.read() + except Exception as exc: + logger.debug("enrich: SysInfoExtended read failed: %s", exc) + return + + try: + from .sysinfo import parse_sysinfo_extended + + parsed = parse_sysinfo_extended(content, source="sysinfo_extended") + identity = parsed.identity + metadata = _sysinfo_extended_cache_metadata(info.path) + if metadata: + sources = identity.setdefault("_sources", {}) + for fld, key in ( + ("usb_vid", "usb_vid"), + ("usb_pid", "usb_pid"), + ("usb_serial", "usb_serial"), + ("scsi_vendor", "scsi_vendor"), + ("scsi_product", "scsi_product"), + ("scsi_revision", "scsi_revision"), + ): + value = metadata.get(key) + if value not in (None, "", b""): + identity[fld] = value + sources[fld] = metadata.get("_source", "sysinfo_extended") + logger.debug( + "enrich: SysInfoExtended parsed bytes=%d keys=%d regex_fallback=%s " + "identity=[%s] caps=[%s]", + len(content), + len(parsed.plist), + parsed.used_regex_fallback, + format_fields(identity, IDENTITY_FIELDS), + format_fields(identity, CAPABILITY_FIELDS, include_false=True), + ) + _apply_sysinfo_extended_identity(info, identity, "SysInfoExtended") + except Exception as exc: + logger.debug("enrich: SysInfoExtended parse failed: %s", exc) + + +def _parse_sysinfo_artwork_formats(content: str) -> dict[int, tuple[int, int]]: + """Extract artwork format definitions from SysInfoExtended XML plist. + + Newer iPods (Nano 6G/7G) embed their artwork capabilities in + SysInfoExtended under keys like ``AlbumArt`` or ``ArtworkFormats``. + Each entry is a dict with at least ``FormatId``, ``RenderWidth``, + ``RenderHeight``. libgpod calls ``itdb_sysinfo_properties_get_cover_art_formats`` + to parse these. + + Returns: + ``{correlation_id: (width, height)}`` — same format as + ``ithmb_formats_for_device()``. Empty dict if nothing found. + """ + try: + from .sysinfo import parse_sysinfo_extended + + return parse_sysinfo_extended(content).cover_art_formats + except Exception: + return {} + + +def _apply_sysinfo_extended_identity( + info: DeviceInfo, + identity: dict, + label: str, +) -> None: + """Merge parsed SysInfoExtended identity/capability fields into DeviceInfo.""" + if not identity: + return + + sources = identity.get("_sources", {}) + + fields = ( + "firewire_guid", + "serial", + "model_number", + "model_family", + "generation", + "capacity", + "color", + "firmware", + "board", + "family_id", + "updater_family_id", + "product_type", + "usb_pid", + "usb_vid", + "usb_serial", + "usbstor_instance_id", + "usb_parent_instance_id", + "usb_grandparent_instance_id", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "connected_bus", + "volume_format", + "db_version", + "shadow_db_version", + "uses_sqlite_db", + "supports_sparse_artwork", + "max_tracks", + "max_file_size_gb", + "max_transfer_speed", + "podcasts_supported", + "voice_memos_supported", + "audio_codecs", + "power_information", + "apple_drm_version", + "artwork_formats", + "photo_formats", + "chapter_image_formats", + ) + for fld in fields: + if fld not in identity: + continue + _set_field_from_source( + info, + fld, + identity[fld], + sources.get(fld, "sysinfo_extended"), + label=label, + ) + + +def _enrich_from_hardware_probe(info: DeviceInfo) -> None: + """Run the full hardware probe pipeline (IOCTL + device tree + USB PID). + + On Windows this sends ``IOCTL_STORAGE_QUERY_PROPERTY`` to the drive handle + (gives serial, firmware, vendor/product), walks the PnP device tree (gives + FW GUID + USB PID), and maps the PID to a model family. + + On macOS/Linux the platform-specific scanner probers run instead. + """ + if not info.path: + return + + import sys as _sys + + _hw_method = "" + try: + if _sys.platform == "win32": + drive_letter = info.drive_letter + if not drive_letter: + return + + # Full IOCTL probe (serial, firmware, vendor) + device tree walk + # (FW GUID, USB PID). _identify_via_direct_ioctl calls + # _walk_device_tree internally. + from .scanner import ( + _identify_via_direct_ioctl, + _setup_win32_prototypes, + ) + _setup_win32_prototypes() + hw = _identify_via_direct_ioctl(drive_letter) + if hw: + _hw_method = "ioctl" + + if not hw: + # Fallback: WMI (slower, subprocess) + try: + from .scanner import _identify_via_usb_for_drive + hw = _identify_via_usb_for_drive(drive_letter) + if hw: + _hw_method = "wmi" + except ImportError: + hw = None + + if not hw: + logger.debug( + "enrich: hardware probe returned no data for mount=%s", + info.path, + ) + return + + elif _sys.platform == "darwin": + from .scanner import _probe_hardware_macos + hw = _probe_hardware_macos(info.path) + _hw_method = "ioreg" + if not hw: + logger.debug( + "enrich: hardware probe returned no data for mount=%s", + info.path, + ) + return + + else: # Linux + from .scanner import _probe_hardware_linux + hw = _probe_hardware_linux(info.path) + _hw_method = "sysfs" + if not hw: + logger.debug( + "enrich: hardware probe returned no data for mount=%s", + info.path, + ) + return + + except (ImportError, Exception) as exc: + logger.debug("enrich: hardware probe failed for %s: %s", info.path, exc) + return + + logger.debug( + "enrich: hardware probe result method=%s identity=[%s]", + _hw_method or "unknown", + format_fields(hw, IDENTITY_FIELDS), + ) + + # On Windows, FW GUID comes from the device tree walk specifically + _fw_source = "device_tree" if _hw_method in ("ioctl", "wmi") else _hw_method + + # Merge hardware results into DeviceInfo (never overwrite existing) + if not info.firewire_guid and hw.get("firewire_guid"): + guid_hex = hw["firewire_guid"] + if guid_hex != "0" * len(guid_hex): + info.firewire_guid = guid_hex + info._field_sources["firewire_guid"] = _fw_source + logger.debug("enrich: FW GUID from hardware: %s", guid_hex) + + if not info.serial and hw.get("serial"): + info.serial = hw["serial"] + info._field_sources["serial"] = _hw_method + logger.debug("enrich: serial from hardware: %s", info.serial) + + if not info.firmware and hw.get("firmware"): + info.firmware = hw["firmware"] + info._field_sources["firmware"] = _hw_method + logger.debug("enrich: firmware from hardware: %s", info.firmware) + + if not info.usb_pid and hw.get("usb_pid"): + info.usb_pid = hw["usb_pid"] + info._field_sources["usb_pid"] = _fw_source + logger.debug("enrich: USB PID from hardware: 0x%04X", info.usb_pid) + + if not info.model_number and hw.get("model_number"): + info.model_number = hw["model_number"] + info._field_sources["model_number"] = _hw_method + logger.debug("enrich: model_number from hardware: %s", info.model_number) + + for fld, value, source in ( + ("usb_vid", hw.get("usb_vid"), _fw_source), + ("usb_serial", hw.get("usb_serial"), _fw_source), + ("usbstor_instance_id", hw.get("usbstor_instance_id"), _fw_source), + ("usb_parent_instance_id", hw.get("usb_parent_instance_id"), _fw_source), + ( + "usb_grandparent_instance_id", + hw.get("usb_grandparent_instance_id"), + _fw_source, + ), + ("scsi_vendor", hw.get("scsi_vendor") or hw.get("vendor"), _hw_method), + ("scsi_product", hw.get("scsi_product") or hw.get("product"), _hw_method), + ("scsi_revision", hw.get("scsi_revision") or hw.get("firmware"), _hw_method), + ): + _set_field_from_source(info, fld, value, source, label="hardware") + + if info.identification_method == "unknown": + info.identification_method = "hardware" + + +def _enrich_from_usb_vpd(info: DeviceInfo) -> None: + """Query iPod firmware via USB SCSI VPD pages for device identification. + + Delegates to :func:`ipod_device.vpd_libusb.identify_via_vpd` on supported + non-Windows platforms, resolves the exact model via serial-last-3 lookup, + and handles post-query remount on Linux/macOS. + + SysInfo writing is NOT done here — the authority module handles it + after all identification is complete. + """ + if sys.platform == "win32": + logger.debug( + "enrich: live VPD skipped on Windows for mount=%s", + info.path, + ) + return + + try: + from .vpd_libusb import identify_via_vpd + except ImportError: + logger.debug("enrich: ipod_device.vpd_libusb not available") + return + + logger.debug( + "enrich: live VPD query start mount=%s pid=%s fwguid=%s", + info.path, + f"0x{info.usb_pid:04X}" if info.usb_pid else "unknown", + info.firewire_guid or "unknown", + ) + result = identify_via_vpd( + mount_path=info.path, + usb_pid=info.usb_pid or 0, + firewire_guid=info.firewire_guid or "", + write_sysinfo_to_device=False, + ) + if result is None: + logger.debug("enrich: live VPD query returned no data for %s", info.path) + return + + vpd_raw = result.get("vpd_info") or {} + vpd_source = str(vpd_raw.get("_source") or result.get("source") or "vpd") + logger.debug( + "enrich: live VPD query result source=%s identity=[%s] caps=[%s]", + vpd_source, + format_fields(result, IDENTITY_FIELDS), + format_fields(vpd_raw, CAPABILITY_FIELDS, include_false=True), + ) + _cache_live_sysinfo_extended(info.path, vpd_raw, vpd_source) + + # Apply VPD-derived fields to DeviceInfo + _set_field_from_source( + info, + "serial", + result["serial"], + vpd_source, + label=vpd_source, + ) + _set_field_from_source( + info, + "firewire_guid", + result["firewire_guid"], + vpd_source, + label=vpd_source, + ) + _set_field_from_source( + info, + "firmware", + result["firmware"], + vpd_source, + label=vpd_source, + ) + if result["model_number"]: + info.model_number = result["model_number"] + info.model_family = result["model_family"] + info.generation = result["generation"] + info._field_sources["model_number"] = vpd_source + info._field_sources["model_family"] = vpd_source + info._field_sources["generation"] = vpd_source + # VPD serial-last-3 is authoritative — always overwrite capacity + # and color even if they were pre-populated from a stale/wrong + # SysInfo model number (e.g. MB029 → 80GB when device is MB565 → 120GB). + if result["capacity"]: + info.capacity = result["capacity"] + info._field_sources["capacity"] = vpd_source + if result["color"]: + info.color = result["color"] + info._field_sources["color"] = vpd_source + + # Extract board from VPD raw data (previously obtained via SysInfo re-read) + _set_field_from_source( + info, + "board", + vpd_raw.get("BoardHwName"), + vpd_source, + label=vpd_source, + ) + + try: + from .sysinfo import ( + ParsedSysInfoExtended, + identity_from_sysinfo_extended, + parse_sysinfo_extended, + ) + + parsed: ParsedSysInfoExtended | None = None + if vpd_raw.get("vpd_raw_xml"): + parsed = parse_sysinfo_extended( + vpd_raw["vpd_raw_xml"], + source=vpd_source, + live=True, + ) + elif isinstance(vpd_raw, dict): + parsed = ParsedSysInfoExtended( + plist=vpd_raw, + source=vpd_source, + live=True, + ) + if parsed: + identity = identity_from_sysinfo_extended( + parsed, + vpd_source, + live=True, + ) + identity_sources = identity.setdefault("_sources", {}) + for fld in ( + "usb_pid", + "usb_vid", + "usb_serial", + "scsi_vendor", + "scsi_product", + "scsi_revision", + ): + value = vpd_raw.get(fld) + if value not in (None, "", b""): + identity[fld] = value + identity_sources[fld] = vpd_source + _apply_sysinfo_extended_identity(info, identity, vpd_source) + except Exception as exc: + logger.debug("enrich: VPD artwork/capability parse failed: %s", exc) + + # Update mount path if pyusb caused a remount to a different location + if result["mount_path"] and result["mount_path"] != info.path: + logger.info("enrich: mount path changed %s → %s", + info.path, result["mount_path"]) + info.path = result["mount_path"] + + if info.serial: + info.identification_method = "usb_vpd" + + +def _enrich_from_serial_lookup(info: DeviceInfo) -> None: + """Look up exact model from serial number's last 3 characters. + + This is very high confidence — the last 3 chars encode the exact model + including capacity, color, and hardware revision. Always fills gaps + even when ``model_number`` is already known, because serial-last-3 + provides exact variant resolution that generic model lookup may miss. + + The derived fields inherit the serial number's authority source, since + the lookup is a deterministic mapping — the trust of the output equals + the trust of the input. + """ + if not info.serial or len(info.serial) < 3: + return + + try: + from .lookup import lookup_by_serial + except ImportError: + return + + result = lookup_by_serial(info.serial) + if not result: + return + + model_num, model_info = result + + # Inherit the serial's source — the derived values are exactly as + # trustworthy as the serial they came from. + _src = info._field_sources.get("serial", "serial_lookup") + + from .authority import _WORST_RANK, SOURCE_RANK + _serial_rank = SOURCE_RANK.get(_src, _WORST_RANK) + + # Apply model_number with the same rank comparison used for other fields. + # This lets a high-authority serial (e.g. from VPD) override a stale or + # wrong ModelNumStr that was read from SysInfo (e.g. a device whose NVRAM + # reports the wrong model after a botched restore or board swap). + _cur_mn_rank = SOURCE_RANK.get( + info._field_sources.get("model_number", "unknown"), _WORST_RANK, + ) + if not info.model_number or _serial_rank <= _cur_mn_rank: + if info.model_number and info.model_number != model_num: + logger.warning( + "enrich: serial last-3 '%s' gives model %s but current " + "model_number is %s (source: %s, rank %d); overriding with " + "serial result (serial source: %s, rank %d)", + info.serial[-3:], model_num, info.model_number, + info._field_sources.get("model_number", "unknown"), + _cur_mn_rank, _src, _serial_rank, + ) + info.model_number = model_num + info._field_sources["model_number"] = _src + + # Serial lookup is authoritative for family/gen — always set these + # (unless a higher-authority source already has them). + _serial_rank = SOURCE_RANK.get(_src, _WORST_RANK) + + _cur_family_rank = SOURCE_RANK.get( + info._field_sources.get("model_family", "unknown"), _WORST_RANK, + ) + if _serial_rank <= _cur_family_rank: + info.model_family = model_info[0] + info._field_sources["model_family"] = _src + + _cur_gen_rank = SOURCE_RANK.get( + info._field_sources.get("generation", "unknown"), _WORST_RANK, + ) + if _serial_rank <= _cur_gen_rank: + info.generation = model_info[1] + info._field_sources["generation"] = _src + + # Serial-last-3 is authoritative for capacity/color — use the same + # rank comparison as family/generation so it overwrites stale values + # from a wrong SysInfo model number. + _cur_cap_rank = SOURCE_RANK.get( + info._field_sources.get("capacity", "unknown"), _WORST_RANK, + ) + if model_info[2] and _serial_rank <= _cur_cap_rank: + info.capacity = model_info[2] + info._field_sources["capacity"] = _src + + _cur_color_rank = SOURCE_RANK.get( + info._field_sources.get("color", "unknown"), _WORST_RANK, + ) + if model_info[3] and _serial_rank <= _cur_color_rank: + info.color = model_info[3] + info._field_sources["color"] = _src + + if info.identification_method in ("unknown", "hardware"): + info.identification_method = "serial" + logger.debug( + "enrich: serial-last-3 '%s' -> %s %s %s %s model=%s source=%s", + info.serial[-3:], model_info[0], model_info[1], + model_info[2], model_info[3], model_num, _src, + ) + + +def _enrich_from_windows_registry(info: DeviceInfo) -> None: + """Windows-only: read iPod FireWire GUID from USBSTOR registry entries. + + The USB serial number for iPod Classic IS the FireWire GUID + (16 hex chars = 8 bytes). This persists in the registry even after + the iPod is disconnected. + + If the device's serial is already known we only accept a GUID from + an instance ID that contains it, avoiding stale GUIDs from + previously-connected iPods. When no serial is available we fall + back to accepting the first valid GUID (best-effort). + """ + import sys as _sys + if _sys.platform != "win32": + return + + try: + import winreg + except ImportError: + return + + try: + usbstor_key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Enum\USBSTOR", + ) + except OSError: + return + + # We'll collect ALL valid GUIDs but prefer one that matches the + # current device's serial (if known). The serial from hardware + # probing is usually the FW GUID itself, but the instance ID also + # contains it so we can cross-check. + known_serial = info.serial.upper() if info.serial else "" + best_guid: str | None = None + + try: + i = 0 + while True: + try: + subkey_name = winreg.EnumKey(usbstor_key, i) + i += 1 + except OSError: + break + + if "Apple" not in subkey_name or "iPod" not in subkey_name: + continue + + try: + device_key = winreg.OpenKey(usbstor_key, subkey_name) + except OSError: + continue + + try: + j = 0 + while True: + try: + instance_id = winreg.EnumKey(device_key, j) + j += 1 + except OSError: + break + + parts = instance_id.split("&") + for part in parts: + part = part.strip() + if len(part) == 16: + try: + guid_bytes = bytes.fromhex(part) + if guid_bytes == b"\x00" * 8: + continue + except ValueError: + continue + + guid_upper = part.upper() + + # If we know the serial, accept only if it + # appears somewhere in the instance ID. + if known_serial: + if known_serial in instance_id.upper(): + info.firewire_guid = guid_upper + logger.debug( + "enrich: FW GUID from registry " + "(serial-matched): %s", guid_upper, + ) + return + else: + # No serial — remember first valid GUID + if best_guid is None: + best_guid = guid_upper + finally: + winreg.CloseKey(device_key) + finally: + winreg.CloseKey(usbstor_key) + + # Fallback: use first valid GUID found (may be from a different iPod) + if best_guid: + info.firewire_guid = best_guid + if known_serial: + logger.warning( + "enrich: FW GUID from registry (no serial match, may be " + "stale): %s", best_guid, + ) + else: + logger.debug( + "enrich: FW GUID from registry (no serial to validate): %s", + best_guid, + ) + + +def _enrich_from_itunesdb_header(info: DeviceInfo) -> None: + """Read the iTunesDB/iTunesCDB mhbd header for hashing_scheme and db_id.""" + import struct + + itdb_path = resolve_itdb_path(info.path) + if not itdb_path: + return + + try: + with open(itdb_path, "rb") as f: + hdr = f.read(256) + + if len(hdr) < 0xA0 or hdr[:4] != b"mhbd": + return + + info.hashing_scheme = struct.unpack(" None: + """Determine checksum type using every available signal. + + Priority: + 1. Family + generation → canonical lookup (covers ALL color variants) + 2. HashInfo file existence → HASH72 + 3. iTunesDB hashing_scheme field + 4. Firmware version hints + 5. FirewireGuid presence hints at post-2007 device + 6. Default to NONE (safe for pre-2007 iPods) + """ + try: + from .capabilities import checksum_type_for_family_gen + from .checksum import ChecksumType + except ImportError: + return + + # Priority 1: family + generation lookup (authoritative, no gaps) + if info.model_family: + ct = checksum_type_for_family_gen( + info.model_family, info.generation or "", + ) + if ct is not None: + info.checksum_type = int(ct) + logger.debug( + "enrich: checksum %s (family=%s, gen=%s)", + ct.name, info.model_family, info.generation or "(all)", + ) + return + + # Priority 2: HashInfo file existence → HASH72 + if info.path: + hi_path = os.path.join( + info.path, "iPod_Control", "Device", "HashInfo", + ) + if os.path.exists(hi_path): + info.checksum_type = int(ChecksumType.HASH72) + logger.debug("enrich: checksum HASH72 (HashInfo file exists)") + return + + # Priority 3: hashing_scheme from iTunesDB header + if info.hashing_scheme == 1: + info.checksum_type = int(ChecksumType.HASH58) + logger.debug("enrich: checksum HASH58 (from iTunesDB header scheme=1)") + return + if info.hashing_scheme == 2: + info.checksum_type = int(ChecksumType.HASH72) + logger.debug("enrich: checksum HASH72 (from iTunesDB header scheme=2)") + return + + # Priority 4: firmware version hints + if info.firmware: + try: + version = int(info.firmware.split(".")[0]) + if version >= 2: + info.checksum_type = int(ChecksumType.UNKNOWN) + logger.debug( + "enrich: checksum UNKNOWN (firmware %s >= 2.x)", + info.firmware, + ) + return + except (ValueError, IndexError): + pass + + # Priority 5: FirewireGuid hints at post-2007 device + if info.firewire_guid: + info.checksum_type = int(ChecksumType.UNKNOWN) + logger.debug("enrich: checksum UNKNOWN (has FW GUID but no model match)") + return + + # Priority 6: default + info.checksum_type = int(ChecksumType.NONE) + logger.debug("enrich: checksum NONE (default; pre-2007 or unidentifiable)") + + +def _enrich_artwork_from_artworkdb(info: DeviceInfo) -> None: + """Scan ArtworkDB binary for mhif format IDs as a last resort. + + Only reads the file header and dataset/format-list chunks (typically + < 8 KB) rather than the entire ArtworkDB, which can be many MB. + """ + artdb_path = os.path.join(info.path, "iPod_Control", "Artwork", "ArtworkDB") + if not os.path.exists(artdb_path): + return + + # The format-ID entries live in the first dataset (mhsd type 3). + # Cap the read at 64 KB — far more than enough for the header region, + # and safe even over a slow USB connection. + _MAX_HEADER_READ = 65536 + + try: + from ArtworkDB_Writer.rgb565 import ALL_KNOWN_FORMATS, _extract_format_ids + with open(artdb_path, "rb") as f: + data = f.read(_MAX_HEADER_READ) + + if len(data) < 24 or data[:4] != b"mhfd": + return + + format_ids = _extract_format_ids(data) + if format_ids: + fmts = {} + for fid in format_ids: + if fid in ALL_KNOWN_FORMATS: + fmts[fid] = ALL_KNOWN_FORMATS[fid] + if fmts: + info.artwork_formats = fmts + logger.debug( + "enrich: artwork formats from ArtworkDB scan: %s", + list(fmts.keys()), + ) + except Exception as exc: + logger.debug("enrich: ArtworkDB scan failed: %s", exc) + + +# ────────────────────────────────────────────────────────────────────── +# Library ID generation (deterministic, hostname-based) +# ────────────────────────────────────────────────────────────────────── + +def generate_library_id() -> bytes: + """Generate a deterministic 8-byte library ID for iOpenPod. + + Based on a hash of ``"iOpenPod"`` + hostname so the same computer + always produces the same ID, but different computers produce + different IDs. + """ + identity = f"iOpenPod:{socket.gethostname()}".encode() + return hashlib.sha256(identity).digest()[:8] diff --git a/src/vendor/ipod_device/lookup.py b/src/vendor/ipod_device/lookup.py new file mode 100644 index 0000000..9e3c82f --- /dev/null +++ b/src/vendor/ipod_device/lookup.py @@ -0,0 +1,170 @@ +"""Model lookup functions — identify iPods from model numbers, serials, etc.""" + +import re + +from .capabilities import _FAMILY_GEN_CAPABILITIES +from .models import IPOD_MODELS, SERIAL_LAST3_TO_MODEL + + +def _identity_text(value: str | None) -> str: + return " ".join(str(value or "").strip().casefold().split()) + + +def extract_model_number(model_str: str) -> str | None: + """Extract normalised model number from ModelNumStr. + + ModelNumStr format varies: + - ``"xA623"`` → ``"MA623"`` + - ``"MC293"`` → ``"MC293"`` + - ``"M9282"`` → ``"M9282"`` + - ``"P9804"`` → ``"M9804"`` (SysInfo sometimes uses non-M first char) + """ + if not model_str: + return None + + # Normalise 'x' prefix: "xA623" → "MA623" + if model_str.startswith('x'): + model_str = 'M' + model_str[1:] + + match = re.match(r'^(M[A-Z]?\d{3,4})', model_str.upper()) + if match: + return match.group(1) + + # Some SysInfo ModelNumStr values use a non-M first character (e.g. "P9804" + # instead of "M9804"). Try substituting M and re-matching. + alt = 'M' + model_str[1:] + match = re.match(r'^(M[A-Z]?\d{3,4})', alt.upper()) + if match: + return match.group(1) + + return model_str.upper()[:5] if len(model_str) >= 5 else model_str.upper() + + +def usb_pid_identity_conflicts( + model_family: str, + generation: str, + pid_family: str, + pid_generation: str, +) -> bool: + """Return True when an exact model tuple cannot belong to a USB PID hint.""" + model_family_norm = _identity_text(model_family) + pid_family_norm = _identity_text(pid_family) + if not model_family_norm or not pid_family_norm: + return False + + model_generation_norm = _identity_text(generation) + pid_generation_norm = _identity_text(pid_generation) + if pid_family_norm == "ipod" and not pid_generation_norm: + return False + + family_compatible = model_family_norm == pid_family_norm + + # Special editions use more specific family labels than USB PID tables. + if not family_compatible and "u2" in model_family_norm: + family_compatible = model_family_norm.startswith(pid_family_norm + " ") + + if not family_compatible: + return True + + if not model_generation_norm or not pid_generation_norm: + return False + if model_generation_norm == pid_generation_norm: + return False + + # The Video 5.5G uses the same coarse USB PID family as the Video 5G. + if ( + pid_family_norm == "ipod video" + and pid_generation_norm == "5th gen" + and model_generation_norm == "5.5th gen" + ): + return False + + return True + + +def get_model_info(model_number: str | None) -> tuple[str, str, str, str] | None: + """Get detailed model information from model number. + + Returns: + Tuple of ``(name, generation, capacity, color)`` or ``None``. + """ + if not model_number: + return None + + if model_number in IPOD_MODELS: + return IPOD_MODELS[model_number] + + # If the first character isn't M, try substituting M (handles SysInfo quirks) + if not model_number.startswith('M') and len(model_number) > 1: + alt = 'M' + model_number[1:] + if alt in IPOD_MODELS: + return IPOD_MODELS[alt] + + for prefix, info in IPOD_MODELS.items(): + if model_number.startswith(prefix[:4]): + return info + + return None + + +def get_friendly_model_name(model_number: str | None) -> str: + """Return a user-friendly model name string.""" + info = get_model_info(model_number) + if info: + name, gen, capacity, color = info + parts = [name, capacity] + if color: + parts.append(color) + if gen: + parts.append(f"({gen})") + return " ".join(p for p in parts if p) + return f"Unknown iPod ({model_number})" if model_number else "Unknown iPod" + + +def lookup_by_serial(serial: str) -> tuple[str, tuple[str, str, str, str]] | None: + """Look up iPod model from a serial number's last 3 characters. + + Returns: + ``(model_number, (family, generation, capacity, color))`` or ``None``. + """ + if not serial or len(serial) < 3: + return None + model_num = SERIAL_LAST3_TO_MODEL.get(serial[-3:]) + if not model_num: + return None + info = IPOD_MODELS.get(model_num) + if not info: + return None + return (model_num, info) + + +def infer_generation( + family: str, + capacity: str = "", +) -> str | None: + """Best-effort generation inference from family + available signals. + + Uses the model table to find which generations match a given capacity. + If only one generation of a family offers that capacity, we can infer + the generation with certainty (e.g. iPod Classic 120GB → 2nd Gen). + + Falls back to returning the sole generation if a family has only one. + Returns ``None`` when the generation is ambiguous. + """ + if not family: + return None + + family_gens = {g for (f, g) in _FAMILY_GEN_CAPABILITIES if f == family} + + if len(family_gens) == 1: + return family_gens.pop() + + if capacity: + matching_gens: set[str] = set() + for _mn, (_mf, _mg, _mc, _color) in IPOD_MODELS.items(): + if _mf == family and _mc == capacity: + matching_gens.add(_mg) + if len(matching_gens) == 1: + return matching_gens.pop() + + return None diff --git a/src/vendor/ipod_device/models.py b/src/vendor/ipod_device/models.py new file mode 100644 index 0000000..a8442e6 --- /dev/null +++ b/src/vendor/ipod_device/models.py @@ -0,0 +1,509 @@ +"""iPod model identification databases — model numbers, USB PIDs, and serial suffixes. + +Data tables +~~~~~~~~~~~ +- ``IPOD_MODELS`` Model number → (family, gen, capacity, color) +- ``USB_PID_TO_MODEL`` USB Product ID → (family, gen) +- ``IPOD_USB_PIDS`` All known iPod USB Product IDs (frozenset) +- ``SERIAL_LAST3_TO_MODEL`` Serial suffix → model number + +Sources +~~~~~~~ +- Universal Compendium iPod Models table (universalcompendium.com) +- The Apple Wiki: Models/iPod (theapplewiki.com) +- Linux USB ID Repository +- libgpod ``itdb_device.c`` +""" + + +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ Comprehensive iPod model database ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ +# +# Maps order number prefixes to (product_line, generation, capacity, color) +# +# Generation naming conventions: +# The full-size iPod line has TWO numbering systems. This table uses the +# product-specific generation (matching what users see in "About" screens), +# with the overall iPod lineage noted in comments. +# +# Overall iPod gen │ Product-specific gen │ Years │ Apple Model +# ─────────────────┼──────────────────────┼───────┼──────────── +# 1st gen │ iPod 1st Gen │ 2001 │ M8541 +# 2nd gen │ iPod 2nd Gen │ 2002 │ A1019 +# 3rd gen │ iPod 3rd Gen │ 2003 │ A1040 +# 4th gen │ iPod 4th Gen │ 2004 │ A1059 +# 4th gen (color) │ iPod Photo │ 2004 │ A1099 +# 5th gen │ iPod Video 5th Gen │ 2005 │ A1136 +# 5.5th gen │ iPod Video 5.5th Gen │ 2006 │ A1136 (Rev B) +# 6th gen │ iPod Classic 1st Gen │ 2007 │ A1238 +# 6.5th gen │ iPod Classic 2nd Gen │ 2008 │ A1238 (Rev A) +# 7th gen │ iPod Classic 3rd Gen │ 2009 │ A1238 (Rev B/C) + +IPOD_MODELS: dict[str, tuple[str, str, str, str]] = { + # ========================================================================== + # iPod Classic (2007-2009) + # ========================================================================== + 'MB029': ("iPod Classic", "1st Gen", "80GB", "Silver"), + 'MB147': ("iPod Classic", "1st Gen", "80GB", "Black"), + 'MB145': ("iPod Classic", "1st Gen", "160GB", "Silver"), + 'MB150': ("iPod Classic", "1st Gen", "160GB", "Black"), + 'MB562': ("iPod Classic", "2nd Gen", "120GB", "Silver"), + 'MB565': ("iPod Classic", "2nd Gen", "120GB", "Black"), + 'MC293': ("iPod Classic", "3rd Gen", "160GB", "Silver"), + 'MC297': ("iPod Classic", "3rd Gen", "160GB", "Black"), + + # ========================================================================== + # iPod (Scroll Wheel) — 1st Generation (2001) + # ========================================================================== + 'M8513': ("iPod", "1st Gen", "5GB", "White"), + 'M8541': ("iPod", "1st Gen", "5GB", "White"), + 'M8697': ("iPod", "1st Gen", "5GB", "White"), + 'M8709': ("iPod", "1st Gen", "10GB", "White"), + + # ========================================================================== + # iPod (Touch Wheel) — 2nd Generation (2002) + # ========================================================================== + 'M8737': ("iPod", "2nd Gen", "10GB", "White"), + 'M8740': ("iPod", "2nd Gen", "10GB", "White"), + 'M8738': ("iPod", "2nd Gen", "20GB", "White"), + 'M8741': ("iPod", "2nd Gen", "20GB", "White"), + + # ========================================================================== + # iPod (Dock Connector) — 3rd Generation (2003) + # ========================================================================== + 'M8976': ("iPod", "3rd Gen", "10GB", "White"), + 'M8946': ("iPod", "3rd Gen", "15GB", "White"), + 'M8948': ("iPod", "3rd Gen", "30GB", "White"), + 'M9244': ("iPod", "3rd Gen", "20GB", "White"), + 'M9245': ("iPod", "3rd Gen", "40GB", "White"), + 'M9460': ("iPod", "3rd Gen", "15GB", "White"), + + # ========================================================================== + # iPod (Click Wheel) — 4th Generation (2004) + # ========================================================================== + 'M9268': ("iPod", "4th Gen", "40GB", "White"), + 'M9282': ("iPod", "4th Gen", "20GB", "White"), + 'ME436': ("iPod", "4th Gen", "40GB", "White"), + 'M9787': ("iPod U2", "4th Gen", "20GB", "Black"), + + # ========================================================================== + # iPod Photo / iPod with color Display — 4th Gen (Color) (2004-2005) + # ========================================================================== + 'M9585': ("iPod Photo", "4th Gen", "40GB", "White"), + 'M9586': ("iPod Photo", "4th Gen", "60GB", "White"), + 'M9829': ("iPod Photo", "4th Gen", "30GB", "White"), + 'M9830': ("iPod Photo", "4th Gen", "60GB", "White"), + 'MA079': ("iPod Photo", "4th Gen", "20GB", "White"), + 'MA127': ("iPod U2", "4th Gen", "20GB", "Black"), + 'MS492': ("iPod Photo", "4th Gen", "30GB", "White"), + 'MA215': ("iPod Photo", "4th Gen", "20GB", "White"), + + # ========================================================================== + # iPod Video — 5th Generation (2005) + # ========================================================================== + 'MA002': ("iPod Video", "5th Gen", "30GB", "White"), + 'MA003': ("iPod Video", "5th Gen", "60GB", "White"), + 'MA146': ("iPod Video", "5th Gen", "30GB", "Black"), + 'MA147': ("iPod Video", "5th Gen", "60GB", "Black"), + 'MA452': ("iPod Video U2", "5th Gen", "30GB", "Black"), + + # ========================================================================== + # iPod Video — 5.5th Generation / Enhanced (Late 2006) + # ========================================================================== + 'MA444': ("iPod Video", "5.5th Gen", "30GB", "White"), + 'MA446': ("iPod Video", "5.5th Gen", "30GB", "Black"), + 'MA448': ("iPod Video", "5.5th Gen", "80GB", "White"), + 'MA450': ("iPod Video", "5.5th Gen", "80GB", "Black"), + 'MA664': ("iPod Video U2", "5.5th Gen", "30GB", "Black"), + + # ========================================================================== + # iPod Mini — 1st Generation (2004) + # ========================================================================== + 'M9160': ("iPod Mini", "1st Gen", "4GB", "Silver"), + 'M9434': ("iPod Mini", "1st Gen", "4GB", "Green"), + 'M9435': ("iPod Mini", "1st Gen", "4GB", "Pink"), + 'M9436': ("iPod Mini", "1st Gen", "4GB", "Blue"), + 'M9437': ("iPod Mini", "1st Gen", "4GB", "Gold"), + + # ========================================================================== + # iPod Mini — 2nd Generation (2005) + # ========================================================================== + 'M9800': ("iPod Mini", "2nd Gen", "4GB", "Silver"), + 'M9801': ("iPod Mini", "2nd Gen", "6GB", "Silver"), + 'M9802': ("iPod Mini", "2nd Gen", "4GB", "Blue"), + 'M9803': ("iPod Mini", "2nd Gen", "6GB", "Blue"), + 'M9804': ("iPod Mini", "2nd Gen", "4GB", "Pink"), + 'M9805': ("iPod Mini", "2nd Gen", "6GB", "Pink"), + 'M9806': ("iPod Mini", "2nd Gen", "4GB", "Green"), + 'M9807': ("iPod Mini", "2nd Gen", "6GB", "Green"), + + # ========================================================================== + # iPod Nano — 1st Generation (2005) + # ========================================================================== + 'MA004': ("iPod Nano", "1st Gen", "2GB", "White"), + 'MA005': ("iPod Nano", "1st Gen", "4GB", "White"), + 'MA099': ("iPod Nano", "1st Gen", "2GB", "Black"), + 'MA107': ("iPod Nano", "1st Gen", "4GB", "Black"), + 'MA350': ("iPod Nano", "1st Gen", "1GB", "White"), + 'MA352': ("iPod Nano", "1st Gen", "1GB", "Black"), + + # ========================================================================== + # iPod Nano — 2nd Generation (2006) + # ========================================================================== + 'MA426': ("iPod Nano", "2nd Gen", "4GB", "Silver"), + 'MA428': ("iPod Nano", "2nd Gen", "4GB", "Blue"), + 'MA477': ("iPod Nano", "2nd Gen", "2GB", "Silver"), + 'MA487': ("iPod Nano", "2nd Gen", "4GB", "Green"), + 'MA489': ("iPod Nano", "2nd Gen", "4GB", "Pink"), + 'MA497': ("iPod Nano", "2nd Gen", "8GB", "Black"), + 'MA725': ("iPod Nano", "2nd Gen", "4GB", "Red"), + 'MA726': ("iPod Nano", "2nd Gen", "8GB", "Red"), + 'MA899': ("iPod Nano", "2nd Gen", "8GB", "Red"), + + # ========================================================================== + # iPod Nano — 3rd Generation (2007) + # ========================================================================== + 'MA978': ("iPod Nano", "3rd Gen", "4GB", "Silver"), + 'MA980': ("iPod Nano", "3rd Gen", "8GB", "Silver"), + 'MB249': ("iPod Nano", "3rd Gen", "8GB", "Blue"), + 'MB253': ("iPod Nano", "3rd Gen", "8GB", "Green"), + 'MB257': ("iPod Nano", "3rd Gen", "8GB", "Red"), + 'MB261': ("iPod Nano", "3rd Gen", "8GB", "Black"), + 'MB453': ("iPod Nano", "3rd Gen", "8GB", "Pink"), + + # ========================================================================== + # iPod Nano — 4th Generation (2008) + # ========================================================================== + 'MB480': ("iPod Nano", "4th Gen", "4GB", "Silver"), + 'MB651': ("iPod Nano", "4th Gen", "4GB", "Blue"), + 'MB654': ("iPod Nano", "4th Gen", "4GB", "Pink"), + 'MB657': ("iPod Nano", "4th Gen", "4GB", "Purple"), + 'MB660': ("iPod Nano", "4th Gen", "4GB", "Orange"), + 'MB663': ("iPod Nano", "4th Gen", "4GB", "Green"), + 'MB666': ("iPod Nano", "4th Gen", "4GB", "Yellow"), + 'MB598': ("iPod Nano", "4th Gen", "8GB", "Silver"), + 'MB732': ("iPod Nano", "4th Gen", "8GB", "Blue"), + 'MB735': ("iPod Nano", "4th Gen", "8GB", "Pink"), + 'MB739': ("iPod Nano", "4th Gen", "8GB", "Purple"), + 'MB742': ("iPod Nano", "4th Gen", "8GB", "Orange"), + 'MB745': ("iPod Nano", "4th Gen", "8GB", "Green"), + 'MB748': ("iPod Nano", "4th Gen", "8GB", "Yellow"), + 'MB751': ("iPod Nano", "4th Gen", "8GB", "Red"), + 'MB754': ("iPod Nano", "4th Gen", "8GB", "Black"), + 'MB903': ("iPod Nano", "4th Gen", "16GB", "Silver"), + 'MB905': ("iPod Nano", "4th Gen", "16GB", "Blue"), + 'MB907': ("iPod Nano", "4th Gen", "16GB", "Pink"), + 'MB909': ("iPod Nano", "4th Gen", "16GB", "Purple"), + 'MB911': ("iPod Nano", "4th Gen", "16GB", "Orange"), + 'MB913': ("iPod Nano", "4th Gen", "16GB", "Green"), + 'MB915': ("iPod Nano", "4th Gen", "16GB", "Yellow"), + 'MB917': ("iPod Nano", "4th Gen", "16GB", "Red"), + 'MB918': ("iPod Nano", "4th Gen", "16GB", "Black"), + + # ========================================================================== + # iPod Nano — 5th Generation (2009) + # ========================================================================== + 'MC027': ("iPod Nano", "5th Gen", "8GB", "Silver"), + 'MC031': ("iPod Nano", "5th Gen", "8GB", "Black"), + 'MC034': ("iPod Nano", "5th Gen", "8GB", "Purple"), + 'MC037': ("iPod Nano", "5th Gen", "8GB", "Blue"), + 'MC040': ("iPod Nano", "5th Gen", "8GB", "Green"), + 'MC043': ("iPod Nano", "5th Gen", "8GB", "Yellow"), + 'MC046': ("iPod Nano", "5th Gen", "8GB", "Orange"), + 'MC049': ("iPod Nano", "5th Gen", "8GB", "Red"), + 'MC050': ("iPod Nano", "5th Gen", "8GB", "Pink"), + 'MC060': ("iPod Nano", "5th Gen", "16GB", "Silver"), + 'MC062': ("iPod Nano", "5th Gen", "16GB", "Black"), + 'MC064': ("iPod Nano", "5th Gen", "16GB", "Purple"), + 'MC066': ("iPod Nano", "5th Gen", "16GB", "Blue"), + 'MC068': ("iPod Nano", "5th Gen", "16GB", "Green"), + 'MC070': ("iPod Nano", "5th Gen", "16GB", "Yellow"), + 'MC072': ("iPod Nano", "5th Gen", "16GB", "Orange"), + 'MC074': ("iPod Nano", "5th Gen", "16GB", "Red"), + 'MC075': ("iPod Nano", "5th Gen", "16GB", "Pink"), + + # ========================================================================== + # iPod Nano — 6th Generation (2010) + # ========================================================================== + 'MC525': ("iPod Nano", "6th Gen", "8GB", "Silver"), + 'MC688': ("iPod Nano", "6th Gen", "8GB", "Graphite"), + 'MC689': ("iPod Nano", "6th Gen", "8GB", "Blue"), + 'MC690': ("iPod Nano", "6th Gen", "8GB", "Green"), + 'MC691': ("iPod Nano", "6th Gen", "8GB", "Orange"), + 'MC692': ("iPod Nano", "6th Gen", "8GB", "Pink"), + 'MC693': ("iPod Nano", "6th Gen", "8GB", "Red"), + 'MC526': ("iPod Nano", "6th Gen", "16GB", "Silver"), + 'MC694': ("iPod Nano", "6th Gen", "16GB", "Graphite"), + 'MC695': ("iPod Nano", "6th Gen", "16GB", "Blue"), + 'MC696': ("iPod Nano", "6th Gen", "16GB", "Green"), + 'MC697': ("iPod Nano", "6th Gen", "16GB", "Orange"), + 'MC698': ("iPod Nano", "6th Gen", "16GB", "Pink"), + 'MC699': ("iPod Nano", "6th Gen", "16GB", "Red"), + + # ========================================================================== + # iPod Nano — 7th Generation (2012) + # ========================================================================== + 'MD475': ("iPod Nano", "7th Gen", "16GB", "Pink"), + 'MD476': ("iPod Nano", "7th Gen", "16GB", "Yellow"), + 'MD477': ("iPod Nano", "7th Gen", "16GB", "Blue"), + 'MD478': ("iPod Nano", "7th Gen", "16GB", "Green"), + 'MD479': ("iPod Nano", "7th Gen", "16GB", "Purple"), + 'MD480': ("iPod Nano", "7th Gen", "16GB", "Silver"), + 'MD481': ("iPod Nano", "7th Gen", "16GB", "Slate"), + 'MD744': ("iPod Nano", "7th Gen", "16GB", "Red"), + 'ME971': ("iPod Nano", "7th Gen", "16GB", "Space Gray"), + 'MKMV2': ("iPod Nano", "7th Gen", "16GB", "Pink"), + 'MKMX2': ("iPod Nano", "7th Gen", "16GB", "Gold"), + 'MKN02': ("iPod Nano", "7th Gen", "16GB", "Blue"), + 'MKN22': ("iPod Nano", "7th Gen", "16GB", "Silver"), + 'MKN52': ("iPod Nano", "7th Gen", "16GB", "Space Gray"), + 'MKN72': ("iPod Nano", "7th Gen", "16GB", "Red"), + + # ========================================================================== + # iPod Shuffle — 1st Generation (2005) + # ========================================================================== + 'M9724': ("iPod Shuffle", "1st Gen", "512MB", "White"), + 'M9725': ("iPod Shuffle", "1st Gen", "1GB", "White"), + + # ========================================================================== + # iPod Shuffle — 2nd Generation (2006-2008) + # ========================================================================== + 'MA546': ("iPod Shuffle", "2nd Gen", "1GB", "Silver"), + 'MA564': ("iPod Shuffle", "2nd Gen", "1GB", "Silver"), + 'MA947': ("iPod Shuffle", "2nd Gen", "1GB", "Pink"), + 'MA949': ("iPod Shuffle", "2nd Gen", "1GB", "Blue"), + 'MA951': ("iPod Shuffle", "2nd Gen", "1GB", "Green"), + 'MA953': ("iPod Shuffle", "2nd Gen", "1GB", "Orange"), + 'MB225': ("iPod Shuffle", "2nd Gen", "1GB", "Silver"), + 'MB227': ("iPod Shuffle", "2nd Gen", "1GB", "Blue"), + 'MB228': ("iPod Shuffle", "2nd Gen", "1GB", "Blue"), + 'MB229': ("iPod Shuffle", "2nd Gen", "1GB", "Green"), + 'MB231': ("iPod Shuffle", "2nd Gen", "1GB", "Red"), + 'MB233': ("iPod Shuffle", "2nd Gen", "1GB", "Purple"), + 'MB518': ("iPod Shuffle", "2nd Gen", "2GB", "Silver"), + 'MB520': ("iPod Shuffle", "2nd Gen", "2GB", "Blue"), + 'MB522': ("iPod Shuffle", "2nd Gen", "2GB", "Green"), + 'MB524': ("iPod Shuffle", "2nd Gen", "2GB", "Red"), + 'MB526': ("iPod Shuffle", "2nd Gen", "2GB", "Purple"), + 'MB811': ("iPod Shuffle", "2nd Gen", "1GB", "Pink"), + 'MB813': ("iPod Shuffle", "2nd Gen", "1GB", "Blue"), + 'MB815': ("iPod Shuffle", "2nd Gen", "1GB", "Green"), + 'MB817': ("iPod Shuffle", "2nd Gen", "1GB", "Red"), + 'MB681': ("iPod Shuffle", "2nd Gen", "2GB", "Pink"), + 'MB683': ("iPod Shuffle", "2nd Gen", "2GB", "Blue"), + 'MB685': ("iPod Shuffle", "2nd Gen", "2GB", "Green"), + 'MB779': ("iPod Shuffle", "2nd Gen", "2GB", "Red"), + 'MC167': ("iPod Shuffle", "2nd Gen", "1GB", "Gold"), + + # ========================================================================== + # iPod Shuffle — 3rd Generation (2009) + # ========================================================================== + 'MB867': ("iPod Shuffle", "3rd Gen", "4GB", "Silver"), + 'MC164': ("iPod Shuffle", "3rd Gen", "4GB", "Black"), + 'MC306': ("iPod Shuffle", "3rd Gen", "2GB", "Silver"), + 'MC323': ("iPod Shuffle", "3rd Gen", "2GB", "Black"), + 'MC381': ("iPod Shuffle", "3rd Gen", "2GB", "Green"), + 'MC384': ("iPod Shuffle", "3rd Gen", "2GB", "Blue"), + 'MC387': ("iPod Shuffle", "3rd Gen", "2GB", "Pink"), + 'MC303': ("iPod Shuffle", "3rd Gen", "4GB", "Stainless Steel"), + 'MC307': ("iPod Shuffle", "3rd Gen", "4GB", "Green"), + 'MC328': ("iPod Shuffle", "3rd Gen", "4GB", "Blue"), + 'MC331': ("iPod Shuffle", "3rd Gen", "4GB", "Pink"), + + # ========================================================================== + # iPod Shuffle — 4th Generation (2010-2015) + # ========================================================================== + 'MC584': ("iPod Shuffle", "4th Gen", "2GB", "Silver"), + 'MC585': ("iPod Shuffle", "4th Gen", "2GB", "Pink"), + 'MC749': ("iPod Shuffle", "4th Gen", "2GB", "Orange"), + 'MC750': ("iPod Shuffle", "4th Gen", "2GB", "Green"), + 'MC751': ("iPod Shuffle", "4th Gen", "2GB", "Blue"), + 'MD773': ("iPod Shuffle", "4th Gen", "2GB", "Pink"), + 'MD774': ("iPod Shuffle", "4th Gen", "2GB", "Yellow"), + 'MD775': ("iPod Shuffle", "4th Gen", "2GB", "Blue"), + 'MD776': ("iPod Shuffle", "4th Gen", "2GB", "Green"), + 'MD777': ("iPod Shuffle", "4th Gen", "2GB", "Purple"), + 'MD778': ("iPod Shuffle", "4th Gen", "2GB", "Silver"), + 'MD779': ("iPod Shuffle", "4th Gen", "2GB", "Slate"), + 'MD780': ("iPod Shuffle", "4th Gen", "2GB", "Red"), + 'ME949': ("iPod Shuffle", "4th Gen", "2GB", "Space Gray"), + 'MKM72': ("iPod Shuffle", "4th Gen", "2GB", "Pink"), + 'MKM92': ("iPod Shuffle", "4th Gen", "2GB", "Gold"), + 'MKME2': ("iPod Shuffle", "4th Gen", "2GB", "Blue"), + 'MKMG2': ("iPod Shuffle", "4th Gen", "2GB", "Silver"), + 'MKMJ2': ("iPod Shuffle", "4th Gen", "2GB", "Space Gray"), + 'MKML2': ("iPod Shuffle", "4th Gen", "2GB", "Red"), +} + + +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ USB Product ID → iPod generation (Apple VID = 0x05AC) ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + +USB_PID_TO_MODEL: dict[int, tuple[str, str]] = { + # ── Normal-mode PIDs (0x120x) ────────────────────────────────────────── + 0x1201: ("iPod", "3rd Gen"), + 0x1202: ("iPod", ""), # 1st/2nd Gen share this PID in USB ID tables + 0x1203: ("iPod", "4th Gen"), + 0x1204: ("iPod Photo", "4th Gen"), + 0x1205: ("iPod Mini", ""), # Mini 1st/2nd Gen share this PID + 0x1206: ("iPod", ""), # USB IDs label this only as "iPod '06'" + 0x1207: ("iPod", ""), # USB IDs label this only as "iPod '07'" + 0x1208: ("iPod", ""), # USB IDs label this only as "iPod '08'" + 0x1209: ("iPod Video", ""), # 5th/5.5th Gen share this coarse PID + 0x120A: ("iPod Nano", ""), # Original nano-era generic PID + + # ── DFU / WTF recovery mode PIDs (0x124x) ───────────────────────────── + 0x1240: ("iPod Nano", "2nd Gen (Recovery)"), + 0x1241: ("iPod Classic", "1st Gen (Recovery)"), + 0x1242: ("iPod Nano", "3rd Gen (Recovery)"), + 0x1243: ("iPod Nano", "4th Gen (Recovery)"), + 0x1245: ("iPod Classic", "3rd Gen (Recovery)"), + 0x1246: ("iPod Nano", "5th Gen (Recovery)"), + 0x1255: ("iPod Nano", "4th Gen (Recovery)"), + + # ── Normal-mode PIDs (0x126x) ────────────────────────────────────────── + 0x1260: ("iPod Nano", "2nd Gen"), + 0x1261: ("iPod Classic", ""), + 0x1262: ("iPod Nano", "3rd Gen"), + 0x1263: ("iPod Nano", "4th Gen"), + 0x1265: ("iPod Nano", "5th Gen"), + 0x1266: ("iPod Nano", "6th Gen"), + 0x1267: ("iPod Nano", "7th Gen"), + + # ── iPod Shuffle PIDs ────────────────────────────────────────────────── + 0x1300: ("iPod Shuffle", "1st Gen"), + 0x1301: ("iPod Shuffle", "2nd Gen"), + 0x1302: ("iPod Shuffle", "3rd Gen"), + 0x1303: ("iPod Shuffle", "4th Gen"), +} + +IPOD_USB_PIDS: frozenset[int] = frozenset(USB_PID_TO_MODEL) + + +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ Serial number last-3-char → model number (from libgpod) ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + +SERIAL_LAST3_TO_MODEL: dict[str, str] = { + # ── iPod Classic ──────────────────────────────────────────────────── + "Y5N": "MB029", "YMV": "MB147", "YMU": "MB145", "YMX": "MB150", + "2C5": "MB562", "2C7": "MB565", + "9ZS": "MC293", "9ZU": "MC297", + # ── iPod 1G (scroll wheel) ───────────────────────────────────────── + "LG6": "M8541", "NAM": "M8541", "MJ2": "M8541", + "ML1": "M8709", "MME": "M8709", + # ── iPod 2G (touch wheel) ────────────────────────────────────────── + "MMB": "M8737", "MMC": "M8738", + "NGE": "M8740", "NGH": "M8740", "MMF": "M8741", + # ── iPod 3G (dock connector) ─────────────────────────────────────── + "NLW": "M8946", "NRH": "M8976", "QQF": "M9460", + "PQ5": "M9244", "PNT": "M9244", "NLY": "M8948", "NM7": "M8948", + "PNU": "M9245", + # ── iPod 4G (click wheel) ────────────────────────────────────────── + "PS9": "M9282", "Q8U": "M9282", "PQ7": "M9268", + # ── iPod U2 (4G click wheel, 20GB) ───────────────────────────────── + "S2X": "M9787", + # ── iPod Photo / Color Display ───────────────────────────────────── + "TDU": "MA079", "TDS": "MA079", "TM2": "MA127", + "SAZ": "M9830", "SB1": "M9830", "SAY": "M9829", + "R5Q": "M9585", "R5R": "M9586", "R5T": "M9586", + # ── iPod Mini 1G ─────────────────────────────────────────────────── + "PFW": "M9160", "PRC": "M9160", + "QKL": "M9436", "QKQ": "M9436", "QKK": "M9435", "QKP": "M9435", + "QKJ": "M9434", "QKN": "M9434", "QKM": "M9437", "QKR": "M9437", + # ── iPod Mini 2G ─────────────────────────────────────────────────── + "S41": "M9800", "S4C": "M9800", "S43": "M9802", "S45": "M9804", "S4G": "M9804", + "S47": "M9806", "S4J": "M9806", "S42": "M9801", "S44": "M9803", + "S48": "M9807", + # ── Shuffle 1G ───────────────────────────────────────────────────── + "RS9": "M9724", "QGV": "M9724", "TSX": "M9724", "PFV": "M9724", + "R80": "M9724", "RSA": "M9725", "TSY": "M9725", "C60": "M9725", + # ── Shuffle 2G ───────────────────────────────────────────────────── + "VTE": "MA546", "VTF": "MA546", + "XQ5": "MA947", "XQS": "MA947", "XQV": "MA949", "XQX": "MA949", + "YX7": "MB228", "XQY": "MA951", "YX8": "MA951", "XR1": "MA953", + "YXA": "MB233", "YX6": "MB225", "YX9": "MB225", + "8CQ": "MC167", "1ZH": "MB518", + # ── Shuffle 3G ───────────────────────────────────────────────────── + "A1S": "MC306", "A78": "MC323", "ALB": "MC381", "ALD": "MC384", + "ALG": "MC387", "4NZ": "MB867", "891": "MC164", + "A1L": "MC303", "A1U": "MC307", "A7B": "MC328", "A7D": "MC331", + # ── Shuffle 4G ───────────────────────────────────────────────────── + "CMJ": "MC584", "CMK": "MC585", "FDM": "MC749", "FDN": "MC750", + "FDP": "MC751", + # ── Nano 1G ──────────────────────────────────────────────────────── + "TUZ": "MA004", "TV0": "MA005", "TUY": "MA099", "TV1": "MA107", + "UYN": "MA350", "UYP": "MA352", + "UNA": "MA350", "UNB": "MA350", "UPR": "MA352", "UPS": "MA352", + "SZB": "MA004", "SZV": "MA004", "SZW": "MA004", + "SZC": "MA005", "SZT": "MA005", + "TJT": "MA099", "TJU": "MA099", "TK2": "MA107", "TK3": "MA107", + # ── Nano 2G ──────────────────────────────────────────────────────── + "VQ5": "MA477", "VQ6": "MA477", + "V8T": "MA426", "V8U": "MA426", + "V8W": "MA428", "V8X": "MA428", + "VQH": "MA487", "VQJ": "MA487", + "VQK": "MA489", "VQL": "MA489", "VKL": "MA489", + "WL2": "MA725", "WL3": "MA725", + "X9A": "MA726", "X9B": "MA726", + "VQT": "MA497", "VQU": "MA497", + "YER": "MA899", "YES": "MA899", + # ── Nano 3G ──────────────────────────────────────────────────────── + "Y0P": "MA978", "Y0R": "MA980", + "YXR": "MB249", "YXV": "MB257", "YXT": "MB253", "YXX": "MB261", + # ── Nano 4G ──────────────────────────────────────────────────────── + "37P": "MB663", "37Q": "MB666", "37H": "MB654", "1P1": "MB480", + "37K": "MB657", "37L": "MB660", "2ME": "MB598", + "3QS": "MB732", "3QT": "MB735", "3QU": "MB739", "3QW": "MB742", + "3QX": "MB745", "3QY": "MB748", "3R0": "MB754", "3QZ": "MB751", + "5B7": "MB903", "5B8": "MB905", "5B9": "MB907", "5BA": "MB909", + "5BB": "MB911", "5BC": "MB913", "5BD": "MB915", "5BE": "MB917", + "5BF": "MB918", + # ── Nano 5G ──────────────────────────────────────────────────────── + "71V": "MC027", "71Y": "MC031", "721": "MC034", "726": "MC037", + "72A": "MC040", "72F": "MC046", "72K": "MC049", "72L": "MC050", + "72Q": "MC060", "72R": "MC062", + "72S": "MC064", "72X": "MC066", "734": "MC068", "738": "MC070", + "739": "MC072", "73A": "MC074", "73B": "MC075", + # ── Nano 6G ──────────────────────────────────────────────────────── + "CMN": "MC525", "CMP": "MC526", + "DVX": "MC688", "DVY": "MC689", "DW0": "MC690", "DW1": "MC691", + "DW2": "MC692", "DW3": "MC693", + "DW4": "MC694", "DW5": "MC695", "DW6": "MC696", "DW7": "MC697", + "DW8": "MC698", "DW9": "MC699", + # ── Nano 7G ──────────────────────────────────────────────────────── + # Source: TheAppleWiki Models/iPod (serial suffix table). Entries there + # are 4-char suffixes (e.g. F0GN); this map stores last-3 to match our + # lookup_by_serial(serial[-3:]) convention. + "0GD": "MD475", "0GM": "MD475", # pink + "0GF": "MD476", "0GN": "MD476", # yellow + "0GG": "MD477", "0GP": "MD477", # blue + "0GH": "MD478", "0GQ": "MD478", # green + "0GJ": "MD479", "0GR": "MD479", # purple + "0GK": "MD480", "0GT": "MD480", # silver + "0GL": "MD481", "0GV": "MD481", # slate + "4LN": "MD744", "4LP": "MD744", # product red + "JQ1": "ME971", # space gray (2013) + "K60": "MKMV2", # pink (2015) + "K61": "MKMX2", # gold (2015) + "K62": "MKN02", # blue (2015) + "K63": "MKN22", # silver (2015) + "K64": "MKN52", # space gray (2015) + "K65": "MKN72", # product red (2015) + # ── Video 5G ─────────────────────────────────────────────────────── + "SZ9": "MA002", "WEC": "MA002", "WED": "MA002", "WEG": "MA002", + "WEH": "MA002", "WEL": "MA002", + "TXK": "MA146", "TXM": "MA146", "WEF": "MA146", + "WEJ": "MA146", "WEK": "MA146", + "SZA": "MA003", "SZU": "MA003", "TXL": "MA147", "TXN": "MA147", + "V9V": "MA452", # iPod Video U2 Special Edition 5G 30GB (Black) + # ── Video 5.5G ───────────────────────────────────────────────────── + "V9K": "MA444", "V9L": "MA444", "WU9": "MA444", + "VQM": "MA446", "V9M": "MA446", "V9N": "MA446", "WEE": "MA446", + "V9P": "MA448", "V9Q": "MA448", + "V9R": "MA450", "V9S": "MA450", "V95": "MA450", + "V96": "MA450", "WUC": "MA450", + "W9G": "MA664", +} diff --git a/src/vendor/ipod_device/scanner.py b/src/vendor/ipod_device/scanner.py new file mode 100644 index 0000000..4c50d66 --- /dev/null +++ b/src/vendor/ipod_device/scanner.py @@ -0,0 +1,2524 @@ +""" +iPod device scanner — discovers connected iPods by scanning mounted drives. + +Uses a unified "gather everything, synthesize once" pipeline that combines +ALL available data sources and picks the best value for each field. + +Detection pipeline: + + **Phase 1 — Hardware probing** (pure Win32, no file I/O, no subprocess): + 1a. IOCTL_STORAGE_QUERY_PROPERTY → vendor, product, firmware, Apple serial + 1b. PnP device tree walk (SetupAPI/cfgmgr32) → FireWire GUID, USB PID + 1c. If both fail: silent fallback to WMI (PowerShell + registry) + + **Phase 2 — Filesystem probing** (file reads on iPod): + 2a. SysInfo / SysInfoExtended → ModelNumStr, FireWire GUID, serial + 2b. iTunesDB header → hashing_scheme (generation class) + + **Phase 3 — Model resolution** (pure computation, per-field priority): + - model_number: SysInfo ModelNumStr → IPOD_MODELS > serial last-3 → IPOD_MODELS + - firewire_guid: device tree > SysInfoExtended > SysInfo > USB serial (always 16 hex chars on iPods) + - serial: SysInfo pszSerialNumber (Apple serial) > IOCTL (only if non-GUID) + - firmware: IOCTL revision > SysInfo visibleBuildID + - usb_pid: device tree USB parent > WMI fallback + - model_family: IPOD_MODELS > USB PID table (with disk-size sanity check) > hashing_scheme + + **Phase 4 — Inline VPD** (macOS only, for incomplete identification): + If model_number is still unknown after Phase 3, query the iPod's + firmware via IOKit SCSI VPD (~1 s, no root, disk stays mounted) + to get the Apple serial. Serial-last-3 lookup resolves exact model + (family, generation, capacity, color). Writes SysInfo to the iPod + so that subsequent scans never need VPD again. +""" + +import ctypes +import logging +import os +import struct +import subprocess +import sys +import threading +from pathlib import Path +from typing import TYPE_CHECKING + +if sys.platform == "win32": + import ctypes.wintypes as wt +elif TYPE_CHECKING: + import ctypes.wintypes as wt # type-checker only + +from .diagnostic_log import ( + CAPABILITY_FIELDS, + IDENTITY_FIELDS, + SOURCE_FIELDS, + format_conflicts, + format_fields, + format_sources, +) +from .info import DeviceInfo +from .models import USB_PID_TO_MODEL + +logger = logging.getLogger(__name__) + +_PROBE_META_FIELDS: tuple[tuple[str, str], ...] = ( + ("_sysinfo_present", "sysinfo"), + ("_sysinfo_keys", "sysinfo_keys"), + ("_sysinfo_extended_present", "sie"), + ("_sysinfo_extended_keys", "sie_keys"), + ("_sysinfo_extended_regex_fallback", "sie_regex"), + ("hashing_scheme", "hash_scheme"), +) + +# Prevents console windows from flashing on Windows during subprocess calls +_SP_KWARGS: dict = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} +) + + +def _extract_guid_from_instance_id(instance_id: str) -> str: + """ + Extract the FireWire GUID (16-char hex string) from a USBSTOR or USB + instance ID. + + The instance ID format depends on whether the USB device reports + ``UniqueID=TRUE`` or ``FALSE``: + + - **UniqueID=TRUE** (simple USB, e.g. Nano 2G): + ``000A270018A1F847&0`` + → GUID is the first ``&``-separated segment. + + - **UniqueID=FALSE** (composite USB, e.g. Classic): + ``8&2F161EF4&0&000A2700138A422D&0`` + → PnP prepends a scope-hash prefix. The GUID is still present + as a 16-char hex segment, just not the first one. + + This helper scans ALL ``&``-separated segments and returns the first + that is exactly 16 hex characters. Returns empty string if not found. + """ + for segment in instance_id.split("&"): + segment = segment.strip() + if len(segment) == 16: + try: + bytes.fromhex(segment) + return segment.upper() + except ValueError: + pass + return "" + + +def _get_drive_letters() -> list[str]: + """Get all available drive letters on Windows.""" + if sys.platform != "win32": + return [] + + import ctypes + bitmask = ctypes.windll.kernel32.GetLogicalDrives() # type: ignore[attr-defined] + letters = [] + for i in range(26): + if bitmask & (1 << i): + letter = chr(65 + i) + letters.append(letter) + return letters + + +def _has_ipod_control(drive_path: str) -> bool: + """Check if a drive has iPod_Control at its root.""" + ipod_control = os.path.join(drive_path, "iPod_Control") + return os.path.isdir(ipod_control) + + +def _get_disk_info(drive_path: str) -> tuple[float, float]: + """Get disk size and free space in GB.""" + try: + import shutil + usage = shutil.disk_usage(drive_path) + return usage.total / (1024**3), usage.free / (1024**3) + except OSError: + return 0.0, 0.0 + + +def _find_ipod_volumes() -> list[tuple[str, str]]: + """ + Find mounted volumes that contain an iPod_Control directory. + + Returns a list of (mount_path, display_name) tuples. + Cross-platform: Windows drive letters, macOS /Volumes, Linux common mount dirs. + """ + candidates: list[tuple[str, str]] = [] + + if sys.platform == "win32": + for letter in _get_drive_letters(): + drive_path = f"{letter}:\\" + try: + if _has_ipod_control(drive_path): + candidates.append((drive_path, f"{letter}:")) + except PermissionError: + continue + + elif sys.platform == "darwin": + # macOS: iPods mount under /Volumes/ + volumes_dir = "/Volumes" + if os.path.isdir(volumes_dir): + for name in os.listdir(volumes_dir): + vol_path = os.path.join(volumes_dir, name) + if os.path.isdir(vol_path): + try: + if _has_ipod_control(vol_path): + candidates.append((vol_path, name)) + except PermissionError: + continue + + else: + # Linux: check common mount locations + import getpass + user = getpass.getuser() + search_dirs = [ + f"/media/{user}", + f"/run/media/{user}", + "/mnt", + ] + # Also check /media/* for distros that mount directly under /media + if os.path.isdir("/media"): + try: + for entry in os.listdir("/media"): + d = os.path.join("/media", entry) + if os.path.isdir(d) and d not in search_dirs: + search_dirs.append(d) + except PermissionError: + pass + + seen: set[str] = set() + for search_dir in search_dirs: + if not os.path.isdir(search_dir): + continue + try: + entries = os.listdir(search_dir) + except PermissionError: + continue + for name in entries: + vol_path = os.path.join(search_dir, name) + if vol_path in seen or not os.path.isdir(vol_path): + continue + seen.add(vol_path) + try: + if _has_ipod_control(vol_path): + candidates.append((vol_path, name)) + except PermissionError: + continue + + logger.debug( + "iPod volume discovery: platform=%s count=%d mounts=%s", + sys.platform, + len(candidates), + ", ".join(display for _path, display in candidates) or "none", + ) + return candidates + + +# ── macOS: BSD name → USB serial mapping via ioreg text parsing ──────── +# +# ioreg's plist (-a) format does NOT include "BSD Name" on child IOMedia +# nodes, but the text format does. We parse the text output to build a +# mapping from BSD whole-disk name (e.g. "disk4") to the owning USB +# device's serial number. A second (plist) query then gives us the +# full device properties keyed by serial. +# +# Cache is built once per scan cycle and cleared at the end of +# scan_for_ipods(). + +_macos_bsd_to_serial: dict[str, str] | None = None +_macos_serial_to_dev: dict[str, dict] | None = None +_macos_cache_lock = threading.Lock() + + +def _clear_macos_usb_cache() -> None: + """Clear cached macOS USB maps between scan/identify cycles.""" + global _macos_bsd_to_serial, _macos_serial_to_dev + with _macos_cache_lock: + _macos_bsd_to_serial = None + _macos_serial_to_dev = None + + +def _build_macos_usb_cache() -> None: + """Build both caches from ioreg in one shot. + + Caller must hold ``_macos_cache_lock``. + """ + global _macos_bsd_to_serial, _macos_serial_to_dev + + import plistlib + import re as _re + import subprocess + + bsd_map: dict[str, str] = {} + dev_map: dict[str, dict] = {} + + # ── 1. Text parse: map BSD whole-disk → USB serial ───────────────── + # + # The text ioreg output for iPod nodes looks like: + # +-o iPod@01130000 + # | "USB Serial Number" = "000A270018A1F847" + # | "idProduct" = 4704 + # ... + # +-o Apple iPod Media + # | "BSD Name" = "disk4" + # + # We track the current USB serial as we scan lines. When we hit a + # "BSD Name" at a deeper indent, we know which USB device owns it. + try: + proc = subprocess.run( + ["ioreg", "-r", "-c", "IOUSBHostDevice", "-n", "iPod", + "-l", "-d", "20", "-w", "0"], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=8, + ) + if proc.returncode == 0 and proc.stdout: + current_serial: str = "" + for line in proc.stdout.splitlines(): + # USB Serial Number + m = _re.search(r'"USB Serial Number"\s*=\s*"([^"]+)"', line) + if m: + current_serial = m.group(1).replace(" ", "").strip() + continue + # BSD Name on IOMedia child + m = _re.search(r'"BSD Name"\s*=\s*"(disk\d+)"', line) + if m and current_serial: + bsd_map[m.group(1)] = current_serial.upper() + except subprocess.TimeoutExpired: + logger.warning("macOS: ioreg text parse timed out") + except Exception as e: + logger.debug("macOS: ioreg text parse failed: %s", e) + + if bsd_map: + logger.debug("macOS: BSD→serial map: %s", bsd_map) + + # ── 2. Plist query: full device properties keyed by serial ───────── + for ioreg_args in [ + ["ioreg", "-a", "-r", "-c", "IOUSBHostDevice", "-n", "iPod"], + ["ioreg", "-a", "-r", "-d", "1", "-c", "IOUSBHostDevice"], + ]: + if dev_map: + break + try: + proc = subprocess.run( + ioreg_args, capture_output=True, timeout=10, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + continue + parsed = plistlib.loads(proc.stdout) + if not isinstance(parsed, list): + parsed = [parsed] + for dev in parsed: + if dev.get("idVendor", 0) != 0x05AC: + continue + serial = (dev.get("USB Serial Number", "") or dev.get("kUSBSerialNumberString", "")) + key = serial.replace(" ", "").strip().upper() + if key: + dev_map[key] = dev + except Exception as e: + logger.debug("ioreg plist query failed: %s", e) + + _macos_bsd_to_serial = bsd_map + _macos_serial_to_dev = dev_map + + +def _probe_hardware_macos(mount_path: str) -> dict: + """ + macOS hardware probing via ioreg + diskutil. + + **Matching strategy**: ``diskutil info`` gives the BSD whole-disk name + for each volume. A text-format ``ioreg`` query maps BSD names to USB + serial numbers (because the text format exposes "BSD Name" on IOMedia + children, while the plist format does not). A second plist query gives + full device properties (PID, serial, firmware) keyed by serial. + + This correctly associates each volume with its own USB device even when + multiple iPods are connected, without relying on SysInfo files. + """ + import plistlib + import subprocess + + result: dict = {} + + # ── Step 1: Confirm USB bus and get BSD whole-disk via diskutil ───── + bsd_whole_disk: str | None = None + try: + proc = subprocess.run( + ["diskutil", "info", "-plist", mount_path], + capture_output=True, timeout=10, + ) + if proc.returncode == 0: + disk_info = plistlib.loads(proc.stdout) + if disk_info.get("BusProtocol") != "USB": + logger.debug( + "macOS probe: %s is not on USB (protocol=%s)", + mount_path, disk_info.get("BusProtocol"), + ) + return result + bsd_whole_disk = disk_info.get("ParentWholeDisk") + except Exception as e: + logger.debug("diskutil info failed for %s: %s", mount_path, e) + + # ── Step 2: Ensure the ioreg caches are built ────────────────────── + with _macos_cache_lock: + if _macos_bsd_to_serial is None or _macos_serial_to_dev is None: + _build_macos_usb_cache() + bsd_map = _macos_bsd_to_serial or {} + dev_map = _macos_serial_to_dev or {} + + if not dev_map: + logger.debug("macOS probe: no Apple USB devices found in ioreg") + return result + + # ── Step 3: Match this volume's BSD name → USB serial → device ───── + target_dev: dict | None = None + + if bsd_whole_disk and bsd_whole_disk in bsd_map: + serial_key = bsd_map[bsd_whole_disk] + target_dev = dev_map.get(serial_key) + if target_dev: + logger.debug( + "macOS probe: %s → %s → FW GUID %s → PID 0x%04X", + mount_path, bsd_whole_disk, serial_key, + target_dev.get("idProduct", 0), + ) + + # Fallback: if only one USB device, use it directly + if not target_dev and len(dev_map) == 1: + target_dev = next(iter(dev_map.values())) + + if not target_dev: + logger.debug( + "macOS probe: could not match %s (bsd=%s) to any Apple " + "USB device", mount_path, bsd_whole_disk or "unknown", + ) + return result + + # ── Step 4: Extract device info ──────────────────────────────────── + pid = target_dev.get("idProduct", 0) + if pid: + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + + # The USB serial number for iPods is the FireWire GUID (16 hex + # chars), NOT the Apple serial number. Store it only as + # firewire_guid — the real Apple serial comes from SysInfo. + usb_serial = (target_dev.get("USB Serial Number", "") or target_dev.get("kUSBSerialNumberString", "")) + if usb_serial: + clean = usb_serial.replace(" ", "").strip() + if len(clean) == 16: + try: + bytes.fromhex(clean) + result["firewire_guid"] = clean.upper() + logger.debug("macOS probe: USB serial is FW GUID: %s", clean.upper()) + except ValueError: + pass + + bcd = target_dev.get("bcdDevice", 0) + if bcd: + major = (bcd >> 8) & 0xFF + minor = bcd & 0xFF + result["firmware"] = f"{major}.{minor:02d}" + + return result + + +def _linux_find_block_device(mount_path: str) -> str | None: + """Resolve a mount path to its block device (e.g. ``/dev/sdb1``). + + Tries three strategies in order: + + 1. ``findmnt`` — present on all modern distros (util-linux), handles + paths with spaces, special characters, and bind mounts correctly. + 2. ``/proc/mounts`` with octal-escape decoding — the kernel escapes + spaces as ``\\040``, tabs as ``\\011``, etc. Previous code compared + the raw escaped field to the unescaped *mount_path*, which failed for + mount points containing spaces + 3. ``lsblk --json`` — robust JSON output, handles spaces in mount points. + """ + import re as _re + + # ── Strategy 1: findmnt (best, handles all edge cases) ───────── + try: + cp = subprocess.run( + ["findmnt", "-n", "-o", "SOURCE", "--target", mount_path], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=5, + ) + if cp.returncode == 0: + dev = cp.stdout.strip().split("\n")[0].strip() + if dev.startswith("/dev/"): + logger.debug("Linux block device via findmnt: %s", dev) + return dev + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # ── Strategy 2: /proc/mounts with octal-escape decode ────────── + def _decode_mount_octal(field: str) -> str: + """Decode octal escapes (\\040 → space, \\011 → tab, etc.).""" + return _re.sub( + r"\\([0-7]{3})", + lambda m: chr(int(m.group(1), 8)), + field, + ) + + try: + with open("/proc/mounts") as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + decoded_mount = _decode_mount_octal(parts[1]) + if decoded_mount == mount_path: + dev = parts[0] + if dev.startswith("/dev/"): + logger.debug("Linux block device via /proc/mounts: %s", dev) + return dev + except OSError: + pass + + # ── Strategy 3: lsblk JSON ───────────────────────────────────── + try: + import json as _json + cp = subprocess.run( + ["lsblk", "-J", "-o", "NAME,MOUNTPOINT"], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=5, + ) + if cp.returncode == 0: + data = _json.loads(cp.stdout) + for dev_entry in data.get("blockdevices", []): + for child in dev_entry.get("children", []): + mp = child.get("mountpoint") or "" + if mp == mount_path: + name = child.get("name", "") + if name: + logger.debug("Linux block device via lsblk: /dev/%s", name) + return f"/dev/{name}" + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + pass + + return None + + +def _linux_usb_info_from_udevadm(device: str) -> dict: + """Extract USB identity fields via ``udevadm info``. + + Returns a dict that may contain *usb_pid*, *firewire_guid*, + *model_family*, and *generation*. Non-destructive — does not detach + drivers or require root. + """ + result: dict = {} + try: + cp = subprocess.run( + ["udevadm", "info", "--query=property", "--name", device], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=5, + ) + if cp.returncode != 0: + return result + + props: dict[str, str] = {} + for line in cp.stdout.splitlines(): + if "=" in line: + k, _, v = line.partition("=") + props[k.strip()] = v.strip() + + # Vendor check — only proceed for Apple devices + id_vendor = props.get("ID_VENDOR_ID", "") + if id_vendor != "05ac": + logger.debug("Linux udevadm %s: not Apple (vendor=%s)", device, id_vendor or "missing") + return result + + # USB PID + id_model = props.get("ID_MODEL_ID", "") + if id_model: + try: + pid = int(id_model, 16) + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + except ValueError: + pass + + # USB serial — on iPods this is the 16-hex-char FireWire GUID + usb_serial = props.get("ID_SERIAL_SHORT", "") + if usb_serial: + clean = usb_serial.replace(" ", "") + if len(clean) == 16: + try: + bytes.fromhex(clean) + result["firewire_guid"] = clean.upper() + except ValueError: + pass + + if result: + logger.debug("Linux udevadm info: %s", result) + + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return result + + +def _linux_usb_info_from_sysfs(base_disk: str) -> dict: + """Walk sysfs from a block device up to the USB ancestor. + + Returns a dict that may contain *usb_pid*, *firewire_guid*, + *model_family*, and *generation*. + """ + result: dict = {} + + sysfs_path = f"/sys/block/{base_disk}/device" + if not os.path.exists(sysfs_path): + return result + + current = os.path.realpath(sysfs_path) + for _ in range(8): + vendor_file = os.path.join(current, "idVendor") + if os.path.exists(vendor_file): + with open(vendor_file) as vf: + vendor = vf.read().strip() + if vendor == "05ac": # Apple + # Read product ID + product_file = os.path.join(current, "idProduct") + if os.path.exists(product_file): + with open(product_file) as pf: + product = pf.read().strip() + try: + pid = int(product, 16) + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + except ValueError: + pass + + # Read USB serial — on iPods this is the FireWire + # GUID (16 hex chars), not the Apple serial number. + serial_file = os.path.join(current, "serial") + if os.path.exists(serial_file): + with open(serial_file) as sf: + usb_serial = sf.read().strip() + if usb_serial: + clean = usb_serial.replace(" ", "") + if len(clean) == 16: + try: + bytes.fromhex(clean) + result["firewire_guid"] = clean.upper() + logger.debug("Linux sysfs: USB serial is FW GUID: %s", clean.upper()) + except ValueError: + pass + break + + current = os.path.dirname(current) + + return result + + +def _linux_usb_info_from_bus_scan(base_disk: str) -> dict: + """Scan ``/sys/bus/usb/devices/`` for an Apple device matching *base_disk*. + + This is a last-resort fallback when neither udevadm nor the sysfs device + walk finds USB identity fields. It works by: + + 1. Following ``/sys/block/{base_disk}`` real path to find which USB bus + address the block device lives under (e.g. ``usb2/2-1``). + 2. Scanning ``/sys/bus/usb/devices/`` for an entry whose ``idVendor`` + is ``05ac`` (Apple) and whose real path is an ancestor of the block + device's real path. + 3. Reading ``idProduct`` and ``serial`` from that USB device. + + Returns a dict that may contain *usb_pid*, *firewire_guid*, + *model_family*, and *generation*. + """ + result: dict = {} + + block_link = f"/sys/block/{base_disk}" + if not os.path.exists(block_link): + return result + + block_real = os.path.realpath(block_link) + + usb_devices_dir = "/sys/bus/usb/devices" + if not os.path.isdir(usb_devices_dir): + return result + + try: + entries = os.listdir(usb_devices_dir) + except OSError: + return result + + for entry in entries: + entry_path = os.path.join(usb_devices_dir, entry) + vendor_file = os.path.join(entry_path, "idVendor") + if not os.path.exists(vendor_file): + continue + try: + with open(vendor_file) as vf: + vendor = vf.read().strip() + except OSError: + continue + if vendor != "05ac": + continue + + # Check if this USB device is an ancestor of the block device + usb_real = os.path.realpath(entry_path) + if not block_real.startswith(usb_real + "/"): + continue + + # Found the Apple USB device that owns this disk + product_file = os.path.join(entry_path, "idProduct") + if os.path.exists(product_file): + try: + with open(product_file) as pf: + product = pf.read().strip() + pid = int(product, 16) + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + except (ValueError, OSError): + pass + + serial_file = os.path.join(entry_path, "serial") + if os.path.exists(serial_file): + try: + with open(serial_file) as sf: + usb_serial = sf.read().strip().replace(" ", "") + if len(usb_serial) == 16: + bytes.fromhex(usb_serial) + result["firewire_guid"] = usb_serial.upper() + logger.debug("Linux USB bus scan: FW GUID: %s", usb_serial.upper()) + except (ValueError, OSError): + pass + + if result: + logger.debug("Linux USB bus scan: %s", result) + break + + return result + + +def _probe_hardware_linux(mount_path: str) -> dict: + """ + Linux hardware probing via sysfs / udevadm / findmnt. + + Traces the mount point → block device → USB device through multiple + strategies to extract the USB PID, serial number, and FireWire GUID. + + Strategies (tried in order for each sub-task): + + Block device lookup: + 1. ``findmnt`` — handles paths with spaces and bind mounts + 2. ``/proc/mounts`` with octal-escape decoding + 3. ``lsblk --json`` + + USB identity extraction: + 1. ``udevadm info`` on the partition device + 2. ``udevadm info`` on the parent disk device (Arch/CachyOS may not + propagate USB properties to partition devices) + 3. sysfs walk — manual traversal from block device to USB ancestor + 4. USB bus scan — walk ``/sys/bus/usb/devices/`` for Apple devices + matching this block device + """ + import re as _re + + result: dict = {} + + try: + device = _linux_find_block_device(mount_path) + if not device: + logger.debug("Linux probe: could not resolve block device for %s", mount_path) + return result + + # Get the base disk name (e.g., sdb from /dev/sdb1) + dev_name = os.path.basename(device) + base_disk = _re.sub(r"\d+$", "", dev_name) # sdb1 → sdb + + # ── Strategy 1: udevadm info on partition ───────────────── + result = _linux_usb_info_from_udevadm(device) + + # ── Strategy 2: udevadm info on parent disk ────────────── + # On Arch-based distros (CachyOS, Manjaro, EndeavourOS), udev + # rules may not propagate USB identity properties (ID_VENDOR_ID, + # ID_MODEL_ID, ID_SERIAL_SHORT) from the USB device to its + # partition children. Querying the parent disk directly works. + if not result and base_disk != dev_name: + result = _linux_usb_info_from_udevadm(f"/dev/{base_disk}") + + # ── Strategy 3: sysfs walk ──────────────────────────────── + if not result: + result = _linux_usb_info_from_sysfs(base_disk) + + # ── Strategy 4: USB bus scan (last resort) ──────────────── + if not result: + result = _linux_usb_info_from_bus_scan(base_disk) + + except Exception as e: + logger.debug("Linux hardware probe failed: %s", e) + + return result + + +def _identify_via_usb_for_drive(drive_letter: str) -> dict | None: + """ + Identify the iPod connected at a specific drive letter via WMI + USB registry. + + Uses WMI to trace: drive letter → Win32_DiskDrive → PNPDeviceID + then cross-references the USBSTOR instance ID to the parent USB device + to get the actual PID for THIS specific device (not stale registry entries). + + Returns dict with keys: firewire_guid, serial, firmware, usb_pid, + model_family, generation + """ + if sys.platform != "win32": + return None + + import subprocess + + result: dict = {} + + # ── Step 1: Use WMI to get the disk PNPDeviceID for this drive letter ── + try: + # Query WMI to find the disk drive associated with this drive letter. + # Chain: LogicalDisk → Partition → DiskDrive + ps_cmd = ( + f"$logdisk = Get-WmiObject Win32_LogicalDisk | " + f"Where-Object {{ $_.DeviceID -eq '{drive_letter}:' }}; " + f"if ($logdisk) {{ " + f" $part = Get-WmiObject -Query \"ASSOCIATORS OF " + f"{{Win32_LogicalDisk.DeviceID='$($logdisk.DeviceID)'}} " + f"WHERE AssocClass=Win32_LogicalDiskToPartition\"; " + f" if ($part) {{ " + f" $disk = Get-WmiObject -Query \"ASSOCIATORS OF " + f"{{Win32_DiskPartition.DeviceID='$($part.DeviceID)'}} " + f"WHERE AssocClass=Win32_DiskDriveToDiskPartition\"; " + f" if ($disk) {{ " + f" Write-Output \"PNP:$($disk.PNPDeviceID)\"; " + f" Write-Output \"SERIAL:$($disk.SerialNumber.Trim())\"; " + f" Write-Output \"MODEL:$($disk.Model)\" " + f" }} " + f" }} " + f"}}" + ) + wmi_result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=10, + **_SP_KWARGS, + ) + pnp_id = "" + for line in wmi_result.stdout.strip().splitlines(): + line = line.strip() + if line.startswith("PNP:"): + pnp_id = line[4:] + elif line.startswith("SERIAL:"): + wmi_serial = line[7:].strip() + if wmi_serial: + # WMI disk serial for iPods is the FireWire GUID + # (16 hex chars), not the Apple serial. + wmi_clean = wmi_serial.replace(" ", "") + if len(wmi_clean) == 16: + try: + bytes.fromhex(wmi_clean) + result.setdefault("firewire_guid", wmi_clean.upper()) + logger.debug("WMI: serial is FW GUID: %s", wmi_clean.upper()) + except ValueError: + result["serial"] = wmi_serial + logger.debug("WMI: non-hex serial (Apple?): %s", wmi_serial) + else: + result["serial"] = wmi_serial + logger.debug("WMI: non-GUID serial (Apple?): %s", wmi_serial) + elif line.startswith("MODEL:"): + pass # Just confirms it's an iPod + + if not pnp_id: + logger.debug("Drive %s: no WMI disk drive found", drive_letter) + return result if result else None + + except Exception as e: + logger.debug("WMI query failed for drive %s: %s", drive_letter, e) + return None + + # ── Step 2: Extract info from the USBSTOR PNPDeviceID ── + # Format varies: + # Simple: USBSTOR\DISK&VEN_APPLE&PROD_IPOD&REV_1.62\000A270018A1F847&0 + # Composite: USBSTOR\DISK&VEN_APPLE&PROD_IPOD&REV_1.62\8&2F161EF4&0&000A2700138A422D&0 + if "USBSTOR" in pnp_id.upper(): + parts = pnp_id.split("\\") + if len(parts) >= 2: + device_desc = parts[1] if len(parts) > 1 else "" + instance_id = parts[2] if len(parts) > 2 else "" + + # Extract firmware revision from "REV_x.xx" + if "REV_" in device_desc.upper(): + rev_part = device_desc.upper().split("REV_")[-1] + result["firmware"] = rev_part + + # Extract FireWire GUID from instance ID + guid = _extract_guid_from_instance_id(instance_id) + if guid: + result["firewire_guid"] = guid + logger.debug("WMI USBSTOR: FW GUID from instance ID: %s", guid) + + # ── Step 3: Find the USB PID for THIS specific device ── + # Cross-reference the USBSTOR instance to its parent USB device. + # We use the extracted GUID (which is the USB iSerialNumber) to find + # the matching USB\VID_05AC&PID_xxxx\ entry in the registry. + try: + import winreg + + # Use the GUID as the cross-reference key (it appears as the USB + # device instance ID). Falls back to scanning all segments. + guid_for_match = result.get("firewire_guid", "") + if not guid_for_match and "\\" in pnp_id: + guid_for_match = _extract_guid_from_instance_id( + pnp_id.split("\\")[-1] + ) + + if guid_for_match: + usb_key = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Enum\USB" + ) + try: + k = 0 + while True: + try: + subkey_name = winreg.EnumKey(usb_key, k) + k += 1 + except OSError: + break + + upper = subkey_name.upper() + if "VID_05AC" not in upper or "PID_" not in upper: + continue + # Skip composite interface sub-devices (MI_xx) + if "MI_" in upper: + continue + + # Check if THIS USB device has our USBSTOR instance ID + try: + pid_key = winreg.OpenKey(usb_key, subkey_name) + except OSError: + continue + + try: + m = 0 + while True: + try: + usb_instance = winreg.EnumKey(pid_key, m) + m += 1 + except OSError: + break + + # Match the USBSTOR GUID to the USB instance + if guid_for_match.upper() in usb_instance.upper(): + pid_str = upper.split("PID_")[1][:4] + try: + pid = int(pid_str, 16) + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + logger.debug( + "Drive %s: matched USB PID 0x%04X via " + "GUID %s", + drive_letter, pid, guid_for_match, + ) + except ValueError: + pass + break # Found our device + finally: + winreg.CloseKey(pid_key) + + # Stop scanning once we found our match + if "usb_pid" in result: + break + + finally: + winreg.CloseKey(usb_key) + + except OSError: + pass + + return result if result else None + + +# ── Direct IOCTL detection (no WMI / PowerShell) ────────────────────────── + +# Windows constants for CreateFileW / DeviceIoControl +_GENERIC_READ = 0x80000000 +_FILE_SHARE_READ = 0x01 +_FILE_SHARE_WRITE = 0x02 +_OPEN_EXISTING = 3 +_IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400 + + +def _identify_via_direct_ioctl(drive_letter: str) -> dict | None: + """ + Query the USB storage device directly via IOCTL_STORAGE_QUERY_PROPERTY. + + Opens the drive handle (``\\\\.\\X:``) and sends a STORAGE_PROPERTY_QUERY + for StorageDeviceProperty. Under the hood Windows issues a SCSI INQUIRY + to the device and returns the parsed result in a STORAGE_DEVICE_DESCRIPTOR. + + This bypasses WMI, PowerShell, and the USB registry entirely — the + response comes straight from the device firmware. + + Returns a dict with: vendor, product, serial, firmware, bus_type, + model_family, generation (if PID can be inferred). + + Only works on Windows (requires kernel32 / DeviceIoControl). + """ + if sys.platform != "win32": + return None + + _setup_win32_prototypes() + + result: dict = {} + path = f"\\\\.\\{drive_letter}:" + + handle = ctypes.windll.kernel32.CreateFileW( # type: ignore[attr-defined] + path, + _GENERIC_READ, + _FILE_SHARE_READ | _FILE_SHARE_WRITE, + None, + _OPEN_EXISTING, + 0, + None, + ) + INVALID = ctypes.c_void_p(-1).value + if handle == INVALID: + logger.debug("Direct IOCTL: cannot open %s (access denied?)", path) + return None + + try: + # STORAGE_PROPERTY_QUERY: + # PropertyId = 0 (StorageDeviceProperty) + # QueryType = 0 (PropertyStandardQuery) + # AdditionalParameters[1] = 0 + query = struct.pack(" str: + if offset_pos + 4 > len(data): + return "" + off = struct.unpack_from("= len(data): + return "" + # Find null terminator + end = off + while end < len(data) and data[end] != 0: + end += 1 + return data[off:end].decode("ascii", errors="replace").strip() + + vendor = _read_str(12) + product = _read_str(16) + revision = _read_str(20) + serial = _read_str(24) + bus_type = struct.unpack_from("= 32 else -1 + removable = bool(data[10]) if len(data) > 10 else False + + logger.debug( + "Direct IOCTL %s: vendor=%r product=%r revision=%r serial=%r " + "bus_type=%d removable=%s", + drive_letter, vendor, product, revision, serial, bus_type, removable, + ) + + # Validate it's actually an Apple iPod + if vendor.lower() not in ("apple", "apple inc.", "apple inc"): + logger.debug("Direct IOCTL: vendor is %r, not Apple — skipping", + vendor) + return None + + result["vendor"] = vendor + result["product"] = product + result["bus_type"] = bus_type + + if revision: + result["firmware"] = revision + + if serial: + # The IOCTL serial for iPods is typically the FireWire GUID + # (16 hex chars), NOT the Apple serial number. Store as + # firewire_guid only — the real Apple serial comes from SysInfo. + clean = serial.replace(" ", "").strip() + if len(clean) == 16: + try: + bytes.fromhex(clean) + result["firewire_guid"] = clean.upper() + logger.debug("Direct IOCTL: serial is FW GUID: %s", clean.upper()) + except ValueError: + # Not a hex string — might be a real Apple serial + result["serial"] = serial + logger.debug("Direct IOCTL: non-hex serial (Apple?): %s", serial) + else: + # Non-16-char serial — could be an actual Apple serial + result["serial"] = serial + logger.debug("Direct IOCTL: non-GUID serial (Apple?): %s", serial) + + finally: + ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined] + + # ── Walk the PnP device tree to get FireWire GUID and USB PID ── + # The SCSI layer gives us vendor/product/serial/firmware, but the + # FireWire GUID (needed for hash generation) and the USB PID live + # in the PnP device tree above the SCSI device. + tree_info = _walk_device_tree(drive_letter) + if tree_info: + for key in ( + "usb_vid", + "usbstor_instance_id", + "usb_parent_instance_id", + "usb_grandparent_instance_id", + ): + if tree_info.get(key): + result[key] = tree_info[key] + if tree_info.get("firewire_guid"): + result["firewire_guid"] = tree_info["firewire_guid"] + logger.debug( + "Drive %s: FW GUID from device tree: %s", + drive_letter, tree_info["firewire_guid"], + ) + if tree_info.get("usb_pid"): + result["usb_pid"] = tree_info["usb_pid"] + if tree_info.get("model_family"): + result.setdefault("model_family", tree_info["model_family"]) + if tree_info.get("generation"): + result.setdefault("generation", tree_info["generation"]) + + return result if result else None + + +# ── PnP device tree walk via SetupAPI + cfgmgr32 ────────────────────────── + +# These constants / structs are scoped to Windows-only. The functions that +# use them already guard with ``sys.platform != "win32"``. + +_IOCTL_STORAGE_GET_DEVICE_NUMBER = 0x002D1080 +_DIGCF_PRESENT = 0x02 +_DIGCF_DEVICEINTERFACE = 0x10 +_CR_SUCCESS = 0 + + +class _GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_ulong), + ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), + ("Data4", ctypes.c_ubyte * 8), + ] + + +class _SP_DEVICE_INTERFACE_DATA(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_ulong), + ("InterfaceClassGuid", _GUID), + ("Flags", ctypes.c_ulong), + ("Reserved", ctypes.POINTER(ctypes.c_ulong)), + ] + + +class _SP_DEVINFO_DATA(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_ulong), + ("ClassGuid", _GUID), + ("DevInst", ctypes.c_ulong), + ("Reserved", ctypes.POINTER(ctypes.c_ulong)), + ] + + +class _STORAGE_DEVICE_NUMBER(ctypes.Structure): + _fields_ = [ + ("DeviceType", ctypes.c_ulong), + ("DeviceNumber", ctypes.c_ulong), + ("PartitionNumber", ctypes.c_ulong), + ] + + +# {53F56307-B6BF-11D0-94F2-00A0C91EFB8B} +_GUID_DEVINTERFACE_DISK = _GUID( + 0x53F56307, 0xB6BF, 0x11D0, + (ctypes.c_ubyte * 8)(0x94, 0xF2, 0x00, 0xA0, 0xC9, 0x1E, 0xFB, 0x8B), +) + + +def _setup_win32_prototypes() -> None: + """ + Declare proper argtypes/restype for Win32 functions used by the direct + backend. Without this, ctypes defaults to ``c_int`` return values which + **truncate 64-bit handles** on 64-bit Windows — a silent, fatal bug. + + Called once on first use; subsequent calls are no-ops. + """ + if getattr(_setup_win32_prototypes, "_done", False): + return + _setup_win32_prototypes._done = True # type: ignore[attr-defined] + + k32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + sa = ctypes.windll.setupapi # type: ignore[attr-defined] + cm = ctypes.windll.cfgmgr32 # type: ignore[attr-defined] + + # ── kernel32 ─────────────────────────────────────────────────────── + k32.CreateFileW.argtypes = [ + wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p, + wt.DWORD, wt.DWORD, wt.HANDLE, + ] + k32.CreateFileW.restype = ctypes.c_void_p # HANDLE (pointer-width) + + k32.DeviceIoControl.argtypes = [ + ctypes.c_void_p, wt.DWORD, + ctypes.c_void_p, wt.DWORD, + ctypes.c_void_p, wt.DWORD, + ctypes.POINTER(wt.DWORD), ctypes.c_void_p, + ] + k32.DeviceIoControl.restype = wt.BOOL + + k32.CloseHandle.argtypes = [ctypes.c_void_p] + k32.CloseHandle.restype = wt.BOOL + + # ── setupapi ─────────────────────────────────────────────────────── + sa.SetupDiGetClassDevsW.argtypes = [ + ctypes.c_void_p, ctypes.c_wchar_p, wt.HWND, wt.DWORD, + ] + sa.SetupDiGetClassDevsW.restype = ctypes.c_void_p # HDEVINFO + + sa.SetupDiEnumDeviceInterfaces.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, + ctypes.c_void_p, wt.DWORD, + ctypes.c_void_p, + ] + sa.SetupDiEnumDeviceInterfaces.restype = wt.BOOL + + sa.SetupDiGetDeviceInterfaceDetailW.argtypes = [ + ctypes.c_void_p, ctypes.c_void_p, + ctypes.c_void_p, wt.DWORD, + ctypes.POINTER(wt.DWORD), ctypes.c_void_p, + ] + sa.SetupDiGetDeviceInterfaceDetailW.restype = wt.BOOL + + sa.SetupDiDestroyDeviceInfoList.argtypes = [ctypes.c_void_p] + sa.SetupDiDestroyDeviceInfoList.restype = wt.BOOL + + # ── cfgmgr32 ────────────────────────────────────────────────────── + cm.CM_Get_Device_ID_Size.argtypes = [ + ctypes.POINTER(ctypes.c_ulong), ctypes.c_ulong, ctypes.c_ulong, + ] + cm.CM_Get_Device_ID_Size.restype = ctypes.c_ulong + + cm.CM_Get_Device_IDW.argtypes = [ + ctypes.c_ulong, ctypes.c_wchar_p, ctypes.c_ulong, ctypes.c_ulong, + ] + cm.CM_Get_Device_IDW.restype = ctypes.c_ulong + + cm.CM_Get_Parent.argtypes = [ + ctypes.POINTER(ctypes.c_ulong), ctypes.c_ulong, ctypes.c_ulong, + ] + cm.CM_Get_Parent.restype = ctypes.c_ulong + + +def _walk_device_tree(drive_letter: str) -> dict: + """ + Walk the Windows PnP device tree from a volume to its USB ancestor. + + Uses only Win32 APIs (SetupAPI + cfgmgr32) — no WMI, no PowerShell: + + Volume (``\\\\.\\D:``) + → ``IOCTL_STORAGE_GET_DEVICE_NUMBER`` → DeviceNumber N + → Enumerate ``GUID_DEVINTERFACE_DISK`` interfaces + → Match by DeviceNumber → get ``DevInst`` + → ``CM_Get_Device_ID`` → USBSTOR instance ID (contains **FireWire GUID**) + → ``CM_Get_Parent`` → USB device ID (contains **PID**) + + Returns dict with any of: ``firewire_guid``, ``usb_pid``, + ``model_family``, ``generation``. + """ + if sys.platform != "win32": + return {} + + _setup_win32_prototypes() + + result: dict = {} + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + setupapi = ctypes.windll.setupapi # type: ignore[attr-defined] + cfgmgr32 = ctypes.windll.cfgmgr32 # type: ignore[attr-defined] + + INVALID = ctypes.c_void_p(-1).value # 0xFFFFFFFFFFFFFFFF on 64-bit + + # ── Step 1: Get the physical device number for this volume ────────── + vol_path = f"\\\\.\\{drive_letter}:" + vol_handle = kernel32.CreateFileW( + vol_path, _GENERIC_READ, _FILE_SHARE_READ | _FILE_SHARE_WRITE, + None, _OPEN_EXISTING, 0, None, + ) + if vol_handle == INVALID: + return result + + try: + sdn = _STORAGE_DEVICE_NUMBER() + returned = wt.DWORD() + ok = kernel32.DeviceIoControl( + vol_handle, _IOCTL_STORAGE_GET_DEVICE_NUMBER, + None, 0, ctypes.byref(sdn), ctypes.sizeof(sdn), + ctypes.byref(returned), None, + ) + if not ok: + return result + target_dev_num = sdn.DeviceNumber + finally: + kernel32.CloseHandle(vol_handle) + + logger.debug("Drive %s: physical device number = %d", + drive_letter, target_dev_num) + + # ── Step 2: Enumerate present disk interfaces, find matching one ─── + hDevInfo = setupapi.SetupDiGetClassDevsW( + ctypes.byref(_GUID_DEVINTERFACE_DISK), None, None, + _DIGCF_PRESENT | _DIGCF_DEVICEINTERFACE, + ) + if hDevInfo == INVALID: + return result + + target_devinst = 0 + + try: + idx = 0 + while True: + iface = _SP_DEVICE_INTERFACE_DATA() + iface.cbSize = ctypes.sizeof(_SP_DEVICE_INTERFACE_DATA) + + if not setupapi.SetupDiEnumDeviceInterfaces( + hDevInfo, None, ctypes.byref(_GUID_DEVINTERFACE_DISK), + idx, ctypes.byref(iface), + ): + break + idx += 1 + + # First call: get required buffer size (expected to fail with + # ERROR_INSUFFICIENT_BUFFER — that's fine, we just need the size) + required = wt.DWORD() + devinfo = _SP_DEVINFO_DATA() + devinfo.cbSize = ctypes.sizeof(_SP_DEVINFO_DATA) + setupapi.SetupDiGetDeviceInterfaceDetailW( + hDevInfo, ctypes.byref(iface), None, 0, + ctypes.byref(required), ctypes.byref(devinfo), + ) + if required.value == 0: + continue + + # Allocate and fill SP_DEVICE_INTERFACE_DETAIL_DATA_W. + # The struct has a DWORD cbSize followed by a WCHAR[] path. + # cbSize must be set to 8 on 64-bit Windows, 6 on 32-bit. + buf_size = required.value + detail_buf = (ctypes.c_byte * buf_size)() + cb_size = 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 6 + struct.pack_into("= 3: + guid = _extract_guid_from_instance_id(parts[2]) + if guid: + result["firewire_guid"] = guid + logger.debug( + "Drive %s: FW GUID from USBSTOR instance: %s", + drive_letter, guid, + ) + + # ── Step 4: Walk up to USB parent → extract PID ──────────────────── + # For simple USB devices the parent is the USB device node: + # USB\VID_05AC&PID_1260\000A270018A1F847 + # For composite USB devices the immediate parent is an interface node: + # USB\VID_05AC&PID_1261&MI_00\7&2551D7E5&0 + # In both cases we get the PID. For composite devices, walk up one + # more level to reach the actual USB device node if we still need + # the GUID (fallback if USBSTOR extraction didn't yield it). + parent = ctypes.c_ulong() + if cfgmgr32.CM_Get_Parent( + ctypes.byref(parent), target_devinst, 0, + ) == _CR_SUCCESS: + id_len2 = ctypes.c_ulong() + if cfgmgr32.CM_Get_Device_ID_Size( + ctypes.byref(id_len2), parent.value, 0, + ) == _CR_SUCCESS: + parent_buf = ctypes.create_unicode_buffer(id_len2.value + 1) + if cfgmgr32.CM_Get_Device_IDW( + parent.value, parent_buf, id_len2.value + 1, 0, + ) == _CR_SUCCESS: + usb_id = parent_buf.value + result["usb_parent_instance_id"] = usb_id + logger.debug("Drive %s: USB parent = %s", + drive_letter, usb_id) + + upper_id = usb_id.upper() + if "VID_" in upper_id: + vid_str = upper_id.split("VID_")[1][:4] + try: + result["usb_vid"] = int(vid_str, 16) + except ValueError: + pass + if "PID_" in upper_id: + pid_str = upper_id.split("PID_")[1][:4] + try: + pid = int(pid_str, 16) + result["usb_pid"] = pid + model_info = USB_PID_TO_MODEL.get(pid) + if model_info: + result["model_family"] = model_info[0] + result["generation"] = model_info[1] + except ValueError: + pass + + # Composite device: parent is USB\...&MI_xx\... (interface) + # Walk up one more level to the real USB device node. + # Its instance ID will have the GUID as a simple segment. + if "MI_" in upper_id and not result.get("firewire_guid"): + grandparent = ctypes.c_ulong() + if cfgmgr32.CM_Get_Parent( + ctypes.byref(grandparent), parent.value, 0, + ) == _CR_SUCCESS: + gp_len = ctypes.c_ulong() + if cfgmgr32.CM_Get_Device_ID_Size( + ctypes.byref(gp_len), grandparent.value, 0, + ) == _CR_SUCCESS: + gp_buf = ctypes.create_unicode_buffer( + gp_len.value + 1 + ) + if cfgmgr32.CM_Get_Device_IDW( + grandparent.value, gp_buf, + gp_len.value + 1, 0, + ) == _CR_SUCCESS: + gp_id = gp_buf.value + result["usb_grandparent_instance_id"] = gp_id + logger.debug( + "Drive %s: USB grandparent = %s", + drive_letter, gp_id, + ) + gp_parts = gp_id.split("\\") + if len(gp_parts) >= 3: + gp_guid = _extract_guid_from_instance_id( + gp_parts[2] + ) + if gp_guid: + result["firewire_guid"] = gp_guid + logger.debug( + "Drive %s: FW GUID from USB " + "grandparent: %s", + drive_letter, gp_guid, + ) + + return result + + +# ── Unified probing functions ────────────────────────────────────────────── + + +def _probe_hardware(mount_path: str, mount_name: str) -> dict: + """ + Phase 1: Hardware probing — query the USB device for identification. + + Platform-specific: + - **Windows**: Direct IOCTL + device tree walk, with WMI fallback. + - **macOS**: system_profiler SPUSBDataType to find the Apple USB device. + - **Linux**: sysfs traversal from block device to USB device. + + Returns a dict that may contain any of: + vendor, product, serial, firmware, bus_type, firewire_guid, + usb_pid, model_family, generation, _sources + """ + result: dict = {} + _hw_method = "" + logger.debug( + "Hardware probe start: mount=%s display=%s platform=%s", + mount_path, + mount_name, + sys.platform, + ) + + if sys.platform == "win32": + # On Windows, mount_name is "D:" — extract the drive letter + drive_letter = mount_name[0] if mount_name and mount_name[0].isalpha() else "" + if not drive_letter: + logger.debug("Hardware probe result: mount=%s no drive letter", mount_path) + return result + + # ── Primary: Direct IOCTL + device tree (fast, no subprocess) ── + ioctl_info = _identify_via_direct_ioctl(drive_letter) + if ioctl_info: + result.update(ioctl_info) + _hw_method = "ioctl" + logger.debug("Hardware probe (direct): %s", result) + + # ── Fallback: WMI (only if direct gave us nothing useful) ────── + if not result: + logger.debug( + "Direct probe failed for drive %s, falling back to WMI", + drive_letter, + ) + wmi_info = _identify_via_usb_for_drive(drive_letter) + if wmi_info: + result.update(wmi_info) + _hw_method = "wmi" + logger.debug("Hardware probe (WMI fallback): %s", result) + + elif sys.platform == "darwin": + result = _probe_hardware_macos(mount_path) + _hw_method = "ioreg" + if result: + logger.debug("Hardware probe (macOS): %s", result) + + else: + result = _probe_hardware_linux(mount_path) + _hw_method = "sysfs" + if result: + logger.debug("Hardware probe (Linux): %s", result) + + # Annotate per-field data sources for authority tracking + if result and _hw_method: + sources = result.setdefault("_sources", {}) + if result.get("firewire_guid"): + # On Windows, FW GUID comes from device tree walk specifically + sources["firewire_guid"] = ( + "device_tree" if _hw_method in ("ioctl", "wmi") else _hw_method + ) + if result.get("serial"): + sources["serial"] = _hw_method + if result.get("firmware"): + sources["firmware"] = _hw_method + if result.get("usb_pid"): + sources["usb_pid"] = ( + "device_tree" if _hw_method in ("ioctl", "wmi") else _hw_method + ) + + logger.debug( + "Hardware probe result: mount=%s method=%s identity=[%s] sources=[%s]", + mount_path, + _hw_method or "none", + format_fields(result, IDENTITY_FIELDS), + format_sources(result.get("_sources", {}), SOURCE_FIELDS), + ) + return result + + +def _probe_filesystem(ipod_path: str) -> dict: + """ + Phase 2: Filesystem probing — read on-device files for identification. + + Reads SysInfo/SysInfoExtended and the iTunesDB header. All file reads + are independent and their results are merged. + + Returns a dict that may contain any of: + model_number, model_family, generation, capacity, color, + serial, firewire_guid, firmware, hashing_scheme + """ + result: dict = {} + + # ── SysInfo / SysInfoExtended ────────────────────────────────────── + sysinfo = _identify_via_sysinfo(ipod_path) + if sysinfo: + result.update(sysinfo) + + # ── iTunesDB header (hashing_scheme) ─────────────────────────────── + hash_info = _identify_via_hashing_scheme(ipod_path) + if hash_info: + # Only take hashing_scheme; model_family from this source is low-confidence + result["hashing_scheme"] = hash_info.get("hashing_scheme", -1) + result.setdefault("_sources", {}).setdefault("hashing_scheme", "itunes") + # Store the hash-inferred family/gen separately so Phase 3 can use + # them as a last resort without overriding higher-confidence sources. + if hash_info.get("model_family"): + result["hash_model_family"] = hash_info["model_family"] + result["hash_generation"] = hash_info.get("generation", "") + + logger.debug( + "Filesystem probe result: mount=%s meta=[%s] identity=[%s] caps=[%s] " + "sources=[%s]", + ipod_path, + format_fields(result, _PROBE_META_FIELDS, include_false=True), + format_fields(result, IDENTITY_FIELDS), + format_fields(result, CAPABILITY_FIELDS, include_false=True), + format_sources(result.get("_sources", {}), SOURCE_FIELDS), + ) + return result + + +def _log_model_resolution(resolved: dict, disk_size_gb: float) -> None: + logger.debug( + "Model resolution result: method=%s disk=%.1fGB identity=[%s] " + "sources=[%s] conflicts=[%s]", + resolved.get("identification_method", "unknown"), + disk_size_gb, + format_fields(resolved, IDENTITY_FIELDS), + format_sources(resolved.get("_sources", {}), SOURCE_FIELDS), + format_conflicts(resolved.get("_conflicts", [])), + ) + + +def _resolve_model( + hw: dict, + fs: dict, + disk_size_gb: float, +) -> dict: + """ + Phase 3: Model resolution — synthesise a final identification from all + collected data with clear per-field priority. + + Returns the resolved fields: model_number, model_family, generation, + capacity, color, firewire_guid, serial, firmware, usb_pid, hashing_scheme, + identification_method, _sources. + """ + from .lookup import get_model_info, usb_pid_identity_conflicts + + resolved: dict = {} + hw_sources = hw.get("_sources", {}) + fs_sources = fs.get("_sources", {}) + sources: dict[str, str] = {} + resolved["_sources"] = sources # reference — mutations visible in resolved + conflicts: list[dict] = [] + resolved["_conflicts"] = conflicts + + extra_fields = ( + "family_id", + "updater_family_id", + "product_type", + "usb_vid", + "usb_serial", + "usbstor_instance_id", + "usb_parent_instance_id", + "usb_grandparent_instance_id", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "connected_bus", + "volume_format", + "db_version", + "shadow_db_version", + "uses_sqlite_db", + "supports_sparse_artwork", + "max_tracks", + "max_file_size_gb", + "max_transfer_speed", + "podcasts_supported", + "voice_memos_supported", + "audio_codecs", + "power_information", + "apple_drm_version", + "artwork_formats", + "photo_formats", + "chapter_image_formats", + ) + for field in extra_fields: + hw_value = hw.get(field) + fs_value = fs.get(field) + if hw_value not in (None, "", b"", {}, []): + resolved[field] = hw_value + sources[field] = hw_sources.get(field, "hardware") + if fs_value not in (None, "", b"", {}, []) and field not in resolved: + resolved[field] = fs_value + sources[field] = fs_sources.get(field, "sysinfo_extended") + + # ── FireWire GUID ────────────────────────────────────────────────── + # Priority: device tree > SysInfoExtended/SysInfo > IOCTL serial + # (The device tree USBSTOR instance is the most authoritative because + # it's guaranteed to be for the currently-connected device at this + # specific drive letter. SysInfo can be stale or missing.) + if hw.get("firewire_guid"): + resolved["firewire_guid"] = hw["firewire_guid"] + sources["firewire_guid"] = hw_sources.get("firewire_guid", "hardware") + elif fs.get("firewire_guid"): + resolved["firewire_guid"] = fs["firewire_guid"] + sources["firewire_guid"] = fs_sources.get("firewire_guid", "sysinfo") + else: + resolved["firewire_guid"] = "" + + # ── Serial (Apple serial number, NOT the USB/FireWire GUID) ──────── + # Only the filesystem layer (SysInfo pszSerialNumber) provides the real + # Apple serial. Hardware probing returns the USB serial which is always + # the FireWire GUID on iPods — that's stored in firewire_guid above. + fs_serial = fs.get("serial", "") + hw_serial = hw.get("serial", "") # rare: non-GUID serial from IOCTL + if fs_serial and not fs_serial.startswith("RAND"): + resolved["serial"] = fs_serial + sources["serial"] = fs_sources.get("serial", "sysinfo") + elif hw_serial and not hw_serial.startswith("RAND"): + resolved["serial"] = hw_serial + sources["serial"] = hw_sources.get("serial", "hardware") + else: + resolved["serial"] = "" + + # ── Firmware ─────────────────────────────────────────────────────── + # Priority: IOCTL revision > SysInfo visibleBuildID + if hw.get("firmware"): + resolved["firmware"] = hw["firmware"] + sources["firmware"] = hw_sources.get("firmware", "hardware") + elif fs.get("firmware"): + resolved["firmware"] = fs["firmware"] + sources["firmware"] = fs_sources.get("firmware", "sysinfo") + else: + resolved["firmware"] = "" + + # ── USB PID ──────────────────────────────────────────────────────── + resolved["usb_pid"] = hw.get("usb_pid", 0) + if resolved["usb_pid"]: + sources.setdefault("usb_pid", hw_sources.get("usb_pid", "hardware")) + + # ── Hashing scheme ───────────────────────────────────────────────── + resolved["hashing_scheme"] = fs.get("hashing_scheme", -1) + + # ── Model identification (layered, highest-confidence wins) ──────── + # + # Layers 1 and 2 are evaluated together so they can cross-check each + # other. When they agree the SysInfo result is used (preserving its + # provenance). When they DISAGREE the serial wins: the last-3 suffix + # is a manufacturer-encoded identifier that is much harder to corrupt + # than the NVRAM-stored ModelNumStr, which can be wrong after a botched + # restore, firmware flash, or logic-board swap (e.g. a device whose + # NVRAM reports M9787/4th-Gen but whose serial encodes MA452/5th-Gen). + # + # Layer 1: SysInfo ModelNumStr → IPOD_MODELS + sysinfo_model = fs.get("model_number", "") + sysinfo_mi: tuple | None = None + if sysinfo_model: + sysinfo_mi = get_model_info(sysinfo_model) + + # Layer 2: Serial last-3-char → IPOD_MODELS + serial = resolved["serial"] + serial_info: dict | None = None + if serial: + serial_info = _identify_via_serial_lookup(serial) + + pid_family = hw.get("model_family", "") + pid_gen = hw.get("generation", "") + if sysinfo_mi and pid_family and usb_pid_identity_conflicts( + sysinfo_mi[0], + sysinfo_mi[1], + pid_family, + pid_gen, + ): + logger.warning( + "_resolve_model: SysInfo ModelNumStr %s (%s %s) conflicts " + "with live USB PID identity %s %s; ignoring cached model", + sysinfo_model, + sysinfo_mi[0], + sysinfo_mi[1], + pid_family, + pid_gen or "(generation unknown)", + ) + conflicts.append({ + "field": "model_number", + "winner": "usb_pid", + "rejected_source": fs_sources.get("model_number", "sysinfo"), + "rejected_value": sysinfo_model, + "reason": "model conflicts with live USB PID identity", + }) + sysinfo_model = "" + sysinfo_mi = None + + if serial_info and pid_family and usb_pid_identity_conflicts( + serial_info.get("model_family", ""), + serial_info.get("generation", ""), + pid_family, + pid_gen, + ): + logger.warning( + "_resolve_model: serial last-3 '%s' resolves to %s %s, " + "which conflicts with live USB PID identity %s %s; ignoring " + "cached serial-derived model", + serial[-3:], + serial_info.get("model_family", "?"), + serial_info.get("generation", "?"), + pid_family, + pid_gen or "(generation unknown)", + ) + conflicts.append({ + "field": "model_number", + "winner": "usb_pid", + "rejected_source": sources.get("serial", "serial_lookup"), + "rejected_value": serial_info.get("model_number", ""), + "reason": "serial-derived model conflicts with live USB PID identity", + }) + serial_info = None + + # Cross-check: if both resolved and they disagree, serial wins. + _use_serial = False + if sysinfo_mi and serial_info: + sr_model = serial_info.get("model_number", "") + if sr_model and sr_model != sysinfo_model: + logger.warning( + "_resolve_model: SysInfo ModelNumStr %s (%s %s) conflicts " + "with serial last-3 '%s' → %s (%s %s); preferring serial " + "(USB PID family: %s)", + sysinfo_model, sysinfo_mi[0], sysinfo_mi[1], + serial[-3:], sr_model, + serial_info.get("model_family", "?"), + serial_info.get("generation", "?"), + hw.get("model_family", "unknown"), + ) + conflicts.append({ + "field": "model_number", + "winner": sources.get("serial", "serial_lookup"), + "rejected_source": fs_sources.get("model_number", "sysinfo"), + "rejected_value": sysinfo_model, + "reason": "SysInfo model conflicts with Apple serial suffix", + }) + _use_serial = True + elif serial_info and not sysinfo_mi: + _use_serial = True + + if sysinfo_mi and not _use_serial: + resolved["model_number"] = sysinfo_model + resolved["model_family"] = sysinfo_mi[0] + resolved["generation"] = sysinfo_mi[1] + resolved["capacity"] = sysinfo_mi[2] + resolved["color"] = sysinfo_mi[3] + resolved["identification_method"] = "sysinfo" + _mn_src = fs_sources.get("model_number", "sysinfo") + sources["model_number"] = _mn_src + sources.setdefault("model_family", _mn_src) + sources.setdefault("generation", _mn_src) + sources.setdefault("capacity", _mn_src) + sources.setdefault("color", _mn_src) + _log_model_resolution(resolved, disk_size_gb) + return resolved + + if _use_serial: + serial_info = serial_info or {} + resolved["serial"] = serial + resolved["model_number"] = serial_info.get("model_number", "") + resolved["model_family"] = serial_info.get("model_family", "iPod") + resolved["generation"] = serial_info.get("generation", "") + resolved["capacity"] = serial_info.get("capacity", "") + resolved["color"] = serial_info.get("color", "") + resolved["identification_method"] = "serial" + # Derived fields inherit the serial's authority source. + _serial_src = sources.get("serial", "serial_lookup") + sources["model_number"] = "serial_lookup" + sources.setdefault("model_family", _serial_src) + sources.setdefault("generation", _serial_src) + sources.setdefault("capacity", _serial_src) + sources.setdefault("color", _serial_src) + _log_model_resolution(resolved, disk_size_gb) + return resolved + + # Layer 3: USB PID → family/generation (coarse) + # No disk-size rejection — modded iPods often have non-stock storage. + pid = resolved["usb_pid"] + pid_family = hw.get("model_family", "") + pid_gen = hw.get("generation", "") + if pid and pid_family: + resolved["model_family"] = pid_family + resolved["generation"] = pid_gen + resolved["identification_method"] = "usb_pid" + sources.setdefault("model_family", "usb_pid") + if pid_gen: + sources.setdefault("generation", "usb_pid") + + # Layer 4: Hashing scheme → generation class (coarsest) + if resolved.get("model_family", "iPod") == "iPod": + hash_family = fs.get("hash_model_family") + if hash_family and hash_family != "iPod": + resolved.setdefault("model_family", hash_family) + resolved.setdefault("generation", fs.get("hash_generation", "")) + resolved["identification_method"] = "hashing" + + # Defaults for anything not yet resolved + resolved.setdefault("model_number", sysinfo_model or "") + resolved.setdefault("model_family", "iPod") + resolved.setdefault("generation", "") + resolved.setdefault("capacity", "") + resolved.setdefault("color", "") + resolved.setdefault("identification_method", "filesystem") + + _log_model_resolution(resolved, disk_size_gb) + return resolved + + +def _identify_via_sysinfo(ipod_path: str) -> dict | None: + """Try to identify via SysInfo / SysInfoExtended files.""" + + result: dict = {} + result["_sources"] = {} + + # Try SysInfoExtended first + sie_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfoExtended") + result["_sysinfo_extended_present"] = os.path.exists(sie_path) + if os.path.exists(sie_path): + try: + from .sysinfo import ( + identity_from_sysinfo_extended, + parse_sysinfo_extended, + ) + + parsed = parse_sysinfo_extended( + Path(sie_path).read_bytes(), + source="sysinfo_extended", + ) + result["_sysinfo_extended_keys"] = len(parsed.plist) + result["_sysinfo_extended_regex_fallback"] = parsed.used_regex_fallback + identity = identity_from_sysinfo_extended(parsed, "sysinfo_extended") + sources = identity.get("_sources", {}) + for key, value in identity.items(): + if key.startswith("_") or key in { + "model_raw", + "sysinfo_extended_raw_xml", + }: + continue + result[key] = value + if key in sources: + result["_sources"][key] = sources[key] + if identity.get("serial"): + logger.debug("SysInfoExtended: Apple serial: %s", identity["serial"]) + if identity.get("firewire_guid"): + logger.debug("SysInfoExtended: FW GUID: %s", identity["firewire_guid"]) + except Exception as exc: + logger.info("SysInfoExtended parse failed for %s: %s", ipod_path, exc) + + # Try SysInfo + sysinfo_path = os.path.join(ipod_path, "iPod_Control", "Device", "SysInfo") + result["_sysinfo_present"] = os.path.exists(sysinfo_path) + if os.path.exists(sysinfo_path): + try: + from .sysinfo import identity_from_sysinfo, parse_sysinfo_text + + sysinfo = parse_sysinfo_text(Path(sysinfo_path).read_text(errors="replace")) + result["_sysinfo_keys"] = len(sysinfo) + identity = identity_from_sysinfo(sysinfo, "sysinfo") + sources = identity.get("_sources", {}) + for key, value in identity.items(): + if key.startswith("_"): + continue + if key in result and result[key] not in (None, ""): + continue + result[key] = value + if key in sources: + result["_sources"].setdefault(key, sources[key]) + if identity.get("model_number"): + from .lookup import get_model_info + + mi = get_model_info(identity["model_number"]) + if mi: + result.setdefault("model_family", mi[0]) + result.setdefault("generation", mi[1]) + result.setdefault("capacity", mi[2]) + result.setdefault("color", mi[3]) + if identity.get("serial"): + logger.debug("SysInfo: Apple serial: %s", identity["serial"]) + if identity.get("firewire_guid"): + logger.debug("SysInfo: FW GUID: %s", identity["firewire_guid"]) + except Exception as exc: + logger.info("SysInfo parse failed for %s: %s", ipod_path, exc) + + return result if any(k != "_sources" for k in result) else None + + +def _extract_ipod_name(ipod_path: str) -> str: + """ + Lightweight extraction of the iPod's user-assigned name from the master + playlist title in the iTunesDB binary. Uses buffered positional reads + so only a few KB are transferred over USB — never reads the whole file. + + For iTunesCDB files (Nano 5G+), the entire file must be read and + decompressed first — but these are typically small (< 200 KB compressed). + + Returns the name string, or empty string if extraction fails. + """ + from .info import resolve_itdb_path + itdb_path = resolve_itdb_path(ipod_path) + if not itdb_path: + return "" + try: + # For iTunesCDB, we need to decompress the whole file first. + # Check if it's a CDB by reading the header. + with open(itdb_path, "rb") as f: + peek = f.read(16) + if len(peek) < 16 or peek[:4] != b"mhbd": + return "" + unk_0x0c = struct.unpack(" str: + """Extract iPod name from a fully in-memory (decompressed) database.""" + import io + return _ipod_name_from_stream(io.BytesIO(data)) + + +def _ipod_name_from_stream(f) -> str: + """Extract iPod name from an open file-like object (positional reads).""" + def _read(n: int) -> bytes: + buf = f.read(n) + if len(buf) < n: + raise EOFError("Unexpected end of iTunesDB") + return buf + + try: + hdr = _read(24) + if hdr[:4] != b"mhbd": + return "" + + mhbd_header_len = struct.unpack(" str: + """Try to read iPod name from the master playlist in an mhsd.""" + mhlp_pos = mhsd_pos + mhsd_hdr_len + f.seek(mhlp_pos) + mhlp_hdr = _read(12) + if mhlp_hdr[:4] != b"mhlp": + return "" + mhlp_hdr_len = struct.unpack(" 1024: + break + sdata = _read(slen) + if enc == 2: + return sdata.decode("utf-8", errors="replace") + else: + return sdata.decode("utf-16-le", errors="replace") + + mhod_pos += mhod_total + return "" # Had the master but couldn't read name + + mhyp_pos += mhyp_total + return "" + + # Walk mhsd children — try type 2 first (classic), fall back to + # type 3 (Nano 5G+ / newer iTunes omit type 2 entirely and put + # the master playlist in type 3 instead). + type3_pos: int | None = None + type3_hdr_len: int = 0 + pos = mhbd_header_len + for _ in range(mhbd_children): + f.seek(pos) + mhsd_hdr = _read(16) + if mhsd_hdr[:4] != b"mhsd": + break + mhsd_hdr_len = struct.unpack(" dict | None: + """ + Identify generation class from iTunesDB hashing_scheme field. + + This is a fallback — it tells us the generation class but not the exact model. + """ + from .info import resolve_itdb_path + itdb_path = resolve_itdb_path(ipod_path) + if not itdb_path: + return None + + try: + with open(itdb_path, "rb") as f: + header = f.read(0x72) + if len(header) < 0x32 or header[:4] != b"mhbd": + return None + + scheme = struct.unpack(" dict | None: + """Look up model from serial number's last 3 characters.""" + from .lookup import lookup_by_serial + + result = lookup_by_serial(serial) + if not result: + return None + + model_num, info = result + return { + "model_number": model_num, + "model_family": info[0], + "generation": info[1], + "capacity": info[2], + "color": info[3], + } + + +def _estimate_capacity_from_disk_size(disk_gb: float) -> str: + """Estimate marketed capacity from actual disk size. + + .. deprecated:: Use :func:`ipod_device.info._estimate_capacity_from_disk_size` directly. + """ + from .info import _estimate_capacity_from_disk_size as _impl + return _impl(disk_gb) + + +def _try_vpd_identification(ipod: DeviceInfo) -> None: + """Attempt full VPD-based identification for an incompletely resolved iPod. + + Delegates to :func:`ipod_device.vpd_libusb.identify_via_vpd` on supported + non-Windows platforms. + + SysInfo writing is NOT done here — the authority module handles it + after all identification is complete. + """ + if sys.platform == "win32": + logger.debug( + "Skipping inline VPD identification on Windows: mount=%s", + ipod.path, + ) + return + + try: + from .vpd_libusb import identify_via_vpd + except ImportError: + return + + result = identify_via_vpd( + mount_path=ipod.path, + usb_pid=ipod.usb_pid, + firewire_guid=ipod.firewire_guid, + write_sysinfo_to_device=False, + ) + if result is None: + return + + # Apply resolved fields + if result["model_number"]: + ipod.model_number = result["model_number"] + ipod.model_family = result["model_family"] + ipod.generation = result["generation"] + ipod.capacity = result["capacity"] + ipod.color = result["color"] + ipod.identification_method = "usb_vpd" + ipod._field_sources["model_number"] = "vpd" + + if not ipod.serial and result["serial"]: + ipod.serial = result["serial"] + ipod._field_sources["serial"] = "vpd" + if not ipod.firewire_guid and result["firewire_guid"]: + ipod.firewire_guid = result["firewire_guid"] + ipod._field_sources["firewire_guid"] = "vpd" + if not ipod.firmware and result["firmware"]: + ipod.firmware = result["firmware"] + ipod._field_sources["firmware"] = "vpd" + + # Update mount path in case pyusb caused a remount to a different path + if result["mount_path"] and result["mount_path"] != ipod.path: + logger.info(" VPD: mount path changed %s → %s", ipod.path, result["mount_path"]) + ipod.path = result["mount_path"] + + +def _display_name_for_mount_path(mount_path: str) -> str: + """Return the same style of display name used by broad volume discovery.""" + if sys.platform == "win32": + drive, _tail = os.path.splitdrive(os.path.abspath(mount_path)) + if drive: + return drive + normalized = os.path.normpath(mount_path) + return os.path.basename(normalized) or normalized + + +def identify_ipod_at_path( + ipod_path: str, + mount_name: str | None = None, +) -> DeviceInfo | None: + """Identify one selected iPod root without scanning every mounted volume.""" + if not ipod_path: + return None + + expanded_path = os.path.expanduser(ipod_path) + drive, tail = os.path.splitdrive(expanded_path) + if sys.platform == "win32" and drive and tail in ("", "."): + mount_path = drive + "\\" + else: + mount_path = os.path.abspath(expanded_path) + virtual = _load_virtual_ipod_mount_if_present(mount_path) + if virtual is not None: + return virtual + if not _has_ipod_control(mount_path): + logger.info("Selected path is not an iPod root: %s", mount_path) + return None + + display_name = mount_name or _display_name_for_mount_path(mount_path) + try: + return _identify_ipod_mount(mount_path, display_name) + finally: + _clear_macos_usb_cache() + + +def _identify_ipod_mount(mount_path: str, display_name: str) -> DeviceInfo: + """Run the full identification pipeline for one already-discovered mount.""" + from .info import enrich + + virtual = _load_virtual_ipod_mount_if_present(mount_path) + if virtual is not None: + return virtual + + ipod = DeviceInfo(path=mount_path, mount_name=display_name) + ipod.disk_size_gb, ipod.free_space_gb = _get_disk_info(mount_path) + logger.info( + "Identifying iPod: mount=%s display=%s disk=%.1fGB free=%.1fGB", + mount_path, + display_name, + ipod.disk_size_gb, + ipod.free_space_gb, + ) + + # Phase 1: Hardware probing + hw = _probe_hardware(mount_path, display_name) + + # Phase 2: Filesystem probing + fs = _probe_filesystem(mount_path) + + # Phase 3: Model resolution (per-field priority merge) + resolved = _resolve_model(hw, fs, ipod.disk_size_gb) + + # Apply resolved fields to the DeviceInfo + ipod.model_number = resolved.get("model_number", "") + ipod.model_family = resolved.get("model_family", "iPod") + ipod.generation = resolved.get("generation", "") + ipod.capacity = resolved.get("capacity", "") + ipod.color = resolved.get("color", "") + ipod.firewire_guid = resolved.get("firewire_guid", "") + ipod.serial = resolved.get("serial", "") + ipod.firmware = resolved.get("firmware", "") + ipod.usb_pid = resolved.get("usb_pid", 0) + ipod.hashing_scheme = resolved.get("hashing_scheme", -1) + ipod.identification_method = resolved.get("identification_method", "filesystem") + # `DeviceInfo.raw_identity_evidence` expects lists of evidence dicts; + # wrap the hw/fs dicts in single-item lists to satisfy the type. + ipod.raw_identity_evidence = { + "hardware": [hw] if hw is not None else [], + "filesystem": [fs] if fs is not None else [], + } + ipod.identity_conflicts = list(resolved.get("_conflicts", [])) + for field in ( + "family_id", + "updater_family_id", + "product_type", + "usb_vid", + "usb_serial", + "usbstor_instance_id", + "usb_parent_instance_id", + "usb_grandparent_instance_id", + "scsi_vendor", + "scsi_product", + "scsi_revision", + "connected_bus", + "volume_format", + "db_version", + "shadow_db_version", + "uses_sqlite_db", + "supports_sparse_artwork", + "max_tracks", + "max_file_size_gb", + "max_transfer_speed", + "podcasts_supported", + "voice_memos_supported", + "audio_codecs", + "power_information", + "apple_drm_version", + "artwork_formats", + "photo_formats", + "chapter_image_formats", + ): + value = resolved.get(field) + if value not in (None, "", b"", {}, []): + setattr(ipod, field, value) + + # Apply per-field provenance from the resolution phase + resolved_sources = resolved.get("_sources", {}) + if resolved_sources: + ipod._field_sources.update(resolved_sources) + + # Phase 4: Inline VPD for incomplete identification (non-Windows only) + if sys.platform != "win32" and not ipod.model_number and ipod.usb_pid: + _try_vpd_identification(ipod) + + # Extract user-assigned iPod name from master playlist + ipod.ipod_name = _extract_ipod_name(mount_path) + + # Estimate capacity from disk size if still unknown + if not ipod.capacity and ipod.disk_size_gb > 0: + ipod.capacity = _estimate_capacity_from_disk_size(ipod.disk_size_gb) + if ipod.capacity: + ipod._field_sources["capacity"] = "disk_size" + + # Phase 5: Enrich (fills checksum, artwork, HashInfo, etc.) + enrich(ipod) + + ipod_data = ipod.__dict__ + logger.info( + "iPod identified: mount=%s display=%s identity=[%s] caps=[%s] " + "method=%s checksum=%s hash_scheme=%s sources=[%s] conflicts=[%s]", + ipod.path, + ipod.display_name, + format_fields(ipod_data, IDENTITY_FIELDS), + format_fields(ipod_data, CAPABILITY_FIELDS, include_false=True), + ipod.identification_method, + ipod.checksum_type, + ipod.hashing_scheme, + format_sources(ipod._field_sources, SOURCE_FIELDS), + format_conflicts(ipod.identity_conflicts), + ) + + return ipod + + +def _load_virtual_ipod_mount_if_present(mount_path: str) -> DeviceInfo | None: + """Load root-level iPodInfo.json metadata when this is a virtual iPod.""" + + try: + from .virtual import ( + ensure_virtual_itunes_database, + has_virtual_ipod_info, + load_virtual_ipod_info, + ) + + if not has_virtual_ipod_info(mount_path): + return None + ensure_virtual_itunes_database(mount_path) + return load_virtual_ipod_info(mount_path) + except Exception as exc: + logger.warning("Virtual iPod metadata could not be loaded: %s", exc) + return None + + +def scan_for_ipods() -> list[DeviceInfo]: + """ + Scan all mounted volumes for connected iPods. + + Uses a unified four-phase pipeline, then calls ``enrich()`` so each + returned :class:`DeviceInfo` is fully populated (checksum type, artwork + formats, HashInfo, disk stats, etc.). + + **Phase 1 — Hardware probing** (platform-specific): + Windows: Direct IOCTL + device tree walk, with silent WMI fallback. + macOS: system_profiler SPUSBDataType for USB device identification. + Linux: sysfs traversal from block device to USB device. + + **Phase 2 — Filesystem probing** (cross-platform file reads): + SysInfo / SysInfoExtended + iTunesDB header. + + **Phase 3 — Model resolution** (per-field priority merge): + SysInfo ModelNumStr > serial last-3 > USB PID > hashing_scheme. + + **Phase 4 — Inline VPD** (macOS only, for incomplete identification): + If model_number is still unknown after Phase 3, query the iPod's + firmware via IOKit SCSI VPD to get the Apple serial, then resolve + via serial-last-3 lookup. Writes SysInfo so this only runs once. + + **Phase 5 — Enrich** (fills derived fields: checksum, artwork, etc.) + + Returns a list of fully-enriched DeviceInfo objects. + """ + ipods: list[DeviceInfo] = [] + logger.info("iPod scan started") + + candidates: list[tuple[str, str]] = [] + try: + candidates = _find_ipod_volumes() + for mount_path, display_name in candidates: + ipods.append(_identify_ipod_mount(mount_path, display_name)) + finally: + # Clear the macOS ioreg caches so they're fresh on the next rescan. + _clear_macos_usb_cache() + + mounts = ", ".join(display for _path, display in candidates) or "none" + logger.info("iPod scan finished: count=%d mounts=%s", len(ipods), mounts) + return ipods diff --git a/src/vendor/ipod_device/sysinfo.py b/src/vendor/ipod_device/sysinfo.py new file mode 100644 index 0000000..6bf6a62 --- /dev/null +++ b/src/vendor/ipod_device/sysinfo.py @@ -0,0 +1,528 @@ +""" +Parsing and evidence helpers for iPod SysInfo and SysInfoExtended data. + +This module intentionally contains no hardware probing. It accepts bytes or +text gathered from files, SCSI VPD, or USB vendor-control reads and turns them +into source-tagged identity/capability data that the scanner and DeviceInfo +enrichment code can consume consistently. +""" + +from __future__ import annotations + +import plistlib +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Any + +COVER_ART_KEYS: tuple[str, ...] = ( + "AlbumArt", + "AlbumArt2", + "ArtworkFormats", + "CoverArt", + "ArtworkCoverArtFormats", +) + +PHOTO_ART_KEYS: tuple[str, ...] = ( + "ImageSpecifications", + "PhotoFormats", +) + +CHAPTER_ART_KEYS: tuple[str, ...] = ( + "ChapterImageSpecs", + "ChapterImageSpecifications", +) + + +@dataclass(frozen=True) +class EvidenceValue: + """One resolved value with provenance.""" + + value: Any + source: str + live: bool = False + raw_key: str = "" + + +@dataclass +class DeviceEvidence: + """Small source-tagged container for identity and capability evidence.""" + + fields: dict[str, EvidenceValue] = field(default_factory=dict) + blobs: dict[str, Any] = field(default_factory=dict) + + def add( + self, + field_name: str, + value: Any, + source: str, + *, + live: bool = False, + raw_key: str = "", + replace: bool = False, + ) -> None: + if value in (None, "", b""): + return + if field_name in self.fields and not replace: + return + self.fields[field_name] = EvidenceValue( + value=value, + source=source, + live=live, + raw_key=raw_key, + ) + + def as_flat_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {k: v.value for k, v in self.fields.items()} + result["_sources"] = {k: v.source for k, v in self.fields.items()} + return result + + +@dataclass +class ParsedSysInfoExtended: + """Parsed representation of a SysInfoExtended XML/plist blob.""" + + plist: dict[str, Any] + raw_xml: bytes = b"" + source: str = "sysinfo_extended" + live: bool = False + used_regex_fallback: bool = False + + @property + def identity(self) -> dict[str, Any]: + return identity_from_sysinfo_extended(self, self.source, live=self.live) + + @property + def cover_art_formats(self) -> dict[int, tuple[int, int]]: + return extract_image_formats(self.plist, COVER_ART_KEYS) + + @property + def photo_formats(self) -> dict[int, tuple[int, int]]: + return extract_image_formats(self.plist, PHOTO_ART_KEYS) + + @property + def chapter_image_formats(self) -> dict[int, tuple[int, int]]: + return extract_image_formats(self.plist, CHAPTER_ART_KEYS) + + +def normalize_guid(value: Any) -> str: + """Return a compact uppercase 16-hex GUID-ish string, or empty string.""" + if value is None: + return "" + guid = str(value).strip().replace(" ", "") + if guid.startswith(("0x", "0X")): + guid = guid[2:] + if not guid or guid == "0" * len(guid): + return "" + try: + bytes.fromhex(guid) + except ValueError: + return "" + return guid.upper() + + +def _coerce_int(value: Any) -> int | str: + if value in (None, ""): + return 0 + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + text = str(value).strip() + if not text: + return 0 + # SysInfo often stores values like "0x00000003" or + # "0x00000003 (3.0 0)"; only the leading numeric token matters. + token = text.split(None, 1)[0] + try: + return int(token, 0) + except ValueError: + return text + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().casefold() + return text in {"1", "true", "yes", "y", "on"} + + +def parse_sysinfo_text(content: str) -> dict[str, str]: + """Parse plain SysInfo key/value text.""" + result: dict[str, str] = {} + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + result[key.strip()] = value.strip() + return result + + +def identity_from_sysinfo( + sysinfo: dict[str, str], + source: str = "sysinfo", +) -> dict[str, Any]: + """Return normalized DeviceInfo-style fields from a SysInfo dict.""" + result: dict[str, Any] = {"_sources": {}} + sources: dict[str, str] = result["_sources"] + + board = sysinfo.get("BoardHwName", "") + if board: + result["board"] = board + sources["board"] = source + + serial = sysinfo.get("pszSerialNumber", "").strip() + if serial: + result["serial"] = serial + sources["serial"] = source + + guid = normalize_guid(sysinfo.get("FirewireGuid", "")) + if guid: + result["firewire_guid"] = guid + sources["firewire_guid"] = source + + firmware = ( + sysinfo.get("visibleBuildID") + or sysinfo.get("VisibleBuildID") + or sysinfo.get("BuildID") + or "" + ) + if firmware: + result["firmware"] = firmware + sources["firmware"] = source + + raw_model = sysinfo.get("ModelNumStr", "") + if raw_model: + try: + from .lookup import extract_model_number + + model_number = extract_model_number(raw_model) + except Exception: + model_number = raw_model.strip() + if model_number: + result["model_raw"] = raw_model + result["model_number"] = model_number + sources["model_number"] = source + + for key, field_name in ( + ("ModelFamily", "model_family"), + ("Generation", "generation"), + ("Capacity", "capacity"), + ("Color", "color"), + ): + value = sysinfo.get(key, "") + if value: + result[field_name] = value + sources[field_name] = source + + pid = sysinfo.get("USBProductID", "") + if pid: + try: + result["usb_pid"] = int(pid, 0) + sources["usb_pid"] = source + except ValueError: + pass + + for keys, field_name in ( + (("FamilyID", "iPodFamily"), "family_id"), + (("UpdaterFamilyID",), "updater_family_id"), + ): + value = next((sysinfo.get(key) for key in keys if key in sysinfo), None) + if value not in (None, ""): + result[field_name] = _coerce_int(value) + sources[field_name] = source + + return result + + +def parse_sysinfo_extended( + content: bytes | str, + *, + source: str = "sysinfo_extended", + live: bool = False, +) -> ParsedSysInfoExtended: + """Parse SysInfoExtended XML/plist data. + + The same payload can come from an on-disk file, SCSI VPD pages, or Apple's + USB vendor-control command. Some devices return leading/trailing bytes or + truncated XML; plist parsing is tried first, then a conservative regex + fallback extracts scalar fields. + """ + if isinstance(content, str): + raw = content.encode("utf-8", errors="replace") + else: + raw = bytes(content) + + raw = _extract_xml_bytes(raw) + plist: dict[str, Any] = {} + used_fallback = False + + if raw: + parse_candidates = [raw] + if b"" not in raw: + parse_candidates.append(raw + b"\n\n") + + for candidate in parse_candidates: + try: + parsed = plistlib.loads(candidate) + except Exception: + continue + if isinstance(parsed, dict): + plist = parsed + raw = candidate + break + + if not plist: + plist = _parse_sysinfo_extended_regex(raw) + used_fallback = bool(plist) + + return ParsedSysInfoExtended( + plist=plist, + raw_xml=raw, + source=source, + live=live, + used_regex_fallback=used_fallback, + ) + + +def identity_from_sysinfo_extended( + parsed: ParsedSysInfoExtended | dict[str, Any], + source: str = "sysinfo_extended", + *, + live: bool = False, +) -> dict[str, Any]: + """Return normalized DeviceInfo-style fields from SysInfoExtended data.""" + plist = parsed.plist if isinstance(parsed, ParsedSysInfoExtended) else parsed + result: dict[str, Any] = {"_sources": {}} + sources: dict[str, str] = result["_sources"] + + def put(field_name: str, value: Any, raw_key: str = "") -> None: + if value in (None, ""): + return + result[field_name] = value + sources[field_name] = source + + serial = str(plist.get("SerialNumber") or "").strip() + if serial and not serial.upper().startswith("RAND"): + put("serial", serial, "SerialNumber") + + guid = normalize_guid( + plist.get("FireWireGUID") + or plist.get("FirewireGuid") + or plist.get("FireWireGuid") + ) + if guid: + put("firewire_guid", guid, "FireWireGUID") + + firmware = ( + plist.get("FireWireVersion") + or plist.get("scsi_revision") + or plist.get("VisibleBuildID") + or plist.get("BuildID") + or plist.get("visibleBuildID") + or "" + ) + if firmware: + put("firmware", str(firmware), "FireWireVersion") + + board = plist.get("BoardHwName") or plist.get("BoardHwID") or "" + if board: + put("board", str(board), "BoardHwName") + + raw_model = str(plist.get("ModelNumStr") or "").strip() + if raw_model: + try: + from .lookup import extract_model_number + + model_number = extract_model_number(raw_model) + except Exception: + model_number = raw_model + if model_number: + result["model_raw"] = raw_model + put("model_number", model_number, "ModelNumStr") + + for key, field_name in ( + ("FamilyID", "family_id"), + ("UpdaterFamilyID", "updater_family_id"), + ("DBVersion", "db_version"), + ("ShadowDBVersion", "shadow_db_version"), + ("MaxTracks", "max_tracks"), + ("MaxTransferSpeed", "max_transfer_speed"), + ): + if key in plist: + put(field_name, _coerce_int(plist[key]), key) + + for key, field_name in ( + ("ProductType", "product_type"), + ("ConnectedBus", "connected_bus"), + ("VolumeFormat", "volume_format"), + ("scsi_vendor", "scsi_vendor"), + ("scsi_product", "scsi_product"), + ("scsi_revision", "scsi_revision"), + ("usb_serial", "usb_serial"), + ): + if key in plist: + put(field_name, str(plist[key]), key) + + for key, field_name in ( + ("usb_pid", "usb_pid"), + ("usb_vid", "usb_vid"), + ("MaxFileSizeInGB", "max_file_size_gb"), + ): + if key in plist: + put(field_name, _coerce_int(plist[key]), key) + + for key, field_name in ( + ("SQLiteDB", "uses_sqlite_db"), + ("SupportsSparseArtwork", "supports_sparse_artwork"), + ("PodcastsSupported", "podcasts_supported"), + ("VoiceMemosSupported", "voice_memos_supported"), + ): + if key in plist: + put(field_name, _coerce_bool(plist[key]), key) + + for key, field_name in ( + ("AudioCodecs", "audio_codecs"), + ("PowerInformation", "power_information"), + ("AppleDRMVersion", "apple_drm_version"), + ): + value = plist.get(key) + if isinstance(value, dict): + put(field_name, value, key) + + artwork_formats = extract_image_formats(plist, COVER_ART_KEYS) + if artwork_formats: + result["artwork_formats"] = artwork_formats + sources["artwork_formats"] = source + photo_formats = extract_image_formats(plist, PHOTO_ART_KEYS) + if photo_formats: + result["photo_formats"] = photo_formats + sources["photo_formats"] = source + chapter_formats = extract_image_formats(plist, CHAPTER_ART_KEYS) + if chapter_formats: + result["chapter_image_formats"] = chapter_formats + sources["chapter_image_formats"] = source + + if isinstance(parsed, ParsedSysInfoExtended): + if parsed.raw_xml: + result["sysinfo_extended_raw_xml"] = parsed.raw_xml + result["sysinfo_extended_used_regex_fallback"] = parsed.used_regex_fallback + + return result + + +def evidence_from_identity( + identity: dict[str, Any], + *, + source: str, + live: bool = False, +) -> DeviceEvidence: + evidence = DeviceEvidence() + sources = identity.get("_sources", {}) + for key, value in identity.items(): + if key.startswith("_") or key in {"model_raw", "sysinfo_extended_raw_xml"}: + continue + evidence.add( + key, + value, + sources.get(key, source), + live=live, + replace=True, + ) + return evidence + + +def extract_image_formats( + plist: dict[str, Any], + keys: Iterable[str] = COVER_ART_KEYS, +) -> dict[int, tuple[int, int]]: + """Extract image format IDs and dimensions from SysInfoExtended plist data.""" + entries: list[Any] = [] + for key in keys: + value = plist.get(key) + if isinstance(value, list): + entries.extend(value) + + formats: dict[int, tuple[int, int]] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + + fmt_id = ( + entry.get("FormatId") + or entry.get("CorrelationID") + or entry.get("format_id") + ) + if fmt_id is None: + continue + + width = ( + entry.get("RenderWidth") + or entry.get("DisplayWidth") + or entry.get("Width") + or entry.get("width") + ) + height = ( + entry.get("RenderHeight") + or entry.get("DisplayHeight") + or entry.get("Height") + or entry.get("height") + ) + if width is None or height is None: + continue + + try: + fmt_int = int(fmt_id) + width_int = int(width) + height_int = int(height) + except (TypeError, ValueError): + continue + + if fmt_int > 0 and width_int > 0 and height_int > 0: + formats[fmt_int] = (width_int, height_int) + + return formats + + +def _extract_xml_bytes(raw: bytes) -> bytes: + raw = bytes(raw or b"").strip(b"\x00\r\n\t ") + if not raw: + return b"" + for marker in (b"= 0: + raw = raw[idx:] + break + return raw.rstrip(b"\x00") + + +def _parse_sysinfo_extended_regex(raw: bytes) -> dict[str, Any]: + text = raw.decode("utf-8", errors="replace") + result: dict[str, Any] = {} + + for match in re.finditer( + r"([^<]+)\s*" + r"(?:((.*?))|((.*?))|" + r"()|())", + text, + flags=re.DOTALL, + ): + key = match.group(1).strip() + string_val = match.group(3) + int_val = match.group(5) + if string_val is not None: + result[key] = string_val.strip() + elif int_val is not None: + try: + result[key] = int(int_val.strip(), 0) + except ValueError: + result[key] = int_val.strip() + elif match.group(6) is not None: + result[key] = True + elif match.group(7) is not None: + result[key] = False + + return result diff --git a/src/vendor/ipod_device/usb_backend.py b/src/vendor/ipod_device/usb_backend.py new file mode 100644 index 0000000..49ccbb2 --- /dev/null +++ b/src/vendor/ipod_device/usb_backend.py @@ -0,0 +1,132 @@ +""" +PyUSB backend resolution helpers. + +PyUSB is pure Python, but on Windows it still needs a native +``libusb-1.0.dll``. The app vendors the official 64-bit libusb DLL under +``vendor/libusb/windows/x64`` and falls back to system/package locations. +""" + +from __future__ import annotations + +import ctypes.util +import logging +import os +import platform +import sys +from collections.abc import Callable +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def get_libusb_backend(): + """Return a PyUSB libusb1 backend, or ``None`` if no backend can load.""" + try: + import usb.backend.libusb1 + except ImportError: + return None + + backend = usb.backend.libusb1.get_backend() + if backend is not None: + return backend + + for candidate in _candidate_libusb_paths(): + backend = _backend_from_path(usb.backend.libusb1.get_backend, candidate) + if backend is not None: + logger.debug("PyUSB libusb backend loaded from %s", candidate) + return backend + + return None + + +def backend_diagnostic() -> str: + """Return a short human-readable backend diagnostic string.""" + try: + import usb.backend.libusb1 + except ImportError: + return "pyusb is not installed" + + if usb.backend.libusb1.get_backend() is not None: + return "system libusb backend available" + + candidates = list(_candidate_libusb_paths()) + if not candidates: + return "no libusb-1.0 library candidates found" + + existing = [str(path) for path in candidates if path.exists()] + if existing: + return "libusb candidates exist but failed to load: " + ", ".join(existing) + return "libusb candidates missing: " + ", ".join(str(path) for path in candidates) + + +def _backend_from_path(get_backend: Callable, path: Path): + if not path.exists(): + return None + + def _find_library(_name: str) -> str: + return str(path) + + try: + return get_backend(find_library=_find_library) + except Exception as exc: + logger.debug("PyUSB backend load failed from %s: %s", path, exc) + return None + + +def _candidate_libusb_paths() -> list[Path]: + candidates: list[Path] = [] + + for env_name in ("IOPENPOD_LIBUSB_DLL", "PYUSB_LIBUSB_DLL"): + env_path = os.environ.get(env_name, "").strip() + if env_path: + candidates.append(Path(env_path)) + + # Optional helper from the ``libusb-package`` wheel when available. + try: + import libusb_package # type: ignore + + path = libusb_package.find_library() + if path: + candidates.append(Path(path)) + except Exception: + pass + + system_path = ctypes.util.find_library("usb-1.0") + if system_path: + candidates.append(Path(system_path)) + + root = Path(__file__).resolve().parent.parent + exe_dir = Path(sys.executable).resolve().parent + if sys.platform == "win32": + arch = platform.architecture()[0] + if arch == "64bit": + candidates.extend([ + root / "vendor" / "libusb" / "windows" / "x64" / "libusb-1.0.dll", + exe_dir / "libusb-1.0.dll", + exe_dir / "vendor" / "libusb" / "windows" / "x64" / "libusb-1.0.dll", + ]) + else: + candidates.extend([ + root / "vendor" / "libusb" / "windows" / "x86" / "libusb-1.0.dll", + exe_dir / "libusb-1.0.dll", + exe_dir / "vendor" / "libusb" / "windows" / "x86" / "libusb-1.0.dll", + ]) + elif sys.platform == "darwin": + candidates.extend([ + root / "vendor" / "libusb" / "macos" / "libusb-1.0.dylib", + exe_dir / "libusb-1.0.dylib", + ]) + else: + candidates.extend([ + root / "vendor" / "libusb" / "linux" / "libusb-1.0.so", + exe_dir / "libusb-1.0.so", + ]) + + unique: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate).casefold() + if key not in seen: + unique.append(candidate) + seen.add(key) + return unique diff --git a/src/vendor/ipod_device/virtual.py b/src/vendor/ipod_device/virtual.py new file mode 100644 index 0000000..0fdc894 --- /dev/null +++ b/src/vendor/ipod_device/virtual.py @@ -0,0 +1,506 @@ +"""Virtual iPod metadata creation and loading. + +Virtual iPods are ordinary folders seeded with enough iPod identity metadata +for the rest of iOpenPod to treat them like a selected device. +""" + +from __future__ import annotations + +import json +import os +import secrets +import shutil +import string +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .capabilities import capabilities_for_family_gen +from .checksum import CHECKSUM_MHBD_SCHEME, ChecksumType +from .info import DeviceInfo, resolve_itdb_path +from .models import IPOD_MODELS, SERIAL_LAST3_TO_MODEL, USB_PID_TO_MODEL + +VIRTUAL_IPOD_INFO_FILENAME = "iPodInfo.json" +_SCHEMA_VERSION = 1 +_SERIAL_ALPHABET = string.ascii_uppercase + string.digits + + +def virtual_ipod_info_path(ipod_path: str | os.PathLike[str]) -> Path: + """Return the root-level metadata path for a virtual iPod.""" + + return Path(ipod_path) / VIRTUAL_IPOD_INFO_FILENAME + + +def has_virtual_ipod_info(ipod_path: str | os.PathLike[str]) -> bool: + """Return whether *ipod_path* contains virtual iPod metadata.""" + + if not str(ipod_path): + return False + return virtual_ipod_info_path(ipod_path).is_file() + + +def available_virtual_ipod_models() -> list[dict[str, str]]: + """Return model choices that can be backed by a known serial suffix.""" + + suffix_by_model = _serial_suffix_by_model() + rows: list[dict[str, str]] = [] + for model_number, (family, generation, capacity, color) in IPOD_MODELS.items(): + suffix = suffix_by_model.get(model_number) + if not suffix: + continue + rows.append( + { + "model_number": model_number, + "model_family": family, + "generation": generation, + "capacity": capacity, + "color": color, + "serial_suffix": suffix, + "display_name": _model_display_name( + model_number, + family, + generation, + capacity, + color, + ), + } + ) + return sorted( + rows, + key=lambda row: ( + row["model_family"], + row["generation"], + row["capacity"], + row["color"], + row["model_number"], + ), + ) + + +def create_virtual_ipod( + ipod_path: str | os.PathLike[str], + model_number: str, + *, + ipod_name: str = "iPod", +) -> DeviceInfo: + """Create a virtual iPod root and return its hydrated DeviceInfo.""" + + root = Path(ipod_path).expanduser().resolve() + if not model_number: + raise ValueError("Choose an iPod model") + if model_number not in IPOD_MODELS: + raise ValueError(f"Unknown iPod model: {model_number}") + + suffix = _serial_suffix_by_model().get(model_number) + if not suffix: + raise ValueError(f"No known serial suffix for model {model_number}") + + family, generation, capacity, color = IPOD_MODELS[model_number] + caps = capabilities_for_family_gen(family, generation) + checksum = caps.checksum if caps is not None else ChecksumType.NONE + firewire_guid = _generate_firewire_guid() + serial = _generate_serial(suffix) + hash_iv = secrets.token_bytes(16) + hash_rndpart = secrets.token_bytes(12) + + _seed_ipod_layout(root, caps) + + payload: dict[str, Any] = { + "schema_version": _SCHEMA_VERSION, + "created_by": "iOpenPod", + "created_at": datetime.now(UTC).isoformat(), + "ipod_name": ipod_name.strip() or "iPod", + "mount_name": root.name or "iPod", + "model_number": model_number, + "model_family": family, + "generation": generation, + "capacity": capacity, + "color": color, + "serial": serial, + "serial_suffix": suffix, + "firewire_guid": firewire_guid, + "firmware": _default_firmware(family, generation), + "board": _default_board_name(family, generation), + "family_id": _default_family_id(family), + "updater_family_id": _default_family_id(family), + "product_type": model_number, + "usb_vid": 0x05AC, + "usb_pid": _usb_pid_for_identity(family, generation), + "usb_serial": firewire_guid, + "connected_bus": "USB", + "volume_format": "FAT32", + "scsi_vendor": "Apple", + "scsi_product": "iPod", + "scsi_revision": _default_firmware(family, generation), + "checksum_type": int(checksum), + "hashing_scheme": CHECKSUM_MHBD_SCHEME.get(checksum, 0), + "hash_info_iv": hash_iv.hex().upper(), + "hash_info_rndpart": hash_rndpart.hex().upper(), + "db_version": caps.db_version if caps is not None else 0, + "shadow_db_version": caps.shadow_db_version if caps is not None else 0, + "uses_sqlite_db": caps.uses_sqlite_db if caps is not None else False, + "supports_sparse_artwork": ( + caps.supports_sparse_artwork if caps is not None else False + ), + "podcasts_supported": caps.supports_podcast if caps is not None else True, + "voice_memos_supported": False, + "artwork_formats": _format_map( + caps.cover_art_formats if caps is not None else () + ), + "photo_formats": _format_map( + caps.photo_formats if caps is not None else () + ), + "chapter_image_formats": {}, + } + + _write_json(root, payload) + _write_virtual_sysinfo(root, payload) + _write_virtual_hash_info(root, firewire_guid, hash_iv, hash_rndpart) + ensure_virtual_itunes_database(root) + return load_virtual_ipod_info(root) + + +def ensure_virtual_itunes_database(ipod_path: str | os.PathLike[str]) -> str | None: + """Create an empty iTunesDB/iTunesCDB for a virtual iPod if it is missing.""" + + root = Path(ipod_path).expanduser().resolve() + existing = resolve_itdb_path(str(root)) + if existing: + return existing + + info = load_virtual_ipod_info(root) + caps = capabilities_for_family_gen(info.model_family, info.generation) + _write_empty_itunes_database( + root, + caps, + ipod_name=info.ipod_name or "iPod", + device_info=info, + ) + return resolve_itdb_path(str(root)) + + +def load_virtual_ipod_info( + ipod_path: str | os.PathLike[str], +) -> DeviceInfo: + """Load a virtual iPod root into a normal DeviceInfo object.""" + + root = Path(ipod_path).expanduser().resolve() + path = virtual_ipod_info_path(root) + if not path.is_file(): + raise FileNotFoundError(f"Virtual iPod metadata not found at {path}") + + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"Invalid virtual iPod metadata at {path}") + + info = DeviceInfo( + path=str(root), + mount_name=str(payload.get("mount_name") or root.name or "iPod"), + ) + info.ipod_name = str(payload.get("ipod_name") or "") + for field in ( + "model_number", + "model_family", + "generation", + "capacity", + "color", + "firewire_guid", + "serial", + "firmware", + "board", + "product_type", + "connected_bus", + "volume_format", + "scsi_vendor", + "scsi_product", + "scsi_revision", + ): + value = payload.get(field) + if value not in (None, ""): + setattr(info, field, str(value)) + info._field_sources[field] = VIRTUAL_IPOD_INFO_FILENAME + + for field in ( + "family_id", + "updater_family_id", + "usb_pid", + "usb_vid", + "db_version", + "shadow_db_version", + "checksum_type", + "hashing_scheme", + ): + raw_value = payload.get(field) + if raw_value not in (None, ""): + value = _coerce_int(raw_value) + setattr(info, field, value) + info._field_sources[field] = VIRTUAL_IPOD_INFO_FILENAME + + for field in ( + "uses_sqlite_db", + "supports_sparse_artwork", + "podcasts_supported", + "voice_memos_supported", + ): + if field in payload: + setattr(info, field, bool(payload.get(field))) + info._field_sources[field] = VIRTUAL_IPOD_INFO_FILENAME + + info.usb_serial = str(payload.get("usb_serial") or info.firewire_guid or "") + if info.usb_serial: + info._field_sources["usb_serial"] = VIRTUAL_IPOD_INFO_FILENAME + + info.hash_info_iv = _bytes_from_hex(payload.get("hash_info_iv"), 16) + info.hash_info_rndpart = _bytes_from_hex(payload.get("hash_info_rndpart"), 12) + if info.hash_info_iv: + info._field_sources["hash_info_iv"] = VIRTUAL_IPOD_INFO_FILENAME + if info.hash_info_rndpart: + info._field_sources["hash_info_rndpart"] = VIRTUAL_IPOD_INFO_FILENAME + + info.artwork_formats = _coerce_format_map(payload.get("artwork_formats")) + info.photo_formats = _coerce_format_map(payload.get("photo_formats")) + info.chapter_image_formats = _coerce_format_map( + payload.get("chapter_image_formats") + ) + for field in ("artwork_formats", "photo_formats", "chapter_image_formats"): + if getattr(info, field): + info._field_sources[field] = VIRTUAL_IPOD_INFO_FILENAME + + caps = capabilities_for_family_gen(info.model_family, info.generation) + if caps is not None: + if not info.db_version: + info.db_version = caps.db_version + if info.checksum_type == 99: + info.checksum_type = int(caps.checksum) + if not info.artwork_formats: + info.artwork_formats = _format_map(caps.cover_art_formats) + if not info.photo_formats: + info.photo_formats = _format_map(caps.photo_formats) + info.shadow_db_version = info.shadow_db_version or caps.shadow_db_version + info.uses_sqlite_db = bool(info.uses_sqlite_db or caps.uses_sqlite_db) + info.supports_sparse_artwork = bool( + info.supports_sparse_artwork or caps.supports_sparse_artwork + ) + info.podcasts_supported = bool(info.podcasts_supported or caps.supports_podcast) + + try: + total_bytes, _used_bytes, free_bytes = shutil.disk_usage(root) + info.disk_size_gb = round(total_bytes / 1e9, 1) + info.free_space_gb = round(free_bytes / 1e9, 1) + except OSError: + pass + + info.identification_method = "filesystem" + return info + + +def _serial_suffix_by_model() -> dict[str, str]: + suffix_by_model: dict[str, str] = {} + for suffix, model_number in sorted(SERIAL_LAST3_TO_MODEL.items()): + suffix_by_model.setdefault(model_number, suffix) + return suffix_by_model + + +def _model_display_name( + model_number: str, + family: str, + generation: str, + capacity: str, + color: str, +) -> str: + parts = [family, generation, capacity, color] + return f"{' '.join(part for part in parts if part)} ({model_number})" + + +def _generate_serial(suffix: str) -> str: + prefix = "".join(secrets.choice(_SERIAL_ALPHABET) for _ in range(8)) + return prefix + suffix + + +def _generate_firewire_guid() -> str: + while True: + guid = secrets.token_hex(8).upper() + if guid != "0" * 16: + return guid + + +def _seed_ipod_layout(root: Path, caps: Any | None) -> None: + (root / "iPod_Control" / "Device").mkdir(parents=True, exist_ok=True) + (root / "iPod_Control" / "iTunes").mkdir(parents=True, exist_ok=True) + (root / "iPod_Control" / "Music").mkdir(parents=True, exist_ok=True) + (root / "iPod_Control" / "Artwork").mkdir(parents=True, exist_ok=True) + if caps is not None and caps.uses_sqlite_db: + ( + root + / "iPod_Control" + / "iTunes" + / "iTunes Library.itlp" + ).mkdir(parents=True, exist_ok=True) + + +def _write_json(root: Path, payload: dict[str, Any]) -> None: + with virtual_ipod_info_path(root).open("w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def _write_virtual_sysinfo(root: Path, payload: dict[str, Any]) -> None: + sysinfo_path = root / "iPod_Control" / "Device" / "SysInfo" + fields = { + "ModelNumStr": payload.get("model_number", ""), + "FirewireGuid": payload.get("firewire_guid", ""), + "pszSerialNumber": payload.get("serial", ""), + "BoardHwName": payload.get("board", ""), + "visibleBuildID": payload.get("firmware", ""), + "ModelFamily": payload.get("model_family", ""), + "Generation": payload.get("generation", ""), + "Capacity": payload.get("capacity", ""), + "Color": payload.get("color", ""), + "USBProductID": _hex_int(payload.get("usb_pid")), + "FamilyID": _hex_int(payload.get("family_id")), + "UpdaterFamilyID": _hex_int(payload.get("updater_family_id")), + } + lines = [f"{key}: {value}" for key, value in fields.items() if value not in ("", None)] + sysinfo_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _write_virtual_hash_info( + root: Path, + firewire_guid: str, + hash_iv: bytes, + hash_rndpart: bytes, +) -> None: + try: + from iTunesDB_Writer.hash72 import write_hash_info + + uuid = bytearray(20) + guid_bytes = bytes.fromhex(firewire_guid) + uuid[: len(guid_bytes)] = guid_bytes + write_hash_info(str(root), bytes(uuid), hash_iv, hash_rndpart) + except Exception: + # The in-memory DeviceInfo carries the same values; this file is a + # compatibility cache for non-GUI write paths. + return + + +def _write_empty_itunes_database( + root: Path, + caps: Any | None, + *, + ipod_name: str, + device_info: DeviceInfo, +) -> None: + """Seed the virtual iPod with an empty database so normal loading succeeds.""" + + from iTunesDB_Writer import write_itunesdb + + from .info import get_current_device, set_current_device + + previous_device = get_current_device() + set_current_device(device_info) + try: + ok = write_itunesdb( + str(root), + [], + backup=False, + pc_file_paths=None, + capabilities=caps, + master_playlist_name=ipod_name, + ) + finally: + set_current_device(previous_device) + + if not ok: + raise RuntimeError("Failed to create an empty iTunesDB for the virtual iPod") + + +def _default_firmware(family: str, generation: str) -> str: + if family == "iPod Classic": + return "2.0.5" + if family == "iPod Nano" and generation in {"5th Gen", "6th Gen", "7th Gen"}: + return "1.0.4" + if "Video" in family: + return "1.3" + return "1.0" + + +def _default_board_name(family: str, generation: str) -> str: + text = f"{family} {generation}".strip() + return "".join(ch for ch in text if ch.isalnum()) or "iPod" + + +def _default_family_id(family: str) -> int: + family_norm = family.casefold() + if "shuffle" in family_norm: + return 0x00000006 + if "nano" in family_norm: + return 0x0000000A + if "classic" in family_norm: + return 0x0000000B + if "mini" in family_norm: + return 0x00000008 + return 0x00000001 + + +def _usb_pid_for_identity(family: str, generation: str) -> int: + normal_pids = { + pid: identity + for pid, identity in USB_PID_TO_MODEL.items() + if not (0x1240 <= pid <= 0x1255) + } + for pid, (pid_family, pid_generation) in normal_pids.items(): + if pid_family == family and pid_generation == generation: + return pid + for pid, (pid_family, pid_generation) in normal_pids.items(): + if pid_family == family and not pid_generation: + return pid + return 0 + + +def _format_map(formats: Any) -> dict[int, tuple[int, int]]: + result: dict[int, tuple[int, int]] = {} + for fmt in formats or (): + fmt_id = int(fmt.format_id) + result[fmt_id] = (int(fmt.width), int(fmt.height)) + return result + + +def _coerce_format_map(value: Any) -> dict[int, tuple[int, int]]: + if not isinstance(value, dict): + return {} + result: dict[int, tuple[int, int]] = {} + for key, item in value.items(): + try: + fmt_id = int(key) + width, height = item + result[fmt_id] = (int(width), int(height)) + except (TypeError, ValueError): + continue + return result + + +def _coerce_int(value: Any) -> int: + if isinstance(value, bool) or value in (None, ""): + return 0 + if isinstance(value, int): + return value + try: + return int(str(value), 0) + except ValueError: + return 0 + + +def _bytes_from_hex(value: Any, expected_len: int) -> bytes: + if not value: + return b"" + try: + data = bytes.fromhex(str(value)) + except ValueError: + return b"" + return data if len(data) == expected_len else b"" + + +def _hex_int(value: Any) -> str: + number = _coerce_int(value) + return f"0x{number:08X}" if number else "" diff --git a/src/vendor/ipod_device/vpd_iokit.py b/src/vendor/ipod_device/vpd_iokit.py new file mode 100644 index 0000000..11d2a64 --- /dev/null +++ b/src/vendor/ipod_device/vpd_iokit.py @@ -0,0 +1,617 @@ +""" +macOS-only IOKit SCSI VPD query for iPods. + +Uses IOKit's SCSITaskLib CFPlugIn to send SCSI INQUIRY VPD commands +directly to iPod hardware without requiring root, driver detach, or +disk unmount. Provides the same dict shape as vpd_libusb so +ipod_device.info._enrich_from_usb_vpd can consume it unchanged. + +Requirements: macOS only (IOKit framework). No third-party packages. +""" + +from __future__ import annotations + +import ctypes +import logging +import plistlib +import re +import struct +import sys +from ctypes import ( + POINTER, + Structure, + byref, + c_char_p, + c_int32, + c_uint8, + c_uint32, + c_uint64, + c_void_p, + cast, + create_string_buffer, +) + +if sys.platform != "darwin": + raise ImportError("ipod_device.vpd_iokit is macOS-only") + +log = logging.getLogger(__name__) + +# ── IOKit / CoreFoundation via ctypes ──────────────────────────────── + +_cf = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" +) +_iok = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/IOKit.framework/IOKit" +) + +# CFString helpers +_cf.CFStringGetCString.argtypes = [c_void_p, c_char_p, c_int32, c_uint32] +_cf.CFStringGetCString.restype = ctypes.c_bool +_cf.CFRelease.argtypes = [c_void_p] +_cf.CFRelease.restype = None +_cf.CFGetTypeID.argtypes = [c_void_p] +_cf.CFGetTypeID.restype = c_uint64 +_cf.CFStringGetTypeID.argtypes = [] +_cf.CFStringGetTypeID.restype = c_uint64 +_cf.CFNumberGetTypeID.argtypes = [] +_cf.CFNumberGetTypeID.restype = c_uint64 +_cf.CFNumberGetValue.argtypes = [c_void_p, c_int32, c_void_p] +_cf.CFNumberGetValue.restype = ctypes.c_bool + +_cf.CFUUIDGetConstantUUIDWithBytes.restype = c_void_p +_cf.CFUUIDGetConstantUUIDWithBytes.argtypes = [c_void_p] + [c_uint8] * 16 + +_cf.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32] +_cf.CFStringCreateWithCString.restype = c_void_p +kCFStringEncodingUTF8 = 0x08000100 + +# IOKit registry +_iok.IOServiceMatching.restype = c_void_p +_iok.IOServiceMatching.argtypes = [c_char_p] +_iok.IOServiceGetMatchingServices.argtypes = [c_uint32, c_void_p, POINTER(c_uint32)] +_iok.IOServiceGetMatchingServices.restype = c_int32 +_iok.IOIteratorNext.argtypes = [c_uint32] +_iok.IOIteratorNext.restype = c_uint32 +_iok.IOObjectRelease.argtypes = [c_uint32] +_iok.IOObjectRelease.restype = c_int32 +_iok.IORegistryEntryGetParentEntry.argtypes = [c_uint32, c_char_p, POINTER(c_uint32)] +_iok.IORegistryEntryGetParentEntry.restype = c_int32 +_iok.IORegistryEntryCreateCFProperty.argtypes = [c_uint32, c_void_p, c_void_p, c_uint32] +_iok.IORegistryEntryCreateCFProperty.restype = c_void_p +_iok.IORegistryEntryGetName.argtypes = [c_uint32, c_char_p] +_iok.IORegistryEntryGetName.restype = c_int32 + +_iok.IOCreatePlugInInterfaceForService.argtypes = [ + c_uint32, c_void_p, c_void_p, POINTER(c_void_p), POINTER(c_int32) +] +_iok.IOCreatePlugInInterfaceForService.restype = c_int32 + +kIOServicePlane = b"IOService" + +# ── UUIDs ──────────────────────────────────────────────────────────── + + +def _make_uuid(*b: int) -> c_void_p: + return _cf.CFUUIDGetConstantUUIDWithBytes(None, *[c_uint8(x) for x in b]) + + +_kSCSITaskDeviceUserClientTypeID = _make_uuid( + 0x7D, 0x66, 0x67, 0x8E, 0x08, 0xA2, 0x11, 0xD5, + 0xA1, 0xB8, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A, +) +_kIOCFPlugInInterfaceID = _make_uuid( + 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, + 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F, +) +# Actual SCSITaskDeviceInterfaceID (from binary disassembly — differs +# from the documented kSCSITaskDeviceInterfaceID = 6BD48AE0-…). +_kSCSITaskDeviceInterfaceID_bytes = bytes([ + 0x1B, 0xBC, 0x41, 0x32, 0x08, 0xA5, 0x11, 0xD5, + 0x90, 0xED, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A, +]) + +# ── SCSI structures ───────────────────────────────────────────────── + + +class _IOVirtualRange(Structure): + _fields_ = [("address", c_uint64), ("length", c_uint64)] + + +class _SCSISenseData(Structure): + _fields_ = [("data", c_uint8 * 18)] + + +_kSCSIDataTransfer_FromTargetToInitiator = 2 + +# ── COM vtable helpers ─────────────────────────────────────────────── +# +# IOKit CFPlugIn interfaces use COM-style vtables: +# obj → *vtable → [NULL, QI, AddRef, Release, version(0x1), method5, ...] +# +# SCSITaskDeviceInterface vtable (slots 5–10): +# [5] IsExclusiveAccessAvailable [6] AddCallbackDispatcherToRunLoop +# [7] RemoveCallbackDispatcherFromRunLoop [8] ObtainExclusiveAccess +# [9] ReleaseExclusiveAccess [10] CreateSCSITask +# +# SCSITaskInterface vtable (slots 5–24): +# [5] IsTaskActive [6] SetTaskAttribute [7] GetTaskAttribute +# [8] SetCommandDescriptorBlock(self, uint8*, uint8) +# [9] GetCommandDescriptorBlockSize [10] GetCommandDescriptorBlock +# [11] SetScatterGatherEntries(self, IOVirtualRange*, uint8, uint64, uint8) +# [12] SetTimeoutDuration(self, uint32) +# [13] GetTimeoutDuration [14] SetTaskCompletionCallback +# [15] ExecuteTaskAsync +# [16] ExecuteTaskSync(self, SenseData*, TaskStatus*, uint64*) +# [17] AbortTask [18] GetServiceResponse [19] GetTaskState +# [20] GetTaskStatus [21] GetRealizedDataTransferCount +# [22] GetAutoSenseData [23] SetSenseDataBuffer +# [24] ResetForNewTask + + +def _vt_ptr(obj: c_void_p, slot: int): + """Return raw function pointer at vtable[slot].""" + vt_base = cast(obj, POINTER(c_void_p))[0] + return cast(vt_base, POINTER(c_void_p))[slot] + + +def _vt_call(obj: c_void_p, slot: int, restype, argtypes, *args): + """Call vtable[slot](obj, *args).""" + fptr = _vt_ptr(obj, slot) + fn = ctypes.CFUNCTYPE(restype, c_void_p, *argtypes)(fptr) + return fn(obj, *args) + + +# ── IOKit registry helpers ─────────────────────────────────────────── + +def _cf_property_string(entry: int, key: str) -> str | None: + """Read a string property from an IOKit registry entry.""" + cf_key = _cf.CFStringCreateWithCString( + None, key.encode(), kCFStringEncodingUTF8 + ) + if not cf_key: + return None + try: + cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0) + if not cf_val: + return None + try: + if _cf.CFGetTypeID(cf_val) != _cf.CFStringGetTypeID(): + return None + buf = ctypes.create_string_buffer(512) + if _cf.CFStringGetCString(cf_val, buf, 512, kCFStringEncodingUTF8): + return buf.value.decode("utf-8", errors="replace") + return None + finally: + _cf.CFRelease(cf_val) + finally: + _cf.CFRelease(cf_key) + + +def _cf_property_int(entry: int, key: str) -> int | None: + """Read an integer property from an IOKit registry entry.""" + cf_key = _cf.CFStringCreateWithCString( + None, key.encode(), kCFStringEncodingUTF8 + ) + if not cf_key: + return None + try: + cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0) + if not cf_val: + return None + try: + if _cf.CFGetTypeID(cf_val) != _cf.CFNumberGetTypeID(): + return None + val = c_int32(0) + # kCFNumberSInt32Type = 3 + _cf.CFNumberGetValue(cf_val, 3, byref(val)) + return val.value + finally: + _cf.CFRelease(cf_val) + finally: + _cf.CFRelease(cf_key) + + +def _walk_parents_for_usb_info(service: int) -> dict: + """Walk IOKit registry parents to find USB device properties.""" + result: dict = {} + entry = service + # Walk up to 10 levels — wrapped in try/finally so the last + # parent IOKit handle is always released even on unexpected errors. + try: + for _ in range(10): + parent = c_uint32(0) + kr = _iok.IORegistryEntryGetParentEntry(entry, kIOServicePlane, byref(parent)) + if kr != 0: + break + # Check for USB device properties + pid = _cf_property_int(parent.value, "idProduct") + if pid is not None and "usb_pid" not in result: + result["usb_pid"] = pid + vid = _cf_property_int(parent.value, "idVendor") + if vid is not None and "usb_vid" not in result: + result["usb_vid"] = vid + serial = _cf_property_string(parent.value, "USB Serial Number") + if serial and "usb_serial" not in result: + result["usb_serial"] = serial + if entry != service: + _iok.IOObjectRelease(entry) + entry = parent.value + if "usb_pid" in result and "usb_serial" in result: + break + finally: + if entry != service: + try: + _iok.IOObjectRelease(entry) + except Exception: + pass + return result + + +# ── SCSI command helpers ───────────────────────────────────────────── + +class _SCSISession: + """Manages exclusive access to one iPod's SCSI interface.""" + + def __init__(self, service: int): + self._service = service + self._plugin: c_void_p | None = None + self._device_if: c_void_p | None = None + self._task = None + self._exclusive = False + + def open(self) -> bool: + """Create plugin → QueryInterface → ObtainExclusiveAccess → CreateTask.""" + # 1. Create plugin + plugin = c_void_p(0) + score = c_int32(0) + kr = _iok.IOCreatePlugInInterfaceForService( + self._service, + _kSCSITaskDeviceUserClientTypeID, + _kIOCFPlugInInterfaceID, + byref(plugin), + byref(score), + ) + if kr != 0: + log.debug("IOCreatePlugInInterfaceForService failed: 0x%08X", kr) + return False + self._plugin = plugin + + # 2. QueryInterface for SCSITaskDeviceInterface + uuid_lo, uuid_hi = struct.unpack(" bytes | None: + """Send SCSI INQUIRY. Returns response bytes or None on failure.""" + if not self._task: + return None + + task = c_void_p(self._task) + + # ResetForNewTask [24] + _vt_call(task, 24, c_int32, []) + + # SetCDB [8] + cdb = (c_uint8 * 16)( + 0x12, + 0x01 if evpd else 0x00, + page & 0xFF, + 0x00, + alloc_len & 0xFF, + 0x00, + *([0] * 10), + ) + kr = _vt_call( + task, 8, c_int32, + [POINTER(c_uint8), c_uint8], + cast(cdb, POINTER(c_uint8)), c_uint8(6), + ) + if kr != 0: + return None + + # SetScatterGatherEntries [11] + resp = create_string_buffer(alloc_len) + iovr = _IOVirtualRange(address=ctypes.addressof(resp), length=alloc_len) + kr = _vt_call( + task, 11, c_int32, + [POINTER(_IOVirtualRange), c_uint8, c_uint64, c_uint8], + byref(iovr), c_uint8(1), c_uint64(alloc_len), + c_uint8(_kSCSIDataTransfer_FromTargetToInitiator), + ) + if kr != 0: + return None + + # SetTimeoutDuration [12] + _vt_call(task, 12, c_int32, [c_uint32], c_uint32(10000)) + + # ExecuteTaskSync [16] + sense = _SCSISenseData() + status = c_uint32(0) + realized = c_uint64(0) + kr = _vt_call( + task, 16, c_int32, + [POINTER(_SCSISenseData), POINTER(c_uint32), POINTER(c_uint64)], + byref(sense), byref(status), byref(realized), + ) + if kr != 0 or realized.value == 0: + return None + + return bytes(resp)[: realized.value] + + +# ── VPD parsing ────────────────────────────────────────────────────── + +def _read_vpd_pages(session: _SCSISession) -> bytes: + """Read and concatenate VPD data pages (0xC2+) from iPod.""" + # Page 0xC0 lists the available data pages + data_pages: list[int] = [] + c0 = session.inquiry(evpd=True, page=0xC0) + if c0 and len(c0) >= 4: + count = c0[3] + data_pages = [p for p in c0[4:4 + count] if p >= 0xC2] + if not data_pages: + # Fallback: try 0xC2–0xFF + data_pages = list(range(0xC2, 0x100)) + + raw = bytearray() + for page in data_pages: + resp = session.inquiry(evpd=True, page=page) + if not resp or len(resp) < 4: + continue + payload_len = resp[3] + payload = resp[4:4 + payload_len] + if not any(payload): + continue + raw.extend(payload) + + # Strip trailing nulls + return bytes(raw).rstrip(b"\x00") + + +def _parse_vpd_xml(raw: bytes) -> dict: + """Parse XML plist from concatenated VPD page data.""" + result: dict = {} + if not raw: + return result + + # Find XML start + for marker in (b"= 0: + raw = raw[idx:] + break + + # Ensure plist is closed (may be truncated) + if b"" not in raw: + raw = raw + b"\n\n" + + try: + plist = plistlib.loads(raw) + if isinstance(plist, dict): + result = plist + except Exception: + # Fallback: regex extraction + result = _parse_vpd_regex(raw) + + return result + + +def _parse_vpd_regex(raw: bytes) -> dict: + """Regex fallback for truncated XML plists.""" + result: dict = {} + text = raw.decode("utf-8", errors="replace") + for m in re.finditer( + r"([^<]+)\s*<(string|integer)>([^<]*)", text + ): + key, typ, val = m.group(1), m.group(2), m.group(3) + if typ == "integer": + try: + result[key] = int(val) + except ValueError: + result[key] = val + else: + result[key] = val + return result + + +# ── Public API ─────────────────────────────────────────────────────── + +def query_ipod_vpd( + usb_pid: int = 0, serial_filter: str = "" +) -> dict | None: + """ + Query a single iPod's device info via IOKit SCSI VPD. + + No root required. Does not detach the mass-storage driver or + unmount the iPod disk — the device stays mounted throughout. + + Parameters + ---------- + usb_pid : int + Target specific USB Product ID (0 = any). + serial_filter : str + Target specific USB serial / FireWire GUID (case-insensitive). + + Returns + ------- + dict or None + Keys include ``SerialNumber``, ``FireWireGUID``, ``FamilyID``, + ``UpdaterFamilyID``, ``vpd_raw_xml``, ``scsi_vendor``, etc. + """ + match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub") + if not match_dict: + return None + iterator = c_uint32(0) + kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator)) + if kr != 0: + return None + + try: + while True: + svc = _iok.IOIteratorNext(iterator.value) + if svc == 0: + break + try: + result = _query_one_service(svc, usb_pid, serial_filter) + if result is not None: + return result + except Exception as exc: + log.debug("Failed to query iPod service %d: %s", svc, exc) + finally: + _iok.IOObjectRelease(svc) + finally: + _iok.IOObjectRelease(iterator.value) + + return None + + +def query_all_ipods() -> list[dict]: + """ + Query every connected iPod via IOKit SCSI VPD. + + Returns a list of dicts (same format as ``query_ipod_vpd``). + No root required; disks stay mounted. + """ + match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub") + if not match_dict: + return [] + iterator = c_uint32(0) + kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator)) + if kr != 0: + return [] + + results: list[dict] = [] + try: + while True: + svc = _iok.IOIteratorNext(iterator.value) + if svc == 0: + break + try: + result = _query_one_service(svc, 0, "") + if result is not None: + results.append(result) + except Exception as exc: + log.debug("Failed to query iPod service %d: %s", svc, exc) + finally: + _iok.IOObjectRelease(svc) + finally: + _iok.IOObjectRelease(iterator.value) + + return results + + +# ── Internal ───────────────────────────────────────────────────────── + +def _query_one_service( + service: int, usb_pid: int, serial_filter: str +) -> dict | None: + """Query one iPod IOKit service. Returns dict or None.""" + # Get USB info from IOKit registry parents + usb_info = _walk_parents_for_usb_info(service) + + # Apply filters + if usb_pid and usb_info.get("usb_pid") != usb_pid: + return None + if serial_filter: + svc_serial = usb_info.get("usb_serial", "") + if svc_serial.upper() != serial_filter.upper(): + return None + + with _SCSISession(service) as session: + if not session.open(): + log.debug("Failed to open SCSI session for service %d", service) + return None + + info: dict = { + "_source": "scsi_vpd", + "_transport": "iokit_scsi_vpd", + } + + # USB identifiers + if "usb_vid" in usb_info: + info["usb_vid"] = usb_info["usb_vid"] + if "usb_pid" in usb_info: + info["usb_pid"] = usb_info["usb_pid"] + if "usb_serial" in usb_info: + info["usb_serial"] = usb_info["usb_serial"] + + # Standard INQUIRY + std = session.inquiry(evpd=False, page=0, alloc_len=96) + if std and len(std) >= 36: + info["scsi_vendor"] = std[8:16].decode("ascii", errors="replace").strip() + info["scsi_product"] = std[16:32].decode("ascii", errors="replace").strip() + info["scsi_revision"] = std[32:36].decode("ascii", errors="replace").strip() + + # VPD page 0x80 — Unit Serial Number + p80 = session.inquiry(evpd=True, page=0x80) + if p80 and len(p80) > 4: + sn = p80[4:].split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() + if sn: + info["vpd_serial"] = sn + + # VPD data pages → XML plist + raw_xml = _read_vpd_pages(session) + if raw_xml: + info["vpd_raw_xml"] = raw_xml + plist_data = _parse_vpd_xml(raw_xml) + info.update(plist_data) + + return info if info else None diff --git a/src/vendor/ipod_device/vpd_libusb.py b/src/vendor/ipod_device/vpd_libusb.py new file mode 100644 index 0000000..3cd0501 --- /dev/null +++ b/src/vendor/ipod_device/vpd_libusb.py @@ -0,0 +1,1353 @@ +""" +Query iPod device information via SCSI INQUIRY VPD pages over USB. + +Apple iPods respond to SCSI INQUIRY with vendor-specific VPD (Vital Product +Data) pages 0xC0-0xFF, which contain a fragmented XML plist with detailed +device information including: + + - SerialNumber (Apple serial — last 3 chars encode exact model) + - FireWireGUID + - FamilyID / UpdaterFamilyID + - BuildID / VisibleBuildID + - ImageSpecifications (artwork format details) + - Audio codec capabilities + +**Platform notes**: + +- **macOS / Linux**: Root/sudo is required because the kernel mass-storage + driver must be temporarily detached to send raw SCSI commands via USB bulk + endpoints. The iPod disk will briefly unmount and remount. +- **Windows**: No elevation needed — libusb can access the device through + WinUSB / libusb-win32 without detaching any driver. + +Usage +~~~~~ +Standalone (writes SysInfo to iPod):: + + # macOS / Linux + sudo uv run python -m ipod_device.vpd_libusb [--write-sysinfo] + + # Windows (no elevation needed for query, may need admin for write) + uv run python -m ipod_device.vpd_libusb [--write-sysinfo] + + # Any platform — manually specify iPod mount path + sudo uv run python -m ipod_device.vpd_libusb --write-sysinfo --path /Volumes/IPOD + +From code:: + + from ipod_device.vpd_libusb import query_ipod_vpd, query_all_ipods + info = query_ipod_vpd(usb_pid=0x1261) # one device + all_info = query_all_ipods() # all connected iPods +""" + +from __future__ import annotations + +import importlib.util +import logging +import os +import plistlib +import re +import struct +import subprocess +import sys + +from .diagnostic_log import CAPABILITY_FIELDS, IDENTITY_FIELDS, format_fields +from .models import IPOD_USB_PIDS as IPOD_PIDS +from .usb_backend import backend_diagnostic, get_libusb_backend + +logger = logging.getLogger(__name__) + +# Prevents console windows from flashing on Windows during subprocess calls +_SP_KWARGS: dict = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} +) + +# Apple USB Vendor ID +APPLE_VID = 0x05AC + + +def _find_ipod_devices() -> list: + """Find all connected iPod USB devices. + + Returns a list of pyusb Device objects. + """ + try: + import usb.core + except ImportError: + logger.error("pyusb not installed — run: uv add pyusb") + return [] + + backend = get_libusb_backend() + if backend is None: + logger.debug("No PyUSB backend available: %s", backend_diagnostic()) + return [] + + devices = [] + found = usb.core.find(find_all=True, idVendor=APPLE_VID, backend=backend) + if found is None: + return devices + for dev in found: + if dev.idProduct in IPOD_PIDS: # type: ignore[union-attr] + devices.append(dev) + return devices + + +def _scsi_inquiry(dev, ep_out, ep_in, tag: int, cdb: bytes, + transfer_len: int) -> tuple[bytes, int]: + """Send a SCSI CDB via USB Mass Storage Bulk-Only CBW, return (data, status). + + The CBW (Command Block Wrapper) wraps SCSI commands for USB transport. + After sending the CDB, we read the response data and the CSW (Command + Status Wrapper) which contains the status byte. + """ + # Build CBW: signature(4) + tag(4) + transfer_len(4) + flags(1) + lun(1) + cdb_len(1) + cbw = struct.pack("= 13 else -1 + + return data, status + + +def _read_vpd_pages(dev, ep_out, ep_in) -> bytes: + """Read Apple VPD pages and concatenate the XML payload. + + The iPod's VPD page layout: + - Page 0xC0: Supported pages list (page codes of data pages) + - Page 0xC1: Unused / empty + - Pages 0xC2+: XML plist data, each page carrying up to 248 bytes + + We first read page 0xC0 to discover which pages have data, then read + each data page and concatenate the payloads. + """ + tag = 100 + + # Step 1: Read page 0xC0 to get the list of supported data pages + supported_pages: list[int] = [] + try: + cdb = bytes([0x12, 0x01, 0xC0, 0x00, 255, 0x00]) + data, status = _scsi_inquiry(dev, ep_out, ep_in, tag, cdb, 255) + tag += 1 + + if status == 0 and len(data) >= 4: + page_len = data[3] # Number of page codes listed + supported_pages = list(data[4:4 + page_len]) + logger.debug("VPD supported pages: %s", + [f"0x{p:02X}" for p in supported_pages]) + except Exception as exc: + logger.debug("VPD page 0xC0 read failed: %s", exc) + + if not supported_pages: + # Fallback: try pages 0xC2-0xFF directly + supported_pages = list(range(0xC2, 0x100)) + + # Step 2: Read each data page (skip 0xC0 and 0xC1 which are metadata) + xml_chunks = [] + for page in supported_pages: + if page <= 0xC1: + continue # Skip metadata pages + + try: + cdb = bytes([0x12, 0x01, page, 0x00, 255, 0x00]) + data, status = _scsi_inquiry(dev, ep_out, ep_in, tag, cdb, 255) + tag += 1 + + if status != 0 or len(data) <= 4: + continue # Skip failed pages, try next + + page_len = data[3] # Actual payload length + if page_len == 0: + continue + + payload = data[4:4 + page_len] + + # Check if page has any real content + if not any(b != 0 for b in payload): + continue + + xml_chunks.append(payload) + except Exception: + continue # Skip failures, try remaining pages + + if not xml_chunks: + return b"" + + # Concatenate all chunks + raw = b"".join(xml_chunks) + + # Trim trailing nulls + while raw and raw[-1:] == b"\x00": + raw = raw[:-1] + + return raw + + +def _parse_vpd_xml(raw: bytes) -> dict: + """Parse the reassembled VPD XML plist into a Python dict. + + The XML may be incomplete or malformed at boundaries, so we try + plistlib first, then fall back to regex extraction. + """ + result: dict = {} + + if not raw: + return result + + # Try to find the XML plist boundaries + xml_start = raw.find(b"" not in xml_data: + xml_data += b"\n\n" + + try: + plist = plistlib.loads(xml_data) + if isinstance(plist, dict): + return plist + except Exception: + pass + + # plistlib failed — fall back to regex + return _parse_vpd_regex(raw) + + +def _parse_vpd_regex(raw: bytes) -> dict: + """Extract key-value pairs from XML plist using regex. + + This handles incomplete/truncated XML that plistlib can't parse. + """ + result: dict = {} + text = raw.decode("utf-8", errors="replace") + + # Simple key-value extraction: XY + # or XY + for m in re.finditer( + r"([^<]+)\s*<(string|integer)>([^<]*)", text + ): + key, typ, val = m.group(1), m.group(2), m.group(3) + if typ == "integer": + try: + result[key] = int(val) + except ValueError: + result[key] = val + else: + result[key] = val + + return result + + +def _read_standard_inquiry(dev, ep_out, ep_in) -> dict: + """Read standard SCSI INQUIRY data (vendor, product, revision).""" + result = {} + try: + cdb = bytes([0x12, 0x00, 0x00, 0x00, 96, 0x00]) + data, status = _scsi_inquiry(dev, ep_out, ep_in, 1, cdb, 96) + if status == 0 and len(data) >= 36: + result["scsi_vendor"] = data[8:16].decode("ascii", errors="replace").strip() + result["scsi_product"] = data[16:32].decode("ascii", errors="replace").strip() + result["scsi_revision"] = data[32:36].decode("ascii", errors="replace").strip() + except Exception as exc: + logger.debug("Standard INQUIRY failed: %s", exc) + return result + + +def query_ipod_vpd( + usb_pid: int = 0, + serial_filter: str = "", +) -> dict | None: + """Query a single iPod's device information via SCSI VPD pages. + + Parameters + ---------- + usb_pid : int, optional + If non-zero, target only the iPod with this USB Product ID. + serial_filter : str, optional + If non-empty, target only the iPod whose USB serial number + (FireWire GUID) matches this string (case-insensitive). + + Returns + ------- + dict or None + A dict with keys like ``SerialNumber``, ``FireWireGUID``, + ``FamilyID``, ``BuildID``, ``usb_pid``, ``usb_serial``, etc. + Returns None if no iPod found or query failed. + + Raises + ------ + PermissionError + If not running as root (kernel driver detach requires root). + """ + try: + import usb.core + import usb.util + except ImportError: + logger.error("pyusb not installed") + return None + + # Find target device + devices = _find_ipod_devices() + if not devices: + logger.info("No iPod USB devices found") + return None + + dev = None + for d in devices: + if usb_pid and d.idProduct != usb_pid: + continue + if serial_filter: + try: + if d.serial_number.upper() != serial_filter.upper(): + continue + except Exception: + continue + dev = d + break + + if not dev: + logger.info("No matching iPod found (pid=0x%04X, serial=%s)", + usb_pid, serial_filter) + return None + + pid = dev.idProduct + try: + usb_serial = dev.serial_number or "" + except Exception: + usb_serial = "" + + logger.info("Querying iPod PID=0x%04X serial=%s", pid, usb_serial) + + # Detach kernel driver (macOS/Linux require root; Windows doesn't need this) + detached = False + if sys.platform != "win32": + try: + if dev.is_kernel_driver_active(0): + dev.detach_kernel_driver(0) + detached = True + logger.debug("Kernel driver detached") + else: + logger.debug("No kernel driver active on interface 0") + except usb.core.USBError as exc: + if "Access denied" in str(exc) or "Operation not permitted" in str(exc): + raise PermissionError( + "Root/sudo required to detach kernel driver for USB VPD query. " + "Run with: sudo uv run python -m ipod_device.vpd_libusb" + ) from exc + raise + + result: dict = { + "usb_vid": APPLE_VID, + "usb_pid": pid, + "usb_serial": usb_serial, + "_source": "scsi_vpd", + "_transport": "usb_bulk_scsi_vpd", + } + + claimed = False + try: + usb.util.claim_interface(dev, 0) + claimed = True + + # Find bulk endpoints + cfg = dev.get_active_configuration() + intf = cfg[(0, 0)] + ep_out = ep_in = None + for ep in intf: + direction = usb.util.endpoint_direction(ep.bEndpointAddress) + if direction == usb.util.ENDPOINT_OUT and not ep_out: + ep_out = ep + elif direction == usb.util.ENDPOINT_IN and not ep_in: + ep_in = ep + + if not ep_out or not ep_in: + logger.error("Could not find bulk endpoints") + return None + + # Standard INQUIRY + std_info = _read_standard_inquiry(dev, ep_out, ep_in) + result.update(std_info) + + # Apple VPD pages + raw_xml = _read_vpd_pages(dev, ep_out, ep_in) + if raw_xml: + vpd_info = _parse_vpd_xml(raw_xml) + result["vpd_raw_xml"] = raw_xml # Keep for SysInfoExtended writing + result.update(vpd_info) + logger.info("VPD query successful: %d keys extracted", + len(vpd_info)) + else: + logger.warning("No VPD data returned from iPod") + + except usb.core.USBError as exc: + logger.error("USB error during VPD query: %s", exc) + except Exception as exc: + logger.error("Unexpected error during VPD query: %s", exc) + finally: + if claimed: + try: + usb.util.release_interface(dev, 0) + except Exception as exc: + logger.debug("Could not release USB interface: %s", exc) + if detached: + try: + dev.attach_kernel_driver(0) + logger.debug("Kernel driver reattached — iPod will remount") + except Exception as exc: + logger.warning( + "Could not reattach kernel driver: %s. " + "Physically disconnect and reconnect the iPod.", exc + ) + + return result + + +def query_all_ipods() -> list[dict]: + """Query all connected iPods and return a list of info dicts. + + Each entry is the result of ``query_ipod_vpd()`` for one device. + Devices that fail to query are silently skipped. + + A brief pause is inserted between device queries to allow the USB + bus to stabilise after kernel driver detach/reattach cycles. + """ + import time + + try: + if importlib.util.find_spec("usb.core") is None: + return [] + except ModuleNotFoundError: + return [] + + devices = _find_ipod_devices() + results = [] + + for i, dev in enumerate(devices): + try: + usb_serial = dev.serial_number or "" + except Exception: + usb_serial = "" + + # Brief pause between devices to let USB bus settle + if i > 0: + logger.debug("Waiting 3s for USB bus to settle...") + time.sleep(3) + + try: + info = query_ipod_vpd( + usb_pid=dev.idProduct, + serial_filter=usb_serial, + ) + if info: + results.append(info) + except PermissionError: + raise # Propagate — all queries need root + except Exception as exc: + logger.debug("Query failed for PID=0x%04X: %s", + dev.idProduct, exc) + + return results + + +def write_sysinfo(ipod_path: str, vpd_info: dict) -> bool: + """Write SysInfo and SysInfoExtended to the iPod from VPD data. + + This populates the files that iTunes normally creates, so that + subsequent non-root runs of iOpenPod can identify the device. + + Parameters + ---------- + ipod_path : str + Mount point of the iPod (e.g., "/Volumes/JOHN'S IPOD"). + vpd_info : dict + Result dict from ``query_ipod_vpd()``. + + Returns + ------- + bool + True if at least one file was written successfully. + """ + device_dir = os.path.join(ipod_path, "iPod_Control", "Device") + os.makedirs(device_dir, exist_ok=True) + + wrote_any = False + + # ── Write SysInfo (plain text key:value format) ──────────────── + sysinfo_path = os.path.join(device_dir, "SysInfo") + try: + lines = [] + serial = vpd_info.get("SerialNumber", "") + if serial: + lines.append(f"pszSerialNumber: {serial}") + + fw_guid = vpd_info.get("FireWireGUID", "") + if not fw_guid: + fw_guid = vpd_info.get("usb_serial", "") + if fw_guid: + lines.append(f"FirewireGuid: 0x{fw_guid}") + + build_id = vpd_info.get("VisibleBuildID", + vpd_info.get("BuildID", "")) + if build_id: + lines.append(f"visibleBuildID: {build_id}") + + board = vpd_info.get("BoardHwName", "") + if board: + lines.append(f"BoardHwName: {board}") + + model = vpd_info.get("ModelNumStr", "") + if model: + lines.append(f"ModelNumStr: {model}") + + # Also store FamilyID and UpdaterFamilyID for future use + fam_id = vpd_info.get("FamilyID") + if fam_id is not None: + lines.append(f"FamilyID: {fam_id}") + + upd_fam_id = vpd_info.get("UpdaterFamilyID") + if upd_fam_id is not None: + lines.append(f"UpdaterFamilyID: {upd_fam_id}") + + if lines: + with open(sysinfo_path, "w") as f: + f.write("\n".join(lines) + "\n") + wrote_any = True + logger.info("Wrote SysInfo (%d fields) to %s", + len(lines), sysinfo_path) + + except Exception as exc: + logger.error("Failed to write SysInfo: %s", exc) + + # ── Write SysInfoExtended (XML plist) ────────────────────────── + sysinfo_ext_path = os.path.join(device_dir, "SysInfoExtended") + raw_xml = vpd_info.get("vpd_raw_xml", b"") + if raw_xml: + try: + # Use raw VPD XML directly — it's already a plist + xml_start = raw_xml.find(b"= 0: + xml_data = raw_xml[xml_start:] + # Ensure proper termination + if b"" not in xml_data: + xml_data += b"\n\n" + with open(sysinfo_ext_path, "wb") as f: + f.write(xml_data) + wrote_any = True + logger.info("Wrote SysInfoExtended to %s", sysinfo_ext_path) + except Exception as exc: + logger.error("Failed to write SysInfoExtended: %s", exc) + + return wrote_any + + +# ────────────────────────────────────────────────────────────────────── +# High-level identification — single entry point for all callers +# ────────────────────────────────────────────────────────────────────── + +def identify_via_vpd( + mount_path: str = "", + usb_pid: int = 0, + firewire_guid: str = "", + *, + write_sysinfo_to_device: bool = True, +) -> dict | None: + """Full iPod identification via SCSI VPD + model lookup + SysInfo write. + + Tries **IOKit** (macOS, no root, no unmount) first, then falls back to + **pyusb** on supported non-Windows platforms (root required on Linux; + may unmount/remount on Linux/macOS). + + On success, resolves the exact model (family, generation, capacity, + color) from the Apple serial's last 3 characters and optionally writes + SysInfo + SysInfoExtended to the iPod for instant future identification. + + Parameters + ---------- + mount_path : str + iPod mount point (e.g. ``"/Volumes/IPOD"``). Required for SysInfo + writing; optional for query-only use. + usb_pid : int + Target a specific USB Product ID (0 = any). + firewire_guid : str + Target a specific USB serial / FireWire GUID (case-insensitive). + write_sysinfo_to_device : bool + If True (default) and *mount_path* is set, write SysInfo files. + + Returns + ------- + dict or None + ``serial``, ``firewire_guid``, ``firmware``, ``model_number``, + ``model_family``, ``generation``, ``capacity``, ``color``, + ``mount_path`` (may differ from input after pyusb remount), + ``sysinfo_written`` (bool), ``vpd_info`` (raw VPD dict). + """ + if sys.platform == "win32": + logger.debug( + "identify_via_vpd skipped on Windows: mount=%s pid=%s fwguid=%s", + mount_path or "unknown", + f"0x{usb_pid:04X}" if usb_pid else "any", + firewire_guid or "unknown", + ) + return None + + logger.debug( + "identify_via_vpd start: mount=%s pid=%s fwguid=%s write_sysinfo=%s", + mount_path or "unknown", + f"0x{usb_pid:04X}" if usb_pid else "any", + firewire_guid or "unknown", + write_sysinfo_to_device, + ) + # ── Step 1: VPD query (IOKit fast path, then pyusb fallback) ─── + vpd_info = _vpd_query_any_platform(usb_pid, firewire_guid, mount_path) + if vpd_info is None: + logger.debug( + "identify_via_vpd: no live SysInfoExtended result for mount=%s " + "pid=%s fwguid=%s", + mount_path or "unknown", + f"0x{usb_pid:04X}" if usb_pid else "any", + firewire_guid or "unknown", + ) + return None + + apple_serial = vpd_info.get("SerialNumber", "") + if not apple_serial: + logger.debug( + "identify_via_vpd: VPD returned no Apple serial source=%s keys=%d", + vpd_info.get("_source", "unknown"), + len([key for key in vpd_info if not str(key).startswith("_")]), + ) + return None + + # ── Step 2: Resolve model from serial-last-3 ────────────────── + vpd_fw_guid = vpd_info.get("FireWireGUID") or vpd_info.get("usb_serial", "") + result: dict = { + "serial": apple_serial, + "firewire_guid": vpd_fw_guid.upper() or firewire_guid, + "firmware": ( + vpd_info.get("FireWireVersion") + or vpd_info.get("scsi_revision") + or vpd_info.get("VisibleBuildID") + or vpd_info.get("BuildID", "") + ), + "model_number": "", + "model_family": "", + "generation": "", + "capacity": "", + "color": "", + "mount_path": mount_path, + "sysinfo_written": False, + "vpd_info": vpd_info, + "source": vpd_info.get("_source", "vpd"), + } + + try: + from .lookup import lookup_by_serial + + lookup = lookup_by_serial(apple_serial) + if lookup: + model_num, info = lookup + result["model_number"] = model_num + result["model_family"] = info[0] + result["generation"] = info[1] + result["capacity"] = info[2] + result["color"] = info[3] + logger.debug( + "identify_via_vpd: serial=%s → %s %s %s %s (%s)", + apple_serial, info[0], info[1], info[2], info[3], model_num, + ) + except ImportError: + pass + + # ── Step 3: Handle pyusb remount (non-Windows, non-IOKit) ───── + used_pyusb = vpd_info.get("_used_pyusb", False) + if used_pyusb and sys.platform != "win32" and mount_path: + result["mount_path"] = _wait_for_remount(mount_path, firewire_guid, vpd_info) + + # ── Step 4: Write SysInfo to iPod ───────────────────────────── + effective_path = result["mount_path"] + if write_sysinfo_to_device and effective_path and os.path.exists(effective_path): + try: + wrote = write_sysinfo(effective_path, vpd_info) + result["sysinfo_written"] = wrote + if wrote: + logger.info("identify_via_vpd: wrote SysInfo to %s", effective_path) + except Exception as exc: + logger.debug("identify_via_vpd: SysInfo write failed: %s", exc) + + logger.debug( + "identify_via_vpd complete: source=%s mount=%s identity=[%s] caps=[%s] " + "sysinfo_written=%s", + result.get("source", "vpd"), + result.get("mount_path") or "unknown", + format_fields(result, IDENTITY_FIELDS), + format_fields(vpd_info, CAPABILITY_FIELDS, include_false=True), + result["sysinfo_written"], + ) + return result + + +def _vpd_query_any_platform( + usb_pid: int, + firewire_guid: str, + mount_path: str = "", + *, + include_usb_vendor: bool | None = None, +) -> dict | None: + """Try live SysInfoExtended transports, returning one merged raw dict.""" + scsi_vpd: dict | None = None + usb_vendor: dict | None = None + + if include_usb_vendor is None: + # Windows keeps the iPod bound to USBSTOR while mounted. The vendored + # libusb backend can enumerate devices, but device-level vendor-control + # transfers are normally blocked by the active mass-storage driver. + include_usb_vendor = sys.platform != "win32" + + logger.debug( + "Live SysInfoExtended query start: platform=%s mount=%s pid=%s " + "fwguid=%s usb_vendor=%s", + sys.platform, + mount_path or "unknown", + f"0x{usb_pid:04X}" if usb_pid else "any", + firewire_guid or "unknown", + include_usb_vendor, + ) + + # ── macOS fast path: IOKit SCSI (no root, no unmount) ────────── + if sys.platform == "darwin": + try: + from .vpd_iokit import query_ipod_vpd as iokit_query + + vpd = iokit_query(usb_pid=usb_pid, serial_filter=firewire_guid) + if vpd and vpd.get("SerialNumber"): + vpd["_source"] = "scsi_vpd" + vpd["_transport"] = "iokit_scsi_vpd" + logger.debug("_vpd_query_any_platform: IOKit SCSI success") + scsi_vpd = vpd + except ImportError: + logger.debug("_vpd_query_any_platform: ipod_device.vpd_iokit not available") + except Exception as exc: + logger.debug("_vpd_query_any_platform: IOKit failed: %s", exc) + + # ── Windows drive-anchored SCSI pass-through ─────────────────── + if scsi_vpd is None and sys.platform == "win32" and mount_path: + try: + from .vpd_windows import query_ipod_vpd_for_path + + vpd = query_ipod_vpd_for_path( + mount_path, + usb_pid=usb_pid, + serial_filter=firewire_guid, + ) + if vpd and vpd.get("SerialNumber"): + scsi_vpd = vpd + except ImportError: + logger.debug( + "_vpd_query_any_platform: ipod_device.vpd_windows not available", + ) + except Exception as exc: + logger.debug("_vpd_query_any_platform: Windows SCSI failed: %s", exc) + + # ── Linux drive-anchored SG_IO SCSI pass-through ──────────────── + if scsi_vpd is None and sys.platform == "linux" and mount_path: + try: + from .vpd_linux import query_ipod_vpd_for_path + + vpd = query_ipod_vpd_for_path( + mount_path, + usb_pid=usb_pid, + serial_filter=firewire_guid, + ) + if vpd and vpd.get("SerialNumber"): + scsi_vpd = vpd + except ImportError: + logger.debug("_vpd_query_any_platform: ipod_device.vpd_linux not available") + except Exception as exc: + logger.debug("_vpd_query_any_platform: Linux SG_IO failed: %s", exc) + + # ── Fallback: pyusb bulk-only SCSI (root on Linux/macOS, no root on Windows) ── + pyusb_allowed = sys.platform != "win32" + if scsi_vpd is None and sys.platform == "win32": + logger.debug( + "_vpd_query_any_platform: pyusb bulk SCSI skipped on Windows " + "during normal identification", + ) + if scsi_vpd is None and sys.platform != "win32": + try: + if os.geteuid() != 0: + logger.debug("_vpd_query_any_platform: pyusb skipped (not root)") + pyusb_allowed = False + except AttributeError: + pass + + if scsi_vpd is None and pyusb_allowed: + try: + vpd = query_ipod_vpd(usb_pid=usb_pid, serial_filter=firewire_guid) + if vpd and vpd.get("SerialNumber"): + vpd["_used_pyusb"] = True + vpd.setdefault("_source", "scsi_vpd") + vpd.setdefault("_transport", "usb_bulk_scsi_vpd") + scsi_vpd = vpd + except PermissionError: + logger.debug("_vpd_query_any_platform: pyusb needs root") + except ImportError: + logger.debug("_vpd_query_any_platform: pyusb not available") + except Exception as exc: + logger.debug("_vpd_query_any_platform: pyusb failed: %s", exc) + + # ── Apple USB vendor-control SysInfoExtended (extra fields on some nanos) ── + if include_usb_vendor: + try: + from .vpd_usb_control import query_ipod_usb_sysinfo_extended + + usb_vendor = query_ipod_usb_sysinfo_extended( + usb_pid=usb_pid, + serial_filter=firewire_guid or ( + (scsi_vpd or {}).get("FireWireGUID") + or (scsi_vpd or {}).get("usb_serial") + or "" + ), + ) + except ImportError: + logger.debug( + "_vpd_query_any_platform: ipod_device.vpd_usb_control not available", + ) + except Exception as exc: + logger.debug("_vpd_query_any_platform: USB vendor query failed: %s", exc) + elif sys.platform == "win32": + logger.debug( + "_vpd_query_any_platform: USB vendor-control skipped on Windows " + "during normal identification", + ) + + merged = _merge_live_sysinfoextended(scsi_vpd, usb_vendor) + logger.debug( + "Live SysInfoExtended query result: scsi=%s usb_vendor=%s merged=%s " + "source=%s identity=[%s] caps=[%s]", + (scsi_vpd or {}).get("_source", "none"), + (usb_vendor or {}).get("_source", "none"), + "yes" if merged else "no", + (merged or {}).get("_source", "none"), + format_fields(merged or {}, IDENTITY_FIELDS), + format_fields(merged or {}, CAPABILITY_FIELDS, include_false=True), + ) + return merged + + +def _merge_live_sysinfoextended( + primary: dict | None, + secondary: dict | None, +) -> dict | None: + """Merge live SCSI and USB vendor SysInfoExtended results. + + The primary transport wins conflicts; the secondary fills missing fields and + is retained under ``usb_vendor_info`` for diagnostics and future parsing. + """ + if primary is None: + return secondary + if secondary is None: + return primary + + merged = dict(primary) + raw_sources = dict(primary.get("_raw_field_sources", {})) + for key, value in secondary.items(): + if key.startswith("_"): + continue + if key not in merged or merged[key] in (None, "", b"", []): + merged[key] = value + raw_sources[key] = secondary.get("_source", "usb_vendor") + + merged["_raw_field_sources"] = raw_sources + merged["_usb_vendor_info"] = secondary + merged["_usb_vendor_raw_xml"] = secondary.get("vpd_raw_xml", b"") + merged["_transport"] = "+".join( + part for part in ( + str(primary.get("_transport", "scsi_vpd")), + str(secondary.get("_transport", "usb_vendor_control")), + ) + if part + ) + merged["_source"] = primary.get("_source", "scsi_vpd") + merged["_used_usb_vendor"] = True + return merged + + +def _wait_for_remount( + original_path: str, firewire_guid: str, vpd_info: dict, +) -> str: + """After a pyusb VPD query unmounts the disk, wait for it to come back. + + Returns the (possibly new) mount path. + """ + import time + + logger.debug("_wait_for_remount: waiting for %s to remount...", original_path) + + usb_serial = vpd_info.get("usb_serial", "") or firewire_guid + + for _attempt in range(12): + time.sleep(1) + # Lookup by USB serial handles path renames after remount + if usb_serial: + new_path = _find_mount_point_for_usb_serial(usb_serial) + if new_path: + if new_path != original_path: + logger.info( + "_wait_for_remount: remounted at %s (was %s)", + new_path, original_path, + ) + return new_path + # Fallback: check if original path is still valid + if os.path.ismount(original_path): + return original_path + + logger.warning( + "_wait_for_remount: iPod did not remount within 12s (serial=%s)", + usb_serial, + ) + return original_path + + +# ────────────────────────────────────────────────────────────────────── +# Mount-point resolution — per-platform implementations +# ────────────────────────────────────────────────────────────────────── + +def _get_mount_point_diskutil(dev_identifier: str) -> str | None: + """macOS: get mount point for a BSD device identifier like 'disk4s2'.""" + import plistlib as _plistlib + import subprocess + + try: + proc = subprocess.run( + ["diskutil", "info", "-plist", dev_identifier], + capture_output=True, timeout=10, + ) + if proc.returncode == 0: + disk_info = _plistlib.loads(proc.stdout) + mp = disk_info.get("MountPoint", "") + if mp: + return mp + except Exception: + pass + return None + + +def _find_mount_macos(usb_serial: str) -> str | None: + """macOS: find iPod mount point via ioreg + diskutil.""" + import subprocess + + serial_upper = usb_serial.upper() + bsd_disk: str | None = None + + try: + proc = subprocess.run( + ["ioreg", "-r", "-c", "IOUSBHostDevice", "-n", "iPod", + "-l", "-d", "20", "-w", "0"], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=8, + ) + if proc.returncode == 0: + current_serial = "" + for line in proc.stdout.splitlines(): + m = re.search(r'"USB Serial Number"\s*=\s*"([^"]+)"', line) + if m: + current_serial = m.group(1).replace(" ", "").strip().upper() + continue + m = re.search(r'"BSD Name"\s*=\s*"(disk\d+)"', line) + if m and current_serial == serial_upper: + bsd_disk = m.group(1) + break + except Exception: + pass + + if not bsd_disk: + return None + + # Try all partitions on this disk via diskutil list + import plistlib as _plistlib + + try: + proc = subprocess.run( + ["diskutil", "list", "-plist", bsd_disk], + capture_output=True, timeout=10, + ) + if proc.returncode == 0: + disk_list = _plistlib.loads(proc.stdout) + partitions = disk_list.get("AllDisksAndPartitions", []) + for entry in partitions: + for part in entry.get("Partitions", []): + dev_id = part.get("DeviceIdentifier", "") + if dev_id: + mp = _get_mount_point_diskutil(dev_id) + if mp: + return mp + dev_id = entry.get("DeviceIdentifier", "") + if dev_id: + mp = _get_mount_point_diskutil(dev_id) + if mp: + return mp + except Exception: + pass + + # Fallback: try s1/s2/s3 directly + for suffix in ("s1", "s2", "s3"): + mp = _get_mount_point_diskutil(bsd_disk + suffix) + if mp: + return mp + + # Last resort: parse mount output + try: + proc = subprocess.run( + ["mount"], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=5, + ) + for line in proc.stdout.splitlines(): + if bsd_disk in line and " on /Volumes/" in line: + m = re.search(r" on (/Volumes/.+?) \(", line) + if m: + return m.group(1) + except Exception: + pass + + return None + + +def _find_mount_linux(usb_serial: str) -> str | None: + """Linux: find iPod mount point via /proc/mounts + sysfs. + + Scans all mounted FAT/HFS+ volumes, traces each back through sysfs + to its USB parent, and matches against the USB serial (FireWire GUID). + """ + serial_upper = usb_serial.upper() + + try: + with open("/proc/mounts") as f: + mounts = f.readlines() + except Exception: + return None + + for line in mounts: + parts = line.split() + if len(parts) < 2: + continue + device, mount_point = parts[0], parts[1] + + if not device.startswith("/dev/sd") and not device.startswith("/dev/disk"): + continue + + # Get the base disk name (sdb from /dev/sdb1) + dev_name = os.path.basename(device) + base_disk = re.sub(r"\d+$", "", dev_name) + + # Walk sysfs to find the parent USB device + sysfs_path = f"/sys/block/{base_disk}/device" + if not os.path.exists(sysfs_path): + continue + + current = os.path.realpath(sysfs_path) + for _ in range(8): + vendor_file = os.path.join(current, "idVendor") + if os.path.exists(vendor_file): + try: + with open(vendor_file) as vf: + vendor = vf.read().strip() + except Exception: + break + if vendor != "05ac": # Not Apple + break + + serial_file = os.path.join(current, "serial") + if os.path.exists(serial_file): + try: + with open(serial_file) as sf: + serial = sf.read().strip().replace(" ", "").upper() + except Exception: + break + if serial == serial_upper: + # Decode octal escapes from /proc/mounts + # (e.g. \040 → space, \011 → tab) + mp = re.sub( + r"\\([0-7]{3})", + lambda _m: chr(int(_m.group(1), 8)), + mount_point, + ) + return mp + break + current = os.path.dirname(current) + + return None + + +def _find_mount_windows(usb_serial: str) -> str | None: + """Windows: find iPod drive letter via WMI. + + Queries Win32_DiskDrive (USBSTOR) → Win32_DiskDriveToDiskPartition → + Win32_LogicalDiskToPartition → Win32_LogicalDisk to trace from the USB + serial to a drive letter. + """ + import subprocess + + serial_upper = usb_serial.upper() + + # Strategy 1: Parse wmic output to match USB serial → drive letter + try: + # Get all USB disk drives + proc = subprocess.run( + ["wmic", "diskdrive", "where", "InterfaceType='USB'", + "get", "DeviceID,PNPDeviceID", "/format:csv"], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=15, + **_SP_KWARGS, + ) + if proc.returncode != 0: + # wmic might not be available on newer Windows — try PowerShell + return _find_mount_windows_ps(usb_serial) + + target_device_id = None + for line in proc.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("Node"): + continue + # CSV: Node,DeviceID,PNPDeviceID + parts = line.split(",") + if len(parts) >= 3: + pnp_id = parts[2].upper() + if serial_upper in pnp_id: + target_device_id = parts[1] + break + + if not target_device_id: + return _find_mount_windows_ps(usb_serial) + + # Map DeviceID → Partition → LogicalDisk + # Use wmic associators + escaped_id = target_device_id.replace("\\", "\\\\") + proc = subprocess.run( + ["wmic", "path", "Win32_DiskDriveToDiskPartition", "where", + f'Antecedent="\\\\\\\\.\\\\{escaped_id}"', + "get", "Dependent", "/format:csv"], + capture_output=True, text=True, timeout=15, + **_SP_KWARGS, + ) + + # This is getting complex — fall back to PowerShell approach + if proc.returncode != 0: + return _find_mount_windows_ps(usb_serial) + + except FileNotFoundError: + # wmic not available + return _find_mount_windows_ps(usb_serial) + except Exception: + return _find_mount_windows_ps(usb_serial) + + return _find_mount_windows_ps(usb_serial) + + +def _find_mount_windows_ps(usb_serial: str) -> str | None: + """Windows: find iPod drive letter via PowerShell. + + Uses CIM/WMI cmdlets to trace USB disk → partition → logical disk. + """ + import subprocess + + serial_upper = usb_serial.upper() + + # PowerShell one-liner that finds the drive letter for a USB disk + # matching a given serial number in the PNPDeviceID + ps_script = ( + "Get-CimInstance Win32_DiskDrive " + "| Where-Object { $_.InterfaceType -eq 'USB' -and " + f"$_.PNPDeviceID -like '*{serial_upper}*' }} " + "| ForEach-Object { " + "$disk = $_; " + "Get-CimAssociatedInstance -InputObject $disk " + "-ResultClassName Win32_DiskPartition " + "| ForEach-Object { " + "Get-CimAssociatedInstance -InputObject $_ " + "-ResultClassName Win32_LogicalDisk } } " + "| Select-Object -ExpandProperty DeviceID" + ) + + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_script], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=20, + **_SP_KWARGS, + ) + if proc.returncode == 0 and proc.stdout.strip(): + drive_letter = proc.stdout.strip().splitlines()[0].strip() + if len(drive_letter) >= 2 and drive_letter[0].isalpha(): + # Return as "D:\" style path + return drive_letter[0] + ":\\" + except Exception as exc: + logger.debug("PowerShell mount lookup failed: %s", exc) + + return None + + +def _find_mount_point_for_usb_serial(usb_serial: str) -> str | None: + """Find the iPod mount point matching a USB serial (FireWire GUID). + + Dispatches to the platform-specific implementation: + - macOS: ioreg + diskutil + - Linux: /proc/mounts + sysfs + - Windows: WMI/PowerShell + """ + if not usb_serial: + return None + + if sys.platform == "darwin": + return _find_mount_macos(usb_serial) + elif sys.platform == "linux": + return _find_mount_linux(usb_serial) + elif sys.platform == "win32": + return _find_mount_windows(usb_serial) + else: + logger.warning("Unsupported platform for mount point lookup: %s", + sys.platform) + return None + + +# ────────────────────────────────────────────────────────────────────── +# CLI entry point +# ────────────────────────────────────────────────────────────────────── + +def main() -> int: + """CLI entry point: query all connected iPods and optionally write SysInfo.""" + import argparse + + logging.basicConfig( + level=logging.DEBUG, + format="%(levelname)s: %(message)s", + ) + + parser = argparse.ArgumentParser( + description="Query iPod device information via USB SCSI VPD pages.", + ) + parser.add_argument( + "--write-sysinfo", action="store_true", + help="Write SysInfo/SysInfoExtended to the iPod for future detection", + ) + parser.add_argument( + "--pid", type=lambda x: int(x, 16), default=0, + help="Target a specific USB PID (hex, e.g. 1261)", + ) + parser.add_argument( + "--path", type=str, default="", + help="iPod mount path (e.g. /Volumes/IPOD or D:\\). " + "If specified, writes SysInfo here instead of auto-detecting.", + ) + args = parser.parse_args() + + # Root check — only applies on macOS/Linux (Windows doesn't need it) + if sys.platform != "win32": + if os.geteuid() != 0: + print("ERROR: Root privileges required. " + "Run with: sudo uv run python -m ipod_device.vpd_libusb") + return 1 + + print("Scanning for iPod USB devices...\n") + + try: + all_info = query_all_ipods() + except PermissionError as exc: + print(f"ERROR: {exc}") + return 1 + + if not all_info: + print("No iPods found or query failed.") + return 1 + + for info in all_info: + pid = info.get("usb_pid", 0) + serial = info.get("SerialNumber", info.get("usb_serial", "?")) + fw_guid = info.get("FireWireGUID", info.get("usb_serial", "")) + family_id = info.get("FamilyID", "?") + build_id = info.get("VisibleBuildID", info.get("BuildID", "?")) + + print(f"{'=' * 60}") + print(f"iPod (USB PID 0x{pid:04X})") + print(f"{'=' * 60}") + print(f" Apple Serial: {serial}") + print(f" FireWire GUID: {fw_guid}") + print(f" FamilyID: {family_id}") + print(f" UpdaterFamilyID: {info.get('UpdaterFamilyID', '?')}") + print(f" BuildID: {build_id}") + print(f" SCSI Vendor: {info.get('scsi_vendor', '?')}") + print(f" SCSI Product: {info.get('scsi_product', '?')}") + print(f" SCSI Revision: {info.get('scsi_revision', '?')}") + + # Try serial-last-3 model lookup + apple_serial = info.get("SerialNumber", "") + if apple_serial and len(apple_serial) >= 3: + try: + from .lookup import lookup_by_serial + result = lookup_by_serial(apple_serial) + if result: + model_num, model_info = result + print(f"\n Model: {model_info[0]} {model_info[1]}") + print(f" Capacity: {model_info[2]}") + print(f" Color: {model_info[3]}") + print(f" Model Number: {model_num}") + except ImportError: + pass + + print() + + # ── Write SysInfo (separate phase — iPods need time to remount) ── + if args.write_sysinfo: + import time + + # On macOS/Linux the disk unmounts during VPD query; Windows doesn't + if sys.platform != "win32": + print("Waiting for iPods to remount...") + time.sleep(8) + + for info in all_info: + usb_ser = info.get("usb_serial", "") + pid = info.get("usb_pid", 0) + + # Use --path if provided, otherwise auto-detect mount point + if args.path: + mount = args.path + else: + mount = None + for attempt in range(3): + mount = _find_mount_point_for_usb_serial(usb_ser) + if mount: + break + print(f" PID 0x{pid:04X}: waiting for remount " + f"(attempt {attempt + 2}/3)...") + time.sleep(5) + + if mount: + print(f"Writing SysInfo for PID 0x{pid:04X} to {mount}...") + if write_sysinfo(mount, info): + print(" Done!") + else: + print(" WARNING: SysInfo write failed") + else: + print(f"WARNING: Could not find mount point for PID 0x{pid:04X} " + f"(serial {usb_ser})") + print(" Use --path to specify the iPod mount path manually.") + + print() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/vendor/ipod_device/vpd_linux.py b/src/vendor/ipod_device/vpd_linux.py new file mode 100644 index 0000000..fb2aca1 --- /dev/null +++ b/src/vendor/ipod_device/vpd_linux.py @@ -0,0 +1,266 @@ +"""Linux SG_IO SCSI VPD reader for mounted iPods. + +This sends SCSI INQUIRY commands to the block device backing the selected +mount point. It avoids PyUSB driver detach/unmount behavior and anchors the +live SysInfoExtended read to the same mounted iPod the UI selected. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import re +import sys + +from .diagnostic_log import CAPABILITY_FIELDS, IDENTITY_FIELDS, format_fields +from .sysinfo import normalize_guid, parse_sysinfo_extended + +logger = logging.getLogger(__name__) + +_SG_IO = 0x2285 +_SG_DXFER_FROM_DEV = -3 + + +class _SG_IO_HDR(ctypes.Structure): + _fields_ = [ + ("interface_id", ctypes.c_int), + ("dxfer_direction", ctypes.c_int), + ("cmd_len", ctypes.c_ubyte), + ("mx_sb_len", ctypes.c_ubyte), + ("iovec_count", ctypes.c_ushort), + ("dxfer_len", ctypes.c_uint), + ("dxferp", ctypes.c_void_p), + ("cmdp", ctypes.c_void_p), + ("sbp", ctypes.c_void_p), + ("timeout", ctypes.c_uint), + ("flags", ctypes.c_uint), + ("pack_id", ctypes.c_int), + ("usr_ptr", ctypes.c_void_p), + ("status", ctypes.c_ubyte), + ("masked_status", ctypes.c_ubyte), + ("msg_status", ctypes.c_ubyte), + ("sb_len_wr", ctypes.c_ubyte), + ("host_status", ctypes.c_ushort), + ("driver_status", ctypes.c_ushort), + ("resid", ctypes.c_int), + ("duration", ctypes.c_uint), + ("info", ctypes.c_uint), + ] + + +def _whole_disk_candidate(device: str) -> str: + real = os.path.realpath(device) + base = os.path.basename(real) + dirname = os.path.dirname(real) + if re.match(r"sd[a-z]+\d+$", base): + return os.path.join(dirname, re.sub(r"\d+$", "", base)) + if re.match(r"(mmcblk|nvme).+p\d+$", base): + return os.path.join(dirname, re.sub(r"p\d+$", "", base)) + return real + + +def _block_candidates(mount_path: str) -> list[str]: + try: + from .scanner import _linux_find_block_device + + partition = _linux_find_block_device(mount_path) + except Exception as exc: + logger.debug("Linux SG_IO: mount lookup failed for %s: %s", mount_path, exc) + partition = None + + candidates: list[str] = [] + for path in (partition, _whole_disk_candidate(partition) if partition else None): + if path and path not in candidates: + candidates.append(path) + return candidates + + +def _scsi_inquiry(fd: int, *, evpd: bool, page: int, alloc_len: int = 255) -> bytes: + import fcntl + + data_buf = ctypes.create_string_buffer(alloc_len) + sense_buf = ctypes.create_string_buffer(64) + cdb = (ctypes.c_ubyte * 6)( + 0x12, + 0x01 if evpd else 0x00, + page & 0xFF, + 0x00, + alloc_len & 0xFF, + 0x00, + ) + + hdr = _SG_IO_HDR() + hdr.interface_id = ord("S") + hdr.dxfer_direction = _SG_DXFER_FROM_DEV + hdr.cmd_len = 6 + hdr.mx_sb_len = len(sense_buf) + hdr.dxfer_len = alloc_len + hdr.dxferp = ctypes.cast(data_buf, ctypes.c_void_p) + hdr.cmdp = ctypes.cast(cdb, ctypes.c_void_p) + hdr.sbp = ctypes.cast(sense_buf, ctypes.c_void_p) + hdr.timeout = 10000 + + fcntl.ioctl(fd, _SG_IO, hdr) + if hdr.status != 0 or hdr.host_status != 0 or hdr.driver_status != 0: + raise OSError( + hdr.status or hdr.host_status or hdr.driver_status, + f"SG_IO INQUIRY failed for page 0x{page:02X}", + ) + + transfer_len = alloc_len - max(int(hdr.resid), 0) + if transfer_len <= 0: + transfer_len = alloc_len + return bytes(data_buf.raw[: min(transfer_len, alloc_len)]) + + +def _read_standard_inquiry(fd: int) -> dict: + result: dict = {} + try: + data = _scsi_inquiry(fd, evpd=False, page=0, alloc_len=96) + except Exception as exc: + logger.debug("Linux SG_IO: standard INQUIRY failed: %s", exc) + return result + + if len(data) >= 36: + result["scsi_vendor"] = data[8:16].decode("ascii", errors="replace").strip() + result["scsi_product"] = data[16:32].decode("ascii", errors="replace").strip() + result["scsi_revision"] = data[32:36].decode("ascii", errors="replace").strip() + return result + + +def _read_vpd_serial(fd: int) -> str: + try: + data = _scsi_inquiry(fd, evpd=True, page=0x80, alloc_len=255) + except Exception: + return "" + if len(data) <= 4: + return "" + return data[4:].split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() + + +def _read_vpd_pages(fd: int) -> bytes: + data_pages: list[int] = [] + try: + c0 = _scsi_inquiry(fd, evpd=True, page=0xC0, alloc_len=255) + if len(c0) >= 4: + count = c0[3] + data_pages = [p for p in c0[4:4 + count] if p >= 0xC2] + except Exception as exc: + logger.debug("Linux SG_IO: VPD page 0xC0 failed: %s", exc) + + if not data_pages: + data_pages = list(range(0xC2, 0x100)) + + chunks: list[bytes] = [] + for page in data_pages: + try: + data = _scsi_inquiry(fd, evpd=True, page=page, alloc_len=255) + except Exception: + continue + if len(data) < 4: + continue + payload_len = data[3] + payload = data[4:4 + payload_len] + if payload and any(payload): + chunks.append(payload) + + return b"".join(chunks).rstrip(b"\x00") + + +def query_ipod_vpd_for_path( + mount_path: str, + *, + usb_pid: int = 0, + serial_filter: str = "", +) -> dict | None: + """Read SCSI VPD SysInfoExtended from a mounted Linux iPod volume.""" + if sys.platform != "linux": + return None + + logger.info( + "Linux SG_IO VPD query start: mount=%s pid=%s filter=%s", + mount_path, + f"0x{usb_pid:04X}" if usb_pid else "unknown", + serial_filter or "none", + ) + for device in _block_candidates(mount_path): + fd: int | None = None + try: + fd = os.open(device, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) + result: dict = { + "_source": "linux_scsi", + "_transport": "linux_sg_io_scsi_vpd", + "block_device": device, + } + if usb_pid: + result["usb_pid"] = usb_pid + result["usb_vid"] = 0x05AC + + result.update(_read_standard_inquiry(fd)) + vpd_serial = _read_vpd_serial(fd) + if vpd_serial: + result["vpd_serial"] = vpd_serial + + raw_xml = _read_vpd_pages(fd) + if not raw_xml: + logger.info("Linux SG_IO: no SysInfoExtended payload from %s", device) + continue + + parsed = parse_sysinfo_extended(raw_xml, source="linux_scsi", live=True) + if not parsed.plist: + logger.info( + "Linux SG_IO: parsed payload had no plist keys on %s", + device, + ) + continue + + result["vpd_raw_xml"] = parsed.raw_xml or raw_xml + result.update(parsed.plist) + log_identity = dict(parsed.identity) + for field in ( + "usb_vid", + "usb_pid", + "scsi_vendor", + "scsi_product", + "scsi_revision", + ): + if result.get(field) not in (None, "", b""): + log_identity[field] = result[field] + + wanted = normalize_guid(serial_filter) + actual = normalize_guid( + result.get("FireWireGUID") + or result.get("usb_serial") + or result.get("vpd_serial") + ) + if wanted and actual and wanted != actual: + logger.info( + "Linux SG_IO: serial filter %s did not match %s on %s", + wanted, + actual, + device, + ) + continue + + logger.info( + "Linux SG_IO VPD query successful for %s: keys=%d " + "identity=[%s] caps=[%s]", + device, + len(parsed.plist), + format_fields(log_identity, IDENTITY_FIELDS), + format_fields(log_identity, CAPABILITY_FIELDS, include_false=True), + ) + return result + except PermissionError as exc: + logger.info("Linux SG_IO: permission denied opening %s: %s", device, exc) + except Exception as exc: + logger.info("Linux SG_IO: query failed for %s: %s", device, exc) + finally: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + + return None diff --git a/src/vendor/ipod_device/vpd_usb_control.py b/src/vendor/ipod_device/vpd_usb_control.py new file mode 100644 index 0000000..f9987b8 --- /dev/null +++ b/src/vendor/ipod_device/vpd_usb_control.py @@ -0,0 +1,211 @@ +""" +Read iPod SysInfoExtended via Apple's USB vendor-control command. + +libgpod uses this path for devices such as later nanos that can expose richer +SysInfoExtended data over a device-level vendor request than through SCSI VPD. +It is intentionally separate from ``vpd_libusb``'s Mass Storage Bulk-Only/SCSI +path so callers can preserve transport provenance. +""" + +from __future__ import annotations + +import logging + +from .diagnostic_log import CAPABILITY_FIELDS, IDENTITY_FIELDS, format_fields +from .models import IPOD_USB_PIDS +from .sysinfo import parse_sysinfo_extended +from .usb_backend import backend_diagnostic, get_libusb_backend + +logger = logging.getLogger(__name__) + +APPLE_VID = 0x05AC + +_REQUEST_TYPE_IN_VENDOR_DEVICE = 0xC0 +_REQUEST_READ_SYSINFO_EXTENDED = 0x40 +_VALUE_SYSINFO_EXTENDED = 0x0002 +_CHUNK_SIZE = 0x1000 +_MAX_CHUNKS = 0xFFFF +_TIMEOUT_MS = 5000 + + +def _find_ipod_devices() -> list: + try: + import usb.core + except ImportError: + logger.info("USB vendor SysInfoExtended: pyusb not installed") + return [] + + backend = get_libusb_backend() + if backend is None: + logger.info( + "USB vendor SysInfoExtended: no PyUSB backend available: %s", + backend_diagnostic(), + ) + return [] + + found = usb.core.find(find_all=True, idVendor=APPLE_VID, backend=backend) + if found is None: + return [] + return [dev for dev in found if getattr(dev, "idProduct", None) in IPOD_USB_PIDS] + + +def _device_serial(dev) -> str: + try: + return (dev.serial_number or "").replace(" ", "").strip().upper() + except Exception: + return "" + + +def _read_sysinfo_extended_from_device(dev) -> bytes: + raw = bytearray() + + for index in range(_MAX_CHUNKS): + chunk = dev.ctrl_transfer( + _REQUEST_TYPE_IN_VENDOR_DEVICE, + _REQUEST_READ_SYSINFO_EXTENDED, + _VALUE_SYSINFO_EXTENDED, + index, + _CHUNK_SIZE, + timeout=_TIMEOUT_MS, + ) + data = bytes(chunk) + raw.extend(data) + if len(data) != _CHUNK_SIZE: + break + + return bytes(raw).rstrip(b"\x00") + + +def query_ipod_usb_sysinfo_extended( + usb_pid: int = 0, + serial_filter: str = "", +) -> dict | None: + """Query one iPod through Apple's USB vendor-control SysInfo command. + + Returns a dict shaped similarly to the SCSI VPD readers, with parsed + SysInfoExtended keys plus ``vpd_raw_xml``, ``usb_pid``, ``usb_serial`` and + provenance metadata. Returns ``None`` if no matching iPod answers. + """ + # _find_ipod_devices already checks for pyusb availability and the backend. + # No need to import usb.core again here. + + serial_filter = serial_filter.replace(" ", "").strip().upper() + logger.info( + "USB vendor SysInfoExtended query start: pid=%s filter=%s", + f"0x{usb_pid:04X}" if usb_pid else "any", + serial_filter or "none", + ) + candidates = [ + dev for dev in _find_ipod_devices() + if not usb_pid or getattr(dev, "idProduct", None) == usb_pid + ] + logger.info( + "USB vendor SysInfoExtended candidates: count=%d pids=%s", + len(candidates), + ", ".join(f"0x{getattr(dev, 'idProduct', 0):04X}" for dev in candidates) or "none", + ) + target = None + if serial_filter: + for dev in candidates: + serial = _device_serial(dev) + if serial == serial_filter: + target = dev + break + if target is None and len(candidates) == 1 and usb_pid: + # Some Windows libusb driver stacks enumerate the device but refuse + # string descriptors. If PID has already narrowed this to a single + # candidate, it is still safe to attempt the read. + target = candidates[0] + elif candidates: + target = candidates[0] + + if target is None: + logger.info("USB vendor SysInfoExtended query: no matching target") + return None + + usb_serial = _device_serial(target) + try: + raw_xml = _read_sysinfo_extended_from_device(target) + except Exception as exc: + message = str(exc) + if "not supported" in message.lower() or "not implemented" in message.lower(): + logger.info( + "USB vendor SysInfoExtended backend is available, but Windows " + "driver access does not support device control transfers for " + "PID=0x%04X serial=%s: %s", + getattr(target, "idProduct", 0), + usb_serial, + exc, + ) + else: + logger.info( + "USB vendor SysInfoExtended read failed for PID=0x%04X serial=%s: %s", + getattr(target, "idProduct", 0), + usb_serial, + exc, + ) + return None + + if not raw_xml: + logger.info( + "USB vendor SysInfoExtended query returned empty payload: " + "PID=0x%04X serial=%s", + getattr(target, "idProduct", 0), + usb_serial, + ) + return None + + parsed = parse_sysinfo_extended(raw_xml, source="usb_vendor", live=True) + if not parsed.plist: + logger.info( + "USB vendor SysInfoExtended parse returned no plist keys: " + "PID=0x%04X serial=%s", + getattr(target, "idProduct", 0), + usb_serial, + ) + return None + + result: dict = { + "usb_pid": getattr(target, "idProduct", 0), + "usb_serial": usb_serial, + "vpd_raw_xml": parsed.raw_xml or raw_xml, + "_source": "usb_vendor", + "_transport": "usb_vendor_control", + "_used_usb_vendor": True, + } + result.update(parsed.plist) + log_identity = dict(parsed.identity) + log_identity["usb_pid"] = getattr(target, "idProduct", 0) + if usb_serial: + log_identity["usb_serial"] = usb_serial + + logger.info( + "USB vendor SysInfoExtended query successful: PID=0x%04X serial=%s " + "keys=%d identity=[%s] caps=[%s]", + getattr(target, "idProduct", 0), + usb_serial, + len(parsed.plist), + format_fields(log_identity, IDENTITY_FIELDS), + format_fields(log_identity, CAPABILITY_FIELDS, include_false=True), + ) + return result + + +def query_all_ipod_usb_sysinfo_extended() -> list[dict]: + """Query every connected iPod that answers the vendor-control command.""" + results: list[dict] = [] + for dev in _find_ipod_devices(): + try: + info = query_ipod_usb_sysinfo_extended( + usb_pid=getattr(dev, "idProduct", 0), + serial_filter=_device_serial(dev), + ) + if info: + results.append(info) + except Exception as exc: + logger.debug( + "USB vendor SysInfoExtended query failed for PID=0x%04X: %s", + getattr(dev, "idProduct", 0), + exc, + ) + return results diff --git a/src/vendor/ipod_device/vpd_windows.py b/src/vendor/ipod_device/vpd_windows.py new file mode 100644 index 0000000..cd8faf8 --- /dev/null +++ b/src/vendor/ipod_device/vpd_windows.py @@ -0,0 +1,311 @@ +""" +Windows SCSI pass-through reader for iPod SysInfoExtended VPD pages. + +This sends SCSI INQUIRY commands to the selected drive with +IOCTL_SCSI_PASS_THROUGH_DIRECT. It is a live hardware source and avoids +selecting the wrong iPod by anchoring the query to the mounted drive letter. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import sys + +from .diagnostic_log import CAPABILITY_FIELDS, IDENTITY_FIELDS, format_fields +from .sysinfo import normalize_guid, parse_sysinfo_extended + +logger = logging.getLogger(__name__) + +# Win32 wintypes are imported inside Windows-only functions to satisfy +# static analysis and keep runtime imports platform-local. + + +_GENERIC_READ = 0x80000000 +_GENERIC_WRITE = 0x40000000 +_FILE_SHARE_READ = 0x00000001 +_FILE_SHARE_WRITE = 0x00000002 +_OPEN_EXISTING = 3 + +_IOCTL_SCSI_PASS_THROUGH_DIRECT = 0x0004D014 +_SCSI_IOCTL_DATA_IN = 1 + + +class _SCSI_PASS_THROUGH_DIRECT(ctypes.Structure): + _fields_ = [ + ("Length", ctypes.c_ushort), + ("ScsiStatus", ctypes.c_ubyte), + ("PathId", ctypes.c_ubyte), + ("TargetId", ctypes.c_ubyte), + ("Lun", ctypes.c_ubyte), + ("CdbLength", ctypes.c_ubyte), + ("SenseInfoLength", ctypes.c_ubyte), + ("DataIn", ctypes.c_ubyte), + ("DataTransferLength", ctypes.c_ulong), + ("TimeOutValue", ctypes.c_ulong), + ("DataBuffer", ctypes.c_void_p), + ("SenseInfoOffset", ctypes.c_ulong), + ("Cdb", ctypes.c_ubyte * 16), + ] + + +def _setup_win32_prototypes() -> None: + if sys.platform != "win32": + return + from ctypes import wintypes as wt + ctypes.windll.kernel32.CreateFileW.argtypes = [ # type: ignore[attr-defined] + wt.LPCWSTR, + wt.DWORD, + wt.DWORD, + wt.LPVOID, + wt.DWORD, + wt.DWORD, + wt.HANDLE, + ] + ctypes.windll.kernel32.CreateFileW.restype = wt.HANDLE # type: ignore[attr-defined] + ctypes.windll.kernel32.DeviceIoControl.argtypes = [ # type: ignore[attr-defined] + wt.HANDLE, + wt.DWORD, + wt.LPVOID, + wt.DWORD, + wt.LPVOID, + wt.DWORD, + ctypes.POINTER(wt.DWORD), + wt.LPVOID, + ] + ctypes.windll.kernel32.DeviceIoControl.restype = wt.BOOL # type: ignore[attr-defined] + ctypes.windll.kernel32.CloseHandle.argtypes = [wt.HANDLE] # type: ignore[attr-defined] + ctypes.windll.kernel32.CloseHandle.restype = wt.BOOL # type: ignore[attr-defined] + + +def _drive_letter_from_path(path: str) -> str: + if not path: + return "" + drive, _tail = os.path.splitdrive(path) + if drive and drive[0].isalpha(): + return drive[0].upper() + if path[0].isalpha(): + return path[0].upper() + return "" + + +def _open_drive(drive_letter: str): + path = f"\\\\.\\{drive_letter}:" + handle = ctypes.windll.kernel32.CreateFileW( # type: ignore[attr-defined] + path, + _GENERIC_READ | _GENERIC_WRITE, + _FILE_SHARE_READ | _FILE_SHARE_WRITE, + None, + _OPEN_EXISTING, + 0, + None, + ) + invalid = ctypes.c_void_p(-1).value + if handle == invalid: + err = getattr(ctypes, "get_last_error", lambda: 0)() + logger.info("Windows SCSI VPD: cannot open %s (err=%d)", path, err) + return None + return handle + + +def _scsi_inquiry(handle, *, evpd: bool, page: int, alloc_len: int = 255) -> bytes: + from ctypes import wintypes as wt + data_buf = ctypes.create_string_buffer(alloc_len) + sptd = _SCSI_PASS_THROUGH_DIRECT() + sptd.Length = ctypes.sizeof(_SCSI_PASS_THROUGH_DIRECT) + sptd.CdbLength = 6 + sptd.DataIn = _SCSI_IOCTL_DATA_IN + sptd.DataTransferLength = alloc_len + sptd.TimeOutValue = 10 + sptd.DataBuffer = ctypes.cast(data_buf, ctypes.c_void_p) + sptd.SenseInfoLength = 0 + sptd.SenseInfoOffset = 0 + + cdb = bytes([ + 0x12, + 0x01 if evpd else 0x00, + page & 0xFF, + 0x00, + alloc_len & 0xFF, + 0x00, + ]) + for idx, byte in enumerate(cdb): + sptd.Cdb[idx] = byte + + returned = wt.DWORD(0) + ok = ctypes.windll.kernel32.DeviceIoControl( # type: ignore[attr-defined] + handle, + _IOCTL_SCSI_PASS_THROUGH_DIRECT, + ctypes.byref(sptd), + ctypes.sizeof(sptd), + ctypes.byref(sptd), + ctypes.sizeof(sptd), + ctypes.byref(returned), + None, + ) + if not ok: + err = getattr(ctypes, "get_last_error", lambda: 0)() + raise OSError(err, f"DeviceIoControl INQUIRY failed for page 0x{page:02X}") + if sptd.ScsiStatus != 0: + raise OSError(sptd.ScsiStatus, f"SCSI status for page 0x{page:02X}") + + transfer_len = min(int(sptd.DataTransferLength or alloc_len), alloc_len) + return bytes(data_buf.raw[:transfer_len]) + + +def _read_standard_inquiry(handle) -> dict: + result: dict = {} + try: + data = _scsi_inquiry(handle, evpd=False, page=0, alloc_len=96) + except Exception as exc: + logger.debug("Windows SCSI VPD: standard INQUIRY failed: %s", exc) + return result + + if len(data) >= 36: + result["scsi_vendor"] = data[8:16].decode("ascii", errors="replace").strip() + result["scsi_product"] = data[16:32].decode("ascii", errors="replace").strip() + result["scsi_revision"] = data[32:36].decode("ascii", errors="replace").strip() + return result + + +def _read_vpd_serial(handle) -> str: + try: + data = _scsi_inquiry(handle, evpd=True, page=0x80, alloc_len=255) + except Exception: + return "" + if len(data) <= 4: + return "" + return data[4:].split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() + + +def _read_vpd_pages(handle) -> bytes: + data_pages: list[int] = [] + try: + c0 = _scsi_inquiry(handle, evpd=True, page=0xC0, alloc_len=255) + if len(c0) >= 4: + count = c0[3] + data_pages = [p for p in c0[4:4 + count] if p >= 0xC2] + logger.debug( + "Windows SCSI VPD pages: %s", + [f"0x{page:02X}" for page in data_pages], + ) + except Exception as exc: + logger.debug("Windows SCSI VPD: page 0xC0 failed: %s", exc) + + if not data_pages: + data_pages = list(range(0xC2, 0x100)) + + chunks: list[bytes] = [] + for page in data_pages: + try: + data = _scsi_inquiry(handle, evpd=True, page=page, alloc_len=255) + except Exception: + continue + if len(data) < 4: + continue + payload_len = data[3] + payload = data[4:4 + payload_len] + if payload and any(payload): + chunks.append(payload) + + return b"".join(chunks).rstrip(b"\x00") + + +def query_ipod_vpd_for_path( + mount_path: str, + *, + usb_pid: int = 0, + serial_filter: str = "", +) -> dict | None: + """Read SCSI VPD SysInfoExtended from the selected Windows drive.""" + if sys.platform != "win32": + return None + + drive_letter = _drive_letter_from_path(mount_path) + if not drive_letter: + logger.debug( + "Windows SCSI VPD: no drive letter for mount path %s", + mount_path, + ) + return None + + logger.debug( + "Windows SCSI VPD query start: drive=%s mount=%s pid=%s filter=%s", + drive_letter, + mount_path, + f"0x{usb_pid:04X}" if usb_pid else "unknown", + serial_filter or "none", + ) + _setup_win32_prototypes() + handle = _open_drive(drive_letter) + if handle is None: + return None + + try: + result: dict = { + "_source": "windows_scsi", + "_transport": "windows_scsi_pass_through", + } + if usb_pid: + result["usb_vid"] = 0x05AC + result["usb_pid"] = usb_pid + + result.update(_read_standard_inquiry(handle)) + vpd_serial = _read_vpd_serial(handle) + if vpd_serial: + result["vpd_serial"] = vpd_serial + + raw_xml = _read_vpd_pages(handle) + if not raw_xml: + logger.debug( + "Windows SCSI VPD: no SysInfoExtended payload for %s", + drive_letter, + ) + return None + + parsed = parse_sysinfo_extended(raw_xml, source="windows_scsi", live=True) + if not parsed.plist: + logger.debug( + "Windows SCSI VPD: parsed payload had no plist keys for %s", + drive_letter, + ) + return None + + result["vpd_raw_xml"] = parsed.raw_xml or raw_xml + result.update(parsed.plist) + log_identity = dict(parsed.identity) + for field in ( + "usb_vid", + "usb_pid", + "scsi_vendor", + "scsi_product", + "scsi_revision", + ): + if result.get(field) not in (None, "", b""): + log_identity[field] = result[field] + + wanted = normalize_guid(serial_filter) + actual = normalize_guid( + result.get("FireWireGUID") + or result.get("usb_serial") + or result.get("vpd_serial") + ) + if wanted and actual and wanted != actual: + logger.debug( + "Windows SCSI VPD: serial filter %s did not match %s", + wanted, + actual, + ) + return None + + logger.debug( + "Windows SCSI VPD query successful for %s: keys=%d identity=[%s] caps=[%s]", + drive_letter, + len(parsed.plist), + format_fields(log_identity, IDENTITY_FIELDS), + format_fields(log_identity, CAPABILITY_FIELDS, include_false=True), + ) + return result + finally: + ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined]