ipodsync 0.1.5__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {ipodsync-0.1.5 → ipodsync-0.1.7}/PKG-INFO +21 -32
  2. {ipodsync-0.1.5 → ipodsync-0.1.7}/README.md +20 -31
  3. {ipodsync-0.1.5 → ipodsync-0.1.7}/pyproject.toml +1 -1
  4. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/__init__.py +1 -1
  5. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/cli.py +94 -37
  6. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/library.py +31 -1
  7. ipodsync-0.1.7/tests/test_gc_orphans.py +76 -0
  8. {ipodsync-0.1.5 → ipodsync-0.1.7}/.github/workflows/ci.yml +0 -0
  9. {ipodsync-0.1.5 → ipodsync-0.1.7}/.github/workflows/publish.yml +0 -0
  10. {ipodsync-0.1.5 → ipodsync-0.1.7}/.gitignore +0 -0
  11. {ipodsync-0.1.5 → ipodsync-0.1.7}/LICENSE +0 -0
  12. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/__main__.py +0 -0
  13. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_art_templates.py +0 -0
  14. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen.py +0 -0
  15. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen2.py +0 -0
  16. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen_rt.py +0 -0
  17. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_py.py +0 -0
  18. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.bin +0 -0
  19. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.py +0 -0
  20. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_lib_templates.py +0 -0
  21. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/artwork_writer.py +0 -0
  22. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/cbk.py +0 -0
  23. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/export.py +0 -0
  24. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/hashab.py +0 -0
  25. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/importer.py +0 -0
  26. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/models.py +0 -0
  27. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/sysinfo.py +0 -0
  28. {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/transport.py +0 -0
  29. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/ArtworkDB +0 -0
  30. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/Locations.itdb +0 -0
  31. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/Locations.itdb.cbk +0 -0
  32. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/hashab-traces.json +0 -0
  33. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/hashab-vectors.json +0 -0
  34. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_artwork_writer.py +0 -0
  35. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_cbk.py +0 -0
  36. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_hashab.py +0 -0
  37. {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_hashab_port.py +0 -0
  38. {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/build_gen2.py +0 -0
  39. {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_art_templates.py +0 -0
  40. {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_hashab_tables.py +0 -0
  41. {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_lib_templates.py +0 -0
  42. {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/transpile_hashab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipodsync
3
- Version: 0.1.5
3
+ Version: 0.1.7
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
@@ -94,6 +94,8 @@ Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
94
94
  ipodsync status # ready / no access / not connected
95
95
  ipodsync list
96
96
  ipodsync add "Song.mp3" # + cover auto
97
+ ipodsync add a.mp3 b.mp3 c.mp3 # several at once (one library write)
98
+ ipodsync add -f ~/Music/album # a whole folder (recursively)
97
99
  ipodsync add "Song.mp3" --no-cover
98
100
  ipodsync export ~/Music/ipod --by-album
99
101
  ipodsync cover 123456789 --image cover.jpg
@@ -110,49 +112,36 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
110
112
 
111
113
  ## Linux: mounting the iPod
112
114
 
113
- On macOS, Finder mounts the iPod automatically. A headless Linux box usually
114
- doesn't the device shows up as a disk but stays unmounted, so `ipodsync` reports
115
- "iPod not found". Mount it yourself, then point `IPODSYNC_MOUNT` at it.
115
+ On macOS, Finder mounts the iPod automatically you never need `--mount`, just run
116
+ `ipodsync add song.mp3`. A headless Linux box usually doesn't mount it: the device
117
+ shows up as a disk but stays unmounted, so `ipodsync` reports "iPod not found".
116
118
 
117
- Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
119
+ The easy way is **mount once, work, unmount once**:
118
120
 
119
121
  ```bash
120
- lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
121
- ```
122
-
123
- For read commands (`list` / `export`), the quickest way is `--mount`: it detects the
124
- device, mounts it read-only (asks for `sudo`), runs the command, then unmounts:
125
-
126
- ```bash
127
- ipodsync --mount list
128
- ```
129
-
130
- Or mount it yourself:
131
-
132
- ```bash
133
- sudo modprobe hfsplus
134
- sudo mkdir -p /mnt/ipod
135
- sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
136
- IPODSYNC_MOUNT=/mnt/ipod ipodsync list
137
- sudo umount /mnt/ipod # before unplugging
122
+ ipodsync --mount # detect + mount the iPod (asks for sudo); stays mounted
123
+ ipodsync add song-1.mp3 # add as many tracks as you like…
124
+ ipodsync add song-2.mp3
125
+ ipodsync add -f ~/Music/album # …or a whole folder at once
126
+ ipodsync list
127
+ ipodsync --unmount # unmount when done — safe to unplug
138
128
  ```
139
129
 
140
- **Writing (`add` / `rm` / `cover`) on Linux.** `--mount` handles it in one command —
141
- it detects the device, mounts HFS+ read-write owned by your user (asks for `sudo`),
142
- runs the command, then unmounts:
130
+ `--mount` leaves the iPod at `/mnt/ipodsync`, which the following commands find on
131
+ their own. Pass `-b` to any write command to snapshot the library first
132
+ (`ipodsync -b add song.mp3`).
143
133
 
144
- ```bash
145
- ipodsync --mount add song.mp3
146
- ipodsync --mount -b add song.mp3 # -b: snapshot the library to ~/ipod-backups first
147
- ```
148
-
149
- Or mount it yourself and point `IPODSYNC_MOUNT` at it:
134
+ Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
150
135
 
151
136
  ```bash
152
137
  sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
153
138
  IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
139
+ sudo umount /mnt/ipod # before unplugging
154
140
  ```
155
141
 
142
+ Find the iPod's partition with `lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT` (an `hfsplus`
143
+ one, roughly the iPod's size — e.g. `sda2 14.7G hfsplus`).
144
+
156
145
  Two catches:
157
146
 
158
147
  - **The GUID.** Signing needs the device's FireWireGUID, which lives in a
@@ -62,6 +62,8 @@ Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
62
62
  ipodsync status # ready / no access / not connected
63
63
  ipodsync list
64
64
  ipodsync add "Song.mp3" # + cover auto
65
+ ipodsync add a.mp3 b.mp3 c.mp3 # several at once (one library write)
66
+ ipodsync add -f ~/Music/album # a whole folder (recursively)
65
67
  ipodsync add "Song.mp3" --no-cover
66
68
  ipodsync export ~/Music/ipod --by-album
67
69
  ipodsync cover 123456789 --image cover.jpg
@@ -78,49 +80,36 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
78
80
 
79
81
  ## Linux: mounting the iPod
80
82
 
81
- On macOS, Finder mounts the iPod automatically. A headless Linux box usually
82
- doesn't the device shows up as a disk but stays unmounted, so `ipodsync` reports
83
- "iPod not found". Mount it yourself, then point `IPODSYNC_MOUNT` at it.
83
+ On macOS, Finder mounts the iPod automatically you never need `--mount`, just run
84
+ `ipodsync add song.mp3`. A headless Linux box usually doesn't mount it: the device
85
+ shows up as a disk but stays unmounted, so `ipodsync` reports "iPod not found".
84
86
 
85
- Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
87
+ The easy way is **mount once, work, unmount once**:
86
88
 
87
89
  ```bash
88
- lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
89
- ```
90
-
91
- For read commands (`list` / `export`), the quickest way is `--mount`: it detects the
92
- device, mounts it read-only (asks for `sudo`), runs the command, then unmounts:
93
-
94
- ```bash
95
- ipodsync --mount list
96
- ```
97
-
98
- Or mount it yourself:
99
-
100
- ```bash
101
- sudo modprobe hfsplus
102
- sudo mkdir -p /mnt/ipod
103
- sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
104
- IPODSYNC_MOUNT=/mnt/ipod ipodsync list
105
- sudo umount /mnt/ipod # before unplugging
90
+ ipodsync --mount # detect + mount the iPod (asks for sudo); stays mounted
91
+ ipodsync add song-1.mp3 # add as many tracks as you like…
92
+ ipodsync add song-2.mp3
93
+ ipodsync add -f ~/Music/album # …or a whole folder at once
94
+ ipodsync list
95
+ ipodsync --unmount # unmount when done — safe to unplug
106
96
  ```
107
97
 
108
- **Writing (`add` / `rm` / `cover`) on Linux.** `--mount` handles it in one command —
109
- it detects the device, mounts HFS+ read-write owned by your user (asks for `sudo`),
110
- runs the command, then unmounts:
98
+ `--mount` leaves the iPod at `/mnt/ipodsync`, which the following commands find on
99
+ their own. Pass `-b` to any write command to snapshot the library first
100
+ (`ipodsync -b add song.mp3`).
111
101
 
112
- ```bash
113
- ipodsync --mount add song.mp3
114
- ipodsync --mount -b add song.mp3 # -b: snapshot the library to ~/ipod-backups first
115
- ```
116
-
117
- Or mount it yourself and point `IPODSYNC_MOUNT` at it:
102
+ Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
118
103
 
119
104
  ```bash
120
105
  sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
121
106
  IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
107
+ sudo umount /mnt/ipod # before unplugging
122
108
  ```
123
109
 
110
+ Find the iPod's partition with `lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT` (an `hfsplus`
111
+ one, roughly the iPod's size — e.g. `sda2 14.7G hfsplus`).
112
+
124
113
  Two catches:
125
114
 
126
115
  - **The GUID.** Signing needs the device's FireWireGUID, which lives in a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ipodsync"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Upload music to an iPod nano 6G/7G without iTunes: SQLite library, hashAB signing, cover art (pure-Python)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,7 +13,7 @@ from .library import ItlpLibrary
13
13
  from .models import Track
14
14
  from .transport import IPod, IPodNotFound, find_ipod
15
15
 
16
- __version__ = "0.1.5"
16
+ __version__ = "0.1.7"
17
17
 
18
18
  __all__ = [
19
19
  "find_ipod",
@@ -169,30 +169,66 @@ def cmd_wait(args):
169
169
  print(f"✅ iPod ready: {ipod.root}")
170
170
 
171
171
 
172
+ AUDIO_EXTS = {".mp3", ".m4a", ".aac", ".alac", ".mp4"}
173
+
174
+
175
+ def _collect_add_files(args) -> list[Path]:
176
+ """Resolve the files to add from positional paths and/or --folder."""
177
+ files: list[Path] = [Path(f) for f in (args.file or [])]
178
+ if args.folder:
179
+ root = Path(args.folder)
180
+ if not root.is_dir():
181
+ raise SystemExit(f"⚠️ --folder: {root} is not a directory")
182
+ files += sorted(p for p in root.rglob("*")
183
+ if p.is_file() and p.suffix.lower() in AUDIO_EXTS)
184
+ # de-dup, keep order
185
+ seen, out = set(), []
186
+ for p in files:
187
+ rp = p.resolve()
188
+ if rp not in seen:
189
+ seen.add(rp)
190
+ out.append(p)
191
+ return out
192
+
193
+
172
194
  def cmd_add(ipod, args):
173
- """Upload an MP3 to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
195
+ """Upload track(s) to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
196
+ files = _collect_add_files(args)
197
+ if not files:
198
+ raise SystemExit("⚠️ add: give a file path, or -f/--folder DIR")
199
+ if len(files) > 1 and (args.title or args.artist or args.album):
200
+ raise SystemExit("⚠️ --title/--artist/--album only apply to a single file")
201
+
174
202
  itlp = ipod.itunes_dir / "iTunes Library.itlp"
175
203
  stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
176
204
  backup = _backup_library(itlp, stamp) if args.backup else None
205
+ art_dir = None if args.no_cover else ipod.control / "Artwork"
206
+ if art_dir is not None and args.backup:
207
+ _backup_artwork(ipod, stamp)
177
208
 
178
209
  guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
179
- print("→ copying the track onto the iPod…")
180
- location, abs_path = copy_audio_to_ipod(ipod, args.file)
210
+ overrides = {"title": args.title, "artist": args.artist, "album": args.album}
181
211
 
212
+ # Edit one working copy of the library, add every file, then write it back once.
182
213
  work = Path("/tmp") / f"itlp_add_{stamp}"
183
214
  shutil.copytree(itlp, work)
184
- overrides = {"title": args.title, "artist": args.artist, "album": args.album}
185
- art_dir = None if args.no_cover else ipod.control / "Artwork"
186
- if art_dir is not None and args.backup:
187
- _backup_artwork(ipod, stamp)
188
- pid = add_mp3_to_library(work, location, args.file, abs_path.stat().st_size,
189
- guid, overrides=overrides, artwork_dir=art_dir)
215
+ n = len(files)
216
+ added = 0
217
+ for i, src in enumerate(files, 1):
218
+ prefix = f"[{i}/{n}] " if n > 1 else ""
219
+ print(f"→ {prefix}copying \"{src.name}\" onto the iPod…")
220
+ location, abs_path = copy_audio_to_ipod(ipod, str(src))
221
+ pid = add_mp3_to_library(work, location, str(src), abs_path.stat().st_size,
222
+ guid, overrides=overrides if n == 1 else {},
223
+ artwork_dir=art_dir)
224
+ cover = "" if args.no_cover else " (+ cover art)"
225
+ print(f" ✓ {prefix}added \"{src.name}\"{cover} · pid {pid}")
226
+ added += 1
227
+
190
228
  for name in ("Library.itdb", "Locations.itdb", "Dynamic.itdb",
191
229
  "Extras.itdb", "Locations.itdb.cbk"):
192
230
  shutil.copy2(work / name, itlp / name)
193
- cover = "" if args.no_cover else " (+ cover art)"
194
- print(f"✓ Added \"{Path(args.file).name}\"{cover} · pid {pid}")
195
- print(" Eject the iPod, then check Songs.")
231
+ print(f"✓ Added {added} track{'s' if added != 1 else ''}. Eject the iPod, then check Songs.")
196
232
  if backup:
197
233
  print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
198
234
 
@@ -265,14 +301,17 @@ def main() -> int:
265
301
  "The iPod mounts as a plain volume; ipodsync edits its SQLite library, "
266
302
  "signs it (hashAB) and writes cover art.",
267
303
  epilog="Set IPODSYNC_MOUNT=/path to point at the iPod explicitly. "
268
- "add/rm/cover back up the library before editing. Run "
269
- "`ipodsync <command> -h` for per-command help.")
304
+ "On Linux, `ipodsync --mount` mounts the iPod once (sudo) and leaves it "
305
+ "mounted, so `add`/`list`/... just work; `ipodsync --unmount` when done. "
306
+ "Run `ipodsync <command> -h` for per-command help.")
270
307
  ap.add_argument("--mount", action="store_true",
271
- help="Linux: auto-mount the iPod (sudo) before the command, unmount after")
308
+ help="Linux: mount the iPod (sudo) and leave it mounted, then exit")
309
+ ap.add_argument("--unmount", "--umount", action="store_true", dest="unmount",
310
+ help="Linux: unmount the iPod mounted by --mount, then exit")
272
311
  ap.add_argument("-b", "--backup", action="store_true",
273
312
  help="back up the library to ~/ipod-backups before editing "
274
313
  "(off by default)")
275
- sub = ap.add_subparsers(dest="cmd", required=True, metavar="<command>")
314
+ sub = ap.add_subparsers(dest="cmd", required=False, metavar="<command>")
276
315
 
277
316
  sub.add_parser("list", help="show tracks on the iPod")
278
317
 
@@ -286,11 +325,12 @@ def main() -> int:
286
325
  pr.add_argument("pid", type=int, help="track pid (see `list`)")
287
326
  pr.add_argument("--delete-file", action="store_true", help="also delete the audio file")
288
327
 
289
- pa = sub.add_parser("add", help="upload an MP3 (cover attached automatically)")
290
- pa.add_argument("file", help="path to the MP3 to upload")
291
- pa.add_argument("--title", help="override the title tag")
292
- pa.add_argument("--artist", help="override the artist tag")
293
- pa.add_argument("--album", help="override the album tag")
328
+ pa = sub.add_parser("add", help="upload a track (or a whole folder); cover attached automatically")
329
+ pa.add_argument("file", nargs="*", help="path(s) to the audio file(s) to upload")
330
+ pa.add_argument("-f", "--folder", help="add every audio file in this folder (recursively)")
331
+ pa.add_argument("--title", help="override the title tag (single file only)")
332
+ pa.add_argument("--artist", help="override the artist tag (single file only)")
333
+ pa.add_argument("--album", help="override the album tag (single file only)")
294
334
  pa.add_argument("--no-cover", action="store_true", help="don't attach the embedded cover")
295
335
 
296
336
  sub.add_parser("status", help="report ready / no-access / not-connected")
@@ -317,26 +357,43 @@ def main() -> int:
317
357
 
318
358
  args = ap.parse_args()
319
359
 
320
- WRITE_CMDS = {"add", "rm", "cover", "pl-create", "pl-add", "pl-rm", "pl-del"}
321
- mounted = None
360
+ if args.unmount:
361
+ return _do_unmount()
322
362
  if args.mount:
323
- from ipodsync.transport import mount_ipod
324
- try:
325
- mp, mounted_by_us = mount_ipod(rw=args.cmd in WRITE_CMDS)
326
- except IPodNotFound as e:
327
- print(f"⚠️ {e}")
328
- return 2
329
- os.environ["IPODSYNC_MOUNT"] = mp
363
+ rc = _do_mount()
364
+ if rc != 0 or args.cmd is None:
365
+ return rc # standalone: mounted, now exit
366
+ if args.cmd is None:
367
+ ap.print_help()
368
+ return 1
369
+ return _dispatch(args)
370
+
371
+
372
+ def _do_mount() -> int:
373
+ """Mount the iPod and leave it mounted for subsequent commands."""
374
+ from ipodsync.transport import mount_ipod
375
+ try:
376
+ mp, mounted_by_us = mount_ipod(rw=True)
377
+ except IPodNotFound as e:
378
+ print(f"⚠️ {e}")
379
+ return 2
380
+ if mounted_by_us:
330
381
  print(f"✓ iPod mounted at {mp}")
331
- mounted = mp if mounted_by_us else None
382
+ else:
383
+ print(f"✓ iPod already mounted at {mp}")
384
+ print(" Now run e.g. `ipodsync add song.mp3`; `ipodsync --unmount` when done.")
385
+ return 0
386
+
332
387
 
388
+ def _do_unmount() -> int:
389
+ from ipodsync.transport import find_ipod, umount_ipod, MOUNTPOINT
333
390
  try:
334
- return _dispatch(args)
335
- finally:
336
- if mounted:
337
- from ipodsync.transport import umount_ipod
338
- umount_ipod(mounted)
339
- print(f"✓ iPod unmounted — safe to unplug")
391
+ mp = str(find_ipod().root)
392
+ except IPodNotFound:
393
+ mp = MOUNTPOINT
394
+ umount_ipod(mp)
395
+ print(f"✓ iPod unmounted ({mp}) — safe to unplug.")
396
+ return 0
340
397
 
341
398
 
342
399
  def _dispatch(args) -> int:
@@ -229,11 +229,40 @@ class ItlpLibrary:
229
229
  self.cx["Library.itdb"].commit()
230
230
 
231
231
  # --- DELETE ---------------------------------------------------------------
232
+ # Lookup tables that are reference-counted by `item`: (table, key col, item col).
233
+ _LOOKUP_TABLES = (
234
+ ("album", "pid", "album_pid"),
235
+ ("artist", "pid", "artist_pid"),
236
+ ("composer", "pid", "composer_pid"),
237
+ ("track_artist", "pid", "track_artist_pid"),
238
+ ("genre_map", "id", "genre_id"),
239
+ )
240
+
241
+ def gc_orphan_lookups(self) -> int:
242
+ """Delete album/artist/composer/track_artist/genre rows no longer referenced
243
+ by any item. Otherwise removed tracks leave behind empty albums in the UI.
244
+
245
+ A full sweep, so it also cleans up orphans left by earlier versions. Returns
246
+ the number of rows removed.
247
+ """
248
+ lib = self.cx["Library.itdb"]
249
+ removed = 0
250
+ for table, key, ref in self._LOOKUP_TABLES:
251
+ try:
252
+ cur = lib.execute(
253
+ f'DELETE FROM "{table}" WHERE "{key}" NOT IN '
254
+ f'(SELECT "{ref}" FROM item WHERE "{ref}" IS NOT NULL)')
255
+ removed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
256
+ except sqlite3.OperationalError:
257
+ continue # table/column absent on this device — skip it
258
+ lib.commit()
259
+ return removed
260
+
232
261
  def remove_track(self, pid: int, *, music_dir: Path | None = None) -> str | None:
233
262
  """Remove a track from all tables. If music_dir is given — also delete the file.
234
263
 
235
264
  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).
265
+ forget resign(). Orphaned album/artist/genre rows are swept by gc_orphan_lookups().
237
266
  """
238
267
  lib = self.cx["Library.itdb"]
239
268
  r = self.cx["Locations.itdb"].execute(
@@ -249,6 +278,7 @@ class ItlpLibrary:
249
278
  self.cx["Locations.itdb"].execute("DELETE FROM location WHERE item_pid=?", (pid,))
250
279
  self.cx["Dynamic.itdb"].execute("DELETE FROM item_stats WHERE item_pid=?", (pid,))
251
280
  self.cx["Extras.itdb"].execute("DELETE FROM chapter WHERE item_pid=?", (pid,))
281
+ self.gc_orphan_lookups()
252
282
  for c in self.cx.values():
253
283
  c.commit()
254
284
 
@@ -0,0 +1,76 @@
1
+ """gc_orphan_lookups() must delete lookup rows (album/artist/track_artist/genre)
2
+ no longer referenced by any item, so removing tracks doesn't leave empty albums."""
3
+ import sqlite3
4
+
5
+ from ipodsync.library import ItlpLibrary
6
+
7
+
8
+ def _make_itlp(tmp_path):
9
+ """A minimal .itlp dir: a Library.itdb with item + lookup tables, plus the
10
+ other three (empty) dbs the constructor opens."""
11
+ d = tmp_path / "iTunes Library.itlp"
12
+ d.mkdir()
13
+ for name in ("Locations.itdb", "Dynamic.itdb", "Extras.itdb"):
14
+ sqlite3.connect(d / name).close() # just needs to exist & be valid
15
+ lib = sqlite3.connect(d / "Library.itdb")
16
+ lib.executescript(
17
+ """
18
+ CREATE TABLE item (pid INTEGER PRIMARY KEY, album_pid INTEGER,
19
+ artist_pid INTEGER, track_artist_pid INTEGER, genre_id INTEGER);
20
+ CREATE TABLE album (pid INTEGER PRIMARY KEY, name TEXT);
21
+ CREATE TABLE artist (pid INTEGER PRIMARY KEY, name TEXT);
22
+ CREATE TABLE track_artist (pid INTEGER PRIMARY KEY, name TEXT);
23
+ CREATE TABLE genre_map (id INTEGER PRIMARY KEY, genre TEXT);
24
+
25
+ INSERT INTO album VALUES (10, 'Kept'), (11, 'Empty');
26
+ INSERT INTO artist VALUES (20, 'Kept'), (21, 'Empty');
27
+ INSERT INTO track_artist VALUES (30, 'Kept'), (31, 'Empty');
28
+ INSERT INTO genre_map VALUES (40, 'Rock'), (41, 'Ambient');
29
+
30
+ -- one item referencing only the *Kept* lookups; the *Empty* ones are orphans
31
+ INSERT INTO item VALUES (100, 10, 20, 30, 40);
32
+ """
33
+ )
34
+ lib.commit()
35
+ lib.close()
36
+ return d
37
+
38
+
39
+ def test_gc_removes_orphans_keeps_referenced(tmp_path):
40
+ d = _make_itlp(tmp_path)
41
+ lib = ItlpLibrary(d)
42
+ try:
43
+ removed = lib.gc_orphan_lookups()
44
+ assert removed == 4 # one orphan in each of the 4 tables
45
+
46
+ cx = lib.cx["Library.itdb"]
47
+ assert [r[0] for r in cx.execute("SELECT pid FROM album ORDER BY pid")] == [10]
48
+ assert [r[0] for r in cx.execute("SELECT pid FROM artist ORDER BY pid")] == [20]
49
+ assert [r[0] for r in cx.execute("SELECT pid FROM track_artist ORDER BY pid")] == [30]
50
+ assert [r[0] for r in cx.execute("SELECT id FROM genre_map ORDER BY id")] == [40]
51
+ finally:
52
+ lib.close()
53
+
54
+
55
+ def test_gc_wipes_all_lookups_when_no_items(tmp_path):
56
+ d = _make_itlp(tmp_path)
57
+ lib = ItlpLibrary(d)
58
+ try:
59
+ lib.cx["Library.itdb"].execute("DELETE FROM item")
60
+ lib.cx["Library.itdb"].commit()
61
+ lib.gc_orphan_lookups()
62
+ for table in ("album", "artist", "track_artist", "genre_map"):
63
+ n = lib.cx["Library.itdb"].execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
64
+ assert n == 0, f"{table} should be empty once no item references it"
65
+ finally:
66
+ lib.close()
67
+
68
+
69
+ def test_gc_tolerates_missing_table(tmp_path):
70
+ """composer table isn't present here — gc must skip it, not raise."""
71
+ d = _make_itlp(tmp_path)
72
+ lib = ItlpLibrary(d)
73
+ try:
74
+ lib.gc_orphan_lookups() # must not raise despite no `composer` table
75
+ finally:
76
+ lib.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes