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
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
|