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.
- {ipodsync-0.1.7 → ipodsync-0.1.9}/PKG-INFO +3 -2
- {ipodsync-0.1.7 → ipodsync-0.1.9}/README.md +2 -1
- {ipodsync-0.1.7 → ipodsync-0.1.9}/pyproject.toml +1 -1
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/artwork_writer.py +73 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/cli.py +24 -6
- ipodsync-0.1.9/tests/test_artwork_compact.py +60 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/.gitignore +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/LICENSE +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/library.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/sysinfo.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/src/ipodsync/transport.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_artwork_writer.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_gc_orphans.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.7 → ipodsync-0.1.9}/tools/gen_lib_templates.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
+
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"
|
|
@@ -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 [--
|
|
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 =
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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("--
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|