Compare commits
4 Commits
8317ad1fe4
...
598bcc69d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598bcc69d3 | ||
|
|
8b122aa938 | ||
|
|
71f35c53cb | ||
|
|
222c2fcffb |
1
.gitignore
vendored
1
.gitignore
vendored
@ -37,4 +37,3 @@ converted/
|
||||
specs.md
|
||||
*.AppImage
|
||||
squashfs-root/
|
||||
iOpenPod/
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
# neo-pod-desktop
|
||||
|
||||
Настольное приложение для управления музыкой на iPod Nano 6G/7G с встроенным iTunes-подобным плеером, конвертацией аудио, полным пайплайном обложек и генерацией iTunesDB/SQLite/ArtworkDB через iOpenPod.
|
||||
Настольное приложение для управления музыкой на iPod Nano 7G с встроенным iTunes-подобным плеером, конвертацией аудио, полным пайплайном обложек и генерацией iTunesDB/SQLite/ArtworkDB.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Встроенный плеер** — воспроизведение локальной библиотеки через QMediaPlayer, отображение обложек альбомов (60x60), перемотка, громкость, повтор (all/one), shuffle
|
||||
- **Конвертация аудио** — FFmpeg конвертация в AAC/M4A (libfdk_aac с AAC-LC профилем, 160 kbps) и MP3 (libmp3lame); конвертация видео для iPod (H.264, 640x480)
|
||||
- **Пайплайн ArtworkDB** — извлечение обложек из аудиофайлов (APIC/covr/FLAC pictures), кодирование в iPod-нативные форматы (RGB565, RGB555, UYVY, JPEG), запись ithmb + ArtworkDB (MHFD→MHII→MHNI структуры)
|
||||
- **Генерация баз iPod** — полная регенерация iTunesDB (бинарный mhbd) и SQLite баз для Nano 6G/7G через iOpenPod, cross-reference валидация db_pid
|
||||
- **Генерация баз iPod** — полная регенерация iTunesDB (бинарный mhbd) и SQLite баз для Nano 7G, cross-reference валидация db_pid
|
||||
- **Синхронизация статистики** — двухсторонняя синхронизация play count, rating, skip count, last played между локальным кэшем (SQLite) и iPod; автосинк при подключении; validation gate против потери данных
|
||||
- **Локальная библиотека** — сортируемая таблица с поддержкой drag-and-drop, фильтрация по альбомам/плейлистам, поиск, разделение на source-треки (FLAC/WAV) и готовые (MP3/M4A)
|
||||
- **Плейлисты** — создание, переименование, удаление, drag-and-drop треков; перенос плейлистов на iPod с двухпроходным сопоставлением (content hash → metadata fallback)
|
||||
@ -112,7 +112,7 @@ neo-pod-desktop/
|
||||
| Технология | Назначение |
|
||||
|---|---|
|
||||
| **PyQt6** | Графический интерфейс, QMediaPlayer, сигналы/слоты |
|
||||
| **iOpenPod** | Генерация iTunesDB + SQLite + ArtworkDB для iPod Nano 6G/7G |
|
||||
| **iOpenPod (vendored)** | Генерация iTunesDB + SQLite + ArtworkDB для iPod Nano 7G |
|
||||
| **FFmpeg** | Конвертация аудио (AAC/MP3) и видео (H.264) |
|
||||
| **mutagen / eyeD3** | Чтение и запись метаданных (ID3, MP4, FLAC, OGG) |
|
||||
| **NumPy + Pillow** | Кодирование обложек (RGB565, масштабирование) |
|
||||
|
||||
@ -6,4 +6,5 @@ pillow>=9.5.0
|
||||
requests>=2.28.2
|
||||
PyQt6>=6.5.0
|
||||
numpy>=1.24.0
|
||||
iopenpod>=1.0.53
|
||||
wasmtime>=30.0.0
|
||||
pycryptodome>=3.20.0
|
||||
|
||||
@ -40,31 +40,13 @@ from mutagen.easyid3 import EasyID3
|
||||
from artwork import prepare_artwork_for_track, ArtworkEntry, write_artworkdb
|
||||
|
||||
def _find_iop_root() -> str:
|
||||
"""Find iOpenPod's installed location.
|
||||
|
||||
Tries in order:
|
||||
1. pip-installed package (via importlib.util.find_spec)
|
||||
2. /tmp/iOpenPod (backward compat / development)
|
||||
"""
|
||||
for pkg in ("iTunesDB_Writer", "SQLiteDB_Writer", "ipod_device"):
|
||||
spec = importlib.util.find_spec(pkg)
|
||||
if spec and spec.origin:
|
||||
parent = os.path.dirname(spec.origin)
|
||||
if os.path.basename(parent) == pkg:
|
||||
return os.path.dirname(parent)
|
||||
|
||||
fallbacks = [
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "iOpenPod"),
|
||||
"/tmp/iOpenPod",
|
||||
os.path.join(os.path.expanduser("~"), "iOpenPod"),
|
||||
]
|
||||
for path in fallbacks:
|
||||
if os.path.isdir(path):
|
||||
return path
|
||||
|
||||
raise ImportError(
|
||||
"iOpenPod not found. Install with: pip install iopenpod"
|
||||
)
|
||||
"""Return the vendored iOpenPod packages path."""
|
||||
vendor_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")
|
||||
if not os.path.isdir(vendor_path):
|
||||
raise ImportError(
|
||||
"iOpenPod vendor packages not found at %s" % vendor_path
|
||||
)
|
||||
return vendor_path
|
||||
|
||||
_IOP_ROOT = _find_iop_root()
|
||||
_IOP_CACHE: dict[str, Any] = {}
|
||||
|
||||
21
src/vendor/SQLiteDB_Writer/__init__.py
vendored
Normal file
21
src/vendor/SQLiteDB_Writer/__init__.py
vendored
Normal 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
64
src/vendor/SQLiteDB_Writer/_helpers.py
vendored
Normal 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
171
src/vendor/SQLiteDB_Writer/cbk_writer.py
vendored
Normal 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))
|
||||
124
src/vendor/SQLiteDB_Writer/dynamic_writer.py
vendored
Normal file
124
src/vendor/SQLiteDB_Writer/dynamic_writer.py
vendored
Normal 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 []))
|
||||
82
src/vendor/SQLiteDB_Writer/extras_writer.py
vendored
Normal file
82
src/vendor/SQLiteDB_Writer/extras_writer.py
vendored
Normal 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)
|
||||
56
src/vendor/SQLiteDB_Writer/genius_writer.py
vendored
Normal file
56
src/vendor/SQLiteDB_Writer/genius_writer.py
vendored
Normal 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)")
|
||||
1189
src/vendor/SQLiteDB_Writer/library_writer.py
vendored
Normal file
1189
src/vendor/SQLiteDB_Writer/library_writer.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
145
src/vendor/SQLiteDB_Writer/locations_writer.py
vendored
Normal file
145
src/vendor/SQLiteDB_Writer/locations_writer.py
vendored
Normal 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))
|
||||
211
src/vendor/SQLiteDB_Writer/sqlite_writer.py
vendored
Normal file
211
src/vendor/SQLiteDB_Writer/sqlite_writer.py
vendored
Normal 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
22
src/vendor/iTunesDB_Parser/__init__.py
vendored
Normal 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
69
src/vendor/iTunesDB_Parser/_parsing.py
vendored
Normal 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
|
||||
88
src/vendor/iTunesDB_Parser/artwork_links.py
vendored
Normal file
88
src/vendor/iTunesDB_Parser/artwork_links.py
vendored
Normal 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
|
||||
143
src/vendor/iTunesDB_Parser/chunk_parser.py
vendored
Normal file
143
src/vendor/iTunesDB_Parser/chunk_parser.py
vendored
Normal 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
|
||||
59
src/vendor/iTunesDB_Parser/exceptions.py
vendored
Normal file
59
src/vendor/iTunesDB_Parser/exceptions.py
vendored
Normal 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"
|
||||
)
|
||||
140
src/vendor/iTunesDB_Parser/ipod_library.py
vendored
Normal file
140
src/vendor/iTunesDB_Parser/ipod_library.py
vendored
Normal 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)
|
||||
39
src/vendor/iTunesDB_Parser/mhbd_parser.py
vendored
Normal file
39
src/vendor/iTunesDB_Parser/mhbd_parser.py
vendored
Normal 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}
|
||||
27
src/vendor/iTunesDB_Parser/mhia_parser.py
vendored
Normal file
27
src/vendor/iTunesDB_Parser/mhia_parser.py
vendored
Normal 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}
|
||||
30
src/vendor/iTunesDB_Parser/mhii_parser.py
vendored
Normal file
30
src/vendor/iTunesDB_Parser/mhii_parser.py
vendored
Normal 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}
|
||||
27
src/vendor/iTunesDB_Parser/mhip_parser.py
vendored
Normal file
27
src/vendor/iTunesDB_Parser/mhip_parser.py
vendored
Normal 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}
|
||||
30
src/vendor/iTunesDB_Parser/mhit_parser.py
vendored
Normal file
30
src/vendor/iTunesDB_Parser/mhit_parser.py
vendored
Normal 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}
|
||||
613
src/vendor/iTunesDB_Parser/mhod_parser.py
vendored
Normal file
613
src/vendor/iTunesDB_Parser/mhod_parser.py
vendored
Normal 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
|
||||
30
src/vendor/iTunesDB_Parser/mhsd_parser.py
vendored
Normal file
30
src/vendor/iTunesDB_Parser/mhsd_parser.py
vendored
Normal 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}
|
||||
34
src/vendor/iTunesDB_Parser/mhyp_parser.py
vendored
Normal file
34
src/vendor/iTunesDB_Parser/mhyp_parser.py
vendored
Normal 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
187
src/vendor/iTunesDB_Parser/otg.py
vendored
Normal 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
112
src/vendor/iTunesDB_Parser/parser.py
vendored
Normal 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
260
src/vendor/iTunesDB_Parser/playcounts.py
vendored
Normal 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
32
src/vendor/iTunesDB_Shared/__init__.py
vendored
Normal 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,
|
||||
})
|
||||
93
src/vendor/iTunesDB_Shared/album_identity.py
vendored
Normal file
93
src/vendor/iTunesDB_Shared/album_identity.py
vendored
Normal 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
378
src/vendor/iTunesDB_Shared/constants.py
vendored
Normal 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 6–10 were added in iTunes 9+ for Genius and other features.
|
||||
# Their child chunk reuses the 'mhli' magic (same as ArtworkDB's image
|
||||
# list, but here it is a generic item list — different semantics).
|
||||
# We skip their contents but must recognise them to avoid crashing.
|
||||
6: "mhsd_type_6", # Empty mhlt stub (purpose unknown, written by libgpod/iTunes)
|
||||
7: "mhsd_type_7", # (reserved, rarely seen)
|
||||
8: "mhsd_type_8", # Artist list (mhli with mhii children, MHOD type 300)
|
||||
9: "mhsd_type_9", # Genius Chill list
|
||||
10: "mhsd_type_10", # Empty mhlt stub (purpose unknown, written by libgpod/iTunes)
|
||||
}
|
||||
|
||||
# maps the database version to an iTunes version
|
||||
version_map = {
|
||||
0x01: "iTunes 1.0",
|
||||
0x02: "iTunes 2.0",
|
||||
0x03: "iTunes 3.0",
|
||||
0x04: "iTunes 4.0",
|
||||
0x05: "iTunes 4.0.1",
|
||||
0x06: "iTunes 4.1",
|
||||
0x07: "iTunes 4.1.1",
|
||||
0x08: "iTunes 4.1.2",
|
||||
0x09: "iTunes 4.2",
|
||||
0x0a: "iTunes 4.5",
|
||||
0x0b: "iTunes 4.7",
|
||||
0x0c: "iTunes 4.71/4.8",
|
||||
0x0d: "iTunes 4.9",
|
||||
0x0e: "iTunes 5",
|
||||
0x0f: "iTunes 6",
|
||||
0x10: "iTunes 6.0.1",
|
||||
0x11: "iTunes 6.0.2-6.0.4",
|
||||
0x12: "iTunes 6.0.5",
|
||||
0x13: "iTunes 7.0",
|
||||
0x14: "iTunes 7.1",
|
||||
0x15: "iTunes 7.2",
|
||||
0x16: "Unknown (0x16)",
|
||||
0x17: "iTunes 7.3.0",
|
||||
0x18: "iTunes 7.3.1-7.3.2",
|
||||
0x19: "iTunes 7.4",
|
||||
0x1a: "iTunes 7.4.1",
|
||||
0x1b: "iTunes 7.4.2",
|
||||
0x1c: "iTunes 7.5",
|
||||
0x1d: "iTunes 7.6",
|
||||
0x1e: "iTunes 7.7",
|
||||
0x1f: "iTunes 8.0",
|
||||
0x20: "iTunes 8.0.1",
|
||||
0x21: "iTunes 8.0.2",
|
||||
0x22: "iTunes 8.1",
|
||||
0x23: "iTunes 8.1.1",
|
||||
0x24: "iTunes 8.2",
|
||||
0x25: "iTunes 8.2.1",
|
||||
0x26: "iTunes 9.0",
|
||||
0x27: "iTunes 9.0.1",
|
||||
0x28: "iTunes 9.0.2",
|
||||
0x29: "iTunes 9.0.3",
|
||||
0x2a: "iTunes 9.1",
|
||||
0x2b: "iTunes 9.1.1",
|
||||
0x2c: "iTunes 9.2",
|
||||
0x2d: "iTunes 9.2.1",
|
||||
0x30: "iTunes 9.2+",
|
||||
# Extended versions for newer databases
|
||||
0x40: "iTunes 10.x",
|
||||
0x50: "iTunes 11.x",
|
||||
0x60: "iTunes 12.x",
|
||||
0x70: "iTunes 12.5+",
|
||||
0x75: "iTunes 12.9+",
|
||||
}
|
||||
|
||||
|
||||
def get_version_name(version_hex: int | str) -> str:
|
||||
"""
|
||||
Get iTunes version name from database version number.
|
||||
|
||||
Args:
|
||||
version_hex: Version as int (0x19) or hex string ('0x19')
|
||||
|
||||
Returns:
|
||||
Human-readable version string
|
||||
"""
|
||||
if isinstance(version_hex, str):
|
||||
# Remove '0x' prefix if present and convert
|
||||
version_hex = int(version_hex, 16) if version_hex.startswith('0x') else int(version_hex)
|
||||
|
||||
if version_hex in version_map:
|
||||
return version_map[version_hex]
|
||||
|
||||
# If not exact match, find closest lower version
|
||||
lower_versions = [v for v in version_map if v <= version_hex]
|
||||
if lower_versions:
|
||||
closest = max(lower_versions)
|
||||
return f"{version_map[closest]} (or newer)"
|
||||
|
||||
return f"Unknown (version {hex(version_hex)})"
|
||||
|
||||
|
||||
# maps the chunk header marker to a readable name
|
||||
# The identifiers read backwards conceptually — the convention is:
|
||||
# mhbd = DataBase Header Marker mhsd = DataSet Header Marker
|
||||
# mhlt = Track List Header Marker mhit = Track Item Header Marker
|
||||
# mhlp = Playlist List Header Marker mhla = Album List Header Marker
|
||||
# mhyp = plaYlist Header Marker mhip = playlist Item Header Marker
|
||||
# mhia = album Item Header Marker mhod = Data Object Header Marker
|
||||
identifier_readable_map = {
|
||||
"mhbd": "Database",
|
||||
"mhsd": "Dataset",
|
||||
"mhlt": "Track List",
|
||||
"mhlp": "Playlist or Podcast List",
|
||||
"mhla": "Album List",
|
||||
"mhli": "Artist List",
|
||||
"mhlp_smart": "Smart Playlist List",
|
||||
"mhia": "Album Item",
|
||||
"mhii": "Artist Item",
|
||||
"mhit": "Track Item",
|
||||
"mhyp": "Playlist",
|
||||
"mhod": "Data Object",
|
||||
"mhip": "Playlist Item",
|
||||
}
|
||||
|
||||
# maps the mhod type to its readable name
|
||||
#
|
||||
# Types 1-14: Track string MHODs (standard sub-header at offset 24)
|
||||
# Types 15-16: Podcast URL MHODs (UTF-8 string at offset 24, NO sub-header)
|
||||
# Type 17: Chapter data (big-endian atom-based binary blob)
|
||||
# Types 18-31: Track string MHODs (standard sub-header)
|
||||
# Type 32: Unknown binary data for video tracks (not a string!)
|
||||
# Types 33-44: Track string MHODs (standard sub-header)
|
||||
# Type 50: Smart playlist preferences (SPLPref binary)
|
||||
# Type 51: Smart playlist rules (SLst — BIG-endian binary)
|
||||
# Type 52: Library playlist sorted index (binary)
|
||||
# Type 53: Library playlist jump table (binary)
|
||||
# Type 100: Playlist column prefs (MHYP child) or position (MHIP child)
|
||||
# Type 102: Playlist settings (post-iTunes 7, binary blob)
|
||||
# Types 200-204: Album item string MHODs (standard sub-header)
|
||||
mhod_type_map = {
|
||||
1: "Title",
|
||||
2: "Location",
|
||||
3: "Album",
|
||||
4: "Artist",
|
||||
5: "Genre",
|
||||
6: "Filetype",
|
||||
7: "eq_setting",
|
||||
8: "Comment",
|
||||
9: "Category",
|
||||
10: "Lyrics",
|
||||
12: "Composer",
|
||||
13: "Grouping",
|
||||
14: "Description Text",
|
||||
15: "Podcast Enclosure URL",
|
||||
16: "Podcast RSS URL",
|
||||
17: "Chapter Data",
|
||||
18: "Subtitle",
|
||||
19: "Show",
|
||||
20: "Episode",
|
||||
21: "TV Network",
|
||||
22: "Album Artist",
|
||||
23: "Sort Artist",
|
||||
24: "Track Keywords",
|
||||
25: "Show Locale",
|
||||
26: "iTunes Store Asset Info",
|
||||
27: "Sort Title",
|
||||
28: "Sort Album",
|
||||
29: "Sort Album Artist",
|
||||
30: "Sort Composer",
|
||||
31: "Sort Show",
|
||||
32: "Unknown for Video Track",
|
||||
33: "Unknown (33)",
|
||||
34: "Unknown (34)",
|
||||
35: "Unknown (35)",
|
||||
36: "Unknown (36)",
|
||||
37: "Content Provider",
|
||||
38: "Unknown (38)",
|
||||
39: "Copyright",
|
||||
40: "Unknown (40)",
|
||||
41: "Unknown (41)",
|
||||
42: "Encoding Quality Descriptor",
|
||||
43: "Purchase Account",
|
||||
44: "Purchaser Name",
|
||||
50: "Smart Playlist Data",
|
||||
51: "Smart Playlist Rules",
|
||||
52: "Library Playlist Index",
|
||||
53: "Library Playlist Jump Table",
|
||||
100: "Column Size or Playlist Order",
|
||||
102: "Playlist Settings (binary)",
|
||||
200: "Album (Used by Album Item)",
|
||||
201: "Artist (Used by Album Item)",
|
||||
202: "Sort Artist (Used by Album Item)",
|
||||
203: "Podcast URL (Used by Album Item)",
|
||||
204: "Show (Used by Album Item)",
|
||||
# Types 300+: Artist item string MHODs (MHSD type 8)
|
||||
300: "Artist (Used by Artist Item)",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Media Type bitmask values (MHIT offset 208 / 0xD0)
|
||||
# From libgpod ItdbMediatype enum and iPodLinux wiki.
|
||||
# ============================================================
|
||||
MEDIA_TYPE_MAP = {
|
||||
0x00000000: "Audio/Video", # shows in both audio and video menus
|
||||
0x00000001: "Audio",
|
||||
0x00000002: "Video", # Movie
|
||||
0x00000004: "Podcast",
|
||||
0x00000006: "Video Podcast",
|
||||
0x00000008: "Audiobook",
|
||||
0x00000020: "Music Video",
|
||||
0x00000040: "TV Show",
|
||||
0x00000060: "TV Show (alt)",
|
||||
0x00000100: "Ringtone", # libgpod ITDB_MEDIATYPE_RINGTONE (1 << 8)
|
||||
0x00000200: "Rental", # iTunes rental movie
|
||||
0x00004000: "Ringtone (alt)", # Alternate ringtone value (some firmware)
|
||||
0x00040000: "iTunes Pass",
|
||||
0x00060000: "Memo / Voice Memo",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Playlist Sort Order (MHYP offset 44 / 0x2C)
|
||||
# From iPodLinux wiki "List Sort Order" and libgpod ItdbPlaylistSortOrder.
|
||||
# ============================================================
|
||||
PLAYLIST_SORT_ORDER_MAP = {
|
||||
0: "default (unset)",
|
||||
1: "playlist order (manual)",
|
||||
# 2: unknown
|
||||
3: "title",
|
||||
4: "album",
|
||||
5: "artist",
|
||||
6: "bitrate",
|
||||
7: "genre",
|
||||
8: "kind",
|
||||
9: "date modified",
|
||||
10: "track number",
|
||||
11: "size",
|
||||
12: "time",
|
||||
13: "year",
|
||||
14: "sample rate",
|
||||
15: "comment",
|
||||
16: "date added",
|
||||
17: "equalizer",
|
||||
18: "composer",
|
||||
# 19: unknown
|
||||
20: "play count",
|
||||
21: "last played",
|
||||
22: "disc number",
|
||||
23: "my rating",
|
||||
24: "release date", # used for Podcasts list
|
||||
25: "BPM",
|
||||
26: "grouping",
|
||||
27: "category",
|
||||
28: "description",
|
||||
29: "show",
|
||||
30: "season",
|
||||
31: "episode number",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Explicit / content advisory flag values (MHIT offset 146 / 0x92)
|
||||
# ============================================================
|
||||
EXPLICIT_FLAG_MAP = {
|
||||
0: "none",
|
||||
1: "explicit",
|
||||
2: "clean",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MHOD Type Integer Constants
|
||||
# Shared by MHOD parser/writer modules.
|
||||
# ============================================================
|
||||
MHOD_TYPE_TITLE = 1
|
||||
MHOD_TYPE_LOCATION = 2
|
||||
MHOD_TYPE_ALBUM = 3
|
||||
MHOD_TYPE_ARTIST = 4
|
||||
MHOD_TYPE_GENRE = 5
|
||||
MHOD_TYPE_FILETYPE = 6
|
||||
MHOD_TYPE_EQ_SETTING = 7
|
||||
MHOD_TYPE_COMMENT = 8
|
||||
MHOD_TYPE_CATEGORY = 9
|
||||
MHOD_TYPE_LYRICS = 10
|
||||
MHOD_TYPE_COMPOSER = 12
|
||||
MHOD_TYPE_GROUPING = 13
|
||||
MHOD_TYPE_DESCRIPTION = 14
|
||||
MHOD_TYPE_PODCAST_ENCLOSURE_URL = 15
|
||||
MHOD_TYPE_PODCAST_RSS_URL = 16
|
||||
MHOD_TYPE_CHAPTER_DATA = 17
|
||||
MHOD_TYPE_SUBTITLE = 18
|
||||
MHOD_TYPE_SHOW_NAME = 19
|
||||
MHOD_TYPE_EPISODE_ID = 20
|
||||
MHOD_TYPE_NETWORK_NAME = 21
|
||||
MHOD_TYPE_ALBUM_ARTIST = 22
|
||||
MHOD_TYPE_SORT_ARTIST = 23
|
||||
MHOD_TYPE_KEYWORDS = 24
|
||||
MHOD_TYPE_SHOW_LOCALE = 25
|
||||
MHOD_TYPE_SORT_NAME = 27
|
||||
MHOD_TYPE_SORT_ALBUM = 28
|
||||
MHOD_TYPE_SORT_ALBUM_ARTIST = 29
|
||||
MHOD_TYPE_SORT_COMPOSER = 30
|
||||
MHOD_TYPE_SORT_SHOW = 31
|
||||
MHOD_TYPE_SMART_PLAYLIST_DATA = 50
|
||||
MHOD_TYPE_SMART_PLAYLIST_RULES = 51
|
||||
MHOD_TYPE_LIBRARY_PLAYLIST_INDEX = 52
|
||||
MHOD_TYPE_LIBRARY_PLAYLIST_JUMP_TABLE = 53
|
||||
MHOD_TYPE_COLUMN_SIZE_OR_ORDER = 100
|
||||
MHOD_TYPE_PLAYLIST_SETTINGS = 102
|
||||
# Album item string types
|
||||
MHOD_TYPE_ALBUM_ALBUM = 200
|
||||
MHOD_TYPE_ALBUM_ARTIST_ITEM = 201
|
||||
MHOD_TYPE_ALBUM_SORT_ARTIST = 202
|
||||
MHOD_TYPE_ALBUM_PODCAST_URL = 203
|
||||
MHOD_TYPE_ALBUM_SHOW = 204
|
||||
# Artist item string type
|
||||
MHOD_TYPE_ARTIST_NAME = 300
|
||||
|
||||
|
||||
# ============================================================
|
||||
# File Format Codes (big-endian ASCII stored as LE u32)
|
||||
# Shared by track and locations writers.
|
||||
# ============================================================
|
||||
FILETYPE_CODES: dict[str, int] = {
|
||||
'mp3': 0x4D503320, # "MP3 "
|
||||
'm4a': 0x4D344120, # "M4A "
|
||||
'm4p': 0x4D345020, # "M4P "
|
||||
'm4b': 0x4D344220, # "M4B "
|
||||
'm4v': 0x4D345620, # "M4V "
|
||||
'mp4': 0x4D503420, # "MP4 "
|
||||
'wav': 0x57415620, # "WAV "
|
||||
'aif': 0x41494646, # "AIFF"
|
||||
'aiff': 0x41494646, # "AIFF"
|
||||
'aac': 0x41414320, # "AAC "
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Media Type Integer Constants (from libgpod Itdb_Mediatype)
|
||||
# Shared by track conversion and writer code.
|
||||
# ============================================================
|
||||
MEDIA_TYPE_AUDIO = 0x01
|
||||
MEDIA_TYPE_VIDEO = 0x02
|
||||
MEDIA_TYPE_PODCAST = 0x04
|
||||
MEDIA_TYPE_VIDEO_PODCAST = 0x06
|
||||
MEDIA_TYPE_AUDIOBOOK = 0x08
|
||||
MEDIA_TYPE_MUSIC_VIDEO = 0x20
|
||||
MEDIA_TYPE_TV_SHOW = 0x40
|
||||
MEDIA_TYPE_RINGTONE = 0x4000
|
||||
MEDIA_TYPE_VIDEO_MASK = MEDIA_TYPE_VIDEO | MEDIA_TYPE_MUSIC_VIDEO | MEDIA_TYPE_TV_SHOW
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Audio Format Flag map (MHIT offset 0x7E)
|
||||
# Maps filetype → codec hint value for the audio_format_flag field.
|
||||
# 0xFFFF = default (MP3/AAC/ALAC), 0x0000 = lossless (WAV/AIFF),
|
||||
# 0x0001 = Audible (M4B audiobooks).
|
||||
# ============================================================
|
||||
AUDIO_FORMAT_FLAG_MAP: dict[str, int] = {
|
||||
'wav': 0x0000,
|
||||
'aif': 0x0000,
|
||||
'aiff': 0x0000,
|
||||
'm4b': 0x0001,
|
||||
}
|
||||
AUDIO_FORMAT_FLAG_DEFAULT: int = 0xFFFF
|
||||
120
src/vendor/iTunesDB_Shared/extraction.py
vendored
Normal file
120
src/vendor/iTunesDB_Shared/extraction.py
vendored
Normal 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
415
src/vendor/iTunesDB_Shared/field_base.py
vendored
Normal 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
48
src/vendor/iTunesDB_Shared/mhbd_defs.py
vendored
Normal 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
21
src/vendor/iTunesDB_Shared/mhia_defs.py
vendored
Normal 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
18
src/vendor/iTunesDB_Shared/mhii_defs.py
vendored
Normal 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
26
src/vendor/iTunesDB_Shared/mhip_defs.py
vendored
Normal 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
141
src/vendor/iTunesDB_Shared/mhit_defs.py
vendored
Normal 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
586
src/vendor/iTunesDB_Shared/mhod_defs.py
vendored
Normal 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
15
src/vendor/iTunesDB_Shared/mhsd_defs.py
vendored
Normal 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
35
src/vendor/iTunesDB_Shared/mhyp_defs.py
vendored
Normal 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
148
src/vendor/iTunesDB_Writer/__init__.py
vendored
Normal 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
280
src/vendor/iTunesDB_Writer/hash58.py
vendored
Normal 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
476
src/vendor/iTunesDB_Writer/hash72.py
vendored
Normal 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
287
src/vendor/iTunesDB_Writer/hashab.py
vendored
Normal 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
1140
src/vendor/iTunesDB_Writer/mhbd_writer.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
138
src/vendor/iTunesDB_Writer/mhip_writer.py
vendored
Normal file
138
src/vendor/iTunesDB_Writer/mhip_writer.py
vendored
Normal 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
|
||||
394
src/vendor/iTunesDB_Writer/mhit_writer.py
vendored
Normal file
394
src/vendor/iTunesDB_Writer/mhit_writer.py
vendored
Normal 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
|
||||
199
src/vendor/iTunesDB_Writer/mhla_writer.py
vendored
Normal file
199
src/vendor/iTunesDB_Writer/mhla_writer.py
vendored
Normal 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)
|
||||
136
src/vendor/iTunesDB_Writer/mhli_writer.py
vendored
Normal file
136
src/vendor/iTunesDB_Writer/mhli_writer.py
vendored
Normal 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)
|
||||
238
src/vendor/iTunesDB_Writer/mhlp_writer.py
vendored
Normal file
238
src/vendor/iTunesDB_Writer/mhlp_writer.py
vendored
Normal 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)
|
||||
59
src/vendor/iTunesDB_Writer/mhlt_writer.py
vendored
Normal file
59
src/vendor/iTunesDB_Writer/mhlt_writer.py
vendored
Normal 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
|
||||
334
src/vendor/iTunesDB_Writer/mhod52_writer.py
vendored
Normal file
334
src/vendor/iTunesDB_Writer/mhod52_writer.py
vendored
Normal 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
|
||||
329
src/vendor/iTunesDB_Writer/mhod_spl_writer.py
vendored
Normal file
329
src/vendor/iTunesDB_Writer/mhod_spl_writer.py
vendored
Normal 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),
|
||||
)
|
||||
434
src/vendor/iTunesDB_Writer/mhod_writer.py
vendored
Normal file
434
src/vendor/iTunesDB_Writer/mhod_writer.py
vendored
Normal 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)
|
||||
101
src/vendor/iTunesDB_Writer/mhsd_writer.py
vendored
Normal file
101
src/vendor/iTunesDB_Writer/mhsd_writer.py
vendored
Normal 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))
|
||||
538
src/vendor/iTunesDB_Writer/mhyp_writer.py
vendored
Normal file
538
src/vendor/iTunesDB_Writer/mhyp_writer.py
vendored
Normal 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,
|
||||
)
|
||||
BIN
src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm
vendored
Normal file
BIN
src/vendor/iTunesDB_Writer/wasm/calcHashAB.wasm
vendored
Normal file
Binary file not shown.
148
src/vendor/ipod_device/__init__.py
vendored
Normal file
148
src/vendor/ipod_device/__init__.py
vendored
Normal 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
184
src/vendor/ipod_device/artwork.py
vendored
Normal 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}
|
||||
119
src/vendor/ipod_device/artwork_presets.py
vendored
Normal file
119
src/vendor/ipod_device/artwork_presets.py
vendored
Normal 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
671
src/vendor/ipod_device/authority.py
vendored
Normal 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
641
src/vendor/ipod_device/capabilities.py
vendored
Normal 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 (1G–3G) and iPod Mobile."""
|
||||
supports_gapless: bool = False
|
||||
"""Device honours gapless playback fields (pregap, postgap,
|
||||
samplecount, gapless_data, gapless_track_flag). Introduced with
|
||||
iPod Video 5.5G (Late 2006)."""
|
||||
|
||||
# ── Artwork ────────────────────────────────────────────────────────
|
||||
supports_artwork: bool = True
|
||||
"""Device has an ArtworkDB and .ithmb files for album art."""
|
||||
supports_photo: bool = False
|
||||
"""Device has additional photo artwork formats (for photo viewer)."""
|
||||
photo_formats: tuple[ArtworkFormat, ...] = ()
|
||||
"""Photo/slideshow ithmb formats used by the Photos database pipeline."""
|
||||
supports_chapter_image: bool = False
|
||||
"""Device has chapter image artwork formats (for enhanced podcasts)."""
|
||||
supports_sparse_artwork: bool = False
|
||||
"""Artwork can be written in sparse mode (Nano 3G+, Classic, Touch)."""
|
||||
supports_alac: bool = True
|
||||
"""Device supports Apple Lossless (ALAC) audio playback.
|
||||
False for iPod 1G–3G and Mini 1G (pre-firmware-update era hardware that
|
||||
received ALAC support only from 4th Gen / Photo / Mini 2G onwards)."""
|
||||
cover_art_formats: tuple[ArtworkFormat, ...] = ()
|
||||
"""Supported cover-art thumbnail sizes. Empty means no artwork."""
|
||||
|
||||
# ── Storage layout ─────────────────────────────────────────────────
|
||||
music_dirs: int = 20
|
||||
"""Number of ``Fxx`` directories under ``iPod_Control/Music/``.
|
||||
Varies 0–50 depending on model and storage capacity."""
|
||||
|
||||
# ── SQLite database ────────────────────────────────────────────────
|
||||
uses_sqlite_db: bool = False
|
||||
"""If True, device uses SQLite databases in
|
||||
``iTunes Library.itlp/`` instead of (or alongside) binary
|
||||
iTunesDB/iTunesCDB. The firmware on Nano 6G/7G reads the SQLite
|
||||
databases and ignores iTunesCDB completely."""
|
||||
|
||||
# ── Writer parameters ──────────────────────────────────────────────
|
||||
db_version: int = 0x30
|
||||
"""iTunesDB version to write in mhbd header. Older iPods need
|
||||
lower values (0x0c for Shuffle 1G/2G, 0x13 for pre-Classic)."""
|
||||
byte_order: str = "le"
|
||||
"""Byte order for database writing. ``"le"`` for almost all models.
|
||||
``"be"`` for iPod Mobile (Motorola ROKR/SLVR/RAZR)."""
|
||||
|
||||
# ── Screen / display ───────────────────────────────────────────────
|
||||
has_screen: bool = True
|
||||
"""Device has a display. Shuffles have no screen."""
|
||||
|
||||
# ── Video encoding limits ──────────────────────────────────────────
|
||||
max_video_width: int = 0
|
||||
"""Maximum H.264 decode width (pixels). 0 = no video support.
|
||||
This is the firmware decode ceiling, not the screen resolution —
|
||||
the device downscales to fit its screen."""
|
||||
max_video_height: int = 0
|
||||
"""Maximum H.264 decode height (pixels). 0 = no video support."""
|
||||
max_video_fps: int = 30
|
||||
"""Maximum frame rate for H.264 decode (fps). All video-capable iPods
|
||||
support 30 fps; PAL-resolution Nano 7G content is typically 25 fps but
|
||||
30 fps playback is still supported."""
|
||||
max_video_bitrate: int = 0
|
||||
"""Hard bitrate ceiling for H.264 decode (kbps). 0 = no explicit cap
|
||||
(quality-controlled by CRF only). Non-zero values enforce a -maxrate
|
||||
flag in ffmpeg.
|
||||
Nano 3G/4G use Baseline Profile Level 1.3, capped at 768 kbps by spec."""
|
||||
h264_level: str = "3.0"
|
||||
"""H.264 Baseline Profile level to target when encoding video.
|
||||
Most iPods support Level 3.0. iPod Classic supports 3.1.
|
||||
Nano 3G/4G are limited to Level 1.3 by their hardware decoder."""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# The master capabilities table
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_FAMILY_GEN_CAPABILITIES: dict[tuple[str, str], DeviceCapabilities] = {
|
||||
|
||||
# ── iPod 1G–3G: earliest models, no podcast, no gapless ───────────
|
||||
("iPod", "1st Gen"): DeviceCapabilities(
|
||||
supports_podcast=False,
|
||||
supports_artwork=False,
|
||||
supports_alac=True,
|
||||
has_screen=True,
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
("iPod", "2nd Gen"): DeviceCapabilities(
|
||||
supports_podcast=False,
|
||||
supports_artwork=False,
|
||||
supports_alac=True,
|
||||
has_screen=True,
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
("iPod", "3rd Gen"): DeviceCapabilities(
|
||||
supports_podcast=False,
|
||||
supports_artwork=False,
|
||||
supports_alac=True,
|
||||
has_screen=True,
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod 4G (Click Wheel): first with podcast support ─────────────
|
||||
("iPod", "4th Gen"): DeviceCapabilities(
|
||||
supports_artwork=False,
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod U2 Special Edition (4th Gen hardware) ────────────────────
|
||||
("iPod U2", "4th Gen"): DeviceCapabilities(
|
||||
supports_artwork=False,
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod Photo (Color Display) ────────────────────────────────────
|
||||
("iPod Photo", "4th Gen"): DeviceCapabilities(
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1009],
|
||||
ARTWORK_FORMATS_BY_ID[1013],
|
||||
ARTWORK_FORMATS_BY_ID[1015],
|
||||
ARTWORK_FORMATS_BY_ID[1019],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1017],
|
||||
ARTWORK_FORMATS_BY_ID[1016],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod Video 5th Gen ────────────────────────────────────────────
|
||||
("iPod Video", "5th Gen"): DeviceCapabilities(
|
||||
supports_video=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1036],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1015],
|
||||
ARTWORK_FORMATS_BY_ID[1019],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1028],
|
||||
ARTWORK_FORMATS_BY_ID[1029],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x19,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
|
||||
# ── iPod Video 5.5th Gen — first with gapless playback ───────────
|
||||
("iPod Video", "5.5th Gen"): DeviceCapabilities(
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1036],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1015],
|
||||
ARTWORK_FORMATS_BY_ID[1019],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1028],
|
||||
ARTWORK_FORMATS_BY_ID[1029],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x19,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
|
||||
# ── iPod Video U2 editions ────────────────────────────────────────
|
||||
("iPod Video U2", "5th Gen"): DeviceCapabilities(
|
||||
supports_video=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1036],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1015],
|
||||
ARTWORK_FORMATS_BY_ID[1019],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1028],
|
||||
ARTWORK_FORMATS_BY_ID[1029],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x19,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
("iPod Video U2", "5.5th Gen"): DeviceCapabilities(
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1036],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1015],
|
||||
ARTWORK_FORMATS_BY_ID[1019],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1028],
|
||||
ARTWORK_FORMATS_BY_ID[1029],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x19,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
|
||||
# ── iPod Classic (all gens): HASH58, gapless, video ───────────────
|
||||
("iPod Classic", "1st Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH58,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1067],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
),
|
||||
supports_chapter_image=True,
|
||||
supports_sparse_artwork=True,
|
||||
cover_art_formats=CLASSIC_COVER_ART_FORMATS,
|
||||
music_dirs=50,
|
||||
db_version=0x30,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
("iPod Classic", "2nd Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH58,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1067],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
),
|
||||
supports_chapter_image=True,
|
||||
supports_sparse_artwork=True,
|
||||
cover_art_formats=CLASSIC_COVER_ART_FORMATS,
|
||||
music_dirs=50,
|
||||
db_version=0x30,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
("iPod Classic", "3rd Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH58,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1067],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
),
|
||||
supports_chapter_image=True,
|
||||
supports_sparse_artwork=True,
|
||||
cover_art_formats=CLASSIC_COVER_ART_FORMATS,
|
||||
music_dirs=50,
|
||||
db_version=0x30,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
|
||||
# ── iPod Mini ─────────────────────────────────────────────────────
|
||||
("iPod Mini", "1st Gen"): DeviceCapabilities(
|
||||
supports_artwork=False,
|
||||
supports_alac=True,
|
||||
music_dirs=6,
|
||||
db_version=0x13,
|
||||
),
|
||||
("iPod Mini", "2nd Gen"): DeviceCapabilities(
|
||||
supports_artwork=False,
|
||||
music_dirs=6,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod Nano 1G/2G ──────────────────────────────────────────────
|
||||
("iPod Nano", "1st Gen"): DeviceCapabilities(
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1032],
|
||||
ARTWORK_FORMATS_BY_ID[1023],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1031],
|
||||
ARTWORK_FORMATS_BY_ID[1027],
|
||||
),
|
||||
music_dirs=14,
|
||||
db_version=0x13,
|
||||
),
|
||||
("iPod Nano", "2nd Gen"): DeviceCapabilities(
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1032],
|
||||
ARTWORK_FORMATS_BY_ID[1023],
|
||||
),
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1031],
|
||||
ARTWORK_FORMATS_BY_ID[1027],
|
||||
),
|
||||
music_dirs=14,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod Nano 3G ("Fat"): first Nano with video, HASH58 ──────────
|
||||
("iPod Nano", "3rd Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH58,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1067],
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
),
|
||||
supports_sparse_artwork=True,
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1061],
|
||||
ARTWORK_FORMATS_BY_ID[1055],
|
||||
ARTWORK_FORMATS_BY_ID[1068],
|
||||
ARTWORK_FORMATS_BY_ID[1060],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x30,
|
||||
max_video_width=320,
|
||||
max_video_height=240,
|
||||
max_video_bitrate=768,
|
||||
h264_level="1.3",
|
||||
),
|
||||
|
||||
# ── iPod Nano 4G: HASH58 ─────────────────────────────────────────
|
||||
("iPod Nano", "4th Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH58,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1024],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
ARTWORK_FORMATS_BY_ID[1079],
|
||||
ARTWORK_FORMATS_BY_ID[1083],
|
||||
),
|
||||
supports_chapter_image=True,
|
||||
supports_sparse_artwork=True,
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1055],
|
||||
ARTWORK_FORMATS_BY_ID[1068],
|
||||
ARTWORK_FORMATS_BY_ID[1071],
|
||||
ARTWORK_FORMATS_BY_ID[1074],
|
||||
ARTWORK_FORMATS_BY_ID[1078],
|
||||
ARTWORK_FORMATS_BY_ID[1084],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x30,
|
||||
max_video_width=480,
|
||||
max_video_height=320,
|
||||
max_video_bitrate=768,
|
||||
h264_level="1.3",
|
||||
),
|
||||
|
||||
# ── iPod Nano 5G: HASH72, compressed DB + SQLite ─────────────────
|
||||
("iPod Nano", "5th Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASH72,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1087],
|
||||
ARTWORK_FORMATS_BY_ID[1079],
|
||||
ARTWORK_FORMATS_BY_ID[1066],
|
||||
),
|
||||
supports_sparse_artwork=True,
|
||||
supports_compressed_db=True,
|
||||
uses_sqlite_db=True,
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1056],
|
||||
ARTWORK_FORMATS_BY_ID[1078],
|
||||
ARTWORK_FORMATS_BY_ID[1073],
|
||||
ARTWORK_FORMATS_BY_ID[1074],
|
||||
),
|
||||
music_dirs=14,
|
||||
db_version=0x30,
|
||||
max_video_width=640,
|
||||
max_video_height=480,
|
||||
),
|
||||
|
||||
# ── iPod Nano 6G: HASHAB, no video ───────────────────────────────
|
||||
("iPod Nano", "6th Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASHAB,
|
||||
supports_video=False,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1092],
|
||||
ARTWORK_FORMATS_BY_ID[1093],
|
||||
),
|
||||
supports_sparse_artwork=True,
|
||||
supports_compressed_db=True,
|
||||
uses_sqlite_db=True,
|
||||
cover_art_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1073],
|
||||
ARTWORK_FORMATS_BY_ID[1085],
|
||||
ARTWORK_FORMATS_BY_ID[1089],
|
||||
ARTWORK_FORMATS_BY_ID[1074],
|
||||
),
|
||||
music_dirs=20,
|
||||
db_version=0x30,
|
||||
),
|
||||
|
||||
# ── iPod Nano 7G: HASHAB, video returns ──────────────────────────
|
||||
("iPod Nano", "7th Gen"): DeviceCapabilities(
|
||||
checksum=ChecksumType.HASHAB,
|
||||
supports_video=True,
|
||||
supports_gapless=True,
|
||||
supports_artwork=True,
|
||||
supports_photo=True,
|
||||
photo_formats=(
|
||||
ARTWORK_FORMATS_BY_ID[1007],
|
||||
ARTWORK_FORMATS_BY_ID[1005],
|
||||
),
|
||||
supports_sparse_artwork=True,
|
||||
supports_compressed_db=True,
|
||||
uses_sqlite_db=True,
|
||||
cover_art_formats=NANO_7G_COVER_ART_FORMATS,
|
||||
music_dirs=20,
|
||||
db_version=0x30,
|
||||
max_video_width=720,
|
||||
max_video_height=576,
|
||||
),
|
||||
|
||||
# ── iPod Shuffle 1G ──────────────────────────────────────────────
|
||||
("iPod Shuffle", "1st Gen"): DeviceCapabilities(
|
||||
is_shuffle=True,
|
||||
shadow_db_version=1,
|
||||
supports_podcast=True,
|
||||
supports_artwork=False,
|
||||
has_screen=False,
|
||||
music_dirs=3,
|
||||
db_version=0x0c,
|
||||
),
|
||||
|
||||
# ── iPod Shuffle 2G ──────────────────────────────────────────────
|
||||
("iPod Shuffle", "2nd Gen"): DeviceCapabilities(
|
||||
is_shuffle=True,
|
||||
shadow_db_version=1,
|
||||
supports_podcast=True,
|
||||
supports_artwork=False,
|
||||
has_screen=False,
|
||||
music_dirs=3,
|
||||
db_version=0x13,
|
||||
),
|
||||
|
||||
# ── iPod Shuffle 3G ──────────────────────────────────────────────
|
||||
("iPod Shuffle", "3rd Gen"): DeviceCapabilities(
|
||||
is_shuffle=True,
|
||||
shadow_db_version=2,
|
||||
supports_podcast=True,
|
||||
supports_artwork=False,
|
||||
has_screen=False,
|
||||
music_dirs=3,
|
||||
db_version=0x19,
|
||||
),
|
||||
|
||||
# ── iPod Shuffle 4G ──────────────────────────────────────────────
|
||||
("iPod Shuffle", "4th Gen"): DeviceCapabilities(
|
||||
is_shuffle=True,
|
||||
shadow_db_version=2,
|
||||
supports_podcast=True,
|
||||
supports_artwork=False,
|
||||
has_screen=False,
|
||||
music_dirs=3,
|
||||
db_version=0x19,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def capabilities_for_family_gen(
|
||||
family: str,
|
||||
generation: str,
|
||||
*,
|
||||
capacity: str | None = None,
|
||||
model_number: str | None = None,
|
||||
) -> DeviceCapabilities | None:
|
||||
"""Return the device capabilities for a (family, generation) pair.
|
||||
|
||||
If the exact pair is not found but *generation* is empty/unknown,
|
||||
checks whether all known generations of *family* share identical
|
||||
capabilities and returns those.
|
||||
|
||||
Returns ``None`` if the pair is not in the lookup table and the
|
||||
family-level fallback is ambiguous.
|
||||
"""
|
||||
caps = _FAMILY_GEN_CAPABILITIES.get((family, generation))
|
||||
if caps is not None:
|
||||
return caps
|
||||
|
||||
if family and not generation:
|
||||
family_caps = [
|
||||
c for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items()
|
||||
if f == family
|
||||
]
|
||||
if family_caps and all(c == family_caps[0] for c in family_caps):
|
||||
return family_caps[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def cover_art_formats_for_family_gen(
|
||||
family: str,
|
||||
generation: str,
|
||||
*,
|
||||
capacity: str | None = None,
|
||||
model_number: str | None = None,
|
||||
) -> tuple[ArtworkFormat, ...]:
|
||||
"""Return cover-art formats for a family/generation pair.
|
||||
|
||||
This is intentionally narrower than ``capabilities_for_family_gen``. Some
|
||||
families have generations with different playback capabilities but the same
|
||||
ArtworkDB cover formats, so a full capability fallback would be ambiguous
|
||||
while artwork generation is still safe.
|
||||
"""
|
||||
_ = capacity, model_number
|
||||
|
||||
caps = _FAMILY_GEN_CAPABILITIES.get((family, generation))
|
||||
if caps is not None:
|
||||
return caps.cover_art_formats if caps.supports_artwork else ()
|
||||
|
||||
if family and not generation:
|
||||
family_formats = [
|
||||
c.cover_art_formats if c.supports_artwork else ()
|
||||
for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items()
|
||||
if f == family
|
||||
]
|
||||
if family_formats and all(formats == family_formats[0] for formats in family_formats):
|
||||
return family_formats[0]
|
||||
|
||||
return ()
|
||||
|
||||
|
||||
def checksum_type_for_family_gen(
|
||||
family: str,
|
||||
generation: str,
|
||||
) -> ChecksumType | None:
|
||||
"""Return the checksum type for a (family, generation) pair.
|
||||
|
||||
Derives the answer from ``_FAMILY_GEN_CAPABILITIES``. If the exact
|
||||
(family, generation) pair is not found but *generation* is empty/unknown,
|
||||
checks whether all known generations of *family* share the same checksum
|
||||
type and returns it.
|
||||
|
||||
Returns ``None`` if the pair is not in the lookup table and the family-
|
||||
level fallback is ambiguous.
|
||||
"""
|
||||
caps = _FAMILY_GEN_CAPABILITIES.get((family, generation))
|
||||
if caps is not None:
|
||||
return caps.checksum
|
||||
|
||||
if family and not generation:
|
||||
family_checksums = {
|
||||
c.checksum
|
||||
for (f, _g), c in _FAMILY_GEN_CAPABILITIES.items()
|
||||
if f == family
|
||||
}
|
||||
if len(family_checksums) == 1:
|
||||
return family_checksums.pop()
|
||||
|
||||
return None
|
||||
41
src/vendor/ipod_device/checksum.py
vendored
Normal file
41
src/vendor/ipod_device/checksum.py
vendored
Normal 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 (1G–5G, Photo, Video, Mini, Nano 1G–2G, Shuffle)
|
||||
HASH58 — iPod Classic (all gens), Nano 3G, Nano 4G
|
||||
HASH72 — Nano 5G
|
||||
HASHAB — Nano 6G, Nano 7G (white-box AES, via WASM module)
|
||||
UNSUPPORTED — Reserved for any future unsupported scheme
|
||||
UNKNOWN — Device not yet identified
|
||||
"""
|
||||
NONE = 0
|
||||
HASH58 = 1
|
||||
HASH72 = 2
|
||||
HASHAB = 3
|
||||
UNSUPPORTED = 98
|
||||
UNKNOWN = 99
|
||||
|
||||
|
||||
# ── MHBD hashing scheme ↔ ChecksumType mapping ──────────────────────────
|
||||
#
|
||||
# The mhbd header at offset 0x30 stores a 16-bit ``hashing_scheme`` value.
|
||||
# These constants map between our ``ChecksumType`` enum and the raw wire
|
||||
# values. Note: HASHAB is enum 3 but wire 4.
|
||||
|
||||
CHECKSUM_MHBD_SCHEME: dict[ChecksumType, int] = {
|
||||
ChecksumType.NONE: 0,
|
||||
ChecksumType.HASH58: 1,
|
||||
ChecksumType.HASH72: 2,
|
||||
ChecksumType.HASHAB: 4,
|
||||
}
|
||||
"""Map ``ChecksumType`` → raw ``hashing_scheme`` field in mhbd header."""
|
||||
|
||||
MHBD_SCHEME_TO_CHECKSUM: dict[int, ChecksumType] = {
|
||||
v: k for k, v in CHECKSUM_MHBD_SCHEME.items()
|
||||
}
|
||||
"""Map raw ``hashing_scheme`` field in mhbd header → ``ChecksumType``."""
|
||||
177
src/vendor/ipod_device/diagnostic_log.py
vendored
Normal file
177
src/vendor/ipod_device/diagnostic_log.py
vendored
Normal 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
495
src/vendor/ipod_device/dump.py
vendored
Normal 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
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
408
src/vendor/ipod_device/images.py
vendored
Normal 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 (1G–4G) ────────────────────────────────────────
|
||||
("ipod", "1st gen", "white"): "iPod1.png",
|
||||
("ipod", "2nd gen", "white"): "iPod1.png",
|
||||
("ipod", "3rd gen", "white"): "iPod2.png",
|
||||
("ipod", "4th gen", "white"): "iPod4-White.png",
|
||||
("ipod u2", "4th gen", "black"): "iPod4-BlackRed.png",
|
||||
|
||||
# ── iPod Photo / iPod with Color Display (4th gen)──────
|
||||
("ipod photo", "4th gen", "white"): "iPod5-White.png",
|
||||
("ipod photo u2", "4th gen", "black"): "iPod5-BlackRed.png",
|
||||
|
||||
# ── iPod with Video (5th Gen / 5.5th Gen) ────────────
|
||||
("ipod video", "5th gen", "white"): "iPod6-White.png",
|
||||
("ipod video", "5th gen", "black"): "iPod6-Black.png",
|
||||
("ipod video", "5.5th gen", "white"): "iPod6-White.png",
|
||||
("ipod video", "5.5th gen", "black"): "iPod6-Black.png",
|
||||
("ipod video u2", "5th gen", "black"): "iPod6-BlackRed.png",
|
||||
("ipod video u2", "5.5th gen", "black"): "iPod6-BlackRed.png",
|
||||
|
||||
# ── iPod Classic (1st–3rd Gen) ───────────────────────
|
||||
("ipod classic", "1st gen", "silver"): "iPod11-Silver.png",
|
||||
("ipod classic", "1st gen", "black"): "iPod11-Black.png",
|
||||
("ipod classic", "2nd gen", "silver"): "iPod11-Silver.png",
|
||||
("ipod classic", "2nd gen", "black"): "iPod11B-Black.png",
|
||||
("ipod classic", "3rd gen", "silver"): "iPod11-Silver.png",
|
||||
("ipod classic", "3rd gen", "black"): "iPod11B-Black.png",
|
||||
|
||||
# ── iPod Mini 1st Gen ────────────
|
||||
("ipod mini", "1st gen", "silver"): "iPod3-Silver.png",
|
||||
("ipod mini", "1st gen", "blue"): "iPod3-Blue.png",
|
||||
("ipod mini", "1st gen", "gold"): "iPod3-Gold.png",
|
||||
("ipod mini", "1st gen", "green"): "iPod3-Green.png",
|
||||
("ipod mini", "1st gen", "pink"): "iPod3-Pink.png",
|
||||
|
||||
# ── iPod Mini 2nd Gen ───
|
||||
("ipod mini", "2nd gen", "silver"): "iPod3-Silver.png",
|
||||
("ipod mini", "2nd gen", "blue"): "iPod3B-Blue.png",
|
||||
("ipod mini", "2nd gen", "green"): "iPod3B-Green.png",
|
||||
("ipod mini", "2nd gen", "pink"): "iPod3B-Pink.png",
|
||||
|
||||
# ── iPod Nano 1st Gen ──────────────────────────────
|
||||
("ipod nano", "1st gen", "white"): "iPod7-White.png",
|
||||
("ipod nano", "1st gen", "black"): "iPod7-Black.png",
|
||||
|
||||
# ── iPod Nano 2nd Gen ─────
|
||||
("ipod nano", "2nd gen", "silver"): "iPod9-Silver.png",
|
||||
("ipod nano", "2nd gen", "black"): "iPod9-Black.png",
|
||||
("ipod nano", "2nd gen", "blue"): "iPod9-Blue.png",
|
||||
("ipod nano", "2nd gen", "green"): "iPod9-Green.png",
|
||||
("ipod nano", "2nd gen", "pink"): "iPod9-Pink.png",
|
||||
("ipod nano", "2nd gen", "red"): "iPod9-Red.png",
|
||||
|
||||
# ── iPod Nano 3rd Gen ─────
|
||||
("ipod nano", "3rd gen", "silver"): "iPod12-Silver.png",
|
||||
("ipod nano", "3rd gen", "black"): "iPod12-Black.png",
|
||||
("ipod nano", "3rd gen", "blue"): "iPod12-Blue.png",
|
||||
("ipod nano", "3rd gen", "green"): "iPod12-Green.png",
|
||||
("ipod nano", "3rd gen", "pink"): "iPod12-Pink.png",
|
||||
("ipod nano", "3rd gen", "red"): "iPod12-Red.png",
|
||||
|
||||
# ── iPod Nano 4th Gen ─────────────────────────────────
|
||||
("ipod nano", "4th gen", "silver"): "iPod15-Silver.png",
|
||||
("ipod nano", "4th gen", "black"): "iPod15-Black.png",
|
||||
("ipod nano", "4th gen", "blue"): "iPod15-Blue.png",
|
||||
("ipod nano", "4th gen", "green"): "iPod15-Green.png",
|
||||
("ipod nano", "4th gen", "orange"): "iPod15-Orange.png",
|
||||
("ipod nano", "4th gen", "pink"): "iPod15-Pink.png",
|
||||
("ipod nano", "4th gen", "purple"): "iPod15-Purple.png",
|
||||
("ipod nano", "4th gen", "red"): "iPod15-Red.png",
|
||||
("ipod nano", "4th gen", "yellow"): "iPod15-Yellow.png",
|
||||
|
||||
# ── iPod Nano 5th Gen ─────────────────────────────────
|
||||
("ipod nano", "5th gen", "silver"): "iPod16-Silver.png",
|
||||
("ipod nano", "5th gen", "black"): "iPod16-Black.png",
|
||||
("ipod nano", "5th gen", "blue"): "iPod16-Blue.png",
|
||||
("ipod nano", "5th gen", "green"): "iPod16-Green.png",
|
||||
("ipod nano", "5th gen", "orange"): "iPod16-Orange.png",
|
||||
("ipod nano", "5th gen", "pink"): "iPod16-Pink.png",
|
||||
("ipod nano", "5th gen", "purple"): "iPod16-Purple.png",
|
||||
("ipod nano", "5th gen", "red"): "iPod16-Red.png",
|
||||
("ipod nano", "5th gen", "yellow"): "iPod16-Yellow.png",
|
||||
|
||||
# ── iPod Nano 6th Gen ─────────────────────────────────
|
||||
("ipod nano", "6th gen", "silver"): "iPod17-Silver.png",
|
||||
("ipod nano", "6th gen", "graphite"): "iPod17-DarkGray.png",
|
||||
("ipod nano", "6th gen", "blue"): "iPod17-Blue.png",
|
||||
("ipod nano", "6th gen", "green"): "iPod17-Green.png",
|
||||
("ipod nano", "6th gen", "orange"): "iPod17-Orange.png",
|
||||
("ipod nano", "6th gen", "pink"): "iPod17-Pink.png",
|
||||
("ipod nano", "6th gen", "red"): "iPod17-Red.png",
|
||||
|
||||
# ── iPod Nano 7th Gen ─────────────────────────────────────────────
|
||||
("ipod nano", "7th gen", "silver"): "iPod18A-Silver.png",
|
||||
("ipod nano", "7th gen", "space gray"): "iPod18A-SpaceGray.png",
|
||||
("ipod nano", "7th gen", "blue"): "iPod18A-Blue.png",
|
||||
("ipod nano", "7th gen", "pink"): "iPod18A-Pink.png",
|
||||
("ipod nano", "7th gen", "red"): "iPod18A-Red.png",
|
||||
("ipod nano", "7th gen", "gold"): "iPod18A-Gold.png",
|
||||
("ipod nano", "7th gen", "slate"): "iPod18-DarkGray.png",
|
||||
("ipod nano", "7th gen", "green"): "iPod18-Green.png",
|
||||
("ipod nano", "7th gen", "purple"): "iPod18-Purple.png",
|
||||
("ipod nano", "7th gen", "yellow"): "iPod18-Yellow.png",
|
||||
|
||||
# ── iPod Shuffle 1st Gen ───────────────────────
|
||||
("ipod shuffle", "1st gen", "white"): "iPod128.png",
|
||||
|
||||
# ── iPod Shuffle 2nd Gen ──────────────────────────────────────────
|
||||
("ipod shuffle", "2nd gen", "silver"): "iPod130-Silver.png",
|
||||
("ipod shuffle", "2nd gen", "blue"): "iPod130-Blue.png",
|
||||
("ipod shuffle", "2nd gen", "green"): "iPod130-Green.png",
|
||||
("ipod shuffle", "2nd gen", "pink"): "iPod130-Pink.png",
|
||||
("ipod shuffle", "2nd gen", "orange"): "iPod130-Orange.png",
|
||||
("ipod shuffle", "2nd gen", "purple"): "iPod130C-Purple.png",
|
||||
("ipod shuffle", "2nd gen", "red"): "iPod130C-Red.png",
|
||||
("ipod shuffle", "2nd gen", "gold"): "iPod130F-Gold.png",
|
||||
|
||||
# ── iPod Shuffle 3rd Gen ───────────────────────────────────
|
||||
("ipod shuffle", "3rd gen", "silver"): "iPod132-Silver.png",
|
||||
("ipod shuffle", "3rd gen", "black"): "iPod132-DarkGray.png",
|
||||
("ipod shuffle", "3rd gen", "blue"): "iPod132-Blue.png",
|
||||
("ipod shuffle", "3rd gen", "green"): "iPod132-Green.png",
|
||||
("ipod shuffle", "3rd gen", "pink"): "iPod132-Pink.png",
|
||||
("ipod shuffle", "3rd gen", "stainless steel"): "iPod132B-Silver.png",
|
||||
|
||||
# ── iPod Shuffle 4th Gen (2010–2017) ───────────────────────────────
|
||||
("ipod shuffle", "4th gen", "silver"): "iPod133D-Silver.png",
|
||||
("ipod shuffle", "4th gen", "space gray"): "iPod133D-SpaceGray.png",
|
||||
("ipod shuffle", "4th gen", "blue"): "iPod133D-Blue.png",
|
||||
("ipod shuffle", "4th gen", "pink"): "iPod133D-Pink.png",
|
||||
("ipod shuffle", "4th gen", "red"): "iPod133D-Red.png",
|
||||
("ipod shuffle", "4th gen", "gold"): "iPod133D-Gold.png",
|
||||
("ipod shuffle", "4th gen", "slate"): "iPod133B-DarkGray.png",
|
||||
("ipod shuffle", "4th gen", "green"): "iPod133B-Green.png",
|
||||
("ipod shuffle", "4th gen", "purple"): "iPod133B-Purple.png",
|
||||
("ipod shuffle", "4th gen", "yellow"): "iPod133B-Yellow.png",
|
||||
("ipod shuffle", "4th gen", "orange"): "iPod133-Orange.png",
|
||||
}
|
||||
|
||||
MODEL_IMAGE: dict[str, str] = {
|
||||
# ── iPod Nano 7th Gen (2012 original → iPod18) ─────────────────────
|
||||
'MD475': 'iPod18-Pink.png',
|
||||
'MD476': 'iPod18-Yellow.png',
|
||||
'MD477': 'iPod18-Blue.png',
|
||||
'MD478': 'iPod18-Green.png',
|
||||
'MD479': 'iPod18-Purple.png',
|
||||
'MD480': 'iPod18-Silver.png',
|
||||
'MD481': 'iPod18-DarkGray.png',
|
||||
'MD744': 'iPod18-Red.png',
|
||||
'ME971': 'iPod18-SpaceGray.png',
|
||||
# ── iPod Nano 7th Gen (2015 refresh → iPod18A) ─────────────────────
|
||||
'MKMV2': 'iPod18A-Pink.png',
|
||||
'MKMX2': 'iPod18A-Gold.png',
|
||||
'MKN02': 'iPod18A-Blue.png',
|
||||
'MKN22': 'iPod18A-Silver.png',
|
||||
'MKN52': 'iPod18A-SpaceGray.png',
|
||||
'MKN72': 'iPod18A-Red.png',
|
||||
# ── iPod Shuffle 2nd Gen — Sept 2007 Rev A (iPod130C) ─────────────
|
||||
'MB227': 'iPod130C-Blue.png',
|
||||
'MB228': 'iPod130C-Blue.png',
|
||||
'MB229': 'iPod130C-Green.png',
|
||||
'MB520': 'iPod130C-Blue.png',
|
||||
'MB522': 'iPod130C-Green.png',
|
||||
# ── iPod Shuffle 2nd Gen — 2008 Rev B (iPod130F) ──────────────────
|
||||
'MB811': 'iPod130F-Pink.png',
|
||||
'MB813': 'iPod130F-Blue.png',
|
||||
'MB815': 'iPod130F-Green.png',
|
||||
'MB817': 'iPod130F-Red.png',
|
||||
'MB681': 'iPod130F-Pink.png',
|
||||
'MB683': 'iPod130F-Blue.png',
|
||||
'MB685': 'iPod130F-Green.png',
|
||||
'MB779': 'iPod130F-Red.png',
|
||||
# ── iPod Shuffle 4th Gen — 2010 original (iPod133) ────────────────
|
||||
'MC584': 'iPod133-Silver.png',
|
||||
'MC585': 'iPod133-Pink.png',
|
||||
'MC750': 'iPod133-Green.png',
|
||||
'MC751': 'iPod133-Blue.png',
|
||||
# ── iPod Shuffle 4th Gen — Late 2012 Rev A (iPod133B) ─────────────
|
||||
'MD773': 'iPod133B-Pink.png',
|
||||
'MD775': 'iPod133B-Blue.png',
|
||||
'MD778': 'iPod133B-Silver.png',
|
||||
'MD780': 'iPod133B-Red.png',
|
||||
'ME949': 'iPod133B-SpaceGray.png',
|
||||
}
|
||||
|
||||
FAMILY_FALLBACK: dict[str, str] = {
|
||||
"ipod": "iPod4-White.png",
|
||||
"ipod u2": "iPod4-BlackRed.png",
|
||||
"ipod photo": "iPod5-White.png",
|
||||
"ipod photo u2": "iPod5-BlackRed.png",
|
||||
"ipod video": "iPod6-White.png",
|
||||
"ipod video u2": "iPod6-BlackRed.png",
|
||||
"ipod classic": "iPod11-Silver.png",
|
||||
"ipod mini": "iPod3-Silver.png",
|
||||
"ipod nano": "iPod15-Silver.png",
|
||||
"ipod shuffle": "iPod133D-Silver.png",
|
||||
}
|
||||
|
||||
GENERIC_IMAGE = "iPodGeneric.png"
|
||||
|
||||
|
||||
# ── Image → accent color (R, G, B) ───────────────────────────────────────────
|
||||
# Maps image filename (case-insensitive, without extension) to the dominant
|
||||
# body color of that iPod model. Used by the "Match iPod" accent color
|
||||
# setting. White/silver models use a generic silver; black/gray use a
|
||||
# generic dark gray; colorful models use their actual body tint.
|
||||
_SILVER = (223, 224, 223)
|
||||
_GRAY = (44, 44, 49)
|
||||
|
||||
IMAGE_COLORS: dict[str, tuple[int, int, int]] = {
|
||||
# ── iPod (original / Photo / Video / Classic) ─────────────────────
|
||||
"ipod1": _SILVER,
|
||||
"ipod2": _SILVER,
|
||||
"ipod4-white": _SILVER,
|
||||
"ipod4-blackred": (163, 36, 24),
|
||||
"ipod5-white": _SILVER,
|
||||
"ipod5-blackred": (163, 36, 24),
|
||||
"ipod6-white": _SILVER,
|
||||
"ipod6-black": _GRAY,
|
||||
"ipod6-blackred": (233, 51, 35),
|
||||
"ipod11-silver": _SILVER,
|
||||
"ipod11-black": _GRAY,
|
||||
"ipod11b-black": _GRAY,
|
||||
# ── iPod Mini 1st Gen ─────────────────────────────────────────────
|
||||
"ipod3-silver": _SILVER,
|
||||
"ipod3-blue": (137, 178, 204),
|
||||
"ipod3-gold": (217, 201, 140),
|
||||
"ipod3-green": (196, 208, 139),
|
||||
"ipod3-pink": (216, 173, 201),
|
||||
# ── iPod Mini 2nd Gen ─────────────────────────────────────────────
|
||||
"ipod3b-blue": (121, 184, 229),
|
||||
"ipod3b-green": (211, 230, 120),
|
||||
"ipod3b-pink": (225, 156, 203),
|
||||
# ── iPod Nano 1st Gen ─────────────────────────────────────────────
|
||||
"ipod7-white": _SILVER,
|
||||
"ipod7-black": _GRAY,
|
||||
# ── iPod Nano 2nd Gen ─────────────────────────────────────────────
|
||||
"ipod9-silver": _SILVER,
|
||||
"ipod9-black": _GRAY,
|
||||
"ipod9-blue": (94, 194, 210),
|
||||
"ipod9-green": (172, 199, 84),
|
||||
"ipod9-pink": (209, 61, 139),
|
||||
"ipod9-red": (206, 67, 66),
|
||||
# ── iPod Nano 3rd Gen ─────────────────────────────────────────────
|
||||
"ipod12-silver": _SILVER,
|
||||
"ipod12-black": _GRAY,
|
||||
"ipod12-blue": (206, 67, 66),
|
||||
"ipod12-green": (170, 220, 168),
|
||||
"ipod12-pink": (200, 80, 146),
|
||||
"ipod12-red": (154, 63, 81),
|
||||
# ── iPod Nano 4th Gen ─────────────────────────────────────────────
|
||||
"ipod15-silver": _SILVER,
|
||||
"ipod15-black": _GRAY,
|
||||
"ipod15-blue": (62, 127, 180),
|
||||
"ipod15-green": (131, 173, 68),
|
||||
"ipod15-orange": (208, 131, 57),
|
||||
"ipod15-pink": (227, 67, 133),
|
||||
"ipod15-purple": (126, 45, 199),
|
||||
"ipod15-red": (209, 62, 66),
|
||||
"ipod15-yellow": (239, 230, 109),
|
||||
# ── iPod Nano 5th Gen ─────────────────────────────────────────────
|
||||
"ipod16-silver": _SILVER,
|
||||
"ipod16-black": _GRAY,
|
||||
"ipod16-blue": (26, 67, 145),
|
||||
"ipod16-green": (52, 119, 61),
|
||||
"ipod16-orange": (215, 102, 43),
|
||||
"ipod16-pink": (217, 49, 103),
|
||||
"ipod16-purple": (65, 9, 127),
|
||||
"ipod16-red": (146, 28, 45),
|
||||
"ipod16-yellow": (236, 209, 78),
|
||||
# ── iPod Nano 6th Gen ─────────────────────────────────────────────
|
||||
"ipod17-silver": _SILVER,
|
||||
"ipod17-darkgray": _GRAY,
|
||||
"ipod17-blue": (105, 128, 168),
|
||||
"ipod17-green": (135, 151, 69),
|
||||
"ipod17-orange": (178, 131, 57),
|
||||
"ipod17-pink": (182, 91, 125),
|
||||
"ipod17-red": (186, 50, 48),
|
||||
# ── iPod Nano 7th Gen (2012 iPod18) ───────────────────────────────
|
||||
"ipod18-silver": _SILVER,
|
||||
"ipod18-darkgray": _GRAY,
|
||||
"ipod18-blue": (91, 187, 212),
|
||||
"ipod18-green": (146, 224, 163),
|
||||
"ipod18-pink": (222, 132, 128),
|
||||
"ipod18-purple": (222, 152, 208),
|
||||
"ipod18-red": (216, 68, 61),
|
||||
"ipod18-yellow": (217, 218, 91),
|
||||
"ipod18-spacegray": _GRAY,
|
||||
# ── iPod Nano 7th Gen (2015 iPod18A) ──────────────────────────────
|
||||
"ipod18a-silver": _SILVER,
|
||||
"ipod18a-spacegray": _GRAY,
|
||||
"ipod18a-blue": (109, 165, 229),
|
||||
"ipod18a-gold": (216, 204, 185),
|
||||
"ipod18a-pink": (236, 115, 167),
|
||||
"ipod18a-red": (232, 105, 97),
|
||||
# ── iPod Shuffle 1st Gen ──────────────────────────────────────────
|
||||
"ipod128": _SILVER,
|
||||
# ── iPod Shuffle 2nd Gen (iPod130) ────────────────────────────────
|
||||
"ipod130-silver": _SILVER,
|
||||
"ipod130-blue": (81, 169, 195),
|
||||
"ipod130-green": (165, 198, 75),
|
||||
"ipod130-orange": (230, 107, 44),
|
||||
"ipod130-pink": (198, 52, 129),
|
||||
# ── iPod Shuffle 2nd Gen Rev A (iPod130C) ─────────────────────────
|
||||
"ipod130c-blue": (152, 205, 206),
|
||||
"ipod130c-green": (167, 217, 164),
|
||||
"ipod130c-purple": (131, 131, 201),
|
||||
"ipod130c-red": (150, 59, 77),
|
||||
# ── iPod Shuffle 2nd Gen Rev B (iPod130F) ─────────────────────────
|
||||
"ipod130f-blue": (50, 110, 179),
|
||||
"ipod130f-gold": (208, 189, 129),
|
||||
"ipod130f-green": (128, 178, 63),
|
||||
"ipod130f-pink": (205, 58, 115),
|
||||
"ipod130f-red": (179, 42, 40),
|
||||
# ── iPod Shuffle 3rd Gen (iPod132) ────────────────────────────────
|
||||
"ipod132-silver": _SILVER,
|
||||
"ipod132-darkgray": _GRAY,
|
||||
"ipod132-blue": (73, 156, 177),
|
||||
"ipod132-green": (147, 189, 77),
|
||||
"ipod132-pink": (204, 75, 117),
|
||||
"ipod132b-silver": _SILVER,
|
||||
# ── iPod Shuffle 4th Gen (2010 iPod133) ───────────────────────────
|
||||
"ipod133-silver": _SILVER,
|
||||
"ipod133-blue": (139, 175, 212),
|
||||
"ipod133-green": (181, 221, 105),
|
||||
"ipod133-orange": (224, 186, 109),
|
||||
"ipod133-pink": (220, 134, 179),
|
||||
# ── iPod Shuffle 4th Gen (2012 iPod133B) ──────────────────────────
|
||||
"ipod133b-silver": _SILVER,
|
||||
"ipod133b-darkgray": _GRAY,
|
||||
"ipod133b-blue": (89, 194, 217),
|
||||
"ipod133b-green": (146, 219, 162),
|
||||
"ipod133b-pink": (219, 122, 118),
|
||||
"ipod133b-purple": (212, 143, 199),
|
||||
"ipod133b-red": (216, 69, 62),
|
||||
"ipod133b-yellow": (213, 213, 89),
|
||||
# ── iPod Shuffle 4th Gen (2015 iPod133D) ──────────────────────────
|
||||
"ipod133d-silver": _SILVER,
|
||||
"ipod133d-spacegray": _GRAY,
|
||||
"ipod133d-blue": (67, 129, 202),
|
||||
"ipod133d-gold": (244, 233, 215),
|
||||
"ipod133d-pink": (237, 115, 167),
|
||||
"ipod133d-red": (223, 85, 76),
|
||||
}
|
||||
|
||||
|
||||
def color_for_image(image_filename: str) -> tuple[int, int, int] | None:
|
||||
"""Return the (R, G, B) accent color for an iPod image filename.
|
||||
|
||||
Returns None if the image is not in the mapping (e.g. iPodGeneric).
|
||||
"""
|
||||
key = image_filename.rsplit(".", 1)[0].lower()
|
||||
return IMAGE_COLORS.get(key)
|
||||
|
||||
|
||||
_DEFAULT_COLOR_PREFERENCE = ("silver", "white")
|
||||
|
||||
|
||||
def resolve_image_filename(
|
||||
family: str,
|
||||
generation: str,
|
||||
color: str = "",
|
||||
) -> str:
|
||||
"""Resolve an image filename through a tiered lookup.
|
||||
|
||||
1. Exact (family, generation, color)
|
||||
2. Inferred default — try "silver" then "white" for (family, generation)
|
||||
3. Family-level fallback
|
||||
4. ``iPodGeneric.png``
|
||||
"""
|
||||
fam = family.lower()
|
||||
gen = generation.lower()
|
||||
col = color.lower().strip()
|
||||
|
||||
if col:
|
||||
filename = COLOR_MAP.get((fam, gen, col))
|
||||
if filename:
|
||||
return filename
|
||||
|
||||
for default_col in _DEFAULT_COLOR_PREFERENCE:
|
||||
filename = COLOR_MAP.get((fam, gen, default_col))
|
||||
if filename:
|
||||
return filename
|
||||
|
||||
return FAMILY_FALLBACK.get(fam, GENERIC_IMAGE)
|
||||
|
||||
|
||||
def image_for_model(model_number: str) -> str:
|
||||
"""Return the exact image filename for a known model number."""
|
||||
override = MODEL_IMAGE.get(model_number)
|
||||
if override:
|
||||
return override
|
||||
|
||||
info = IPOD_MODELS.get(model_number)
|
||||
if info:
|
||||
return resolve_image_filename(info[0], info[1], info[3])
|
||||
|
||||
return GENERIC_IMAGE
|
||||
2435
src/vendor/ipod_device/info.py
vendored
Normal file
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
170
src/vendor/ipod_device/lookup.py
vendored
Normal 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
509
src/vendor/ipod_device/models.py
vendored
Normal 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
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
528
src/vendor/ipod_device/sysinfo.py
vendored
Normal 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
132
src/vendor/ipod_device/usb_backend.py
vendored
Normal 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
506
src/vendor/ipod_device/virtual.py
vendored
Normal 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
617
src/vendor/ipod_device/vpd_iokit.py
vendored
Normal 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 5–10):
|
||||
# [5] IsExclusiveAccessAvailable [6] AddCallbackDispatcherToRunLoop
|
||||
# [7] RemoveCallbackDispatcherFromRunLoop [8] ObtainExclusiveAccess
|
||||
# [9] ReleaseExclusiveAccess [10] CreateSCSITask
|
||||
#
|
||||
# SCSITaskInterface vtable (slots 5–24):
|
||||
# [5] IsTaskActive [6] SetTaskAttribute [7] GetTaskAttribute
|
||||
# [8] SetCommandDescriptorBlock(self, uint8*, uint8)
|
||||
# [9] GetCommandDescriptorBlockSize [10] GetCommandDescriptorBlock
|
||||
# [11] SetScatterGatherEntries(self, IOVirtualRange*, uint8, uint64, uint8)
|
||||
# [12] SetTimeoutDuration(self, uint32)
|
||||
# [13] GetTimeoutDuration [14] SetTaskCompletionCallback
|
||||
# [15] ExecuteTaskAsync
|
||||
# [16] ExecuteTaskSync(self, SenseData*, TaskStatus*, uint64*)
|
||||
# [17] AbortTask [18] GetServiceResponse [19] GetTaskState
|
||||
# [20] GetTaskStatus [21] GetRealizedDataTransferCount
|
||||
# [22] GetAutoSenseData [23] SetSenseDataBuffer
|
||||
# [24] ResetForNewTask
|
||||
|
||||
|
||||
def _vt_ptr(obj: c_void_p, slot: int):
|
||||
"""Return raw function pointer at vtable[slot]."""
|
||||
vt_base = cast(obj, POINTER(c_void_p))[0]
|
||||
return cast(vt_base, POINTER(c_void_p))[slot]
|
||||
|
||||
|
||||
def _vt_call(obj: c_void_p, slot: int, restype, argtypes, *args):
|
||||
"""Call vtable[slot](obj, *args)."""
|
||||
fptr = _vt_ptr(obj, slot)
|
||||
fn = ctypes.CFUNCTYPE(restype, c_void_p, *argtypes)(fptr)
|
||||
return fn(obj, *args)
|
||||
|
||||
|
||||
# ── IOKit registry helpers ───────────────────────────────────────────
|
||||
|
||||
def _cf_property_string(entry: int, key: str) -> str | None:
|
||||
"""Read a string property from an IOKit registry entry."""
|
||||
cf_key = _cf.CFStringCreateWithCString(
|
||||
None, key.encode(), kCFStringEncodingUTF8
|
||||
)
|
||||
if not cf_key:
|
||||
return None
|
||||
try:
|
||||
cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0)
|
||||
if not cf_val:
|
||||
return None
|
||||
try:
|
||||
if _cf.CFGetTypeID(cf_val) != _cf.CFStringGetTypeID():
|
||||
return None
|
||||
buf = ctypes.create_string_buffer(512)
|
||||
if _cf.CFStringGetCString(cf_val, buf, 512, kCFStringEncodingUTF8):
|
||||
return buf.value.decode("utf-8", errors="replace")
|
||||
return None
|
||||
finally:
|
||||
_cf.CFRelease(cf_val)
|
||||
finally:
|
||||
_cf.CFRelease(cf_key)
|
||||
|
||||
|
||||
def _cf_property_int(entry: int, key: str) -> int | None:
|
||||
"""Read an integer property from an IOKit registry entry."""
|
||||
cf_key = _cf.CFStringCreateWithCString(
|
||||
None, key.encode(), kCFStringEncodingUTF8
|
||||
)
|
||||
if not cf_key:
|
||||
return None
|
||||
try:
|
||||
cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0)
|
||||
if not cf_val:
|
||||
return None
|
||||
try:
|
||||
if _cf.CFGetTypeID(cf_val) != _cf.CFNumberGetTypeID():
|
||||
return None
|
||||
val = c_int32(0)
|
||||
# kCFNumberSInt32Type = 3
|
||||
_cf.CFNumberGetValue(cf_val, 3, byref(val))
|
||||
return val.value
|
||||
finally:
|
||||
_cf.CFRelease(cf_val)
|
||||
finally:
|
||||
_cf.CFRelease(cf_key)
|
||||
|
||||
|
||||
def _walk_parents_for_usb_info(service: int) -> dict:
|
||||
"""Walk IOKit registry parents to find USB device properties."""
|
||||
result: dict = {}
|
||||
entry = service
|
||||
# Walk up to 10 levels — wrapped in try/finally so the last
|
||||
# parent IOKit handle is always released even on unexpected errors.
|
||||
try:
|
||||
for _ in range(10):
|
||||
parent = c_uint32(0)
|
||||
kr = _iok.IORegistryEntryGetParentEntry(entry, kIOServicePlane, byref(parent))
|
||||
if kr != 0:
|
||||
break
|
||||
# Check for USB device properties
|
||||
pid = _cf_property_int(parent.value, "idProduct")
|
||||
if pid is not None and "usb_pid" not in result:
|
||||
result["usb_pid"] = pid
|
||||
vid = _cf_property_int(parent.value, "idVendor")
|
||||
if vid is not None and "usb_vid" not in result:
|
||||
result["usb_vid"] = vid
|
||||
serial = _cf_property_string(parent.value, "USB Serial Number")
|
||||
if serial and "usb_serial" not in result:
|
||||
result["usb_serial"] = serial
|
||||
if entry != service:
|
||||
_iok.IOObjectRelease(entry)
|
||||
entry = parent.value
|
||||
if "usb_pid" in result and "usb_serial" in result:
|
||||
break
|
||||
finally:
|
||||
if entry != service:
|
||||
try:
|
||||
_iok.IOObjectRelease(entry)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ── SCSI command helpers ─────────────────────────────────────────────
|
||||
|
||||
class _SCSISession:
|
||||
"""Manages exclusive access to one iPod's SCSI interface."""
|
||||
|
||||
def __init__(self, service: int):
|
||||
self._service = service
|
||||
self._plugin: c_void_p | None = None
|
||||
self._device_if: c_void_p | None = None
|
||||
self._task = None
|
||||
self._exclusive = False
|
||||
|
||||
def open(self) -> bool:
|
||||
"""Create plugin → QueryInterface → ObtainExclusiveAccess → CreateTask."""
|
||||
# 1. Create plugin
|
||||
plugin = c_void_p(0)
|
||||
score = c_int32(0)
|
||||
kr = _iok.IOCreatePlugInInterfaceForService(
|
||||
self._service,
|
||||
_kSCSITaskDeviceUserClientTypeID,
|
||||
_kIOCFPlugInInterfaceID,
|
||||
byref(plugin),
|
||||
byref(score),
|
||||
)
|
||||
if kr != 0:
|
||||
log.debug("IOCreatePlugInInterfaceForService failed: 0x%08X", kr)
|
||||
return False
|
||||
self._plugin = plugin
|
||||
|
||||
# 2. QueryInterface for SCSITaskDeviceInterface
|
||||
uuid_lo, uuid_hi = struct.unpack("<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 0xC2–0xFF
|
||||
data_pages = list(range(0xC2, 0x100))
|
||||
|
||||
raw = bytearray()
|
||||
for page in data_pages:
|
||||
resp = session.inquiry(evpd=True, page=page)
|
||||
if not resp or len(resp) < 4:
|
||||
continue
|
||||
payload_len = resp[3]
|
||||
payload = resp[4:4 + payload_len]
|
||||
if not any(payload):
|
||||
continue
|
||||
raw.extend(payload)
|
||||
|
||||
# Strip trailing nulls
|
||||
return bytes(raw).rstrip(b"\x00")
|
||||
|
||||
|
||||
def _parse_vpd_xml(raw: bytes) -> dict:
|
||||
"""Parse XML plist from concatenated VPD page data."""
|
||||
result: dict = {}
|
||||
if not raw:
|
||||
return result
|
||||
|
||||
# Find XML start
|
||||
for marker in (b"<?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
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
266
src/vendor/ipod_device/vpd_linux.py
vendored
Normal 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
|
||||
211
src/vendor/ipod_device/vpd_usb_control.py
vendored
Normal file
211
src/vendor/ipod_device/vpd_usb_control.py
vendored
Normal 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
311
src/vendor/ipod_device/vpd_windows.py
vendored
Normal 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]
|
||||
@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic tests for neo-pod-desktop
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
||||
|
||||
from audio_converter import AudioConverter
|
||||
from metadata_handler import MetadataHandler
|
||||
|
||||
|
||||
class TestAudioConverter(unittest.TestCase):
|
||||
"""Test audio converter functionality"""
|
||||
|
||||
def test_sanitize_filename(self):
|
||||
"""Test filename sanitization"""
|
||||
converter = AudioConverter()
|
||||
|
||||
sanitized = converter._sanitize_filename('File: with "invalid" chars?')
|
||||
self.assertEqual(sanitized, 'File_ with _invalid_ chars_')
|
||||
|
||||
long_name = "A" * 150
|
||||
sanitized = converter._sanitize_filename(long_name)
|
||||
self.assertEqual(len(sanitized), 100)
|
||||
self.assertTrue(sanitized.endswith('...'))
|
||||
|
||||
|
||||
class TestMetadataHandler(unittest.TestCase):
|
||||
"""Test metadata handler functionality"""
|
||||
|
||||
@patch('requests.get')
|
||||
def test_download_thumbnail(self, mock_get):
|
||||
"""Test thumbnail download"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.content = b'fake_image_data'
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with patch('PIL.Image.open') as mock_image_open:
|
||||
mock_image = MagicMock()
|
||||
mock_image.save.return_value = None
|
||||
mock_image_open.return_value = mock_image
|
||||
|
||||
handler = MetadataHandler()
|
||||
result = handler.download_thumbnail("https://example.com/image.jpg")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
mock_get.assert_called_once_with("https://example.com/image.jpg", timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user