Vendored 5 packages from iOpenPod (TheRealSavi/iOpenPod): iTunesDB_Writer/ — binary iTunesDB generation iTunesDB_Shared/ — field defs, constants, album identity SQLiteDB_Writer/ — SQLite databases for Nano 6G/7G iTunesDB_Parser/ — play stats reading from iPod ipod_device/ — device detection, FireWire ID, capabilities Rationale: iOpenPod is a standalone iPod manager app, not a library. Pulling it as a pip dependency is heavy and confusing for users. Changes: + src/vendor/ — 77 .py files + calcHashAB.wasm (506 KB) ~ ipod_nano7_db.py — _find_iop_root() now returns src/vendor/ ~ requirements.txt — iopenpod replaced with wasmtime + pycryptodome ~ .gitignore — removed iOpenPod/ exclusion (setup.py reads requirements.txt automatically)
137 lines
4.2 KiB
Python
137 lines
4.2 KiB
Python
"""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)
|