ipodsync 0.1.6__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {ipodsync-0.1.6 → ipodsync-0.1.8}/PKG-INFO +1 -1
  2. {ipodsync-0.1.6 → ipodsync-0.1.8}/pyproject.toml +1 -1
  3. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/__init__.py +1 -1
  4. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/artwork_writer.py +73 -0
  5. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/cli.py +13 -1
  6. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/library.py +31 -1
  7. ipodsync-0.1.8/tests/test_artwork_compact.py +60 -0
  8. ipodsync-0.1.8/tests/test_gc_orphans.py +76 -0
  9. {ipodsync-0.1.6 → ipodsync-0.1.8}/.github/workflows/ci.yml +0 -0
  10. {ipodsync-0.1.6 → ipodsync-0.1.8}/.github/workflows/publish.yml +0 -0
  11. {ipodsync-0.1.6 → ipodsync-0.1.8}/.gitignore +0 -0
  12. {ipodsync-0.1.6 → ipodsync-0.1.8}/LICENSE +0 -0
  13. {ipodsync-0.1.6 → ipodsync-0.1.8}/README.md +0 -0
  14. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/__main__.py +0 -0
  15. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_art_templates.py +0 -0
  16. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_gen.py +0 -0
  17. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_gen2.py +0 -0
  18. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_gen_rt.py +0 -0
  19. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_py.py +0 -0
  20. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_tables.bin +0 -0
  21. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_hashab_tables.py +0 -0
  22. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/_lib_templates.py +0 -0
  23. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/cbk.py +0 -0
  24. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/export.py +0 -0
  25. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/hashab.py +0 -0
  26. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/importer.py +0 -0
  27. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/models.py +0 -0
  28. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/sysinfo.py +0 -0
  29. {ipodsync-0.1.6 → ipodsync-0.1.8}/src/ipodsync/transport.py +0 -0
  30. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/data/ArtworkDB +0 -0
  31. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/data/Locations.itdb +0 -0
  32. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/data/Locations.itdb.cbk +0 -0
  33. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/data/hashab-traces.json +0 -0
  34. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/data/hashab-vectors.json +0 -0
  35. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/test_artwork_writer.py +0 -0
  36. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/test_cbk.py +0 -0
  37. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/test_hashab.py +0 -0
  38. {ipodsync-0.1.6 → ipodsync-0.1.8}/tests/test_hashab_port.py +0 -0
  39. {ipodsync-0.1.6 → ipodsync-0.1.8}/tools/build_gen2.py +0 -0
  40. {ipodsync-0.1.6 → ipodsync-0.1.8}/tools/gen_art_templates.py +0 -0
  41. {ipodsync-0.1.6 → ipodsync-0.1.8}/tools/gen_hashab_tables.py +0 -0
  42. {ipodsync-0.1.6 → ipodsync-0.1.8}/tools/gen_lib_templates.py +0 -0
  43. {ipodsync-0.1.6 → ipodsync-0.1.8}/tools/transpile_hashab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipodsync
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Upload music to an iPod nano 6G/7G without iTunes: SQLite library, hashAB signing, cover art (pure-Python).
5
5
  Project-URL: Homepage, https://github.com/buldiei/ipodsync
6
6
  Project-URL: Repository, https://github.com/buldiei/ipodsync
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ipodsync"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Upload music to an iPod nano 6G/7G without iTunes: SQLite library, hashAB signing, cover art (pure-Python)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,7 +13,7 @@ from .library import ItlpLibrary
13
13
  from .models import Track
14
14
  from .transport import IPod, IPodNotFound, find_ipod
15
15
 
16
- __version__ = "0.1.6"
16
+ __version__ = "0.1.8"
17
17
 
18
18
  __all__ = [
19
19
  "find_ipod",
@@ -243,6 +243,79 @@ def attach_cover(artwork_dir: str | Path, song_id: int, cover_bytes: bytes) -> i
243
243
  return image_id
244
244
 
245
245
 
246
+ # --- removing artwork / compacting the .ithmb files -----------------------------
247
+ def _offset_field_positions() -> dict[int, int]:
248
+ """Byte position of each format's ithmb_offset field inside a 928-byte mhii,
249
+ derived from the atom templates (mirrors _build_mhii's layout)."""
250
+ pos = len(MHII_HEADER)
251
+ out: dict[int, int] = {}
252
+ for fid in FORMAT_IDS:
253
+ pos += len(MHOD_CONTAINER)
254
+ out[fid] = pos + 20 # ithmb_offset lives at byte 20 of the mhni
255
+ pos += len(MHNI[fid]) + len(STRMHOD[fid])
256
+ return out
257
+
258
+
259
+ _OFFSET_POS = _offset_field_positions()
260
+ _SLOT = {fid: slot for fid, _w, _h, slot in FORMATS}
261
+
262
+
263
+ def compact(artwork_dir: str | Path, keep_song_ids) -> dict:
264
+ """Drop artwork for tracks not in `keep_song_ids` and repack the .ithmb files,
265
+ reclaiming the freed frames. `keep_song_ids` are (signed or unsigned) pids.
266
+
267
+ Surviving frames are copied verbatim to freshly-written .ithmb files and each
268
+ mhii's offsets are patched to the new positions; this preserves iTunes-made and
269
+ ipodsync-made covers alike. `Locations.itdb` is untouched, so no cbk re-sign.
270
+ Returns {'kept': n, 'removed': n} (no-op if there's no ArtworkDB).
271
+ """
272
+ artwork_dir = Path(artwork_dir)
273
+ db_path = artwork_dir / "ArtworkDB"
274
+ if not db_path.exists():
275
+ return {"kept": 0, "removed": 0}
276
+ keep = {int(s) % U64 for s in keep_song_ids}
277
+ db = ArtworkDB.load(db_path)
278
+
279
+ old = {}
280
+ for fid in FORMAT_IDS:
281
+ p = artwork_dir / f"F{fid}_1.ithmb"
282
+ old[fid] = p.read_bytes() if p.exists() else b""
283
+ new_frames = {fid: bytearray() for fid in FORMAT_IDS}
284
+
285
+ new_blocks: list[bytes] = []
286
+ removed = 0
287
+ for m in db.mhii_blocks:
288
+ sid = struct.unpack_from("<Q", m, 20)[0]
289
+ if sid not in keep:
290
+ removed += 1
291
+ continue
292
+ mb = bytearray(m)
293
+ for fid in FORMAT_IDS:
294
+ slot = _SLOT[fid]
295
+ off = struct.unpack_from("<i", mb, _OFFSET_POS[fid])[0]
296
+ frame = old[fid][off:off + slot]
297
+ if len(frame) < slot: # keep every frame slot-sized
298
+ frame = frame + b"\x00" * (slot - len(frame))
299
+ struct.pack_into("<i", mb, _OFFSET_POS[fid], len(new_frames[fid]))
300
+ new_frames[fid] += frame
301
+ new_blocks.append(bytes(mb))
302
+
303
+ if removed == 0:
304
+ return {"kept": len(new_blocks), "removed": 0}
305
+
306
+ db.mhii_blocks = new_blocks
307
+ db.song_ids = {struct.unpack_from("<Q", b, 20)[0] for b in new_blocks}
308
+ for fid in FORMAT_IDS:
309
+ p = artwork_dir / f"F{fid}_1.ithmb"
310
+ tmp = p.with_suffix(".tmp")
311
+ tmp.write_bytes(bytes(new_frames[fid]))
312
+ tmp.replace(p)
313
+ tmp = db_path.with_suffix(".tmp")
314
+ tmp.write_bytes(db.serialize())
315
+ tmp.replace(db_path)
316
+ return {"kept": len(new_blocks), "removed": removed}
317
+
318
+
246
319
  # --- extracting the embedded cover from audio -----------------------------------
247
320
  def extract_embedded_cover(audio_path: str | Path) -> bytes | None:
248
321
  """Extract the embedded cover bytes from MP3 (APIC) or M4A (covr)."""
@@ -71,18 +71,30 @@ def cmd_export(ipod, args):
71
71
 
72
72
 
73
73
  def cmd_rm(ipod, args):
74
+ from ipodsync.artwork_writer import compact
75
+
74
76
  itlp = ipod.itunes_dir / "iTunes Library.itlp"
75
77
  stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
76
78
  backup = _backup_library(itlp, stamp) if args.backup else None
79
+ if args.backup:
80
+ _backup_artwork(ipod, stamp)
77
81
 
78
82
  guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
79
83
  lib = ItlpLibrary(itlp)
80
84
  music = ipod.music_dir if args.delete_file else None
81
- loc = lib.remove_track(args.pid, music_dir=music)
85
+ lib.remove_track(args.pid, music_dir=music)
82
86
  lib.resign(guid)
87
+ live = {t["pid"] for t in lib.list_tracks()} # remaining tracks, after removal
83
88
  lib.close()
89
+
90
+ # drop the removed track's thumbnails from ArtworkDB + repack the .ithmb files
91
+ stats = compact(ipod.control / "Artwork", live)
92
+
84
93
  print(f"✓ Removed track pid {args.pid}"
85
94
  f"{' (+ deleted the file)' if args.delete_file else ''}. Eject the iPod.")
95
+ if stats["removed"]:
96
+ print(f" Reclaimed cover art for {stats['removed']} track"
97
+ f"{'s' if stats['removed'] != 1 else ''}.")
86
98
  if backup:
87
99
  print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
88
100
 
@@ -229,11 +229,40 @@ class ItlpLibrary:
229
229
  self.cx["Library.itdb"].commit()
230
230
 
231
231
  # --- DELETE ---------------------------------------------------------------
232
+ # Lookup tables that are reference-counted by `item`: (table, key col, item col).
233
+ _LOOKUP_TABLES = (
234
+ ("album", "pid", "album_pid"),
235
+ ("artist", "pid", "artist_pid"),
236
+ ("composer", "pid", "composer_pid"),
237
+ ("track_artist", "pid", "track_artist_pid"),
238
+ ("genre_map", "id", "genre_id"),
239
+ )
240
+
241
+ def gc_orphan_lookups(self) -> int:
242
+ """Delete album/artist/composer/track_artist/genre rows no longer referenced
243
+ by any item. Otherwise removed tracks leave behind empty albums in the UI.
244
+
245
+ A full sweep, so it also cleans up orphans left by earlier versions. Returns
246
+ the number of rows removed.
247
+ """
248
+ lib = self.cx["Library.itdb"]
249
+ removed = 0
250
+ for table, key, ref in self._LOOKUP_TABLES:
251
+ try:
252
+ cur = lib.execute(
253
+ f'DELETE FROM "{table}" WHERE "{key}" NOT IN '
254
+ f'(SELECT "{ref}" FROM item WHERE "{ref}" IS NOT NULL)')
255
+ removed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
256
+ except sqlite3.OperationalError:
257
+ continue # table/column absent on this device — skip it
258
+ lib.commit()
259
+ return removed
260
+
232
261
  def remove_track(self, pid: int, *, music_dir: Path | None = None) -> str | None:
233
262
  """Remove a track from all tables. If music_dir is given — also delete the file.
234
263
 
235
264
  Returns the location of the deleted file (or None). After removal, don't
236
- forget resign(). We leave orphaned album/artist/genre (iTunes is fine with it).
265
+ forget resign(). Orphaned album/artist/genre rows are swept by gc_orphan_lookups().
237
266
  """
238
267
  lib = self.cx["Library.itdb"]
239
268
  r = self.cx["Locations.itdb"].execute(
@@ -249,6 +278,7 @@ class ItlpLibrary:
249
278
  self.cx["Locations.itdb"].execute("DELETE FROM location WHERE item_pid=?", (pid,))
250
279
  self.cx["Dynamic.itdb"].execute("DELETE FROM item_stats WHERE item_pid=?", (pid,))
251
280
  self.cx["Extras.itdb"].execute("DELETE FROM chapter WHERE item_pid=?", (pid,))
281
+ self.gc_orphan_lookups()
252
282
  for c in self.cx.values():
253
283
  c.commit()
254
284
 
@@ -0,0 +1,60 @@
1
+ """compact() must drop the removed track's mhii + thumbnails and repack the
2
+ .ithmb files, while keeping surviving covers byte-for-byte."""
3
+ import io
4
+ import struct
5
+
6
+ from ipodsync.artwork_writer import (FORMAT_IDS, U64, ArtworkDB, _OFFSET_POS, _SLOT,
7
+ attach_cover, compact, encode_cover)
8
+
9
+
10
+ def _solid_png(color):
11
+ from PIL import Image
12
+ b = io.BytesIO()
13
+ Image.new("RGB", (16, 16), color).save(b, "PNG")
14
+ return b.getvalue()
15
+
16
+
17
+ def _frame_at(artwork_dir, fid, mhii_bytes):
18
+ off = struct.unpack_from("<i", mhii_bytes, _OFFSET_POS[fid])[0]
19
+ data = (artwork_dir / f"F{fid}_1.ithmb").read_bytes()
20
+ return data[off:off + _SLOT[fid]]
21
+
22
+
23
+ def test_compact_drops_removed_keeps_survivors(tmp_path):
24
+ covers = {1001: _solid_png((200, 20, 20)),
25
+ 1002: _solid_png((20, 200, 20)),
26
+ 1003: _solid_png((20, 20, 200))}
27
+ for sid, png in covers.items():
28
+ assert attach_cover(tmp_path, sid, png) is not None
29
+
30
+ for fid in FORMAT_IDS: # 3 frames each before compaction
31
+ assert (tmp_path / f"F{fid}_1.ithmb").stat().st_size == 3 * _SLOT[fid]
32
+
33
+ stats = compact(tmp_path, {1001, 1003}) # drop 1002
34
+ assert stats == {"kept": 2, "removed": 1}
35
+
36
+ for fid in FORMAT_IDS: # 2 frames each after
37
+ assert (tmp_path / f"F{fid}_1.ithmb").stat().st_size == 2 * _SLOT[fid]
38
+
39
+ db = ArtworkDB.load(tmp_path / "ArtworkDB")
40
+ got = {struct.unpack_from("<Q", m, 20)[0]: m for m in db.mhii_blocks}
41
+ assert set(got) == {1001, 1003} # 1002 gone from the DB
42
+
43
+ # survivors' frames still equal the freshly-encoded slots (verbatim copy)
44
+ for sid in (1001, 1003):
45
+ want = encode_cover(covers[sid])
46
+ for fid in FORMAT_IDS:
47
+ assert _frame_at(tmp_path, fid, got[sid]) == want[fid], (sid, fid)
48
+
49
+
50
+ def test_compact_noop_when_nothing_removed(tmp_path):
51
+ attach_cover(tmp_path, 7, _solid_png((10, 10, 10)))
52
+ before = {fid: (tmp_path / f"F{fid}_1.ithmb").read_bytes() for fid in FORMAT_IDS}
53
+ stats = compact(tmp_path, {7, 999}) # 7 kept, 999 absent -> nothing to drop
54
+ assert stats["removed"] == 0
55
+ for fid in FORMAT_IDS: # files untouched
56
+ assert (tmp_path / f"F{fid}_1.ithmb").read_bytes() == before[fid]
57
+
58
+
59
+ def test_compact_no_artworkdb_is_safe(tmp_path):
60
+ assert compact(tmp_path, {1, 2}) == {"kept": 0, "removed": 0}
@@ -0,0 +1,76 @@
1
+ """gc_orphan_lookups() must delete lookup rows (album/artist/track_artist/genre)
2
+ no longer referenced by any item, so removing tracks doesn't leave empty albums."""
3
+ import sqlite3
4
+
5
+ from ipodsync.library import ItlpLibrary
6
+
7
+
8
+ def _make_itlp(tmp_path):
9
+ """A minimal .itlp dir: a Library.itdb with item + lookup tables, plus the
10
+ other three (empty) dbs the constructor opens."""
11
+ d = tmp_path / "iTunes Library.itlp"
12
+ d.mkdir()
13
+ for name in ("Locations.itdb", "Dynamic.itdb", "Extras.itdb"):
14
+ sqlite3.connect(d / name).close() # just needs to exist & be valid
15
+ lib = sqlite3.connect(d / "Library.itdb")
16
+ lib.executescript(
17
+ """
18
+ CREATE TABLE item (pid INTEGER PRIMARY KEY, album_pid INTEGER,
19
+ artist_pid INTEGER, track_artist_pid INTEGER, genre_id INTEGER);
20
+ CREATE TABLE album (pid INTEGER PRIMARY KEY, name TEXT);
21
+ CREATE TABLE artist (pid INTEGER PRIMARY KEY, name TEXT);
22
+ CREATE TABLE track_artist (pid INTEGER PRIMARY KEY, name TEXT);
23
+ CREATE TABLE genre_map (id INTEGER PRIMARY KEY, genre TEXT);
24
+
25
+ INSERT INTO album VALUES (10, 'Kept'), (11, 'Empty');
26
+ INSERT INTO artist VALUES (20, 'Kept'), (21, 'Empty');
27
+ INSERT INTO track_artist VALUES (30, 'Kept'), (31, 'Empty');
28
+ INSERT INTO genre_map VALUES (40, 'Rock'), (41, 'Ambient');
29
+
30
+ -- one item referencing only the *Kept* lookups; the *Empty* ones are orphans
31
+ INSERT INTO item VALUES (100, 10, 20, 30, 40);
32
+ """
33
+ )
34
+ lib.commit()
35
+ lib.close()
36
+ return d
37
+
38
+
39
+ def test_gc_removes_orphans_keeps_referenced(tmp_path):
40
+ d = _make_itlp(tmp_path)
41
+ lib = ItlpLibrary(d)
42
+ try:
43
+ removed = lib.gc_orphan_lookups()
44
+ assert removed == 4 # one orphan in each of the 4 tables
45
+
46
+ cx = lib.cx["Library.itdb"]
47
+ assert [r[0] for r in cx.execute("SELECT pid FROM album ORDER BY pid")] == [10]
48
+ assert [r[0] for r in cx.execute("SELECT pid FROM artist ORDER BY pid")] == [20]
49
+ assert [r[0] for r in cx.execute("SELECT pid FROM track_artist ORDER BY pid")] == [30]
50
+ assert [r[0] for r in cx.execute("SELECT id FROM genre_map ORDER BY id")] == [40]
51
+ finally:
52
+ lib.close()
53
+
54
+
55
+ def test_gc_wipes_all_lookups_when_no_items(tmp_path):
56
+ d = _make_itlp(tmp_path)
57
+ lib = ItlpLibrary(d)
58
+ try:
59
+ lib.cx["Library.itdb"].execute("DELETE FROM item")
60
+ lib.cx["Library.itdb"].commit()
61
+ lib.gc_orphan_lookups()
62
+ for table in ("album", "artist", "track_artist", "genre_map"):
63
+ n = lib.cx["Library.itdb"].execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
64
+ assert n == 0, f"{table} should be empty once no item references it"
65
+ finally:
66
+ lib.close()
67
+
68
+
69
+ def test_gc_tolerates_missing_table(tmp_path):
70
+ """composer table isn't present here — gc must skip it, not raise."""
71
+ d = _make_itlp(tmp_path)
72
+ lib = ItlpLibrary(d)
73
+ try:
74
+ lib.gc_orphan_lookups() # must not raise despite no `composer` table
75
+ finally:
76
+ lib.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes