ipodsync 0.1.6__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.6 → ipodsync-0.1.7}/PKG-INFO +1 -1
- {ipodsync-0.1.6 → ipodsync-0.1.7}/pyproject.toml +1 -1
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/library.py +31 -1
- ipodsync-0.1.7/tests/test_gc_orphans.py +76 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/.gitignore +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/LICENSE +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/README.md +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/artwork_writer.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/cli.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/sysinfo.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/src/ipodsync/transport.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/test_artwork_writer.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.6 → ipodsync-0.1.7}/tools/gen_lib_templates.py +0 -0
- {ipodsync-0.1.6 → 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
|
|
@@ -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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|