Fix Nano 7 database: duration samples, HASHAB cbk, duplicate detection, Locations.itdb

- Fix avformat_info duration: store in SAMPLES not ms (firmware uses this to
  determine playback length; wrong units caused tracks to stop at ~3-11 seconds)
- Add Locations.itdb insert/delete for each track (ipod_path mapping)
- Add sync_locations_cbk() with HASHAB signing via WASM module
- Detect FirewireGuid from SysInfo for cbk header signature
- Add duplicate detection in add_track (title+artist match, skip if exists)
- Add get_orphaned_files() and delete_orphaned_files() for cleanup
- Sync cbk after every add/delete operation
This commit is contained in:
Maksim Totmin 2026-05-31 14:39:04 +07:00
parent 4ee62764ca
commit 11c40c8a99

View File

@ -9,9 +9,11 @@ import struct
import time import time
import random import random
import zlib import zlib
import hashlib
import sqlite3 import sqlite3
import logging import logging
import shutil import shutil
import re
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
@ -29,14 +31,53 @@ class Nano7Database:
def __init__(self, mountpoint: str): def __init__(self, mountpoint: str):
self.mountpoint = mountpoint self.mountpoint = mountpoint
self.sqlite_path = os.path.join(
mountpoint, "iPod_Control", "iTunes", "iTunes Library.itlp", "Library.itdb"
)
self.itunes_dir = os.path.join(mountpoint, "iPod_Control", "iTunes") self.itunes_dir = os.path.join(mountpoint, "iPod_Control", "iTunes")
self.sqlite_path = os.path.join(
self.itunes_dir, "iTunes Library.itlp", "Library.itdb"
)
self.locations_path = os.path.join(
self.itunes_dir, "iTunes Library.itlp", "Locations.itdb"
)
self.locations_cbk_path = os.path.join(
self.itunes_dir, "iTunes Library.itlp", "Locations.itdb.cbk"
)
self.cdb_path = os.path.join(self.itunes_dir, "iTunesCDB") self.cdb_path = os.path.join(self.itunes_dir, "iTunesCDB")
self.cdb_ext_path = os.path.join(self.itunes_dir, "iTunesCDB.ext") self.cdb_ext_path = os.path.join(self.itunes_dir, "iTunesCDB.ext")
self.music_base = os.path.join(mountpoint, "iPod_Control", "Music") self.music_base = os.path.join(mountpoint, "iPod_Control", "Music")
# FireWire GUID for HASHAB signing (Nano 6G/7G)
self.firewire_id = self._detect_firewire_id()
# Extension codes as 4-byte big-endian ASCII
self.EXT_CODES = {
".mp3": struct.unpack(">I", b"MP3 ")[0],
".m4a": struct.unpack(">I", b"M4A ")[0],
".aac": struct.unpack(">I", b"AAC ")[0],
".mp4": struct.unpack(">I", b"MP4 ")[0],
}
def _detect_firewire_id(self) -> bytes:
"""Parse FirewireGuid from SysInfo file on the iPod."""
sysinfo_path = os.path.join(
self.mountpoint, "iPod_Control", "Device", "SysInfo"
)
if not os.path.exists(sysinfo_path):
logger.warning("SysInfo not found, HASHAB signing disabled")
return b""
try:
with open(sysinfo_path, 'r') as f:
for line in f:
if line.startswith('FirewireGuid:'):
hex_str = line.split(':')[1].strip()
if hex_str.startswith('0x'):
hex_str = hex_str[2:]
fwid = bytes.fromhex(hex_str)
logger.info(f"FireWire GUID: {hex_str}")
return fwid
except Exception as e:
logger.warning(f"Failed to parse FirewireGuid: {e}")
return b""
def get_next_pid(self) -> int: def get_next_pid(self) -> int:
"""Generate a unique negative pid (like existing tracks)""" """Generate a unique negative pid (like existing tracks)"""
return random.randint(-(2**63), -1) return random.randint(-(2**63), -1)
@ -55,8 +96,8 @@ class Nano7Database:
target = d target = d
return target or "F00" return target or "F00"
def copy_track_to_ipod(self, filepath: str) -> str: def copy_track_to_ipod(self, filepath: str) -> tuple:
"""Copy track to iPod_Control/Music/FXX/ and return iPod path""" """Copy track to iPod_Control/Music/FXX/ and return (relative_path, full_ipod_path)"""
filename = os.path.basename(filepath) filename = os.path.basename(filepath)
base, ext = os.path.splitext(filename) base, ext = os.path.splitext(filename)
@ -72,10 +113,10 @@ class Nano7Database:
target_path = os.path.join(self.music_base, target_dir, new_name) target_path = os.path.join(self.music_base, target_dir, new_name)
shutil.copy2(filepath, target_path) shutil.copy2(filepath, target_path)
# Return iPod path format (colon-separated) relative_path = f"{target_dir}/{new_name}"
ipod_path = f":iPod_Control:Music:{target_dir}:{new_name}" ipod_path = f":iPod_Control:Music:{target_dir}:{new_name}"
logger.info(f"Copied track to {ipod_path}") logger.info(f"Copied track to {ipod_path}")
return ipod_path return relative_path, ipod_path
def get_audio_info(self, filepath: str) -> Dict[str, Any]: def get_audio_info(self, filepath: str) -> Dict[str, Any]:
"""Extract audio information from file""" """Extract audio information from file"""
@ -232,27 +273,50 @@ class Nano7Database:
return info return info
def add_track(self, filepath: str, **kwargs) -> Dict[str, Any]: def add_track(self, filepath: str, **kwargs) -> Optional[Dict[str, Any]]:
"""Add a track to the iPod Nano 7 database""" """Add a track to the iPod Nano 7 database.
Returns None if track already exists (duplicate detection).
"""
if not os.path.exists(filepath): if not os.path.exists(filepath):
raise FileNotFoundError(f"File not found: {filepath}") raise FileNotFoundError(f"File not found: {filepath}")
# Copy file to iPod # Get audio info for duplicate detection
ipod_path = self.copy_track_to_ipod(filepath)
# Get audio info
audio_info = self.get_audio_info(filepath) audio_info = self.get_audio_info(filepath)
# Apply overrides
for key, value in kwargs.items(): for key, value in kwargs.items():
audio_info[key] = value audio_info[key] = value
title = audio_info.get("title", os.path.splitext(os.path.basename(filepath))[0])
artist = audio_info.get("artist", "Unknown Artist")
album = audio_info.get("album", "Unknown Album")
# Check for duplicate: same title + artist + album
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
cursor.execute(
"SELECT pid, title FROM item WHERE title = ? AND artist = ?",
(title, artist)
)
existing = cursor.fetchone()
if existing:
logger.info(f"Track already exists: '{existing[1]}' by {artist}, skipping")
conn.close()
return None
# Copy file to iPod
relative_path, ipod_path = self.copy_track_to_ipod(filepath)
# Generate pid # Generate pid
pid = self.get_next_pid() pid = self.get_next_pid()
# Get file size
file_size = os.path.getsize(filepath)
# Get extension code
ext = os.path.splitext(filepath)[1].lower()
ext_code = self.EXT_CODES.get(ext, self.EXT_CODES[".m4a"])
# Get current max physical_order # Get current max physical_order
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
cursor.execute("SELECT MAX(physical_order) FROM item") cursor.execute("SELECT MAX(physical_order) FROM item")
max_order = cursor.fetchone()[0] or 0 max_order = cursor.fetchone()[0] or 0
physical_order = max_order + 1 physical_order = max_order + 1
@ -260,11 +324,6 @@ class Nano7Database:
# Get current timestamp (mac epoch: seconds since 1904) # Get current timestamp (mac epoch: seconds since 1904)
mac_epoch = int(time.time()) + 2082844800 mac_epoch = int(time.time()) + 2082844800
# Get or create artist/album entries
artist = audio_info.get("artist", "Unknown Artist")
album = audio_info.get("album", "Unknown Album")
title = audio_info.get("title", os.path.splitext(os.path.basename(filepath))[0])
# Check if artist exists # Check if artist exists
cursor.execute("SELECT pid FROM artist WHERE name = ?", (artist,)) cursor.execute("SELECT pid FROM artist WHERE name = ?", (artist,))
row = cursor.fetchone() row = cursor.fetchone()
@ -352,6 +411,10 @@ class Nano7Database:
) )
# Insert into avformat_info # Insert into avformat_info
# CRITICAL: duration in avformat_info is in SAMPLES, not milliseconds!
# Firmware uses this to determine when to stop playback.
# samples = duration_ms * sample_rate / 1000
duration_samples = int(audio_info["duration_ms"] * audio_info["sample_rate"] / 1000)
cursor.execute( cursor.execute(
""" """
INSERT INTO avformat_info ( INSERT INTO avformat_info (
@ -360,15 +423,14 @@ class Nano7Database:
gapless_encoding_delay, gapless_encoding_drain, gapless_encoding_delay, gapless_encoding_drain,
gapless_last_frame_resynch, analysis_inhibit_flags, gapless_last_frame_resynch, analysis_inhibit_flags,
audio_fingerprint, volume_normalization_energy audio_fingerprint, volume_normalization_energy
) VALUES (?, 0, ?, ?, ?, ?, ?, 1, 2112, 316, 0, 0, 0, 0) ) VALUES (?, 0, ?, ?, 0, ?, ?, 1, 2112, 316, 0, 0, 0, 0)
""", """,
( (
pid, pid,
audio_info["audio_format"], audio_info["audio_format"],
audio_info["bitrate"], audio_info["bitrate"],
audio_info["channels"],
audio_info["sample_rate"], audio_info["sample_rate"],
audio_info["duration_ms"], duration_samples,
), ),
) )
@ -404,6 +466,9 @@ class Nano7Database:
conn.commit() conn.commit()
conn.close() conn.close()
# Insert location record in Locations.itdb
self._insert_location(pid, relative_path, ext_code, file_size, mac_epoch)
result = { result = {
"pid": pid, "pid": pid,
"title": title, "title": title,
@ -413,6 +478,13 @@ class Nano7Database:
"physical_order": physical_order, "physical_order": physical_order,
} }
logger.info(f"Added track: {title} by {artist}") logger.info(f"Added track: {title} by {artist}")
# Sync iTunesCDB so firmware accepts the modified database
self.sync_itunescdb()
# Regenerate HASHAB-signed checksums for Locations.itdb (Nano 6G/7G)
self.sync_locations_cbk()
return result return result
def get_track_count(self) -> int: def get_track_count(self) -> int:
@ -605,12 +677,27 @@ class Nano7Database:
conn.commit() conn.commit()
conn.close() conn.close()
# Remove location record
self._delete_location(pid)
# Remove physical file # Remove physical file
if file_path and os.path.exists(file_path): if file_path and os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
logger.info(f"Deleted physical file: {file_path}") logger.info(f"Deleted physical file: {file_path}")
else:
logger.warning(
f"Physical file not found for pid={pid} ({artist} - {title}). "
"Use 'Clean Orphaned Files' to find and remove orphaned files."
)
logger.info(f"Deleted track pid={pid}: {artist} - {title}") logger.info(f"Deleted track pid={pid}: {artist} - {title}")
# Sync iTunesCDB so firmware accepts the modified database
self.sync_itunescdb()
# Regenerate HASHAB-signed checksums for Locations.itdb (Nano 6G/7G)
self.sync_locations_cbk()
return True return True
except Exception as e: except Exception as e:
@ -696,3 +783,297 @@ class Nano7Database:
WHERE pid NOT IN (SELECT DISTINCT album_pid FROM item WHERE album_pid IS NOT NULL) WHERE pid NOT IN (SELECT DISTINCT album_pid FROM item WHERE album_pid IS NOT NULL)
""" """
) )
def _insert_location(self, pid: int, relative_path: str, ext_code: int, file_size: int, mac_epoch: int):
"""Insert a file location record into Locations.itdb and update track_size_calc."""
if not os.path.exists(self.locations_path):
logger.warning(f"Locations database not found at {self.locations_path}")
return
conn = sqlite3.connect(self.locations_path)
cursor = conn.cursor()
cursor.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, 1179208773, ?, ?, 1, ?, ?, 0, 0, 0, 0)
""",
(pid, relative_path, ext_code, mac_epoch, file_size),
)
conn.commit()
conn.close()
logger.info(f"Added location record: {relative_path} for pid={pid}")
# Update total audio size in Library.itdb
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
cursor.execute("UPDATE track_size_calc SET size = size + ? WHERE kind = 'audio'", (file_size,))
conn.commit()
conn.close()
def _delete_location(self, pid: int):
"""Remove a file location record from Locations.itdb and update track_size_calc."""
if not os.path.exists(self.locations_path):
return
# Get file size before deleting
conn = sqlite3.connect(self.locations_path)
cursor = conn.cursor()
cursor.execute("SELECT file_size FROM location WHERE item_pid = ?", (pid,))
row = cursor.fetchone()
file_size = row[0] if row else 0
cursor.execute("DELETE FROM location WHERE item_pid = ?", (pid,))
conn.commit()
conn.close()
# Update total audio size in Library.itdb
if file_size > 0:
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
cursor.execute("UPDATE track_size_calc SET size = MAX(0, size - ?) WHERE kind = 'audio'", (file_size,))
conn.commit()
conn.close()
# ---- Helper methods for tag extraction (used by multiple callers) ----
def sync_itunescdb(self):
"""Regenerate iTunesCDB compressed database and update hash in iTunesCDB.ext.
Must be called after any modification to Library.itdb so the iPod firmware
accepts the database as valid.
"""
import zlib
import re
# Read current Library.itdb
with open(self.sqlite_path, "rb") as f:
lib_data = f.read()
# Compute new SHA1 hash
new_hash = hashlib.sha1(lib_data).hexdigest()
# Read existing iTunesCDB to extract the header (first 244 bytes)
with open(self.cdb_path, "rb") as f:
cdb_data = f.read()
# Find zlib header (0x78 0x9c is most common, but also check 0x78 0xda and 0x78 0x01)
zlib_offset = -1
for pattern in [b"\x78\x9c", b"\x78\xda", b"\x78\x01", b"\x78\x5e"]:
zlib_offset = cdb_data.find(pattern)
if zlib_offset >= 0:
break
if zlib_offset == -1:
logger.error("Could not find zlib header in iTunesCDB")
return
header = cdb_data[:zlib_offset]
# Compress Library.itdb
compressed = zlib.compress(lib_data)
# Write new iTunesCDB
with open(self.cdb_path, "wb") as f:
f.write(header + compressed)
# Update hash in iTunesCDB.ext
with open(self.cdb_ext_path, "r") as f:
ext_content = f.read()
new_ext_content = re.sub(r"itunesdb_hash=\w+", f"itunesdb_hash={new_hash}", ext_content)
with open(self.cdb_ext_path, "w") as f:
f.write(new_ext_content)
logger.info(f"Synced iTunesCDB: hash={new_hash}, compressed={len(compressed)} bytes")
def sync_locations_cbk(self):
"""Regenerate Locations.itdb.cbk with HASHAB-signed block checksums.
Nano 6G/7G firmware requires this file to accept Locations.itdb.
Without valid cbk, the device will ignore location records.
File format:
[57 bytes] HASHAB signature of final_sha1 (Nano 6G/7G)
[20 bytes] final_sha1 = SHA1(all block SHA1s concatenated)
[Nx20 bytes] SHA1 of each 1024-byte block of Locations.itdb
"""
if not os.path.exists(self.locations_path):
logger.warning("Locations.itdb not found, skipping cbk generation")
return
if not self.firewire_id or len(self.firewire_id) < 8:
logger.warning("No FireWire GUID available, cbk generation disabled")
return
BLOCK_SIZE = 1024
# Read Locations.itdb
with open(self.locations_path, 'rb') as f:
locations_data = f.read()
# Compute SHA1 of each 1024-byte block
block_sha1s = []
offset = 0
while offset < len(locations_data):
block = locations_data[offset:offset + BLOCK_SIZE]
block_sha1s.append(hashlib.sha1(block).digest())
offset += BLOCK_SIZE
# 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()
)
# Compute 57-byte HASHAB signature
try:
from hashab import compute_hashab
header = compute_hashab(final_sha1, self.firewire_id[:8])
if len(header) != 57:
logger.error(f"HASHAB returned {len(header)} bytes, expected 57")
return
except Exception as e:
logger.error(f"Failed to compute HASHAB signature: {e}")
return
# Write cbk file: header + final_sha1 + block_sha1s
with open(self.locations_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 "
"(57-byte HASHAB + 20-byte final SHA1 + %d block SHA1s)",
total_size, len(block_sha1s)
)
# ---- Helper methods for tag extraction (used by multiple callers) ----
def _get_file_tags(self, fpath: str) -> tuple:
"""Extract title and artist from an audio file's tags.
Returns:
(title, artist) tuple, both strings (may be empty)
"""
title = ""
artist = ""
ext = os.path.splitext(fpath)[1].lower()
try:
audio = MutagenFile(fpath)
if audio is None:
return title, artist
if ext == ".mp3":
try:
easy = EasyID3(fpath)
if "title" in easy:
title = str(easy["title"][0])
if "artist" in easy:
artist = str(easy["artist"][0])
except Exception:
pass
elif ext in (".m4a", ".aac", ".mp4"):
from mutagen.mp4 import MP4
tags = audio.tags
if "\xa9nam" in tags:
title = str(tags["\xa9nam"][0])
if "\xa9ART" in tags:
artist = str(tags["\xa9ART"][0])
else:
# FLAC, OGG, etc.
tags = audio.tags
if tags:
try:
if "title" in tags:
val = tags["title"]
title = str(val[0] if isinstance(val, list) else val)
if "artist" in tags:
val = tags["artist"]
artist = str(val[0] if isinstance(val, list) else val)
except (ValueError, TypeError):
pass
except Exception:
pass
return title, artist
# ---- Orphaned file detection and cleanup ----
def get_orphaned_files(self) -> list[dict]:
"""Find audio files on disk that have no corresponding DB entry.
Returns:
List of dicts with 'file_path', 'size', 'title', 'artist' for each orphaned file.
"""
music_base = os.path.join(self.mountpoint, "iPod_Control", "Music")
if not os.path.exists(music_base):
return []
# Get all (title, artist) pairs from the database (case-insensitive)
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
cursor.execute("SELECT LOWER(title), LOWER(artist) FROM item")
db_pairs = {(r[0] or "", r[1] or "") for r in cursor.fetchall()}
conn.close()
orphans = []
for folder in sorted(os.listdir(music_base)):
folder_path = os.path.join(music_base, folder)
if not os.path.isdir(folder_path):
continue
for fname in os.listdir(folder_path):
fpath = os.path.join(folder_path, fname)
if not os.path.isfile(fpath):
continue
ext = os.path.splitext(fname)[1].lower()
if ext not in (".mp3", ".m4a", ".aac", ".mp4"):
continue
file_title, file_artist = self._get_file_tags(fpath)
key = (file_title.lower(), file_artist.lower())
# If the file's title+artist pair is NOT in the DB, it's orphaned
if key not in db_pairs:
size = os.path.getsize(fpath)
orphans.append({
"file_path": fpath,
"size": size,
"title": file_title or fname,
"artist": file_artist or "Unknown",
})
return orphans
def delete_orphaned_files(self, file_paths: list[str] = None) -> int:
"""Delete orphaned files from disk.
Args:
file_paths: List of file paths to delete. If None, delete all orphaned files.
Returns:
Number of files successfully deleted.
"""
if file_paths is None:
orphans = self.get_orphaned_files()
file_paths = [o["file_path"] for o in orphans]
deleted = 0
for fpath in file_paths:
try:
if os.path.exists(fpath):
os.remove(fpath)
logger.info(f"Deleted orphaned file: {fpath}")
deleted += 1
except Exception as e:
logger.error(f"Failed to delete orphaned file {fpath}: {e}")
return deleted