ipodsync 0.1.2__tar.gz → 0.1.4__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.2 → ipodsync-0.1.4}/PKG-INFO +52 -1
- {ipodsync-0.1.2 → ipodsync-0.1.4}/README.md +51 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/pyproject.toml +1 -1
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/artwork_writer.py +19 -9
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/cli.py +15 -2
- ipodsync-0.1.4/src/ipodsync/sysinfo.py +118 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/transport.py +70 -4
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/test_artwork_writer.py +14 -0
- ipodsync-0.1.2/src/ipodsync/sysinfo.py +0 -50
- {ipodsync-0.1.2 → ipodsync-0.1.4}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/.gitignore +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/LICENSE +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/library.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/tools/gen_lib_templates.py +0 -0
- {ipodsync-0.1.2 → ipodsync-0.1.4}/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.4
|
|
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
|
|
@@ -104,6 +104,57 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
|
|
|
104
104
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
105
105
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
106
106
|
|
|
107
|
+
## Linux: mounting the iPod
|
|
108
|
+
|
|
109
|
+
On macOS, Finder mounts the iPod automatically. A headless Linux box usually
|
|
110
|
+
doesn't — the device shows up as a disk but stays unmounted, so `ipodsync` reports
|
|
111
|
+
"iPod not found". Mount it yourself, then point `IPODSYNC_MOUNT` at it.
|
|
112
|
+
|
|
113
|
+
Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For read commands (`list` / `export`), the quickest way is `--mount`, which mounts
|
|
120
|
+
the iPod for you via `udisksctl` (no root) and runs the command:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
ipodsync --mount list
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Or mount it yourself:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
sudo modprobe hfsplus
|
|
130
|
+
sudo mkdir -p /mnt/ipod
|
|
131
|
+
sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
|
|
132
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
133
|
+
sudo umount /mnt/ipod # before unplugging
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Writing (`add` / `rm` / `cover`) on Linux** needs a read-write mount owned by
|
|
137
|
+
your user (so it can write files), and the GUID (below). Mount it like this:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Two catches:
|
|
144
|
+
|
|
145
|
+
- **The GUID.** Signing needs the device's FireWireGUID, which lives in a
|
|
146
|
+
`Device/SysInfo*` file — but those are HFS+-compressed and unreadable by the Linux
|
|
147
|
+
driver. ipodsync works around it by reading the GUID from the device's **USB serial**
|
|
148
|
+
automatically. If that fails, pass it yourself:
|
|
149
|
+
```bash
|
|
150
|
+
udevadm info -q property -n /dev/sda | grep ID_SERIAL
|
|
151
|
+
# ID_SERIAL=Apple_iPod_0123456789ABCDEF-0:0
|
|
152
|
+
IPODSYNC_FIREWIRE_GUID=0123456789ABCDEF IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
153
|
+
```
|
|
154
|
+
- **Journaling.** If the mount comes up read-only, the volume is *journaled* HFS+;
|
|
155
|
+
disable the journal once, on a Mac: `diskutil disableJournal /Volumes/iPod`. Forcing
|
|
156
|
+
it with `-o force,rw` on a still-journaled volume can corrupt the filesystem — don't.
|
|
157
|
+
|
|
107
158
|
## How it works
|
|
108
159
|
|
|
109
160
|
- **Transport** — mass storage: files are written under `iPod_Control/`.
|
|
@@ -72,6 +72,57 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
|
|
|
72
72
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
73
73
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
74
74
|
|
|
75
|
+
## Linux: mounting the iPod
|
|
76
|
+
|
|
77
|
+
On macOS, Finder mounts the iPod automatically. A headless Linux box usually
|
|
78
|
+
doesn't — the device shows up as a disk but stays unmounted, so `ipodsync` reports
|
|
79
|
+
"iPod not found". Mount it yourself, then point `IPODSYNC_MOUNT` at it.
|
|
80
|
+
|
|
81
|
+
Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For read commands (`list` / `export`), the quickest way is `--mount`, which mounts
|
|
88
|
+
the iPod for you via `udisksctl` (no root) and runs the command:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ipodsync --mount list
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or mount it yourself:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
sudo modprobe hfsplus
|
|
98
|
+
sudo mkdir -p /mnt/ipod
|
|
99
|
+
sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
|
|
100
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
101
|
+
sudo umount /mnt/ipod # before unplugging
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Writing (`add` / `rm` / `cover`) on Linux** needs a read-write mount owned by
|
|
105
|
+
your user (so it can write files), and the GUID (below). Mount it like this:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Two catches:
|
|
112
|
+
|
|
113
|
+
- **The GUID.** Signing needs the device's FireWireGUID, which lives in a
|
|
114
|
+
`Device/SysInfo*` file — but those are HFS+-compressed and unreadable by the Linux
|
|
115
|
+
driver. ipodsync works around it by reading the GUID from the device's **USB serial**
|
|
116
|
+
automatically. If that fails, pass it yourself:
|
|
117
|
+
```bash
|
|
118
|
+
udevadm info -q property -n /dev/sda | grep ID_SERIAL
|
|
119
|
+
# ID_SERIAL=Apple_iPod_0123456789ABCDEF-0:0
|
|
120
|
+
IPODSYNC_FIREWIRE_GUID=0123456789ABCDEF IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
121
|
+
```
|
|
122
|
+
- **Journaling.** If the mount comes up read-only, the volume is *journaled* HFS+;
|
|
123
|
+
disable the journal once, on a Mac: `diskutil disableJournal /Volumes/iPod`. Forcing
|
|
124
|
+
it with `-o force,rw` on a still-journaled volume can corrupt the filesystem — don't.
|
|
125
|
+
|
|
75
126
|
## How it works
|
|
76
127
|
|
|
77
128
|
- **Transport** — mass storage: files are written under `iPod_Control/`.
|
|
@@ -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.4"
|
|
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"
|
|
@@ -58,20 +58,30 @@ FIRST_IMAGE_ID = 101
|
|
|
58
58
|
|
|
59
59
|
# --- encoding a cover to RGB565-LE ----------------------------------------------
|
|
60
60
|
def to_rgb565(image_bytes: bytes, width: int, height: int, slot: int) -> bytes:
|
|
61
|
-
"""Resize the cover to width×height, pack into RGB565-LE
|
|
61
|
+
"""Resize the cover to width×height, pack into RGB565-LE.
|
|
62
|
+
|
|
63
|
+
Each row is padded to the device's row stride (`slot // height`), matching
|
|
64
|
+
iTunes: e.g. F1016 is 57×57 pixels but stored with a 116-byte (58 px) stride —
|
|
65
|
+
2 zero bytes at the end of every row. Getting this wrong shears the thumbnail.
|
|
66
|
+
"""
|
|
62
67
|
from PIL import Image
|
|
63
68
|
|
|
64
69
|
im = Image.open(io.BytesIO(image_bytes)).convert("RGB").resize(
|
|
65
70
|
(width, height), Image.LANCZOS)
|
|
66
71
|
rgb = im.tobytes() # width*height*3
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
for
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
stride = slot // height # bytes per row the device reads
|
|
73
|
+
out = bytearray()
|
|
74
|
+
for y in range(height):
|
|
75
|
+
row = bytearray(stride) # zero-padded to the stride
|
|
76
|
+
base, j = y * width * 3, 0
|
|
77
|
+
for _ in range(width):
|
|
78
|
+
v = ((rgb[base] >> 3) << 11) | ((rgb[base + 1] >> 2) << 5) | (rgb[base + 2] >> 3)
|
|
79
|
+
row[j] = v & 0xFF
|
|
80
|
+
row[j + 1] = v >> 8
|
|
81
|
+
base += 3
|
|
82
|
+
j += 2
|
|
83
|
+
out += row
|
|
84
|
+
if len(out) < slot: # safety (slot == height*stride already)
|
|
75
85
|
out.extend(b"\x00" * (slot - len(out)))
|
|
76
86
|
return bytes(out)
|
|
77
87
|
|
|
@@ -14,6 +14,7 @@ or a sync will drop manually-added tracks.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
|
+
import os
|
|
17
18
|
import shutil
|
|
18
19
|
from datetime import datetime, timezone
|
|
19
20
|
from pathlib import Path
|
|
@@ -77,7 +78,7 @@ def cmd_rm(ipod, args):
|
|
|
77
78
|
shutil.copytree(itlp, backup)
|
|
78
79
|
print(f".itlp backup: {backup}")
|
|
79
80
|
|
|
80
|
-
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo)
|
|
81
|
+
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
|
|
81
82
|
lib = ItlpLibrary(itlp)
|
|
82
83
|
music = ipod.music_dir if args.delete_file else None
|
|
83
84
|
loc = lib.remove_track(args.pid, music_dir=music)
|
|
@@ -181,7 +182,7 @@ def cmd_add(ipod, args):
|
|
|
181
182
|
shutil.copytree(itlp, backup)
|
|
182
183
|
print(f".itlp backup: {backup}")
|
|
183
184
|
|
|
184
|
-
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo)
|
|
185
|
+
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
|
|
185
186
|
location, abs_path = copy_audio_to_ipod(ipod, args.file)
|
|
186
187
|
print(f"File copied: {location}")
|
|
187
188
|
|
|
@@ -264,6 +265,8 @@ def main() -> int:
|
|
|
264
265
|
epilog="Set IPODSYNC_MOUNT=/path to point at the iPod explicitly. "
|
|
265
266
|
"add/rm/cover back up the library before editing. Run "
|
|
266
267
|
"`ipodsync <command> -h` for per-command help.")
|
|
268
|
+
ap.add_argument("--mount", action="store_true",
|
|
269
|
+
help="Linux: mount the iPod first via udisksctl (read-only)")
|
|
267
270
|
sub = ap.add_subparsers(dest="cmd", required=True, metavar="<command>")
|
|
268
271
|
|
|
269
272
|
sub.add_parser("list", help="show tracks on the iPod")
|
|
@@ -309,6 +312,16 @@ def main() -> int:
|
|
|
309
312
|
|
|
310
313
|
args = ap.parse_args()
|
|
311
314
|
|
|
315
|
+
if args.mount:
|
|
316
|
+
from ipodsync.transport import mount_ipod
|
|
317
|
+
try:
|
|
318
|
+
mp = mount_ipod()
|
|
319
|
+
except IPodNotFound as e:
|
|
320
|
+
print(f"⚠️ {e}")
|
|
321
|
+
return 2
|
|
322
|
+
os.environ["IPODSYNC_MOUNT"] = mp
|
|
323
|
+
print(f"Mounted iPod at {mp}")
|
|
324
|
+
|
|
312
325
|
# commands that don't require a ready iPod
|
|
313
326
|
if args.cmd == "status":
|
|
314
327
|
return cmd_status(args)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Read the device's FirewireGuid — needed for the hashAB signature.
|
|
2
|
+
|
|
3
|
+
FirewireGuid is an 8-byte device identifier. It normally lives in
|
|
4
|
+
`iPod_Control/Device/SysInfoExtended` (XML plist) or `SysInfo` (text), as a hex
|
|
5
|
+
string like `0x000A2700XXXXXXXX` (16 hex chars).
|
|
6
|
+
|
|
7
|
+
On a Mac-formatted iPod mounted on Linux those files are often unreadable (HFS+
|
|
8
|
+
transparent compression / restrictive perms). As fallbacks we accept the GUID via
|
|
9
|
+
the `IPODSYNC_FIREWIRE_GUID` env var, and derive it from the device's USB serial
|
|
10
|
+
(e.g. udev `ID_SERIAL=Apple_iPod_0123456789ABCDEF-0:0`), which stays readable.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ENV_GUID = "IPODSYNC_FIREWIRE_GUID"
|
|
21
|
+
|
|
22
|
+
# 0x + 16 hex, or a FireWireGUID key near 16 hex chars
|
|
23
|
+
_GUID_RE = re.compile(
|
|
24
|
+
r"(?:FireWireGUID|FirewireGuid)\D{0,40}?(?:0x)?([0-9A-Fa-f]{16})",
|
|
25
|
+
re.IGNORECASE | re.DOTALL,
|
|
26
|
+
)
|
|
27
|
+
_BARE_HEX_RE = re.compile(r"0x([0-9A-Fa-f]{16})")
|
|
28
|
+
_SERIAL_RE = re.compile(r"ID_SERIAL=\S*?([0-9A-Fa-f]{16})")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FirewireGuidNotFound(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean_hex(s: str) -> str | None:
|
|
36
|
+
s = s.strip().lower()
|
|
37
|
+
if s.startswith("0x"):
|
|
38
|
+
s = s[2:]
|
|
39
|
+
return s if re.fullmatch(r"[0-9a-f]{16}", s) else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _extract(text: str) -> str | None:
|
|
43
|
+
m = _GUID_RE.search(text) or _BARE_HEX_RE.search(text)
|
|
44
|
+
return m.group(1) if m else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _from_env() -> bytes | None:
|
|
48
|
+
v = os.environ.get(ENV_GUID)
|
|
49
|
+
if not v:
|
|
50
|
+
return None
|
|
51
|
+
h = _clean_hex(v)
|
|
52
|
+
if not h:
|
|
53
|
+
raise FirewireGuidNotFound(
|
|
54
|
+
f"{ENV_GUID}={v!r} is not 16 hex digits (e.g. 0123456789ABCDEF)")
|
|
55
|
+
return bytes.fromhex(h)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _from_files(*paths: Path | None) -> bytes | None:
|
|
59
|
+
for p in paths:
|
|
60
|
+
if p is None:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
if not p.exists():
|
|
64
|
+
continue
|
|
65
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
66
|
+
except OSError:
|
|
67
|
+
continue # unreadable (e.g. HFS+ compression on Linux)
|
|
68
|
+
h = _extract(text)
|
|
69
|
+
if h:
|
|
70
|
+
return bytes.fromhex(h)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run(cmd: list[str]) -> str:
|
|
75
|
+
try:
|
|
76
|
+
return subprocess.run(cmd, capture_output=True, text=True,
|
|
77
|
+
timeout=5).stdout.strip()
|
|
78
|
+
except (OSError, subprocess.SubprocessError):
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _from_usb_serial(mount: Path | None) -> bytes | None:
|
|
83
|
+
"""Linux: derive the GUID from the iPod's USB serial (readable when the
|
|
84
|
+
HFS+ SysInfo* files are not)."""
|
|
85
|
+
if mount is None or not sys.platform.startswith("linux"):
|
|
86
|
+
return None
|
|
87
|
+
src = _run(["findmnt", "-no", "SOURCE", "--target", str(mount)])
|
|
88
|
+
devices = [src] if src else []
|
|
89
|
+
if src:
|
|
90
|
+
pk = _run(["lsblk", "-no", "pkname", src])
|
|
91
|
+
if pk:
|
|
92
|
+
devices.append(f"/dev/{pk}")
|
|
93
|
+
for dev in devices:
|
|
94
|
+
m = _SERIAL_RE.search(_run(["udevadm", "info", "-q", "property", "-n", dev]))
|
|
95
|
+
if m:
|
|
96
|
+
return bytes.fromhex(m.group(1))
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_firewire_guid(sysinfo_extended: Path, sysinfo: Path | None = None,
|
|
101
|
+
mount: Path | None = None) -> bytes:
|
|
102
|
+
"""Return the FirewireGuid as 8 bytes (big-endian, as in the hex string).
|
|
103
|
+
|
|
104
|
+
Resolution order: `IPODSYNC_FIREWIRE_GUID` env → SysInfoExtended/SysInfo files
|
|
105
|
+
→ the device's USB serial (Linux). `mount` is the iPod root (for the USB fallback).
|
|
106
|
+
"""
|
|
107
|
+
for guid in (_from_env(), _from_files(sysinfo_extended, sysinfo),
|
|
108
|
+
_from_usb_serial(mount)):
|
|
109
|
+
if guid:
|
|
110
|
+
return guid
|
|
111
|
+
raise FirewireGuidNotFound(
|
|
112
|
+
"Could not read the device FireWireGUID.\n"
|
|
113
|
+
f" Tried {sysinfo_extended} / {sysinfo}"
|
|
114
|
+
+ (" (unreadable — Mac-formatted HFS+ files often aren't readable on Linux)\n"
|
|
115
|
+
if not sys.platform == "darwin" else "\n")
|
|
116
|
+
+ f" Fix: pass it via {ENV_GUID}=<16 hex digits>. Find it with:\n"
|
|
117
|
+
+ " udevadm info -q property -n /dev/sdXN | grep ID_SERIAL (Linux)\n"
|
|
118
|
+
+ " e.g. ID_SERIAL=Apple_iPod_0123456789ABCDEF-0:0 -> 0123456789ABCDEF")
|
|
@@ -16,6 +16,8 @@ removable volume when the terminal lacks Full Disk Access (TCC). We tell that st
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
19
21
|
import sys
|
|
20
22
|
import time
|
|
21
23
|
from dataclasses import dataclass
|
|
@@ -140,10 +142,7 @@ def find_ipod(name_hint: str = "iPod") -> IPod:
|
|
|
140
142
|
return ipod
|
|
141
143
|
if status is Access.NO_ACCESS:
|
|
142
144
|
raise IPodNotFound(NO_ACCESS_HINT.format(vols=", ".join(v.name for v in blocked)))
|
|
143
|
-
raise IPodNotFound(
|
|
144
|
-
"iPod not found. Connect the device (enable Disk Use), or set "
|
|
145
|
-
"IPODSYNC_MOUNT=/path/to/mount."
|
|
146
|
-
)
|
|
145
|
+
raise IPodNotFound(_not_found_message())
|
|
147
146
|
|
|
148
147
|
|
|
149
148
|
def wait_for_ipod(timeout: float = 120, interval: float = 2,
|
|
@@ -163,3 +162,70 @@ def wait_for_ipod(timeout: float = 120, interval: float = 2,
|
|
|
163
162
|
raise IPodNotFound(f"iPod did not become available within {timeout}s "
|
|
164
163
|
f"(last state: {status.value})")
|
|
165
164
|
time.sleep(interval)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- Linux: detect / mount an unmounted iPod block device -----------------------
|
|
168
|
+
def _run(cmd: list[str]) -> tuple[str, str]:
|
|
169
|
+
try:
|
|
170
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=8)
|
|
171
|
+
return p.stdout, p.stderr
|
|
172
|
+
except (OSError, subprocess.SubprocessError):
|
|
173
|
+
return "", ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def find_ipod_block_device() -> dict | None:
|
|
177
|
+
"""Linux: locate the iPod's HFS+ block device.
|
|
178
|
+
|
|
179
|
+
Lists hfsplus partitions and confirms each is an Apple iPod via udev
|
|
180
|
+
(lsblk vendor/model aren't always populated on the partition).
|
|
181
|
+
Returns {'path': '/dev/sdXN', 'mountpoint': str|None} or None.
|
|
182
|
+
"""
|
|
183
|
+
if not sys.platform.startswith("linux"):
|
|
184
|
+
return None
|
|
185
|
+
out, _ = _run(["lsblk", "-rno", "PATH,FSTYPE,MOUNTPOINT"])
|
|
186
|
+
for line in out.splitlines():
|
|
187
|
+
f = line.split(None, 2)
|
|
188
|
+
if len(f) < 2 or f[1] != "hfsplus":
|
|
189
|
+
continue
|
|
190
|
+
path, mp = f[0], (f[2] if len(f) > 2 else "")
|
|
191
|
+
info, _ = _run(["udevadm", "info", "-q", "property", "-n", path])
|
|
192
|
+
low = info.lower()
|
|
193
|
+
if ("apple_ipod" in low or "id_model=ipod" in low
|
|
194
|
+
or "id_vendor=apple" in low or "scsi_vendor=apple" in low):
|
|
195
|
+
return {"path": path, "mountpoint": mp or None}
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _not_found_message() -> str:
|
|
200
|
+
dev = find_ipod_block_device()
|
|
201
|
+
if dev and dev.get("path") and not dev.get("mountpoint"):
|
|
202
|
+
return (f"iPod detected at {dev['path']} but not mounted.\n"
|
|
203
|
+
f" Mount it (read-only, for list/export): ipodsync --mount <command>\n"
|
|
204
|
+
f" or manually: udisksctl mount -b {dev['path']}\n"
|
|
205
|
+
" For writing (add/rm) mount read-write with uid= — see the README "
|
|
206
|
+
"\"Linux: mounting the iPod\".")
|
|
207
|
+
return ("iPod not found. Connect the device (enable Disk Use on the iPod), "
|
|
208
|
+
"or set IPODSYNC_MOUNT=/path/to/mount.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def mount_ipod() -> str:
|
|
212
|
+
"""Mount the iPod via udisksctl (Linux, no root). Returns the mountpoint.
|
|
213
|
+
|
|
214
|
+
This is read-only for a journaled HFS+ volume — enough for list/export; for
|
|
215
|
+
add/rm mount read-write with uid= yourself (see the README).
|
|
216
|
+
"""
|
|
217
|
+
if sys.platform == "darwin":
|
|
218
|
+
raise IPodNotFound("On macOS the iPod is mounted automatically by Finder.")
|
|
219
|
+
dev = find_ipod_block_device()
|
|
220
|
+
if not dev or not dev.get("path"):
|
|
221
|
+
raise IPodNotFound("No iPod block device found — is it connected?")
|
|
222
|
+
if dev.get("mountpoint"):
|
|
223
|
+
return dev["mountpoint"]
|
|
224
|
+
out, err = _run(["udisksctl", "mount", "-b", dev["path"]])
|
|
225
|
+
m = re.search(r"\bat\s+(\S+)", out)
|
|
226
|
+
if m:
|
|
227
|
+
return m.group(1).rstrip(".")
|
|
228
|
+
raise IPodNotFound(
|
|
229
|
+
f"Could not mount {dev['path']} via udisksctl"
|
|
230
|
+
+ (f": {err.strip()}" if err.strip() else "")
|
|
231
|
+
+ ". Mount it manually — see the README.")
|
|
@@ -137,3 +137,17 @@ def test_row_or_template_fallback_on_empty():
|
|
|
137
137
|
got["title"] = "x"
|
|
138
138
|
assert library._EMPTY_TEMPLATES["item"].get("title") != "x"
|
|
139
139
|
c.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_f1016_row_stride_padding():
|
|
143
|
+
"""F1016 is 57×57 pixels but stored with a 116-byte (58 px) row stride — two
|
|
144
|
+
zero bytes at the end of every row, like iTunes. Wrong stride shears the thumb."""
|
|
145
|
+
from PIL import Image
|
|
146
|
+
import io
|
|
147
|
+
buf = io.BytesIO()
|
|
148
|
+
Image.new("RGB", (80, 80), (200, 30, 30)).save(buf, format="PNG")
|
|
149
|
+
data = aw.to_rgb565(buf.getvalue(), 57, 57, 6612)
|
|
150
|
+
assert len(data) == 6612
|
|
151
|
+
stride = 6612 // 57 # 116
|
|
152
|
+
for y in range(57):
|
|
153
|
+
assert data[y * stride + 114:y * stride + 116] == b"\x00\x00", f"row {y}"
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Read the device's FirewireGuid — needed for the hashAB signature.
|
|
2
|
-
|
|
3
|
-
FirewireGuid is an 8-byte device identifier. It lives in
|
|
4
|
-
`iPod_Control/Device/SysInfoExtended` (XML plist) or `SysInfo` (text),
|
|
5
|
-
as a hex string like `0x000A2700XXXXXXXX` (16 hex characters).
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import re
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
# 0x + 16 hex, or a FireWireGUID key near 16 hex chars
|
|
13
|
-
_GUID_RE = re.compile(
|
|
14
|
-
r"(?:FireWireGUID|FirewireGuid)\D{0,40}?(?:0x)?([0-9A-Fa-f]{16})",
|
|
15
|
-
re.IGNORECASE | re.DOTALL,
|
|
16
|
-
)
|
|
17
|
-
_BARE_HEX_RE = re.compile(r"0x([0-9A-Fa-f]{16})")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class FirewireGuidNotFound(RuntimeError):
|
|
21
|
-
pass
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _extract(text: str) -> str | None:
|
|
25
|
-
m = _GUID_RE.search(text)
|
|
26
|
-
if m:
|
|
27
|
-
return m.group(1)
|
|
28
|
-
m = _BARE_HEX_RE.search(text)
|
|
29
|
-
if m:
|
|
30
|
-
return m.group(1)
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def read_firewire_guid(sysinfo_extended: Path, sysinfo: Path | None = None) -> bytes:
|
|
35
|
-
"""Return FirewireGuid as 8 bytes (big-endian, as in the hex string).
|
|
36
|
-
|
|
37
|
-
Tries SysInfoExtended, then SysInfo. errors='ignore' — the files may
|
|
38
|
-
contain binary tails.
|
|
39
|
-
"""
|
|
40
|
-
for p in (sysinfo_extended, sysinfo):
|
|
41
|
-
if p is None or not p.exists():
|
|
42
|
-
continue
|
|
43
|
-
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
44
|
-
hexs = _extract(text)
|
|
45
|
-
if hexs:
|
|
46
|
-
return bytes.fromhex(hexs)
|
|
47
|
-
raise FirewireGuidNotFound(
|
|
48
|
-
f"Could not find FireWireGUID in {sysinfo_extended}"
|
|
49
|
-
+ (f" / {sysinfo}" if sysinfo else "")
|
|
50
|
-
)
|
|
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
|