ipodsync 0.1.7__tar.gz → 0.1.9__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.7 → ipodsync-0.1.9}/PKG-INFO +3 -2
  2. {ipodsync-0.1.7 → ipodsync-0.1.9}/README.md +2 -1
  3. {ipodsync-0.1.7 → ipodsync-0.1.9}/pyproject.toml +1 -1
  4. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/__init__.py +1 -1
  5. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/artwork_writer.py +73 -0
  6. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/cli.py +24 -6
  7. ipodsync-0.1.9/tests/test_artwork_compact.py +60 -0
  8. {ipodsync-0.1.7 → ipodsync-0.1.9}/.github/workflows/ci.yml +0 -0
  9. {ipodsync-0.1.7 → ipodsync-0.1.9}/.github/workflows/publish.yml +0 -0
  10. {ipodsync-0.1.7 → ipodsync-0.1.9}/.gitignore +0 -0
  11. {ipodsync-0.1.7 → ipodsync-0.1.9}/LICENSE +0 -0
  12. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/__main__.py +0 -0
  13. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_art_templates.py +0 -0
  14. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen.py +0 -0
  15. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen2.py +0 -0
  16. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen_rt.py +0 -0
  17. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_py.py +0 -0
  18. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_tables.bin +0 -0
  19. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_tables.py +0 -0
  20. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_lib_templates.py +0 -0
  21. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/cbk.py +0 -0
  22. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/export.py +0 -0
  23. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/hashab.py +0 -0
  24. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/importer.py +0 -0
  25. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/library.py +0 -0
  26. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/models.py +0 -0
  27. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/sysinfo.py +0 -0
  28. {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/transport.py +0 -0
  29. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/ArtworkDB +0 -0
  30. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/Locations.itdb +0 -0
  31. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/Locations.itdb.cbk +0 -0
  32. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/hashab-traces.json +0 -0
  33. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/hashab-vectors.json +0 -0
  34. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_artwork_writer.py +0 -0
  35. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_cbk.py +0 -0
  36. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_gc_orphans.py +0 -0
  37. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_hashab.py +0 -0
  38. {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_hashab_port.py +0 -0
  39. {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/build_gen2.py +0 -0
  40. {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_art_templates.py +0 -0
  41. {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_hashab_tables.py +0 -0
  42. {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_lib_templates.py +0 -0
  43. {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/transpile_hashab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipodsync
3
- Version: 0.1.7
3
+ Version: 0.1.9
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
@@ -99,7 +99,8 @@ ipodsync add -f ~/Music/album # a whole folder (recursively)
99
99
  ipodsync add "Song.mp3" --no-cover
100
100
  ipodsync export ~/Music/ipod --by-album
101
101
  ipodsync cover 123456789 --image cover.jpg
102
- ipodsync rm 123456789 --delete-file
102
+ ipodsync rm 123456789 # removes the track and its audio file
103
+ ipodsync rm 123456789 --keep-file # …but leave the audio file on the iPod
103
104
  ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
104
105
  ```
105
106
 
@@ -67,7 +67,8 @@ ipodsync add -f ~/Music/album # a whole folder (recursively)
67
67
  ipodsync add "Song.mp3" --no-cover
68
68
  ipodsync export ~/Music/ipod --by-album
69
69
  ipodsync cover 123456789 --image cover.jpg
70
- ipodsync rm 123456789 --delete-file
70
+ ipodsync rm 123456789 # removes the track and its audio file
71
+ ipodsync rm 123456789 --keep-file # …but leave the audio file on the iPod
71
72
  ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
72
73
  ```
73
74
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ipodsync"
7
- version = "0.1.7"
7
+ version = "0.1.9"
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.7"
16
+ __version__ = "0.1.9"
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)."""
@@ -4,7 +4,7 @@
4
4
  ipodsync export DEST [--by-album] [--no-tag] # download everything from the iPod
5
5
  ipodsync export DEST --pid PID # download a single track
6
6
  ipodsync add FILE [--no-cover] # upload a track (+cover auto)
7
- ipodsync rm PID [--delete-file] # remove a track (+resign)
7
+ ipodsync rm PID [--keep-file] # remove a track + its file (+resign)
8
8
  ipodsync cover PID [--image IMG] # attach a cover to a track
9
9
 
10
10
  Export is read-only. add/rm/cover write to the database; pass -b to back the library
@@ -71,18 +71,33 @@ 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)
81
+
82
+ if args.delete_file: # deprecated: deleting the file is the default now
83
+ print(" note: --delete-file is now the default; drop it, or use --keep-file to keep the file.")
77
84
 
78
85
  guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
79
86
  lib = ItlpLibrary(itlp)
80
- music = ipod.music_dir if args.delete_file else None
81
- loc = lib.remove_track(args.pid, music_dir=music)
87
+ music = None if args.keep_file else ipod.music_dir # delete the file unless --keep-file
88
+ lib.remove_track(args.pid, music_dir=music)
82
89
  lib.resign(guid)
90
+ live = {t["pid"] for t in lib.list_tracks()} # remaining tracks, after removal
83
91
  lib.close()
84
- print(f"✓ Removed track pid {args.pid}"
85
- f"{' (+ deleted the file)' if args.delete_file else ''}. Eject the iPod.")
92
+
93
+ # drop the removed track's thumbnails from ArtworkDB + repack the .ithmb files
94
+ stats = compact(ipod.control / "Artwork", live)
95
+
96
+ tail = " (kept the audio file)" if args.keep_file else " (+ deleted the file)"
97
+ print(f"✓ Removed track pid {args.pid}{tail}. Eject the iPod.")
98
+ if stats["removed"]:
99
+ print(f" Reclaimed cover art for {stats['removed']} track"
100
+ f"{'s' if stats['removed'] != 1 else ''}.")
86
101
  if backup:
87
102
  print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
88
103
 
@@ -323,7 +338,10 @@ def main() -> int:
323
338
 
324
339
  pr = sub.add_parser("rm", help="remove a track from the iPod")
325
340
  pr.add_argument("pid", type=int, help="track pid (see `list`)")
326
- pr.add_argument("--delete-file", action="store_true", help="also delete the audio file")
341
+ pr.add_argument("--keep-file", action="store_true",
342
+ help="keep the audio file on the iPod (default: delete it too)")
343
+ pr.add_argument("--delete-file", action="store_true",
344
+ help=argparse.SUPPRESS) # deprecated: deletion is the default now
327
345
 
328
346
  pa = sub.add_parser("add", help="upload a track (or a whole folder); cover attached automatically")
329
347
  pa.add_argument("file", nargs="*", help="path(s) to the audio file(s) to upload")
@@ -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}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes