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/export.py ADDED
@@ -0,0 +1,91 @@
1
+ """Download tracks FROM the iPod to the computer (read-only for the device).
2
+
3
+ The iPod stores files as iPod_Control/Music/Fxx/libgpodNNN.mp3 (obfuscated names),
4
+ with metadata in SQLite. Export = copy the file under a normal name
5
+ "Artist - Title.ext" and set ID3/MP4 tags from the database.
6
+
7
+ Tags are written via mutagen (gracefully — if the package isn't installed, just a copy).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ _BAD = re.compile(r'[/\\:*?"<>|\x00-\x1f]')
16
+
17
+
18
+ def safe_name(name: str) -> str:
19
+ return _BAD.sub("_", (name or "").strip()).strip(". ") or "track"
20
+
21
+
22
+ def _ext_from_location(location: str) -> str:
23
+ suf = Path(location).suffix
24
+ return suf if suf else ".mp3"
25
+
26
+
27
+ def _write_tags(path: Path, track: dict) -> bool:
28
+ try:
29
+ import mutagen
30
+ from mutagen.easyid3 import EasyID3
31
+ from mutagen.easymp4 import EasyMP4
32
+ except Exception:
33
+ return False
34
+
35
+ ext = path.suffix.lower()
36
+ try:
37
+ if ext == ".mp3":
38
+ try:
39
+ audio = EasyID3(path)
40
+ except mutagen.id3.ID3NoHeaderError: # type: ignore[attr-defined]
41
+ audio = mutagen.File(path, easy=True)
42
+ audio.add_tags()
43
+ elif ext in (".m4a", ".mp4", ".aac", ".alac"):
44
+ audio = EasyMP4(path)
45
+ else:
46
+ return False
47
+ for key, val in (("title", track.get("title")), ("artist", track.get("artist")),
48
+ ("album", track.get("album")), ("genre", track.get("genre"))):
49
+ if val:
50
+ audio[key] = str(val)
51
+ if track.get("track_number"):
52
+ audio["tracknumber"] = str(track["track_number"])
53
+ if track.get("year"):
54
+ audio["date"] = str(track["year"])
55
+ audio.save()
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+
61
+ def export_track(music_dir: Path, track: dict, dest_dir: Path, *,
62
+ tag: bool = True, layout: str = "flat") -> Path:
63
+ """Copy a single track from the device into dest_dir.
64
+
65
+ music_dir — iPod_Control/Music on the device (ipod.music_dir).
66
+ layout: "flat" -> "Artist - Title.ext"; "artist_album" -> Artist/Album/…
67
+ """
68
+ src = Path(music_dir) / track["location"]
69
+ ext = _ext_from_location(track["location"])
70
+ artist = safe_name(track.get("artist") or "Unknown Artist")
71
+ title = safe_name(track.get("title") or "Untitled")
72
+
73
+ if layout == "artist_album":
74
+ album = safe_name(track.get("album") or "Unknown Album")
75
+ out_dir = Path(dest_dir) / artist / album
76
+ base = f"{track.get('track_number') or 0:02d} {title}" if track.get("track_number") else title
77
+ else:
78
+ out_dir = Path(dest_dir)
79
+ base = f"{artist} - {title}"
80
+ out_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ dst = out_dir / (safe_name(base) + ext)
83
+ n = 1
84
+ while dst.exists():
85
+ dst = out_dir / (safe_name(base) + f" ({n})" + ext)
86
+ n += 1
87
+
88
+ shutil.copy2(src, dst)
89
+ if tag:
90
+ _write_tags(dst, track)
91
+ return dst
ipodsync/hashab.py ADDED
@@ -0,0 +1,44 @@
1
+ """hashAB — anti-tamper signature for the iPod nano 6G/7G SQLite database.
2
+
3
+ Pure-Python (no native lib): the algorithm (white-box AES, reverse-engineered
4
+ from dstaley/hashab, public domain) is ported into `_hashab_py`/`_hashab_gen*`.
5
+ Verified against 100/100 test vectors. API preserved: HashAB().sign(sha1, uuid, rnd).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from ._hashab_py import calc_hashab
12
+
13
+ SHA1_LEN = 20
14
+ UUID_LEN = 8
15
+ RND_LEN = 23
16
+ OUTPUT_LEN = 57 # signature, starts with 03 00
17
+
18
+
19
+ class HashABError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class HashAB:
24
+ """Pure-Python hashAB. lib_path is ignored (kept for compatibility)."""
25
+
26
+ def __init__(self, lib_path: str | os.PathLike | None = None):
27
+ pass
28
+
29
+ def sign(self, sha1: bytes, uuid: bytes, rnd_bytes: bytes | None = None) -> bytes:
30
+ """Return the 57-byte hashAB signature.
31
+
32
+ sha1 — 20 bytes (SHA1 of the hash-protected region).
33
+ uuid — 8 bytes, the device's FirewireGuid.
34
+ rnd_bytes — 23 random bytes (embedded in the signature); defaults to os.urandom.
35
+ """
36
+ if len(sha1) != SHA1_LEN:
37
+ raise HashABError(f"sha1 must be {SHA1_LEN} bytes, not {len(sha1)}")
38
+ if len(uuid) != UUID_LEN:
39
+ raise HashABError(f"uuid must be {UUID_LEN} bytes, not {len(uuid)}")
40
+ if rnd_bytes is None:
41
+ rnd_bytes = os.urandom(RND_LEN)
42
+ if len(rnd_bytes) != RND_LEN:
43
+ raise HashABError(f"rnd_bytes must be {RND_LEN} bytes, not {len(rnd_bytes)}")
44
+ return calc_hashab(sha1, uuid, rnd_bytes)
ipodsync/importer.py ADDED
@@ -0,0 +1,137 @@
1
+ """Upload an audio file TO the iPod: copy into Fxx/ + metadata + add_track.
2
+
3
+ MP3 is supported for now (the format codes are known from the reference). AAC/ALAC
4
+ (m4a) is the next step (needs audio_format/extension codes; FLAC — via ffmpeg to ALAC).
5
+
6
+ Metadata is read via mutagen (required for this operation).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ import shutil
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from .library import ItlpLibrary, TrackMeta
16
+
17
+ MAC_EPOCH = 978307200
18
+ MP3_EXT_4CC = 1297101600 # 0x4d503320 ('MP3 ')
19
+ MP3_AUDIO_FORMAT = 301
20
+ NUM_MUSIC_FOLDERS = 50 # F00..F49
21
+
22
+
23
+ def _mac_now() -> int:
24
+ return int(datetime.now(timezone.utc).timestamp()) - MAC_EPOCH
25
+
26
+
27
+ def _pick_target(music_dir: Path, ext: str) -> tuple[str, Path]:
28
+ """Pick an existing Fxx folder and a free name. Returns (location, abs_path)."""
29
+ for _ in range(200):
30
+ fx = f"F{random.randint(0, NUM_MUSIC_FOLDERS - 1):02d}"
31
+ d = Path(music_dir) / fx
32
+ if d.is_dir():
33
+ name = f"idmix{random.randint(100000, 9999999)}{ext}"
34
+ if not (d / name).exists():
35
+ return f"{fx}/{name}", d / name
36
+ raise RuntimeError("could not find a free Fxx folder on the device")
37
+
38
+
39
+ def read_mp3_meta(path: Path) -> dict:
40
+ """Extract duration/bitrate/sample rate and tags from an MP3 via mutagen."""
41
+ from mutagen.mp3 import MP3
42
+ from mutagen.easyid3 import EasyID3
43
+
44
+ mp3 = MP3(path)
45
+ info = mp3.info
46
+ tags = {}
47
+ try:
48
+ tags = EasyID3(path)
49
+ except Exception:
50
+ pass
51
+
52
+ def g(k):
53
+ v = tags.get(k)
54
+ return v[0] if v else ""
55
+
56
+ tn = g("tracknumber")
57
+ try:
58
+ tn = int(str(tn).split("/")[0]) if tn else 0
59
+ except ValueError:
60
+ tn = 0
61
+ yr = g("date")
62
+ try:
63
+ yr = int(str(yr)[:4]) if yr else 0
64
+ except ValueError:
65
+ yr = 0
66
+ return {
67
+ "title": g("title"), "artist": g("artist"), "album": g("album"),
68
+ "genre": g("genre"), "track_number": tn, "year": yr,
69
+ "total_time_ms": int(info.length * 1000),
70
+ "bit_rate": int(getattr(info, "bitrate", 0) // 1000),
71
+ "sample_rate": int(getattr(info, "sample_rate", 44100)),
72
+ }
73
+
74
+
75
+ def copy_audio_to_ipod(ipod, src: str | Path, ext: str = ".mp3") -> tuple[str, Path]:
76
+ """Copy an audio file into Fxx/ on the device. Returns (location, abs_path)."""
77
+ location, abs_path = _pick_target(ipod.music_dir, ext)
78
+ shutil.copy2(Path(src), abs_path)
79
+ return location, abs_path
80
+
81
+
82
+ def add_mp3_to_library(itlp_dir: str | Path, location: str, meta_src: str | Path,
83
+ file_size: int, guid: bytes, *, overrides: dict | None = None,
84
+ artwork_dir: str | Path | None = None) -> int:
85
+ """Add an already-copied MP3 to the .itlp library (itlp_dir) + resign.
86
+
87
+ itlp_dir — directory with the .itdb files (a working copy is fine). meta_src —
88
+ the source file to read tags from. If artwork_dir is given (iPod_Control/Artwork
89
+ on the device) and the file has embedded cover art, it is automatically linked to
90
+ the track (frames appended to .ithmb, mhii to ArtworkDB, fields in item). Returns the pid.
91
+ """
92
+ meta = read_mp3_meta(Path(meta_src))
93
+ if overrides:
94
+ meta.update({k: v for k, v in overrides.items() if v is not None})
95
+ if not meta.get("title"):
96
+ meta["title"] = Path(meta_src).stem
97
+
98
+ tm = TrackMeta(
99
+ location=location, title=meta["title"], artist=meta.get("artist", ""),
100
+ album=meta.get("album", ""), genre=meta.get("genre", ""),
101
+ total_time_ms=meta["total_time_ms"], track_number=meta.get("track_number", 0),
102
+ year=meta.get("year", 0), bit_rate=meta.get("bit_rate", 0),
103
+ sample_rate=meta.get("sample_rate", 44100), audio_format=MP3_AUDIO_FORMAT,
104
+ file_size=file_size, extension=MP3_EXT_4CC, kind="MPEG audio file",
105
+ )
106
+ lib = ItlpLibrary(itlp_dir)
107
+ try:
108
+ pid = lib.add_track(tm, date=_mac_now())
109
+ if artwork_dir is not None:
110
+ attach_cover_for_track(lib, pid, meta_src, artwork_dir)
111
+ lib.resign(guid)
112
+ finally:
113
+ lib.close()
114
+ return pid
115
+
116
+
117
+ def attach_cover_for_track(lib: ItlpLibrary, pid: int, audio_src: str | Path,
118
+ artwork_dir: str | Path) -> int | None:
119
+ """Extract the embedded cover art from audio_src and link it to track pid.
120
+
121
+ Appends thumbnails to .ithmb + an mhii to ArtworkDB (incrementally) and
122
+ sets item.artwork_status/artwork_cache_id in lib. Returns image_id
123
+ (for item.artwork_cache_id) or None if there's no cover / it's already linked.
124
+ """
125
+ from .artwork_writer import U64, attach_cover, extract_embedded_cover
126
+
127
+ cover = extract_embedded_cover(audio_src)
128
+ if not cover:
129
+ return None
130
+ image_id = attach_cover(artwork_dir, pid % U64, cover)
131
+ if image_id is not None:
132
+ lib.set_track_artwork(pid, image_id)
133
+ # album cover art (the "Albums" view/lists) — points to this track, like iTunes
134
+ album_pid = lib.album_pid_of(pid)
135
+ if album_pid:
136
+ lib.set_album_artwork(album_pid, pid)
137
+ return image_id
ipodsync/library.py ADDED
@@ -0,0 +1,360 @@
1
+ """Editing the nano 7G SQLite library (iTunes Library.itlp) via native sqlite3.
2
+
3
+ The device's real database (not iTunesCDB). Adding a track = INSERT rows into
4
+ all the needed tables (we clone an existing track as a template so we don't have
5
+ to list dozens of columns) + regeneration of Locations.itdb.cbk.
6
+
7
+ Only Locations.itdb is hash-protected (via .cbk) — the other .itdb are plain SQLite.
8
+
9
+ STATUS: first version. Sort fields (*_order) are set approximately
10
+ (appended at the end); the device usually recomputes them. Test on the
11
+ device iteratively (with an .itlp backup).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import sqlite3
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .cbk import build_cbk
22
+ from .hashab import HashAB
23
+
24
+ # Template rows for adding to an EMPTY library (fallback, when a table is
25
+ # empty — a freshly wiped database). See scripts/gen_lib_templates.py.
26
+ try:
27
+ from . import _lib_templates as _tpl
28
+ _EMPTY_TEMPLATES = {
29
+ "item": _tpl.ITEM_TEMPLATE, "artist": _tpl.ARTIST_TEMPLATE,
30
+ "album": _tpl.ALBUM_TEMPLATE, "track_artist": _tpl.TRACK_ARTIST_TEMPLATE,
31
+ "location": _tpl.LOCATION_TEMPLATE,
32
+ }
33
+ except ImportError: # generated by scripts/gen_lib_templates.py
34
+ _EMPTY_TEMPLATES = {}
35
+
36
+
37
+ def _rand_pid() -> int:
38
+ """Random signed 64-bit pid (as in the database)."""
39
+ v = int.from_bytes(os.urandom(8), "little", signed=True)
40
+ return v
41
+
42
+
43
+ def _row_or_template(db, sql: str, table: str, args=()) -> dict | None:
44
+ """A row from the DB, or if the table is empty — a fresh COPY of the built-in template."""
45
+ got = _row(db, sql, args)
46
+ if got is not None:
47
+ return got
48
+ tmpl = _EMPTY_TEMPLATES.get(table)
49
+ return dict(tmpl) if tmpl is not None else None
50
+
51
+
52
+ def _row(db, sql: str, args=()) -> dict | None:
53
+ """db — a Connection or Cursor (both have .execute returning a cursor)."""
54
+ cur = db.execute(sql, args)
55
+ r = cur.fetchone()
56
+ return dict(r) if r is not None else None
57
+
58
+
59
+ def _insert(db, table: str, row: dict) -> None:
60
+ cols = ",".join(f'"{k}"' for k in row)
61
+ ph = ",".join("?" for _ in row)
62
+ db.execute(f'INSERT INTO "{table}" ({cols}) VALUES ({ph})', list(row.values()))
63
+
64
+
65
+ def _max_order(db, table: str, col: str) -> int:
66
+ cur = db.execute(f'SELECT COALESCE(MAX("{col}"),0) FROM "{table}"')
67
+ return cur.fetchone()[0] or 0
68
+
69
+
70
+ @dataclass
71
+ class TrackMeta:
72
+ location: str # "Fxx/NAME.ext" relative to iPod_Control/Music
73
+ title: str
74
+ artist: str
75
+ album: str
76
+ genre: str = ""
77
+ total_time_ms: int = 0
78
+ track_number: int = 0
79
+ year: int = 0
80
+ bit_rate: int = 0
81
+ sample_rate: int = 44100
82
+ audio_format: int = 301 # 301 = MP3 (from avformat_info)
83
+ file_size: int = 0
84
+ extension: int = 1297101600 # 0x4d503320 (4CC 'MP3 ')
85
+ kind: str = "MPEG audio file"
86
+
87
+
88
+ class ItlpLibrary:
89
+ """Opens the set of .itdb files in the iTunes Library.itlp directory."""
90
+
91
+ DBS = ("Library.itdb", "Locations.itdb", "Dynamic.itdb", "Extras.itdb")
92
+
93
+ def __init__(self, itlp_dir: str | os.PathLike):
94
+ self.dir = Path(itlp_dir)
95
+ self.cx: dict[str, sqlite3.Connection] = {}
96
+ for name in self.DBS:
97
+ c = sqlite3.connect(self.dir / name)
98
+ c.row_factory = sqlite3.Row
99
+ self.cx[name] = c
100
+
101
+ def close(self):
102
+ for c in self.cx.values():
103
+ c.close()
104
+
105
+ def _get_or_create_lookup(self, cur, table, name, name_order) -> int:
106
+ """Find a row by name, or clone a template one with a new pid/name."""
107
+ got = _row(cur, f'SELECT * FROM "{table}" WHERE name=?', (name,))
108
+ if got:
109
+ return got["pid"]
110
+ tmpl = _row_or_template(cur, f'SELECT * FROM "{table}" LIMIT 1', table)
111
+ pid = _rand_pid()
112
+ tmpl.update(pid=pid, name=name, sort_name=name, name_order=name_order)
113
+ _insert(cur, table, tmpl)
114
+ return pid
115
+
116
+ def add_track(self, m: TrackMeta, *, date: int) -> int:
117
+ """Add a track to all tables. Returns the pid of the new item."""
118
+ lib = self.cx["Library.itdb"].cursor()
119
+ loc = self.cx["Locations.itdb"].cursor()
120
+ dyn = self.cx["Dynamic.itdb"].cursor()
121
+ ext = self.cx["Extras.itdb"].cursor()
122
+
123
+ tmpl_item = _row_or_template(lib, "SELECT * FROM item LIMIT 1", "item")
124
+ if tmpl_item is None:
125
+ raise ValueError(
126
+ "no template track in item and no built-in template "
127
+ "(run scripts/gen_lib_templates.py)")
128
+ item_pid = _rand_pid()
129
+
130
+ # genre/artist/album/track_artist lookups
131
+ genre_id = None
132
+ if m.genre:
133
+ g = _row(lib, "SELECT id FROM genre_map WHERE genre=?", (m.genre,))
134
+ if g:
135
+ genre_id = g["id"]
136
+ else:
137
+ genre_id = _max_order(lib, "genre_map", "id") + 1
138
+ _insert(lib, "genre_map", {"id": genre_id, "genre": m.genre, "has_music": 1,
139
+ "artist_count_calc": 1, "album_count_calc": 1,
140
+ "album_artist_count_calc": 1})
141
+ nord = _max_order(lib, "artist", "name_order") + 100
142
+ artist_pid = self._get_or_create_lookup(lib, "artist", m.artist, nord)
143
+ aord = _max_order(lib, "album", "name_order") + 100
144
+ album_pid = self._get_or_create_lookup(lib, "album", m.album, aord)
145
+ # track_artist (small sequential pids)
146
+ ta = _row(lib, "SELECT pid FROM track_artist WHERE name=?", (m.artist,))
147
+ track_artist_pid = ta["pid"] if ta else (_max_order(lib, "track_artist", "pid") + 1)
148
+ if not ta:
149
+ tmpl_ta = _row_or_template(lib, "SELECT * FROM track_artist LIMIT 1", "track_artist")
150
+ tmpl_ta.update(pid=track_artist_pid, name=m.artist, sort_name=m.artist, name_order=nord)
151
+ _insert(lib, "track_artist", tmpl_ta)
152
+
153
+ # item (clone template + patch)
154
+ end = _max_order(lib, "item", "title_order") + 100
155
+ tmpl_item.update(
156
+ pid=item_pid, title=m.title, artist=m.artist, album=m.album,
157
+ sort_title=m.title, sort_artist=m.artist, sort_album=m.album,
158
+ year=m.year, total_time_ms=m.total_time_ms, track_number=m.track_number,
159
+ genre_id=genre_id, album_pid=album_pid, artist_pid=artist_pid,
160
+ track_artist_pid=track_artist_pid, date_modified=date,
161
+ title_order=end, album_order=end, artist_order=nord,
162
+ album_artist_order=nord, album_by_artist_order=nord,
163
+ )
164
+ _insert(lib, "item", tmpl_item)
165
+
166
+ # avformat_info
167
+ _insert(lib, "avformat_info", {"item_pid": item_pid, "audio_format": m.audio_format,
168
+ "bit_rate": m.bit_rate, "sample_rate": m.sample_rate})
169
+ # into the master playlist
170
+ master = _row(lib, "SELECT primary_container_pid AS p FROM db_info")["p"]
171
+ phys = _max_order(lib, "item_to_container", "physical_order") + 1
172
+ _insert(lib, "item_to_container",
173
+ {"item_pid": item_pid, "container_pid": master, "physical_order": phys})
174
+ # track_size_calc.audio += file_size
175
+ lib.execute("UPDATE track_size_calc SET size=COALESCE(size,0)+? WHERE kind='audio'",
176
+ (m.file_size,))
177
+
178
+ # location
179
+ tmpl_loc = _row_or_template(loc, "SELECT * FROM location LIMIT 1", "location")
180
+ tmpl_loc.update(item_pid=item_pid, location=m.location, file_size=m.file_size,
181
+ extension=m.extension, date_created=date)
182
+ _insert(loc, "location", tmpl_loc)
183
+
184
+ # item_stats (Dynamic), chapter (Extras) — clone the template onto the new item_pid
185
+ for cur, table in ((dyn, "item_stats"), (ext, "chapter")):
186
+ t = _row(cur, f"SELECT * FROM {table} LIMIT 1")
187
+ if t:
188
+ t["item_pid"] = item_pid
189
+ _insert(cur, table, t)
190
+
191
+ for c in self.cx.values():
192
+ c.commit()
193
+ return item_pid
194
+
195
+ # --- READ -----------------------------------------------------------------
196
+ def list_tracks(self) -> list[dict]:
197
+ """All library tracks with path and metadata (for listing/export)."""
198
+ lib = self.cx["Library.itdb"]
199
+ loc = {r["item_pid"]: r for r in
200
+ self.cx["Locations.itdb"].execute(
201
+ "SELECT item_pid,location,file_size,extension FROM location")}
202
+ genres = {r["id"]: r["genre"] for r in lib.execute("SELECT id,genre FROM genre_map")}
203
+ out = []
204
+ for it in lib.execute(
205
+ "SELECT pid,title,artist,album,genre_id,year,track_number,total_time_ms "
206
+ "FROM item ORDER BY artist,album,track_number"):
207
+ lr = loc.get(it["pid"])
208
+ out.append({
209
+ "pid": it["pid"], "title": it["title"], "artist": it["artist"],
210
+ "album": it["album"], "genre": genres.get(it["genre_id"], ""),
211
+ "year": it["year"], "track_number": it["track_number"],
212
+ "duration_ms": int(it["total_time_ms"] or 0),
213
+ "location": lr["location"] if lr else None,
214
+ "file_size": lr["file_size"] if lr else 0,
215
+ "extension": lr["extension"] if lr else 0,
216
+ })
217
+ return out
218
+
219
+ # --- UPDATE ---------------------------------------------------------------
220
+ def update_track(self, pid: int, **fields) -> None:
221
+ """Update a track's tags. Allowed keys: title/artist/album/year/track_number."""
222
+ allowed = {"title", "artist", "album", "year", "track_number"}
223
+ upd = {k: v for k, v in fields.items() if k in allowed}
224
+ if not upd:
225
+ return
226
+ sets = ",".join(f'"{k}"=?' for k in upd)
227
+ self.cx["Library.itdb"].execute(
228
+ f"UPDATE item SET {sets} WHERE pid=?", [*upd.values(), pid])
229
+ self.cx["Library.itdb"].commit()
230
+
231
+ # --- DELETE ---------------------------------------------------------------
232
+ def remove_track(self, pid: int, *, music_dir: Path | None = None) -> str | None:
233
+ """Remove a track from all tables. If music_dir is given — also delete the file.
234
+
235
+ 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).
237
+ """
238
+ lib = self.cx["Library.itdb"]
239
+ r = self.cx["Locations.itdb"].execute(
240
+ "SELECT location,file_size FROM location WHERE item_pid=?", (pid,)).fetchone()
241
+ location = r["location"] if r else None
242
+ fsize = (r["file_size"] if r else 0) or 0
243
+
244
+ lib.execute("DELETE FROM item WHERE pid=?", (pid,))
245
+ lib.execute("DELETE FROM avformat_info WHERE item_pid=?", (pid,))
246
+ lib.execute("DELETE FROM item_to_container WHERE item_pid=?", (pid,))
247
+ lib.execute("UPDATE track_size_calc SET size=MAX(0,COALESCE(size,0)-?) WHERE kind='audio'",
248
+ (fsize,))
249
+ self.cx["Locations.itdb"].execute("DELETE FROM location WHERE item_pid=?", (pid,))
250
+ self.cx["Dynamic.itdb"].execute("DELETE FROM item_stats WHERE item_pid=?", (pid,))
251
+ self.cx["Extras.itdb"].execute("DELETE FROM chapter WHERE item_pid=?", (pid,))
252
+ for c in self.cx.values():
253
+ c.commit()
254
+
255
+ if music_dir is not None and location:
256
+ f = Path(music_dir) / location
257
+ if f.exists():
258
+ f.unlink()
259
+ return location
260
+
261
+ # --- PLAYLISTS -------------------------------------------------------------
262
+ # container (Library.itdb) = playlist; item_to_container = membership;
263
+ # container_ui (Dynamic.itdb) = UI settings. Locations.itdb is NOT touched,
264
+ # so resign() is NOT needed for playlist operations.
265
+
266
+ def master_pid(self) -> int:
267
+ return self.cx["Library.itdb"].execute(
268
+ "SELECT primary_container_pid AS p FROM db_info").fetchone()["p"]
269
+
270
+ def list_playlists(self) -> list[dict]:
271
+ lib = self.cx["Library.itdb"]
272
+ master = self.master_pid()
273
+ out = []
274
+ for c in lib.execute("SELECT pid,name,is_hidden FROM container ORDER BY name_order"):
275
+ cnt = lib.execute("SELECT COUNT(*) FROM item_to_container WHERE container_pid=?",
276
+ (c["pid"],)).fetchone()[0]
277
+ out.append({"pid": c["pid"], "name": c["name"], "count": cnt,
278
+ "is_master": c["pid"] == master, "hidden": bool(c["is_hidden"])})
279
+ return out
280
+
281
+ def create_playlist(self, name: str, *, date: int) -> int:
282
+ """Create a user playlist. Returns the pid."""
283
+ lib = self.cx["Library.itdb"]
284
+ dyn = self.cx["Dynamic.itdb"]
285
+ master = self.master_pid()
286
+ tmpl = (_row(lib, "SELECT * FROM container WHERE pid<>? LIMIT 1", (master,))
287
+ or _row(lib, "SELECT * FROM container LIMIT 1"))
288
+ pid = _rand_pid()
289
+ tmpl.update(pid=pid, name=name, name_order=_max_order(lib, "container", "name_order") + 1,
290
+ distinguished_kind=0, parent_pid=0, is_hidden=0,
291
+ date_created=date, date_modified=date)
292
+ _insert(lib, "container", tmpl)
293
+ tui = _row(dyn, "SELECT * FROM container_ui LIMIT 1")
294
+ if tui:
295
+ tui["container_pid"] = pid
296
+ _insert(dyn, "container_ui", tui)
297
+ for c in self.cx.values():
298
+ c.commit()
299
+ return pid
300
+
301
+ def add_to_playlist(self, container_pid: int, item_pid: int) -> None:
302
+ lib = self.cx["Library.itdb"]
303
+ nxt = lib.execute(
304
+ "SELECT COALESCE(MAX(physical_order),-1)+1 FROM item_to_container WHERE container_pid=?",
305
+ (container_pid,)).fetchone()[0]
306
+ _insert(lib, "item_to_container",
307
+ {"item_pid": item_pid, "container_pid": container_pid, "physical_order": nxt})
308
+ lib.commit()
309
+
310
+ def remove_from_playlist(self, container_pid: int, item_pid: int) -> None:
311
+ self.cx["Library.itdb"].execute(
312
+ "DELETE FROM item_to_container WHERE container_pid=? AND item_pid=?",
313
+ (container_pid, item_pid))
314
+ self.cx["Library.itdb"].commit()
315
+
316
+ def delete_playlist(self, pid: int) -> None:
317
+ if pid == self.master_pid():
318
+ raise ValueError("cannot delete the master playlist")
319
+ self.cx["Library.itdb"].execute("DELETE FROM container WHERE pid=?", (pid,))
320
+ self.cx["Library.itdb"].execute("DELETE FROM item_to_container WHERE container_pid=?", (pid,))
321
+ self.cx["Dynamic.itdb"].execute("DELETE FROM container_ui WHERE container_pid=?", (pid,))
322
+ for c in self.cx.values():
323
+ c.commit()
324
+
325
+ # --- COVERS (artwork fields; the ArtworkDB/.ithmb files are rendered by libgpod) ---
326
+ def clear_artwork(self) -> None:
327
+ """Reset artwork statuses (before a full cover rebuild)."""
328
+ self.cx["Library.itdb"].execute("UPDATE item SET artwork_status=0, artwork_cache_id=0")
329
+ self.cx["Library.itdb"].execute("UPDATE album SET artwork_status=0, artwork_item_pid=NULL")
330
+ self.cx["Library.itdb"].commit()
331
+
332
+ def set_track_artwork(self, pid: int, cache_id: int, status: int = 1) -> None:
333
+ self.cx["Library.itdb"].execute(
334
+ "UPDATE item SET artwork_status=?, artwork_cache_id=? WHERE pid=?",
335
+ (status, cache_id, pid))
336
+ self.cx["Library.itdb"].commit()
337
+
338
+ def set_album_artwork(self, album_pid: int, item_pid: int, status: int = 0) -> None:
339
+ """Attach the album cover to a representative track (as iTunes does).
340
+
341
+ iTunes keeps album.artwork_status=0 but fills in artwork_item_pid — via
342
+ it, the Albums view/lists pull the cover through item.artwork_cache_id.
343
+ """
344
+ self.cx["Library.itdb"].execute(
345
+ "UPDATE album SET artwork_status=?, artwork_item_pid=? WHERE pid=?",
346
+ (status, item_pid, album_pid))
347
+ self.cx["Library.itdb"].commit()
348
+
349
+ def album_pid_of(self, item_pid: int) -> int | None:
350
+ r = self.cx["Library.itdb"].execute(
351
+ "SELECT album_pid FROM item WHERE pid=?", (item_pid,)).fetchone()
352
+ return r["album_pid"] if r else None
353
+
354
+ # --- signing --------------------------------------------------------------
355
+ def resign(self, firewire_guid: bytes, hasher: HashAB | None = None) -> None:
356
+ """Regenerate Locations.itdb.cbk after edits."""
357
+ self.cx["Locations.itdb"].commit()
358
+ loc_bytes = (self.dir / "Locations.itdb").read_bytes()
359
+ cbk = build_cbk(loc_bytes, firewire_guid, hasher=hasher)
360
+ (self.dir / "Locations.itdb.cbk").write_bytes(cbk)
ipodsync/models.py ADDED
@@ -0,0 +1,31 @@
1
+ """Data models for uploading."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class Track:
10
+ """A single track to upload.
11
+
12
+ path — source audio file (mp3/aac/alac/flac; flac will be transcoded).
13
+ After copying to the device, the uploader fills in ipod_path/dbid.
14
+ """
15
+ path: Path
16
+ title: str = ""
17
+ artist: str = ""
18
+ album: str = ""
19
+ genre: str = ""
20
+ track_number: int = 0
21
+ total_tracks: int = 0
22
+ duration_ms: int = 0
23
+ filesize: int = 0
24
+ # filled in during upload:
25
+ ipod_path: str = "" # path like ":iPod_Control:Music:F00:XXXX.m4a"
26
+ dbid: int = 0 # 64-bit unique track id in the database
27
+
28
+ def __post_init__(self):
29
+ self.path = Path(self.path)
30
+ if not self.title:
31
+ self.title = self.path.stem