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.
- ipodsync/__init__.py +27 -0
- ipodsync/__main__.py +5 -0
- ipodsync/_art_templates.py +29 -0
- ipodsync/_hashab_gen.py +1588 -0
- ipodsync/_hashab_gen2.py +838 -0
- ipodsync/_hashab_gen_rt.py +134 -0
- ipodsync/_hashab_py.py +285 -0
- ipodsync/_hashab_tables.bin +0 -0
- ipodsync/_hashab_tables.py +31 -0
- ipodsync/_lib_templates.py +16 -0
- ipodsync/artwork_writer.py +252 -0
- ipodsync/cbk.py +44 -0
- ipodsync/cli.py +314 -0
- ipodsync/export.py +91 -0
- ipodsync/hashab.py +44 -0
- ipodsync/importer.py +137 -0
- ipodsync/library.py +360 -0
- ipodsync/models.py +31 -0
- ipodsync/sysinfo.py +50 -0
- ipodsync/transport.py +165 -0
- ipodsync-0.1.0.dist-info/METADATA +114 -0
- ipodsync-0.1.0.dist-info/RECORD +25 -0
- ipodsync-0.1.0.dist-info/WHEEL +4 -0
- ipodsync-0.1.0.dist-info/entry_points.txt +2 -0
- ipodsync-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|