refactor: vendor iOpenPod core modules instead of pip dependency

Vendored 5 packages from iOpenPod (TheRealSavi/iOpenPod):
  iTunesDB_Writer/  — binary iTunesDB generation
  iTunesDB_Shared/  — field defs, constants, album identity
  SQLiteDB_Writer/  — SQLite databases for Nano 6G/7G
  iTunesDB_Parser/  — play stats reading from iPod
  ipod_device/      — device detection, FireWire ID, capabilities

Rationale: iOpenPod is a standalone iPod manager app, not a library.
Pulling it as a pip dependency is heavy and confusing for users.

Changes:
  + src/vendor/   — 77 .py files + calcHashAB.wasm (506 KB)
  ~ ipod_nano7_db.py  — _find_iop_root() now returns src/vendor/
  ~ requirements.txt   — iopenpod replaced with wasmtime + pycryptodome
  ~ .gitignore         — removed iOpenPod/ exclusion
  (setup.py reads requirements.txt automatically)
This commit is contained in:
Maksim Totmin 2026-06-02 09:16:19 +07:00
parent 222c2fcffb
commit 71f35c53cb
81 changed files with 24609 additions and 27 deletions

1
.gitignore vendored
View File

@ -37,4 +37,3 @@ converted/
specs.md
*.AppImage
squashfs-root/
iOpenPod/

View File

@ -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

View File

@ -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
"""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 not found. Install with: pip install iopenpod"
"iOpenPod vendor packages not found at %s" % vendor_path
)
return vendor_path
_IOP_ROOT = _find_iop_root()
_IOP_CACHE: dict[str, Any] = {}

21
src/vendor/SQLiteDB_Writer/__init__.py vendored Normal file
View File

@ -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"]

64
src/vendor/SQLiteDB_Writer/_helpers.py vendored Normal file
View File

@ -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()

171
src/vendor/SQLiteDB_Writer/cbk_writer.py vendored Normal file
View File

@ -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))

View File

@ -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 []))

View File

@ -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)

View File

@ -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)")

File diff suppressed because it is too large Load Diff

View File

@ -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))

View File

@ -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

22
src/vendor/iTunesDB_Parser/__init__.py vendored Normal file
View File

@ -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",
]

69
src/vendor/iTunesDB_Parser/_parsing.py vendored Normal file
View File

@ -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("<H")
UINT32_LE = struct.Struct("<I")
UINT64_LE = struct.Struct("<Q")
INT32_LE = struct.Struct("<i")
FLOAT32_LE = struct.Struct("<f")
# The generic chunk header shared by every iTunesDB chunk:
# +0x00 chunk_type (4 bytes ASCII)
# +0x04 header_len (u32 LE)
# +0x08 length_or_child_count (u32 LE)
_GENERIC_HEADER = struct.Struct("<4sII")
GENERIC_HEADER_SIZE = _GENERIC_HEADER.size # 12 bytes
ParseResult = dict[str, Any]
"""Return type of every chunk parser: ``{"next_offset": int, "data": ...}``."""
def read_generic_header(
data: bytes | bytearray,
offset: int,
) -> 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

View File

@ -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

View File

@ -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": <parsed>}``.
"""
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

View File

@ -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"
)

View File

@ -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)

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

@ -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}

187
src/vendor/iTunesDB_Parser/otg.py vendored Normal file
View File

@ -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": <sequential-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 = "<I"
elif magic == b"ohpm":
fmt = ">I"
else:
logger.warning("OTG: %s has unrecognised magic %r — skipping",
os.path.basename(path_str), magic)
return None
header_len = struct.unpack_from(fmt, raw, 4)[0]
entry_len = struct.unpack_from(fmt, raw, 8)[0]
entry_num = struct.unpack_from(fmt, raw, 12)[0]
if header_len < 0x14:
logger.warning("OTG: %s header_len %d < 20 — skipping",
os.path.basename(path_str), header_len)
return None
if entry_len < 4:
logger.warning("OTG: %s entry_len %d < 4 — skipping",
os.path.basename(path_str), entry_len)
return None
items: list[dict] = []
for i in range(entry_num):
offset = header_len + entry_len * i
if offset + 4 > 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,
}

112
src/vendor/iTunesDB_Parser/parser.py vendored Normal file
View File

@ -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"]

260
src/vendor/iTunesDB_Parser/playcounts.py vendored Normal file
View File

@ -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,
)

32
src/vendor/iTunesDB_Shared/__init__.py vendored Normal file
View File

@ -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,
})

View File

@ -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

378
src/vendor/iTunesDB_Shared/constants.py vendored Normal file
View File

@ -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 610 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

120
src/vendor/iTunesDB_Shared/extraction.py vendored Normal file
View File

@ -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

415
src/vendor/iTunesDB_Shared/field_base.py vendored Normal file
View File

@ -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. ``'<I'``, ``'B'``, ``'<8s'``).
read_transform: Callable applied AFTER unpacking on parse.
write_transform: Callable applied BEFORE packing on write.
default: Value used when the field is absent from the data dict.
validator: Callable that raises :class:`ValueError` if the value is invalid.
min_header_length: Minimum ``header_length`` for this field to exist.
``None`` means the field is always present.
required: If ``True``, :func:`write_fields` raises
:class:`MissingRequiredFieldError` when the field is absent.
section_type: ASCII tag of the parent section (e.g. ``'mhit'``).
"""
name: str
offset: int
size: int
struct_format: str
read_transform: Callable[..., Any] | None = None
write_transform: Callable[..., Any] | None = None
default: Any = 0
validator: Callable[..., None] | None = None
min_header_length: int | None = None
required: bool = False
section_type: str = ""
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3b. List-container header sizes
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Simple list chunks (mhlt, mhla, mhli, mhlp) have only the 12-byte
# generic header padded to 92 bytes. They don't carry FieldDef lists.
MHLT_HEADER_SIZE: int = 92
MHLA_HEADER_SIZE: int = 92
MHLI_HEADER_SIZE: int = 92
MHLP_HEADER_SIZE: int = 92
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. Factory Helpers
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _u32(name: str, offset: int, **kw: Any) -> FieldDef:
return FieldDef(name=name, offset=offset, size=4, struct_format="<I", **kw)
def _i32(name: str, offset: int, **kw: Any) -> FieldDef:
return FieldDef(name=name, offset=offset, size=4, struct_format="<i", **kw)
def _u16(name: str, offset: int, **kw: Any) -> FieldDef:
return FieldDef(name=name, offset=offset, size=2, struct_format="<H", **kw)
def _u64(name: str, offset: int, **kw: Any) -> FieldDef:
return FieldDef(name=name, offset=offset, size=8, struct_format="<Q", **kw)
def _u8(name: str, offset: int, **kw: Any) -> 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="<f", **kw)
def _raw(name: str, offset: int, size: int, **kw: Any) -> 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("<I", buffer, offset + 4, header_length)
struct.pack_into("<I", buffer, offset + 8, total_length_or_count)

48
src/vendor/iTunesDB_Shared/mhbd_defs.py vendored Normal file
View File

@ -0,0 +1,48 @@
"""MHBD (Database Header) field definitions.
Declarative :class:`FieldDef` list for the MHBD chunk the root of
the iTunesDB file. Both the parser and writer derive their behaviour
from these definitions.
"""
from .field_base import FieldDef, _u32, _i32, _u16, _u64, _raw
_S = "mhbd"
# Writer header size (matching iTunes / libgpod default).
MHBD_HEADER_SIZE: int = 244 # 0xF4
# Named offsets used by hash modules for zeroing before signing.
MHBD_OFFSET_DB_ID: int = 0x18 # 8 bytes (u64)
MHBD_OFFSET_HASHING_SCHEME: int = 0x30 # 2 bytes (u16)
MHBD_OFFSET_UNK_0x32: int = 0x32 # 20 bytes (raw)
MHBD_OFFSET_HASH58: int = 0x58 # 20 bytes (raw)
MHBD_OFFSET_HASH72: int = 0x72 # 46 bytes (raw)
MHBD_OFFSET_HASHAB: int = 0xAB # 57 bytes (raw)
MHBD_FIELDS: list[FieldDef] = [
_u32("compressed", 0x0C, section_type=_S, default=1),
_u32("version", 0x10, section_type=_S, required=True),
_u32("child_count", 0x14, section_type=_S),
_u64("db_id", 0x18, section_type=_S, required=True),
_u16("platform", 0x20, section_type=_S, default=2),
_u16("unk0x22", 0x22, section_type=_S, default=0),
_u64("db_id_2", 0x24, section_type=_S),
_u32("unk0x2c", 0x2C, section_type=_S),
_u16("hashing_scheme", 0x30, section_type=_S),
_raw("unk0x32", 0x32, 20, section_type=_S),
_raw("language", 0x46, 2, section_type=_S, default=b"en"),
_u64("db_persistent_id", 0x48, section_type=_S),
_u32("unk0x50", 0x50, section_type=_S, default=1),
_u32("unk0x54", 0x54, section_type=_S, default=15),
_raw("hash58", 0x58, 20, section_type=_S),
_i32("timezone_offset", 0x6C, section_type=_S),
_u16("hash_type_indicator", 0x70, section_type=_S),
_raw("hash72", 0x72, 46, section_type=_S),
# Extended fields — only in newer database headers.
_u16("audio_language", 0xA0, section_type=_S, min_header_length=0xA2),
_u16("subtitle_language", 0xA2, section_type=_S, min_header_length=0xA4),
_u16("unk0xa4", 0xA4, section_type=_S, min_header_length=0xA6),
_u16("unk0xa6", 0xA6, section_type=_S, min_header_length=0xA8),
_u16("cdb_flag", 0xA8, section_type=_S, min_header_length=0xAA),
]

21
src/vendor/iTunesDB_Shared/mhia_defs.py vendored Normal file
View File

@ -0,0 +1,21 @@
"""MHIA (Album Item) field definitions.
Declarative :class:`FieldDef` list for the MHIA chunk an album
record inside an MHLA (album list).
"""
from .field_base import FieldDef, _u32, _u16, _u64
_S = "mhia"
MHIA_HEADER_SIZE: int = 88
MHIA_FIELDS: list[FieldDef] = [
_u32("child_count", 0x0C, section_type=_S),
_u32("album_id", 0x10, section_type=_S, required=True),
_u64("sql_id", 0x14, section_type=_S),
_u16("platform_flag", 0x1C, section_type=_S, default=2),
_u16("album_compilation_flag", 0x1E, section_type=_S),
# Representative track db_track_id — only populated by some iTunes versions
_u64("album_track_db_id", 0x20, section_type=_S, min_header_length=0x28),
]

18
src/vendor/iTunesDB_Shared/mhii_defs.py vendored Normal file
View File

@ -0,0 +1,18 @@
"""MHII (Artist Item) field definitions.
Declarative :class:`FieldDef` list for the MHII chunk an artist
record inside an MHLI (artist list, MHSD type 8).
"""
from .field_base import FieldDef, _u32, _u64
_S = "mhii"
MHII_HEADER_SIZE: int = 80
MHII_FIELDS: list[FieldDef] = [
_u32("child_count", 0x0C, section_type=_S),
_u32("artist_id", 0x10, section_type=_S, required=True),
_u64("sql_id", 0x14, section_type=_S),
_u32("platform_flag", 0x1C, section_type=_S, default=2),
]

26
src/vendor/iTunesDB_Shared/mhip_defs.py vendored Normal file
View File

@ -0,0 +1,26 @@
"""MHIP (Playlist Item) field definitions.
Declarative :class:`FieldDef` list for the MHIP chunk a playlist
entry that references a track by ``track_id``.
"""
from .field_base import FieldDef, _u32, _u16, _u64, mac_to_unix, unix_to_mac
_S = "mhip"
MHIP_HEADER_SIZE: int = 76
MHIP_FIELDS: list[FieldDef] = [
_u32("child_count", 0x0C, section_type=_S),
_u16("podcast_group_flag", 0x10, section_type=_S),
_u16("unk0x12", 0x12, section_type=_S),
_u32("group_id", 0x14, section_type=_S),
_u32("track_id", 0x18, section_type=_S, required=True),
_u32("timestamp", 0x1C, section_type=_S,
read_transform=mac_to_unix, write_transform=unix_to_mac),
_u32("group_id_ref", 0x20, section_type=_S),
# Extended (header_length >= 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),
]

141
src/vendor/iTunesDB_Shared/mhit_defs.py vendored Normal file
View File

@ -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),
]

586
src/vendor/iTunesDB_Shared/mhod_defs.py vendored Normal file
View File

@ -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("<I", data[offset + 0x18:offset + 0x1C])[0]
def mhod_string_length(data, offset) -> int:
"""Byte length of string data at 0x1C."""
return struct.unpack("<I", data[offset + 0x1C:offset + 0x20])[0]
def mhod_string_unk0x20(data, offset) -> int:
return struct.unpack("<I", data[offset + 0x20:offset + 0x24])[0]
def mhod_string_unk0x24(data, offset) -> int:
return struct.unpack("<I", data[offset + 0x24:offset + 0x28])[0]
# ============================================================
# SPLPref — Smart Playlist Preferences (MHOD type 50)
# ============================================================
# All functions take body_offset = start of SPLPref data (MHOD chunk + header_length).
#
# Based on libgpod's SPLPref struct (itdb_spl.c) and the iPodLinux wiki.
#
# +0x00: liveUpdate (1 byte) — 1 = auto-update when library changes
# +0x01: checkRules (1 byte) — 1 = limit by rules (match checked items)
# +0x02: checkLimits (1 byte) — 1 = limit by size/count/time
# +0x03: limitType (1 byte) — what the limit applies to (see SPL_LIMIT_TYPE_MAP)
# +0x04: limitSort (1 byte) — how to choose items when limited (see SPL_LIMIT_SORT_MAP)
# +0x05: pad (3 bytes)
# +0x08: limitValue (4 bytes LE) — the limit value
# +0x0C: matchCheckedOnly (1 byte) — 1 = only match checked items
# +0x0D: reverseSort (1 byte) — if set, limitsort |= 0x80000000
def mhod_spl_live_update(data, body_offset) -> 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("<I", data[body_offset + 8:body_offset + 12])[0]
def mhod_spl_match_checked_only(data, body_offset) -> 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("<I", data[body_offset:body_offset + 4])[0]
def mhod52_count(data, body_offset) -> int:
return struct.unpack("<I", data[body_offset + 4:body_offset + 8])[0]
def mhod53_sort_type(data, body_offset) -> int:
return struct.unpack("<I", data[body_offset:body_offset + 4])[0]
def mhod53_count(data, body_offset) -> int:
return struct.unpack("<I", data[body_offset + 4:body_offset + 8])[0]
# ============================================================
# MHOD Type 100 — Playlist Position (MHIP context)
# ============================================================
# In MHIP context (body ≤ 20 bytes):
# +0x00: position (4 bytes LE) — 0-based track position in playlist
def mhod100_position(data, body_offset) -> int:
return struct.unpack("<I", data[body_offset:body_offset + 4])[0]

15
src/vendor/iTunesDB_Shared/mhsd_defs.py vendored Normal file
View File

@ -0,0 +1,15 @@
"""MHSD (DataSet) field definitions.
Declarative :class:`FieldDef` list for the MHSD chunk a dataset
container with a type field that determines its child.
"""
from .field_base import FieldDef, _u32
_S = "mhsd"
MHSD_HEADER_SIZE: int = 96
MHSD_FIELDS: list[FieldDef] = [
_u32("dataset_type", 0x0C, section_type=_S, required=True),
]

35
src/vendor/iTunesDB_Shared/mhyp_defs.py vendored Normal file
View File

@ -0,0 +1,35 @@
"""MHYP (Playlist) field definitions.
Declarative :class:`FieldDef` list for the MHYP chunk a single
playlist record.
"""
from .field_base import FieldDef, _u8, _u16, _u32, _u64, mac_to_unix, unix_to_mac
_S = "mhyp"
MHYP_HEADER_SIZE: int = 184
MHYP_FIELDS: list[FieldDef] = [
_u32("mhod_child_count", 0x0C, section_type=_S),
_u32("mhip_child_count", 0x10, section_type=_S),
_u8("master_flag", 0x14, section_type=_S),
_u8("flag1", 0x15, section_type=_S),
_u8("flag2", 0x16, section_type=_S),
_u8("flag3", 0x17, section_type=_S),
_u32("timestamp", 0x18, section_type=_S,
read_transform=mac_to_unix, write_transform=unix_to_mac),
_u64("playlist_id", 0x1C, section_type=_S),
_u32("unk0x24", 0x24, section_type=_S),
_u16("string_mhod_child_count", 0x28, section_type=_S),
_u16("podcast_flag", 0x2A, section_type=_S),
_u32("sort_order", 0x2C, section_type=_S),
# Extended
_u64("db_id_2", 0x3C, section_type=_S, min_header_length=0x44),
_u64("playlist_id_2", 0x44, section_type=_S, min_header_length=0x4C),
_u16("mhsd5_type", 0x50, section_type=_S, min_header_length=0x52),
_u16("mhsd5_type_2", 0x52, section_type=_S, min_header_length=0x54),
_u32("mhsd5_special_flag", 0x54, section_type=_S, min_header_length=0x58),
_u32("timestamp_2", 0x58, section_type=_S, min_header_length=0x5C,
read_transform=mac_to_unix, write_transform=unix_to_mac),
]

148
src/vendor/iTunesDB_Writer/__init__.py vendored Normal file
View File

@ -0,0 +1,148 @@
"""
iTunesDB Writer module for iOpenPod.
This module provides write support for iTunesDB (and iTunesCDB) files.
Supported devices:
- Pre-2007 iPods (1G-5G, Mini, Photo, Nano 1G-2G): No hash required
- iPod Classic (all gens), Nano 3G, Nano 4G: HASH58 (needs FireWire ID)
- iPod Nano 5G: HASH72 (requires HashInfo file from an iTunes sync)
- iPod Nano 6G/7G: HASHAB (needs FireWire ID + WASM runtime)
Usage:
from iTunesDB_Writer import write_checksum, detect_checksum_type
checksum_type = detect_checksum_type(ipod_path)
with open(itunesdb_path, 'rb') as f:
itdb_data = bytearray(f.read())
success = write_checksum(itdb_data, ipod_path)
"""
from ipod_device import ChecksumType
from ipod_device import detect_checksum_type, get_firewire_id
from .hash58 import (
compute_hash58,
write_hash58,
)
from .hash72 import (
compute_hash72,
write_hash72,
read_hash_info,
extract_hash_info,
extract_hash_info_to_dict,
)
from .hashab import (
compute_hashab,
write_hashab,
)
from .mhit_writer import TrackInfo, write_mhit
from iTunesDB_Shared.constants import (
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,
)
from .mhyp_writer import PlaylistInfo, write_playlist, write_mhyp
from .mhli_writer import write_mhli, write_mhii_artist, write_mhli_empty
from .mhod_spl_writer import (
SmartPlaylistPrefs,
SmartPlaylistRules,
SmartPlaylistRule,
prefs_from_parsed,
rules_from_parsed,
)
from .mhbd_writer import write_itunesdb, write_mhbd, extract_db_info
def write_checksum(itdb_data: bytearray, ipod_path: str) -> 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',
]

280
src/vendor/iTunesDB_Writer/hash58.py vendored Normal file
View File

@ -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 <ipod_path> <itunesdb_path>")
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)

476
src/vendor/iTunesDB_Writer/hash72.py vendored Normal file
View File

@ -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 <ipod_path> <itunesdb_path>")
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)

287
src/vendor/iTunesDB_Writer/hashab.py vendored Normal file
View File

@ -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 <ipod_path> <itunesdb_path>")
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)

1140
src/vendor/iTunesDB_Writer/mhbd_writer.py vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,138 @@
"""MHIP Writer — Write playlist item chunks for iTunesDB.
MHIP chunks are playlist entries that reference tracks by their ID.
Each playlist (MHYP) contains MHIP entries for each track in the playlist.
The binary layout of the header is defined declaratively in
``iTunesDB_Shared.field_defs.MHIP_FIELDS``.
Cross-referenced against:
- iTunesDB_Shared/field_defs.py (single source of truth for offsets)
- iTunesDB_Parser/mhip_parser.py parse_playlistItem()
- libgpod itdb_itunesdb.c: mk_mhip(), write_podcast_mhips()
"""
import struct
from iTunesDB_Shared.field_base import write_fields, write_generic_header
from iTunesDB_Shared.mhip_defs import MHIP_HEADER_SIZE
from iTunesDB_Shared.mhod_defs import (
MHOD_HEADER_SIZE as _MHOD_HEADER_SIZE,
MHOD100_POSITION_BODY_SIZE,
write_mhod_header,
)
from iTunesDB_Shared.constants import MHOD_TYPE_TITLE
def write_mhip(
track_id: int,
position: int = 0,
mhip_id: int = 0,
timestamp: int = 0,
podcast_group_flag: int = 0,
podcast_group_ref: int = 0,
track_persistent_id: int = 0,
mhip_persistent_id: int = 0,
) -> 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('<I', position) + (b'\x00' * 16)
return header + data
def write_mhip_podcast_group(album_name: str, group_id: int) -> 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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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('<I', body_header, 0, sort_type) # sort type
struct.pack_into('<I', body_header, 4, num_tracks) # number of entries
# Remaining 40 bytes are zero padding
# Track indices
indices_data = bytearray(4 * num_tracks)
for i, idx in enumerate(sorted_indices):
struct.pack_into('<I', indices_data, i * 4, idx)
return bytes(header) + bytes(body_header) + bytes(indices_data), jump_entries
def write_mhod_type53(sort_type: int, jump_entries: list[tuple[int, int, int]]) -> 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('<I', body_header, 0, sort_type) # sort type
struct.pack_into('<I', body_header, 4, num_entries) # number of entries
# 8 bytes zero padding
# Jump table entries: each is letter(u16) + pad(u16) + start(u32) + count(u32)
entries_data = bytearray(MHOD53_ENTRY_SIZE * num_entries)
for i, (letter, start, count) in enumerate(jump_entries):
offset = i * MHOD53_ENTRY_SIZE
struct.pack_into('<H', entries_data, offset, letter) # letter (UTF-16)
struct.pack_into('<H', entries_data, offset + 2, 0) # padding
struct.pack_into('<I', entries_data, offset + 4, start) # start index
struct.pack_into('<I', entries_data, offset + 8, count) # count
return bytes(header) + bytes(body_header) + bytes(entries_data)
def write_library_indices(tracks: list["TrackInfo"], capabilities=None) -> 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

View File

@ -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('<I', body, 8, prefs.limit_value)
body[12] = 1 if prefs.match_checked_only else 0
body[13] = reverse
# Remaining bytes (14..131) are zero padding
return write_mhod_header(50, MHOD_HEADER_SIZE + SPLPREF_BODY_SIZE) + bytes(body)
# ────────────────────────────────────────────────────────────
# MHOD Type 51 — Smart Playlist Rules (SLst)
# ────────────────────────────────────────────────────────────
def _write_spl_rule(rule: SmartPlaylistRule) -> 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),
)

View File

@ -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('<IIII', 1, string_len, unk_0x20, unk_0x24)
return header + type_header + string_data
def write_mhod_location(path: str) -> 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("<III", unk024, unk028, unk032)
# Complete body = preamble + sean_header + atoms
body = preamble + sean_header + bytes(atoms)
# MHOD header + body
total_length = MHOD_HEADER_SIZE + len(body)
header = write_mhod_header(MHOD_TYPE_CHAPTER_DATA, total_length)
return header + body
def build_chapter_blob(
chapters: list[dict],
unk024: int = 0,
unk028: int = 0,
unk032: int = 0,
) -> 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)

View File

@ -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))

View File

@ -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('<I', data, 0x18, 0) # 6 x 0s
struct.pack_into('<I', data, 0x1C, 0)
struct.pack_into('<I', data, 0x20, 0)
struct.pack_into('<I', data, 0x24, 0)
struct.pack_into('<I', data, 0x28, 0)
struct.pack_into('<I', data, 0x2C, 0)
struct.pack_into('<I', data, 0x30, 0x010084) # magic value from libgpod
struct.pack_into('<I', data, 0x34, 0x05) # ?
struct.pack_into('<I', data, 0x38, 0x09) # ?
struct.pack_into('<I', data, 0x3C, 0x03) # ?
struct.pack_into('<I', data, 0x40, 0x120001) # ?
struct.pack_into('<I', data, 0x44, 0) # ?
struct.pack_into('<I', data, 0x48, 0) # ?
struct.pack_into('<I', data, 0x4C, 0x640014) # ?
struct.pack_into('<I', data, 0x50, 0x01) # bool? (visible?)
struct.pack_into('<I', data, 0x54, 0) # 2x0
struct.pack_into('<I', data, 0x58, 0)
struct.pack_into('<I', data, 0x5C, 0x320014) # ?
struct.pack_into('<I', data, 0x60, 0x01) # bool? (visible?)
struct.pack_into('<I', data, 0x64, 0) # 2x0
struct.pack_into('<I', data, 0x68, 0)
struct.pack_into('<I', data, 0x6C, 0x5a0014) # ?
struct.pack_into('<I', data, 0x70, 0x01) # bool? (visible?)
struct.pack_into('<I', data, 0x74, 0) # 2x0
struct.pack_into('<I', data, 0x78, 0)
struct.pack_into('<I', data, 0x7C, 0x500014) # ?
struct.pack_into('<I', data, 0x80, 0x01) # bool? (visible?)
struct.pack_into('<I', data, 0x84, 0) # 2x0
struct.pack_into('<I', data, 0x88, 0)
struct.pack_into('<I', data, 0x8C, 0x7d0015) # ?
struct.pack_into('<I', data, 0x90, 0x01) # bool? (visible?)
# Rest is zeros (padding to 0x288)
return bytes(data)
def _write_mhod100_raw(raw_body: bytes) -> 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,
)

Binary file not shown.

148
src/vendor/ipod_device/__init__.py vendored Normal file
View File

@ -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

184
src/vendor/ipod_device/artwork.py vendored Normal file
View File

@ -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}

View File

@ -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())

671
src/vendor/ipod_device/authority.py vendored Normal file
View File

@ -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"<?xml", b"<plist"):
idx = raw.find(marker)
if idx >= 0:
raw = raw[idx:]
break
raw = raw.strip(b"\x00\r\n\t ")
if raw and b"</plist>" not in raw:
raw += b"\n</dict>\n</plist>"
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)

641
src/vendor/ipod_device/capabilities.py vendored Normal file
View File

@ -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 (1G3G) 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 1G3G 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 050 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 1G3G: 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

41
src/vendor/ipod_device/checksum.py vendored Normal file
View File

@ -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 (1G5G, Photo, Video, Mini, Nano 1G2G, 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``."""

177
src/vendor/ipod_device/diagnostic_log.py vendored Normal file
View File

@ -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)

495
src/vendor/ipod_device/dump.py vendored Normal file
View File

@ -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())

1022
src/vendor/ipod_device/eject.py vendored Normal file

File diff suppressed because it is too large Load Diff

408
src/vendor/ipod_device/images.py vendored Normal file
View File

@ -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 (1G4G) ────────────────────────────────────────
("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 (1st3rd 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 (20102017) ───────────────────────────────
("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

2435
src/vendor/ipod_device/info.py vendored Normal file

File diff suppressed because it is too large Load Diff

170
src/vendor/ipod_device/lookup.py vendored Normal file
View File

@ -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

509
src/vendor/ipod_device/models.py vendored Normal file
View File

@ -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",
}

2524
src/vendor/ipod_device/scanner.py vendored Normal file

File diff suppressed because it is too large Load Diff

528
src/vendor/ipod_device/sysinfo.py vendored Normal file
View File

@ -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"</plist>" not in raw:
parse_candidates.append(raw + b"\n</dict>\n</plist>")
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"<?xml", b"<plist"):
idx = raw.find(marker)
if idx >= 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"<key>([^<]+)</key>\s*"
r"(?:(<string>(.*?)</string>)|(<integer>(.*?)</integer>)|"
r"(<true\s*/>)|(<false\s*/>))",
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

132
src/vendor/ipod_device/usb_backend.py vendored Normal file
View File

@ -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

506
src/vendor/ipod_device/virtual.py vendored Normal file
View File

@ -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 ""

617
src/vendor/ipod_device/vpd_iokit.py vendored Normal file
View File

@ -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 510):
# [5] IsExclusiveAccessAvailable [6] AddCallbackDispatcherToRunLoop
# [7] RemoveCallbackDispatcherFromRunLoop [8] ObtainExclusiveAccess
# [9] ReleaseExclusiveAccess [10] CreateSCSITask
#
# SCSITaskInterface vtable (slots 524):
# [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("<QQ", _kSCSITaskDeviceInterfaceID_bytes)
device_if = c_void_p(0)
hr = _vt_call(
plugin, 1, c_uint32,
[c_uint64, c_uint64, POINTER(c_void_p)],
c_uint64(uuid_lo), c_uint64(uuid_hi), byref(device_if),
)
if hr != 0:
log.debug("QueryInterface failed: 0x%08X", hr)
return False
self._device_if = device_if
# 3. ObtainExclusiveAccess [8]
kr = _vt_call(device_if, 8, c_int32, [])
if kr != 0:
log.debug("ObtainExclusiveAccess failed: 0x%08X", kr)
return False
self._exclusive = True
# 4. CreateSCSITask [10]
task = _vt_call(device_if, 10, c_void_p, [])
if not task:
log.debug("CreateSCSITask returned NULL")
return False
self._task = task
return True
def close(self):
"""Release all resources. Each step is guarded so a failure
in one does not prevent cleanup of the others."""
if self._task:
try:
_vt_call(c_void_p(self._task), 3, c_uint32, []) # Release
except Exception:
log.debug("SCSISession: task Release failed", exc_info=True)
self._task = None
if self._exclusive and self._device_if:
try:
_vt_call(self._device_if, 9, c_int32, []) # ReleaseExclusive
except Exception:
log.debug("SCSISession: ReleaseExclusive failed", exc_info=True)
self._exclusive = False
if self._device_if:
try:
_vt_call(self._device_if, 3, c_uint32, []) # Release
except Exception:
log.debug("SCSISession: device_if Release failed", exc_info=True)
self._device_if = None
if self._plugin:
try:
_vt_call(self._plugin, 3, c_uint32, []) # Release
except Exception:
log.debug("SCSISession: plugin Release failed", exc_info=True)
self._plugin = None
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def inquiry(self, evpd: bool, page: int, alloc_len: int = 255) -> 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 0xC20xFF
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"<?xml", b"<plist"):
idx = raw.find(marker)
if idx >= 0:
raw = raw[idx:]
break
# Ensure plist is closed (may be truncated)
if b"</plist>" not in raw:
raw = raw + b"\n</dict>\n</plist>"
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"<key>([^<]+)</key>\s*<(string|integer)>([^<]*)</\2>", 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

1353
src/vendor/ipod_device/vpd_libusb.py vendored Normal file

File diff suppressed because it is too large Load Diff

266
src/vendor/ipod_device/vpd_linux.py vendored Normal file
View File

@ -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

View File

@ -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

311
src/vendor/ipod_device/vpd_windows.py vendored Normal file
View File

@ -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]