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.
- {ipodsync-0.1.5 → ipodsync-0.1.7}/PKG-INFO +21 -32
- {ipodsync-0.1.5 → ipodsync-0.1.7}/README.md +20 -31
- {ipodsync-0.1.5 → ipodsync-0.1.7}/pyproject.toml +1 -1
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/cli.py +94 -37
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/library.py +31 -1
- ipodsync-0.1.7/tests/test_gc_orphans.py +76 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/.gitignore +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/LICENSE +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/artwork_writer.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/sysinfo.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/src/ipodsync/transport.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_artwork_writer.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.5 → ipodsync-0.1.7}/tools/gen_lib_templates.py +0 -0
- {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.
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
+
The easy way is **mount once, work, unmount once**:
|
|
118
120
|
|
|
119
121
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
+
The easy way is **mount once, work, unmount once**:
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
269
|
-
"`
|
|
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:
|
|
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=
|
|
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
|
|
290
|
-
pa.add_argument("file", help="path to the
|
|
291
|
-
pa.add_argument("--
|
|
292
|
-
pa.add_argument("--
|
|
293
|
-
pa.add_argument("--
|
|
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
|
-
|
|
321
|
-
|
|
360
|
+
if args.unmount:
|
|
361
|
+
return _do_unmount()
|
|
322
362
|
if args.mount:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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().
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|