ipodsync 0.1.3__tar.gz → 0.1.5__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.3 → ipodsync-0.1.5}/PKG-INFO +18 -5
- {ipodsync-0.1.3 → ipodsync-0.1.5}/README.md +17 -4
- {ipodsync-0.1.3 → ipodsync-0.1.5}/pyproject.toml +1 -1
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/artwork_writer.py +19 -9
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/cli.py +56 -40
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/transport.py +23 -13
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/test_artwork_writer.py +14 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/.gitignore +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/LICENSE +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/library.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/src/ipodsync/sysinfo.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/tools/gen_lib_templates.py +0 -0
- {ipodsync-0.1.3 → ipodsync-0.1.5}/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.5
|
|
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
|
|
@@ -98,8 +98,12 @@ ipodsync add "Song.mp3" --no-cover
|
|
|
98
98
|
ipodsync export ~/Music/ipod --by-album
|
|
99
99
|
ipodsync cover 123456789 --image cover.jpg
|
|
100
100
|
ipodsync rm 123456789 --delete-file
|
|
101
|
+
ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
|
|
101
102
|
```
|
|
102
103
|
|
|
104
|
+
Writes don't back up the library by default — pass `-b` / `--backup` to snapshot
|
|
105
|
+
`iTunes Library.itlp` to `~/ipod-backups` before editing.
|
|
106
|
+
|
|
103
107
|
The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mnt`
|
|
104
108
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
105
109
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
@@ -116,8 +120,8 @@ Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
|
|
|
116
120
|
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
|
|
117
121
|
```
|
|
118
122
|
|
|
119
|
-
For read commands (`list` / `export`), the quickest way is `--mount
|
|
120
|
-
|
|
123
|
+
For read commands (`list` / `export`), the quickest way is `--mount`: it detects the
|
|
124
|
+
device, mounts it read-only (asks for `sudo`), runs the command, then unmounts:
|
|
121
125
|
|
|
122
126
|
```bash
|
|
123
127
|
ipodsync --mount list
|
|
@@ -133,11 +137,20 @@ IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
|
133
137
|
sudo umount /mnt/ipod # before unplugging
|
|
134
138
|
```
|
|
135
139
|
|
|
136
|
-
**Writing (`add` / `rm` / `cover`) on Linux
|
|
137
|
-
|
|
140
|
+
**Writing (`add` / `rm` / `cover`) on Linux.** `--mount` handles it in one command —
|
|
141
|
+
it detects the device, mounts HFS+ read-write owned by your user (asks for `sudo`),
|
|
142
|
+
runs the command, then unmounts:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
ipodsync --mount add song.mp3
|
|
146
|
+
ipodsync --mount -b add song.mp3 # -b: snapshot the library to ~/ipod-backups first
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Or mount it yourself and point `IPODSYNC_MOUNT` at it:
|
|
138
150
|
|
|
139
151
|
```bash
|
|
140
152
|
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
153
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
141
154
|
```
|
|
142
155
|
|
|
143
156
|
Two catches:
|
|
@@ -66,8 +66,12 @@ ipodsync add "Song.mp3" --no-cover
|
|
|
66
66
|
ipodsync export ~/Music/ipod --by-album
|
|
67
67
|
ipodsync cover 123456789 --image cover.jpg
|
|
68
68
|
ipodsync rm 123456789 --delete-file
|
|
69
|
+
ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
Writes don't back up the library by default — pass `-b` / `--backup` to snapshot
|
|
73
|
+
`iTunes Library.itlp` to `~/ipod-backups` before editing.
|
|
74
|
+
|
|
71
75
|
The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mnt`
|
|
72
76
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
73
77
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
@@ -84,8 +88,8 @@ Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
|
|
|
84
88
|
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
|
|
85
89
|
```
|
|
86
90
|
|
|
87
|
-
For read commands (`list` / `export`), the quickest way is `--mount
|
|
88
|
-
|
|
91
|
+
For read commands (`list` / `export`), the quickest way is `--mount`: it detects the
|
|
92
|
+
device, mounts it read-only (asks for `sudo`), runs the command, then unmounts:
|
|
89
93
|
|
|
90
94
|
```bash
|
|
91
95
|
ipodsync --mount list
|
|
@@ -101,11 +105,20 @@ IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
|
101
105
|
sudo umount /mnt/ipod # before unplugging
|
|
102
106
|
```
|
|
103
107
|
|
|
104
|
-
**Writing (`add` / `rm` / `cover`) on Linux
|
|
105
|
-
|
|
108
|
+
**Writing (`add` / `rm` / `cover`) on Linux.** `--mount` handles it in one command —
|
|
109
|
+
it detects the device, mounts HFS+ read-write owned by your user (asks for `sudo`),
|
|
110
|
+
runs the command, then unmounts:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ipodsync --mount add song.mp3
|
|
114
|
+
ipodsync --mount -b add song.mp3 # -b: snapshot the library to ~/ipod-backups first
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or mount it yourself and point `IPODSYNC_MOUNT` at it:
|
|
106
118
|
|
|
107
119
|
```bash
|
|
108
120
|
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
121
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
109
122
|
```
|
|
110
123
|
|
|
111
124
|
Two catches:
|
|
@@ -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.5"
|
|
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
|
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
ipodsync rm PID [--delete-file] # remove a track (+resign)
|
|
8
8
|
ipodsync cover PID [--image IMG] # attach a cover to a track
|
|
9
9
|
|
|
10
|
-
Export is read-only
|
|
11
|
-
Browsing the iPod in Finder/Music is fine; just don't let
|
|
12
|
-
or a sync will drop manually-added tracks.
|
|
10
|
+
Export is read-only. add/rm/cover write to the database; pass -b to back the library
|
|
11
|
+
up to ~/ipod-backups first. Browsing the iPod in Finder/Music is fine; just don't let
|
|
12
|
+
Apple's software auto-sync it, or a sync will drop manually-added tracks.
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
@@ -73,10 +73,7 @@ def cmd_export(ipod, args):
|
|
|
73
73
|
def cmd_rm(ipod, args):
|
|
74
74
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
75
75
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
76
|
-
backup =
|
|
77
|
-
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
shutil.copytree(itlp, backup)
|
|
79
|
-
print(f".itlp backup: {backup}")
|
|
76
|
+
backup = _backup_library(itlp, stamp) if args.backup else None
|
|
80
77
|
|
|
81
78
|
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
|
|
82
79
|
lib = ItlpLibrary(itlp)
|
|
@@ -84,21 +81,20 @@ def cmd_rm(ipod, args):
|
|
|
84
81
|
loc = lib.remove_track(args.pid, music_dir=music)
|
|
85
82
|
lib.resign(guid)
|
|
86
83
|
lib.close()
|
|
87
|
-
print(f"Removed track pid
|
|
88
|
-
f"{'
|
|
89
|
-
|
|
84
|
+
print(f"✓ Removed track pid {args.pid}"
|
|
85
|
+
f"{' (+ deleted the file)' if args.delete_file else ''}. Eject the iPod.")
|
|
86
|
+
if backup:
|
|
87
|
+
print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
|
|
90
88
|
|
|
91
89
|
|
|
92
|
-
def _edit_library(ipod, fn, *, label: str):
|
|
93
|
-
"""
|
|
90
|
+
def _edit_library(ipod, fn, *, label: str, backup: bool = False):
|
|
91
|
+
"""Edit a /tmp copy via fn(lib), copy Library/Dynamic.itdb back.
|
|
94
92
|
|
|
95
93
|
Playlist operations don't touch Locations.itdb, so we don't rebuild cbk.
|
|
96
94
|
"""
|
|
97
95
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
98
96
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
99
|
-
|
|
100
|
-
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
-
shutil.copytree(itlp, backup)
|
|
97
|
+
bak = _backup_library(itlp, stamp) if backup else None
|
|
102
98
|
work = Path("/tmp") / f"itlp_{label}_{stamp}"
|
|
103
99
|
shutil.copytree(itlp, work)
|
|
104
100
|
|
|
@@ -109,7 +105,7 @@ def _edit_library(ipod, fn, *, label: str):
|
|
|
109
105
|
lib.close()
|
|
110
106
|
for name in ("Library.itdb", "Dynamic.itdb"):
|
|
111
107
|
shutil.copy2(work / name, itlp / name)
|
|
112
|
-
print(
|
|
108
|
+
print("✓ Done — eject the iPod." + (f" (backup: {bak.name})" if bak else ""))
|
|
113
109
|
return result
|
|
114
110
|
|
|
115
111
|
|
|
@@ -123,24 +119,24 @@ def cmd_playlists(ipod, args):
|
|
|
123
119
|
|
|
124
120
|
def cmd_pl_create(ipod, args):
|
|
125
121
|
pid = _edit_library(ipod, lambda lib: lib.create_playlist(args.name, date=_mac_now()),
|
|
126
|
-
label="plcreate")
|
|
122
|
+
label="plcreate", backup=args.backup)
|
|
127
123
|
print(f"Created playlist '{args.name}' pid={pid}")
|
|
128
124
|
|
|
129
125
|
|
|
130
126
|
def cmd_pl_add(ipod, args):
|
|
131
127
|
_edit_library(ipod, lambda lib: [lib.add_to_playlist(args.playlist, t) for t in args.track],
|
|
132
|
-
label="pladd")
|
|
128
|
+
label="pladd", backup=args.backup)
|
|
133
129
|
print(f"Tracks added: {len(args.track)} → playlist {args.playlist}")
|
|
134
130
|
|
|
135
131
|
|
|
136
132
|
def cmd_pl_rm(ipod, args):
|
|
137
133
|
_edit_library(ipod, lambda lib: lib.remove_from_playlist(args.playlist, args.track),
|
|
138
|
-
label="plrm")
|
|
134
|
+
label="plrm", backup=args.backup)
|
|
139
135
|
print(f"Track {args.track} removed from playlist {args.playlist}")
|
|
140
136
|
|
|
141
137
|
|
|
142
138
|
def cmd_pl_del(ipod, args):
|
|
143
|
-
_edit_library(ipod, lambda lib: lib.delete_playlist(args.playlist), label="pldel")
|
|
139
|
+
_edit_library(ipod, lambda lib: lib.delete_playlist(args.playlist), label="pldel", backup=args.backup)
|
|
144
140
|
print(f"Playlist {args.playlist} deleted")
|
|
145
141
|
|
|
146
142
|
|
|
@@ -177,30 +173,37 @@ def cmd_add(ipod, args):
|
|
|
177
173
|
"""Upload an MP3 to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
|
|
178
174
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
179
175
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
180
|
-
backup =
|
|
181
|
-
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
-
shutil.copytree(itlp, backup)
|
|
183
|
-
print(f".itlp backup: {backup}")
|
|
176
|
+
backup = _backup_library(itlp, stamp) if args.backup else None
|
|
184
177
|
|
|
185
178
|
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
|
|
179
|
+
print("→ copying the track onto the iPod…")
|
|
186
180
|
location, abs_path = copy_audio_to_ipod(ipod, args.file)
|
|
187
|
-
print(f"File copied: {location}")
|
|
188
181
|
|
|
189
182
|
work = Path("/tmp") / f"itlp_add_{stamp}"
|
|
190
183
|
shutil.copytree(itlp, work)
|
|
191
184
|
overrides = {"title": args.title, "artist": args.artist, "album": args.album}
|
|
192
185
|
art_dir = None if args.no_cover else ipod.control / "Artwork"
|
|
193
|
-
if art_dir is not None:
|
|
186
|
+
if art_dir is not None and args.backup:
|
|
194
187
|
_backup_artwork(ipod, stamp)
|
|
195
188
|
pid = add_mp3_to_library(work, location, args.file, abs_path.stat().st_size,
|
|
196
189
|
guid, overrides=overrides, artwork_dir=art_dir)
|
|
197
190
|
for name in ("Library.itdb", "Locations.itdb", "Dynamic.itdb",
|
|
198
191
|
"Extras.itdb", "Locations.itdb.cbk"):
|
|
199
192
|
shutil.copy2(work / name, itlp / name)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
193
|
+
cover = "" if args.no_cover else " (+ cover art)"
|
|
194
|
+
print(f"✓ Added \"{Path(args.file).name}\"{cover} · pid {pid}")
|
|
195
|
+
print(" Eject the iPod, then check Songs.")
|
|
196
|
+
if backup:
|
|
197
|
+
print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _backup_library(itlp: Path, stamp: str) -> Path:
|
|
201
|
+
"""Copy the whole .itlp to ~/ipod-backups (only when -b/--backup is given)."""
|
|
202
|
+
dst = Path.home() / "ipod-backups" / f"itlp-{stamp}"
|
|
203
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
shutil.copytree(itlp, dst)
|
|
205
|
+
print(f"→ backed up the library ({dst.name})")
|
|
206
|
+
return dst
|
|
204
207
|
|
|
205
208
|
|
|
206
209
|
def _backup_artwork(ipod, stamp: str) -> None:
|
|
@@ -208,8 +211,7 @@ def _backup_artwork(ipod, stamp: str) -> None:
|
|
|
208
211
|
art = ipod.control / "Artwork"
|
|
209
212
|
if art.exists():
|
|
210
213
|
dst = Path.home() / "ipod-backups" / f"Artwork-{stamp}"
|
|
211
|
-
shutil.copytree(art, dst)
|
|
212
|
-
print(f"Artwork backup: {dst}")
|
|
214
|
+
shutil.copytree(art, dst) # backed up alongside the library
|
|
213
215
|
|
|
214
216
|
|
|
215
217
|
def cmd_cover(ipod, args):
|
|
@@ -220,7 +222,8 @@ def cmd_cover(ipod, args):
|
|
|
220
222
|
|
|
221
223
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
222
224
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
223
|
-
|
|
225
|
+
if args.backup:
|
|
226
|
+
_backup_artwork(ipod, stamp)
|
|
224
227
|
|
|
225
228
|
if args.image:
|
|
226
229
|
cover = Path(args.image).read_bytes()
|
|
@@ -252,8 +255,7 @@ def cmd_cover(ipod, args):
|
|
|
252
255
|
finally:
|
|
253
256
|
lib.close()
|
|
254
257
|
shutil.copy2(work / "Library.itdb", itlp / "Library.itdb")
|
|
255
|
-
print(f"Cover attached to pid
|
|
256
|
-
f"album{'+' if album_pid else ' —'}). Eject iPod.")
|
|
258
|
+
print(f"✓ Cover attached to pid {args.pid}. Eject the iPod and check the album art.")
|
|
257
259
|
|
|
258
260
|
|
|
259
261
|
def main() -> int:
|
|
@@ -266,7 +268,10 @@ def main() -> int:
|
|
|
266
268
|
"add/rm/cover back up the library before editing. Run "
|
|
267
269
|
"`ipodsync <command> -h` for per-command help.")
|
|
268
270
|
ap.add_argument("--mount", action="store_true",
|
|
269
|
-
help="Linux: mount the iPod
|
|
271
|
+
help="Linux: auto-mount the iPod (sudo) before the command, unmount after")
|
|
272
|
+
ap.add_argument("-b", "--backup", action="store_true",
|
|
273
|
+
help="back up the library to ~/ipod-backups before editing "
|
|
274
|
+
"(off by default)")
|
|
270
275
|
sub = ap.add_subparsers(dest="cmd", required=True, metavar="<command>")
|
|
271
276
|
|
|
272
277
|
sub.add_parser("list", help="show tracks on the iPod")
|
|
@@ -312,22 +317,33 @@ def main() -> int:
|
|
|
312
317
|
|
|
313
318
|
args = ap.parse_args()
|
|
314
319
|
|
|
320
|
+
WRITE_CMDS = {"add", "rm", "cover", "pl-create", "pl-add", "pl-rm", "pl-del"}
|
|
321
|
+
mounted = None
|
|
315
322
|
if args.mount:
|
|
316
323
|
from ipodsync.transport import mount_ipod
|
|
317
324
|
try:
|
|
318
|
-
mp = mount_ipod()
|
|
325
|
+
mp, mounted_by_us = mount_ipod(rw=args.cmd in WRITE_CMDS)
|
|
319
326
|
except IPodNotFound as e:
|
|
320
327
|
print(f"⚠️ {e}")
|
|
321
328
|
return 2
|
|
322
329
|
os.environ["IPODSYNC_MOUNT"] = mp
|
|
323
|
-
print(f"
|
|
330
|
+
print(f"✓ iPod mounted at {mp}")
|
|
331
|
+
mounted = mp if mounted_by_us else None
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
return _dispatch(args)
|
|
335
|
+
finally:
|
|
336
|
+
if mounted:
|
|
337
|
+
from ipodsync.transport import umount_ipod
|
|
338
|
+
umount_ipod(mounted)
|
|
339
|
+
print(f"✓ iPod unmounted — safe to unplug")
|
|
324
340
|
|
|
325
|
-
|
|
341
|
+
|
|
342
|
+
def _dispatch(args) -> int:
|
|
326
343
|
if args.cmd == "status":
|
|
327
344
|
return cmd_status(args)
|
|
328
345
|
if args.cmd == "wait":
|
|
329
346
|
return cmd_wait(args)
|
|
330
|
-
|
|
331
347
|
try:
|
|
332
348
|
ipod = find_ipod()
|
|
333
349
|
except IPodNotFound as e:
|
|
@@ -208,11 +208,15 @@ def _not_found_message() -> str:
|
|
|
208
208
|
"or set IPODSYNC_MOUNT=/path/to/mount.")
|
|
209
209
|
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
"""Mount the iPod via udisksctl (Linux, no root). Returns the mountpoint.
|
|
211
|
+
MOUNTPOINT = "/mnt/ipodsync"
|
|
213
212
|
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
|
|
214
|
+
def mount_ipod(rw: bool = False) -> tuple[str, bool]:
|
|
215
|
+
"""Mount the iPod on Linux via `sudo mount` (asks for a password if needed).
|
|
216
|
+
|
|
217
|
+
Auto-detects the current device node (which changes across re-plugs), mounts
|
|
218
|
+
HFS+ read-only (or force,rw,uid= for writes) at MOUNTPOINT. Returns
|
|
219
|
+
(mountpoint, mounted_by_us) — if it was already mounted, mounted_by_us is False.
|
|
216
220
|
"""
|
|
217
221
|
if sys.platform == "darwin":
|
|
218
222
|
raise IPodNotFound("On macOS the iPod is mounted automatically by Finder.")
|
|
@@ -220,12 +224,18 @@ def mount_ipod() -> str:
|
|
|
220
224
|
if not dev or not dev.get("path"):
|
|
221
225
|
raise IPodNotFound("No iPod block device found — is it connected?")
|
|
222
226
|
if dev.get("mountpoint"):
|
|
223
|
-
return dev["mountpoint"]
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
227
|
+
return dev["mountpoint"], False
|
|
228
|
+
opts = f"force,rw,uid={os.getuid()},gid={os.getgid()},umask=022" if rw else "ro"
|
|
229
|
+
print(f"Mounting {dev['path']} at {MOUNTPOINT} ({opts})…")
|
|
230
|
+
subprocess.run(["sudo", "mkdir", "-p", MOUNTPOINT]) # inherit stdio (prompt)
|
|
231
|
+
r = subprocess.run(["sudo", "mount", "-t", "hfsplus", "-o", opts,
|
|
232
|
+
dev["path"], MOUNTPOINT])
|
|
233
|
+
if r.returncode != 0:
|
|
234
|
+
raise IPodNotFound(
|
|
235
|
+
f"Failed to mount {dev['path']} (see the error above). "
|
|
236
|
+
"See the README \"Linux: mounting the iPod\".")
|
|
237
|
+
return MOUNTPOINT, True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def umount_ipod(mountpoint: str) -> None:
|
|
241
|
+
subprocess.run(["sudo", "umount", mountpoint])
|
|
@@ -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}"
|
|
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
|