ipodsync 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,252 @@
1
+ """Pure-Python cover writer for the iPod nano 7G (without libgpod).
2
+
3
+ The format was taken from the iTunes reference on the device (native/fixtures/itunes_artwork/):
4
+ the nano 7G stores covers in `iPod_Control/Artwork/`:
5
+
6
+ - `ArtworkDB` — a binary container of atoms (mhfd → 3×mhsd → mhli/mhla/mhlf).
7
+ The image list (mhsd index=1) contains one `mhii` per track; each
8
+ `mhii` describes 4 thumbnails (per format 1010/1013/1015/1016) via
9
+ mhod(type=2)→mhni→mhod(type=3, file name), plus a service mhod(type=6, mhaf).
10
+ - `F<fmt>_1.ithmb` — raw RGB565-LE frames back to back, `slot` bytes per frame.
11
+
12
+ Link to the SQLite library: `item.artwork_status=1`,
13
+ `item.artwork_cache_id = mhii.image_id`; `mhii.song_id = unsigned(item.pid)`.
14
+
15
+ The write strategy is a **surgical incremental append**: existing
16
+ `mhii` and album/format lists are preserved byte-for-byte, new frames are
17
+ appended to the end of `.ithmb`, and a new `mhii` is added to the list. This way we
18
+ don't break the 700+ covers already generated by iTunes. `Locations.itdb` is not touched,
19
+ so a cbk signature isn't needed.
20
+
21
+ All structures are little-endian. Offsets were verified against the reference; the atom
22
+ templates are real iTunes bytes, we only patch image_id/song_id/ithmb_offset.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ import struct
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ from ._art_templates import (MHFD_TEMPLATE, MHII_HEADER, MHIF_ALL, MHLA_TEMPLATE,
32
+ MHLF_TEMPLATE, MHLI_TEMPLATE, MHNI, MHOD_CONTAINER,
33
+ MHOD_MHAF, STRMHOD)
34
+
35
+ U64 = 1 << 64
36
+
37
+ # --- nano 7G thumbnail formats (format_id, width, height, slot_bytes) -----------
38
+ # slot == width*height*2, except 1016 (57×57=6498, slot 6612 = 57×58: the device
39
+ # reads the top 57×57, the slot's tail is ignored -> we pad with zeros).
40
+ FORMATS: list[tuple[int, int, int, int]] = [
41
+ (1010, 240, 240, 115200), # Now Playing
42
+ (1013, 50, 50, 5000),
43
+ (1015, 58, 58, 6728),
44
+ (1016, 57, 57, 6612),
45
+ ]
46
+ FORMAT_IDS = [f[0] for f in FORMATS]
47
+
48
+ # Atom templates (MHII_HEADER/MHNI/STRMHOD/MHOD_*/MHFD/MHL*/MHIF_ALL) — real
49
+ # bytes from the iTunes reference, see ipodsync/_art_templates.py. We only patch image_id/
50
+ # song_id (in mhii) and ithmb_offset (in mhni).
51
+
52
+ MHFD_HDR = 132
53
+ MHSD_HDR = 96
54
+ MHL_HDR = 92
55
+ MHII_LEN = 928
56
+ FIRST_IMAGE_ID = 101
57
+
58
+
59
+ # --- encoding a cover to RGB565-LE ----------------------------------------------
60
+ def to_rgb565(image_bytes: bytes, width: int, height: int, slot: int) -> bytes:
61
+ """Resize the cover to width×height, pack into RGB565-LE, pad to slot."""
62
+ from PIL import Image
63
+
64
+ im = Image.open(io.BytesIO(image_bytes)).convert("RGB").resize(
65
+ (width, height), Image.LANCZOS)
66
+ rgb = im.tobytes() # width*height*3
67
+ out = bytearray(width * height * 2)
68
+ j = 0
69
+ for i in range(0, len(rgb), 3):
70
+ v = ((rgb[i] >> 3) << 11) | ((rgb[i + 1] >> 2) << 5) | (rgb[i + 2] >> 3)
71
+ out[j] = v & 0xFF
72
+ out[j + 1] = v >> 8
73
+ j += 2
74
+ if len(out) < slot:
75
+ out.extend(b"\x00" * (slot - len(out)))
76
+ return bytes(out)
77
+
78
+
79
+ def encode_cover(image_bytes: bytes) -> dict[int, bytes]:
80
+ """Cover -> {format_id: rgb565_slot_bytes} for all 4 formats."""
81
+ return {fid: to_rgb565(image_bytes, w, h, slot) for fid, w, h, slot in FORMATS}
82
+
83
+
84
+ # --- ArtworkDB model (surgical parse/build) -------------------------------------
85
+ @dataclass
86
+ class ArtworkDB:
87
+ """A parsed ArtworkDB. Existing records are kept as raw bytes."""
88
+ mhii_blocks: list[bytes] = field(default_factory=list) # raw mhii (verbatim)
89
+ song_ids: set[int] = field(default_factory=set) # already covered (unsigned)
90
+ next_id: int = FIRST_IMAGE_ID
91
+ mhsd_album: bytes = b"" # the whole mhsd(index=2) verbatim (or we synthesize it)
92
+ mhsd_file: bytes = b"" # the whole mhsd(index=3) verbatim (or we synthesize it)
93
+
94
+ @classmethod
95
+ def load(cls, path: str | Path) -> "ArtworkDB":
96
+ p = Path(path)
97
+ if not p.exists():
98
+ return cls.empty()
99
+ return cls.parse(p.read_bytes())
100
+
101
+ @classmethod
102
+ def empty(cls) -> "ArtworkDB":
103
+ mhsd_album = _mhsd(2, MHSD_HDR + len(MHLA_TEMPLATE)) + MHLA_TEMPLATE
104
+ mhlf = MHLF_TEMPLATE + MHIF_ALL
105
+ mhsd_file = _mhsd(3, MHSD_HDR + len(mhlf)) + mhlf
106
+ return cls(next_id=FIRST_IMAGE_ID, mhsd_album=mhsd_album, mhsd_file=mhsd_file)
107
+
108
+ @classmethod
109
+ def parse(cls, data: bytes) -> "ArtworkDB":
110
+ if data[:4] != b"mhfd":
111
+ raise ValueError("not an ArtworkDB (no mhfd header)")
112
+ next_id = _i32(data, 28)
113
+ o = MHFD_HDR
114
+ mhii_blocks: list[bytes] = []
115
+ song_ids: set[int] = set()
116
+ mhsd_album = mhsd_file = b""
117
+ n_mhsd = _i32(data, 20)
118
+ for _ in range(n_mhsd):
119
+ total = _i32(data, o + 8)
120
+ index = struct.unpack_from("<h", data, o + 12)[0]
121
+ block = data[o:o + total]
122
+ if index == 1: # image list: mhsd → mhli → mhii*
123
+ mhli_off = MHSD_HDR
124
+ num = _i32(block, mhli_off + 8)
125
+ q = mhli_off + MHL_HDR
126
+ for _i in range(num):
127
+ mtot = _i32(block, q + 8)
128
+ mhii = block[q:q + mtot]
129
+ mhii_blocks.append(mhii)
130
+ song_ids.add(struct.unpack_from("<Q", mhii, 20)[0])
131
+ q += mtot
132
+ elif index == 2:
133
+ mhsd_album = block
134
+ elif index == 3:
135
+ mhsd_file = block
136
+ o += total
137
+ if not mhsd_album:
138
+ mhsd_album = _mhsd(2, MHSD_HDR + len(MHLA_TEMPLATE)) + MHLA_TEMPLATE
139
+ if not mhsd_file:
140
+ mhlf = MHLF_TEMPLATE + MHIF_ALL
141
+ mhsd_file = _mhsd(3, MHSD_HDR + len(mhlf)) + mhlf
142
+ return cls(mhii_blocks, song_ids, next_id, mhsd_album, mhsd_file)
143
+
144
+ def has(self, song_id: int) -> bool:
145
+ return (song_id % U64) in self.song_ids
146
+
147
+ def add_record(self, song_id: int, offsets: dict[int, int]) -> int:
148
+ """Add an mhii for a track. offsets: {format_id: ithmb_offset}. -> image_id."""
149
+ song_id %= U64
150
+ image_id = self.next_id
151
+ self.mhii_blocks.append(_build_mhii(image_id, song_id, offsets))
152
+ self.song_ids.add(song_id)
153
+ self.next_id += 1
154
+ return image_id
155
+
156
+ def serialize(self) -> bytes:
157
+ mhii_all = b"".join(self.mhii_blocks)
158
+ n = len(self.mhii_blocks)
159
+ mhli = bytearray(MHLI_TEMPLATE)
160
+ struct.pack_into("<i", mhli, 8, n)
161
+ mhsd_img = _mhsd(1, MHSD_HDR + MHL_HDR + len(mhii_all)) + bytes(mhli) + mhii_all
162
+
163
+ total = MHFD_HDR + len(mhsd_img) + len(self.mhsd_album) + len(self.mhsd_file)
164
+ mhfd = bytearray(MHFD_TEMPLATE)
165
+ struct.pack_into("<i", mhfd, 8, total)
166
+ struct.pack_into("<i", mhfd, 20, 3) # num_children
167
+ struct.pack_into("<i", mhfd, 28, self.next_id)
168
+ return bytes(mhfd) + mhsd_img + self.mhsd_album + self.mhsd_file
169
+
170
+
171
+ def _i32(b, o):
172
+ return struct.unpack_from("<i", b, o)[0]
173
+
174
+
175
+ def _mhsd(index: int, total: int) -> bytes:
176
+ b = bytearray(MHSD_HDR)
177
+ b[0:4] = b"mhsd"
178
+ struct.pack_into("<i", b, 4, MHSD_HDR)
179
+ struct.pack_into("<i", b, 8, total)
180
+ struct.pack_into("<h", b, 12, index)
181
+ return bytes(b)
182
+
183
+
184
+ def _build_mhii(image_id: int, song_id: int, offsets: dict[int, int]) -> bytes:
185
+ hdr = bytearray(MHII_HEADER)
186
+ struct.pack_into("<i", hdr, 16, image_id)
187
+ struct.pack_into("<Q", hdr, 20, song_id % U64)
188
+ body = bytearray()
189
+ for fid in FORMAT_IDS:
190
+ body += MHOD_CONTAINER
191
+ mhni = bytearray(MHNI[fid])
192
+ struct.pack_into("<i", mhni, 20, offsets[fid])
193
+ body += mhni
194
+ body += STRMHOD[fid]
195
+ body += MHOD_MHAF
196
+ out = bytes(hdr) + bytes(body)
197
+ assert len(out) == MHII_LEN, f"mhii len {len(out)} != {MHII_LEN}"
198
+ return out
199
+
200
+
201
+ # --- appending to .ithmb --------------------------------------------------------
202
+ def append_thumb(artwork_dir: Path, format_id: int, slot_bytes: bytes) -> int:
203
+ """Append a frame to F<fmt>_1.ithmb. Returns ithmb_offset (bytes from the start)."""
204
+ path = Path(artwork_dir) / f"F{format_id}_1.ithmb"
205
+ offset = path.stat().st_size if path.exists() else 0
206
+ with open(path, "ab") as f:
207
+ f.write(slot_bytes)
208
+ return offset
209
+
210
+
211
+ # --- high-level operation -------------------------------------------------------
212
+ def attach_cover(artwork_dir: str | Path, song_id: int, cover_bytes: bytes) -> int | None:
213
+ """Attach a cover to a track (song_id = unsigned pid).
214
+
215
+ Appends 4 thumbnails to .ithmb and an mhii to ArtworkDB (incrementally,
216
+ without breaking what exists). Returns the image_id for item.artwork_cache_id,
217
+ or None if the track already has a cover (we don't repeat).
218
+ """
219
+ artwork_dir = Path(artwork_dir)
220
+ artwork_dir.mkdir(parents=True, exist_ok=True)
221
+ db_path = artwork_dir / "ArtworkDB"
222
+ db = ArtworkDB.load(db_path)
223
+ if db.has(song_id):
224
+ return None
225
+
226
+ slots = encode_cover(cover_bytes)
227
+ offsets = {fid: append_thumb(artwork_dir, fid, slots[fid]) for fid in FORMAT_IDS}
228
+ image_id = db.add_record(song_id, offsets)
229
+
230
+ tmp = db_path.with_suffix(".tmp")
231
+ tmp.write_bytes(db.serialize())
232
+ tmp.replace(db_path)
233
+ return image_id
234
+
235
+
236
+ # --- extracting the embedded cover from audio -----------------------------------
237
+ def extract_embedded_cover(audio_path: str | Path) -> bytes | None:
238
+ """Extract the embedded cover bytes from MP3 (APIC) or M4A (covr)."""
239
+ p = Path(audio_path)
240
+ ext = p.suffix.lower()
241
+ try:
242
+ if ext == ".mp3":
243
+ from mutagen.id3 import ID3
244
+ apic = ID3(p).getall("APIC")
245
+ return apic[0].data if apic else None
246
+ if ext in (".m4a", ".mp4", ".aac", ".alac"):
247
+ from mutagen.mp4 import MP4
248
+ covr = MP4(p).tags.get("covr") if MP4(p).tags else None
249
+ return bytes(covr[0]) if covr else None
250
+ except Exception:
251
+ return None
252
+ return None
ipodsync/cbk.py ADDED
@@ -0,0 +1,44 @@
1
+ """Generate Locations.itdb.cbk — the anti-tamper signature of the nano 7G SQLite DB.
2
+
3
+ The format was worked out from the tunesreloaded code (modules/hashAB.js) and
4
+ verified byte-for-byte against the reference in native/fixtures/:
5
+
6
+ cbk = signature(57) + master_sha1(20) + block_sha1[i](20) * N
7
+
8
+ where Locations.itdb is split into 1024-byte blocks (the last one padded with
9
+ zeros to 1024), each block -> SHA1; the concatenation of all block-SHA1s -> SHA1 =
10
+ master; signature = calcHashAB(master, FirewireGuid, rnd=FIXED_RND).
11
+
12
+ tunesreloaded/libgpod use the fixed rnd b"ABCDEFGHIJKLMNOPQRSTUVW"
13
+ (the same one used in the hashab test vectors) — so cbk is fully deterministic.
14
+ Only Locations.itdb is hash-protected; the other .itdb files are plain SQLite.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+
20
+ from .hashab import HashAB
21
+
22
+ CBK_BLOCK_SIZE = 1024
23
+ FIXED_RND = b"ABCDEFGHIJKLMNOPQRSTUVW" # 23 bytes, as in tunesreloaded/test vectors
24
+
25
+
26
+ def build_cbk(locations_itdb: bytes, firewire_guid: bytes,
27
+ hasher: HashAB | None = None) -> bytes:
28
+ """Build the contents of Locations.itdb.cbk for a given Locations.itdb.
29
+
30
+ firewire_guid — 8 bytes (big-endian, as from sysinfo.read_firewire_guid).
31
+ """
32
+ h = hasher or HashAB()
33
+
34
+ digests = []
35
+ for i in range(0, len(locations_itdb), CBK_BLOCK_SIZE):
36
+ block = locations_itdb[i:i + CBK_BLOCK_SIZE]
37
+ if len(block) < CBK_BLOCK_SIZE:
38
+ block = block + b"\x00" * (CBK_BLOCK_SIZE - len(block))
39
+ digests.append(hashlib.sha1(block).digest())
40
+
41
+ concat = b"".join(digests)
42
+ master = hashlib.sha1(concat).digest()
43
+ signature = h.sign(master, firewire_guid, rnd_bytes=FIXED_RND)
44
+ return signature + master + concat
ipodsync/cli.py ADDED
@@ -0,0 +1,314 @@
1
+ """CLI `ipodsync` for managing an iPod nano 6G/7G library.
2
+
3
+ ipodsync list # show tracks
4
+ ipodsync export DEST [--by-album] [--no-tag] # download everything from the iPod
5
+ ipodsync export DEST --pid PID # download a single track
6
+ ipodsync add FILE [--no-cover] # upload a track (+cover auto)
7
+ ipodsync rm PID [--delete-file] # remove a track (+resign)
8
+ ipodsync cover PID [--image IMG] # attach a cover to a track
9
+
10
+ Export is read-only for the device. add/rm/cover write to the database (they back up .itlp).
11
+ Do not open this iPod in iTunes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import shutil
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ MAC_EPOCH = 978307200
21
+
22
+
23
+ def _mac_now() -> int:
24
+ return int(datetime.now(timezone.utc).timestamp()) - MAC_EPOCH
25
+
26
+
27
+ from ipodsync.export import export_track
28
+ from ipodsync.importer import add_mp3_to_library, copy_audio_to_ipod
29
+ from ipodsync.library import ItlpLibrary
30
+ from ipodsync.sysinfo import read_firewire_guid
31
+ from ipodsync.transport import (NO_ACCESS_HINT, Access, IPodNotFound, find_ipod,
32
+ probe_ipods, wait_for_ipod)
33
+
34
+
35
+ def _lib(ipod):
36
+ return ItlpLibrary(ipod.itunes_dir / "iTunes Library.itlp")
37
+
38
+
39
+ def cmd_list(ipod, args):
40
+ lib = _lib(ipod)
41
+ tracks = lib.list_tracks()
42
+ for t in tracks:
43
+ dur = t["duration_ms"] // 1000
44
+ print(f" [{t['pid']:>20}] {t['artist'] or '—'} — {t['title'] or '—'}"
45
+ f" ({t['album'] or '—'}, {dur//60}:{dur % 60:02d}) {t['location']}")
46
+ print(f"\nTotal: {len(tracks)} tracks")
47
+ lib.close()
48
+
49
+
50
+ def cmd_export(ipod, args):
51
+ lib = _lib(ipod)
52
+ tracks = lib.list_tracks()
53
+ if args.pid is not None:
54
+ tracks = [t for t in tracks if t["pid"] == args.pid]
55
+ dest = Path(args.dest)
56
+ layout = "artist_album" if args.by_album else "flat"
57
+ ok = 0
58
+ for t in tracks:
59
+ if not t["location"]:
60
+ continue
61
+ try:
62
+ dst = export_track(ipod.music_dir, t, dest, tag=not args.no_tag, layout=layout)
63
+ print(f" ✓ {dst.relative_to(dest)}")
64
+ ok += 1
65
+ except Exception as e: # noqa: BLE001
66
+ print(f" ✗ {t.get('title')!r}: {e}")
67
+ print(f"\nDownloaded {ok}/{len(tracks)} to {dest}")
68
+ lib.close()
69
+
70
+
71
+ def cmd_rm(ipod, args):
72
+ itlp = ipod.itunes_dir / "iTunes Library.itlp"
73
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
74
+ backup = Path.home() / "ipod-backups" / f"itlp-{stamp}"
75
+ backup.parent.mkdir(parents=True, exist_ok=True)
76
+ shutil.copytree(itlp, backup)
77
+ print(f".itlp backup: {backup}")
78
+
79
+ guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo)
80
+ lib = ItlpLibrary(itlp)
81
+ music = ipod.music_dir if args.delete_file else None
82
+ loc = lib.remove_track(args.pid, music_dir=music)
83
+ lib.resign(guid)
84
+ lib.close()
85
+ print(f"Removed track pid={args.pid} (file: {loc or '—'}"
86
+ f"{', deleted' if args.delete_file else ''}). Eject iPod.")
87
+ print(f"Rollback: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
88
+
89
+
90
+ def _edit_library(ipod, fn, *, label: str):
91
+ """Back up .itlp, edit a copy via fn(lib), copy Library/Dynamic.itdb back.
92
+
93
+ Playlist operations don't touch Locations.itdb, so we don't rebuild cbk.
94
+ """
95
+ itlp = ipod.itunes_dir / "iTunes Library.itlp"
96
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
97
+ backup = Path.home() / "ipod-backups" / f"itlp-{stamp}"
98
+ backup.parent.mkdir(parents=True, exist_ok=True)
99
+ shutil.copytree(itlp, backup)
100
+ work = Path("/tmp") / f"itlp_{label}_{stamp}"
101
+ shutil.copytree(itlp, work)
102
+
103
+ lib = ItlpLibrary(work)
104
+ try:
105
+ result = fn(lib)
106
+ finally:
107
+ lib.close()
108
+ for name in ("Library.itdb", "Dynamic.itdb"):
109
+ shutil.copy2(work / name, itlp / name)
110
+ print(f".itlp backup: {backup}. Eject iPod.")
111
+ return result
112
+
113
+
114
+ def cmd_playlists(ipod, args):
115
+ lib = _lib(ipod)
116
+ for p in lib.list_playlists():
117
+ tag = " [master]" if p["is_master"] else (" [hidden]" if p["hidden"] else "")
118
+ print(f" [{p['pid']:>20}] {p['name']} ({p['count']} tracks){tag}")
119
+ lib.close()
120
+
121
+
122
+ def cmd_pl_create(ipod, args):
123
+ pid = _edit_library(ipod, lambda lib: lib.create_playlist(args.name, date=_mac_now()),
124
+ label="plcreate")
125
+ print(f"Created playlist '{args.name}' pid={pid}")
126
+
127
+
128
+ def cmd_pl_add(ipod, args):
129
+ _edit_library(ipod, lambda lib: [lib.add_to_playlist(args.playlist, t) for t in args.track],
130
+ label="pladd")
131
+ print(f"Tracks added: {len(args.track)} → playlist {args.playlist}")
132
+
133
+
134
+ def cmd_pl_rm(ipod, args):
135
+ _edit_library(ipod, lambda lib: lib.remove_from_playlist(args.playlist, args.track),
136
+ label="plrm")
137
+ print(f"Track {args.track} removed from playlist {args.playlist}")
138
+
139
+
140
+ def cmd_pl_del(ipod, args):
141
+ _edit_library(ipod, lambda lib: lib.delete_playlist(args.playlist), label="pldel")
142
+ print(f"Playlist {args.playlist} deleted")
143
+
144
+
145
+ def cmd_status(args):
146
+ st, ipod, blocked = probe_ipods()
147
+ if st is Access.READY:
148
+ lib = _lib(ipod)
149
+ try:
150
+ n = len(lib.list_tracks())
151
+ finally:
152
+ lib.close()
153
+ print(f"✅ iPod ready: {ipod.root} ({n} tracks)")
154
+ elif st is Access.NO_ACCESS:
155
+ print("⛔ " + NO_ACCESS_HINT.format(vols=", ".join(v.name for v in blocked)))
156
+ else:
157
+ print("❌ iPod not connected (no volume in /Volumes). Enable Disk Use.")
158
+
159
+
160
+ def cmd_wait(args):
161
+ print("⏳ Waiting for iPod…")
162
+ last = {"s": None}
163
+
164
+ def on_wait(status, blocked):
165
+ if status != last["s"]:
166
+ last["s"] = status
167
+ note = " (no access — see Full Disk Access)" if status is Access.NO_ACCESS else ""
168
+ print(f" …{status.value}{note}")
169
+
170
+ ipod = wait_for_ipod(timeout=args.timeout, interval=args.interval, on_wait=on_wait)
171
+ print(f"✅ iPod ready: {ipod.root}")
172
+
173
+
174
+ def cmd_add(ipod, args):
175
+ """Upload an MP3 to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
176
+ itlp = ipod.itunes_dir / "iTunes Library.itlp"
177
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
178
+ backup = Path.home() / "ipod-backups" / f"itlp-{stamp}"
179
+ backup.parent.mkdir(parents=True, exist_ok=True)
180
+ shutil.copytree(itlp, backup)
181
+ print(f".itlp backup: {backup}")
182
+
183
+ guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo)
184
+ location, abs_path = copy_audio_to_ipod(ipod, args.file)
185
+ print(f"File copied: {location}")
186
+
187
+ work = Path("/tmp") / f"itlp_add_{stamp}"
188
+ shutil.copytree(itlp, work)
189
+ overrides = {"title": args.title, "artist": args.artist, "album": args.album}
190
+ art_dir = None if args.no_cover else ipod.control / "Artwork"
191
+ if art_dir is not None:
192
+ _backup_artwork(ipod, stamp)
193
+ pid = add_mp3_to_library(work, location, args.file, abs_path.stat().st_size,
194
+ guid, overrides=overrides, artwork_dir=art_dir)
195
+ for name in ("Library.itdb", "Locations.itdb", "Dynamic.itdb",
196
+ "Extras.itdb", "Locations.itdb.cbk"):
197
+ shutil.copy2(work / name, itlp / name)
198
+ print(f"Track added pid={pid}"
199
+ f"{'' if args.no_cover else ' (cover attached if one was embedded)'}."
200
+ " Eject iPod and check Songs.")
201
+ print(f"Rollback: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
202
+
203
+
204
+ def _backup_artwork(ipod, stamp: str) -> None:
205
+ """Back up iPod_Control/Artwork before appending covers (ithmb append)."""
206
+ art = ipod.control / "Artwork"
207
+ if art.exists():
208
+ dst = Path.home() / "ipod-backups" / f"Artwork-{stamp}"
209
+ shutil.copytree(art, dst)
210
+ print(f"Artwork backup: {dst}")
211
+
212
+
213
+ def cmd_cover(ipod, args):
214
+ """Attach a cover to an already-uploaded track (pid). Source is the track file on the iPod
215
+ (embedded APIC) or an explicit --image."""
216
+ from ipodsync.artwork_writer import U64, attach_cover, extract_embedded_cover
217
+ from ipodsync.library import ItlpLibrary
218
+
219
+ itlp = ipod.itunes_dir / "iTunes Library.itlp"
220
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
221
+ _backup_artwork(ipod, stamp)
222
+
223
+ if args.image:
224
+ cover = Path(args.image).read_bytes()
225
+ else:
226
+ lib = ItlpLibrary(itlp)
227
+ try:
228
+ t = next((t for t in lib.list_tracks() if t["pid"] == args.pid), None)
229
+ finally:
230
+ lib.close()
231
+ if not t or not t["location"]:
232
+ print(f"⚠️ track pid={args.pid} not found"); return
233
+ cover = extract_embedded_cover(ipod.music_dir / t["location"])
234
+ if not cover:
235
+ print("⚠️ the track file has no embedded cover (APIC/covr). "
236
+ "Provide an image via --image."); return
237
+
238
+ image_id = attach_cover(ipod.control / "Artwork", args.pid % U64, cover)
239
+ if image_id is None:
240
+ print(f"Track pid={args.pid} already has a cover — skipping."); return
241
+
242
+ work = Path("/tmp") / f"itlp_cover_{stamp}"
243
+ shutil.copytree(itlp, work)
244
+ lib = ItlpLibrary(work)
245
+ try:
246
+ lib.set_track_artwork(args.pid, image_id)
247
+ album_pid = lib.album_pid_of(args.pid)
248
+ if album_pid:
249
+ lib.set_album_artwork(album_pid, args.pid)
250
+ finally:
251
+ lib.close()
252
+ shutil.copy2(work / "Library.itdb", itlp / "Library.itdb")
253
+ print(f"Cover attached to pid={args.pid} (cache_id={image_id}, "
254
+ f"album{'+' if album_pid else ' —'}). Eject iPod.")
255
+
256
+
257
+ def main() -> int:
258
+ ap = argparse.ArgumentParser()
259
+ sub = ap.add_subparsers(dest="cmd", required=True)
260
+ sub.add_parser("list")
261
+ pe = sub.add_parser("export")
262
+ pe.add_argument("dest")
263
+ pe.add_argument("--pid", type=int)
264
+ pe.add_argument("--by-album", action="store_true")
265
+ pe.add_argument("--no-tag", action="store_true")
266
+ pr = sub.add_parser("rm")
267
+ pr.add_argument("pid", type=int)
268
+ pr.add_argument("--delete-file", action="store_true")
269
+ pa = sub.add_parser("add")
270
+ pa.add_argument("file", help="path to MP3")
271
+ pa.add_argument("--title")
272
+ pa.add_argument("--artist")
273
+ pa.add_argument("--album")
274
+ pa.add_argument("--no-cover", action="store_true",
275
+ help="don't attach the embedded cover")
276
+ sub.add_parser("status")
277
+ pw = sub.add_parser("wait")
278
+ pw.add_argument("--timeout", type=float, default=120)
279
+ pw.add_argument("--interval", type=float, default=2)
280
+
281
+ sub.add_parser("playlists")
282
+ pc = sub.add_parser("pl-create"); pc.add_argument("name")
283
+ ppa = sub.add_parser("pl-add")
284
+ ppa.add_argument("playlist", type=int); ppa.add_argument("track", type=int, nargs="+")
285
+ ppr = sub.add_parser("pl-rm")
286
+ ppr.add_argument("playlist", type=int); ppr.add_argument("track", type=int)
287
+ ppd = sub.add_parser("pl-del"); ppd.add_argument("playlist", type=int)
288
+ pcv = sub.add_parser("cover")
289
+ pcv.add_argument("pid", type=int)
290
+ pcv.add_argument("--image", help="cover image file (otherwise the track's APIC is used)")
291
+ args = ap.parse_args()
292
+
293
+ # commands that don't require a ready iPod
294
+ if args.cmd == "status":
295
+ return cmd_status(args)
296
+ if args.cmd == "wait":
297
+ return cmd_wait(args)
298
+
299
+ try:
300
+ ipod = find_ipod()
301
+ except IPodNotFound as e:
302
+ print(f"⚠️ {e}")
303
+ return 2
304
+ handlers = {
305
+ "list": cmd_list, "export": cmd_export, "rm": cmd_rm, "add": cmd_add,
306
+ "playlists": cmd_playlists, "pl-create": cmd_pl_create, "pl-add": cmd_pl_add,
307
+ "pl-rm": cmd_pl_rm, "pl-del": cmd_pl_del, "cover": cmd_cover,
308
+ }
309
+ handlers[args.cmd](ipod, args)
310
+ return 0
311
+
312
+
313
+ if __name__ == "__main__":
314
+ raise SystemExit(main())