ipodsync 0.1.4__tar.gz → 0.1.6__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.4 → ipodsync-0.1.6}/PKG-INFO +25 -23
- {ipodsync-0.1.4 → ipodsync-0.1.6}/README.md +24 -22
- {ipodsync-0.1.4 → ipodsync-0.1.6}/pyproject.toml +1 -1
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/__init__.py +1 -1
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/cli.py +135 -62
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/transport.py +23 -13
- {ipodsync-0.1.4 → ipodsync-0.1.6}/.github/workflows/ci.yml +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/.github/workflows/publish.yml +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/.gitignore +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/LICENSE +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/__main__.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_art_templates.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen2.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen_rt.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_py.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_tables.bin +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_tables.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_lib_templates.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/artwork_writer.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/cbk.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/export.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/hashab.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/importer.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/library.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/models.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/sysinfo.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/ArtworkDB +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/Locations.itdb +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/Locations.itdb.cbk +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/hashab-traces.json +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/hashab-vectors.json +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_artwork_writer.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_cbk.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_hashab.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_hashab_port.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/build_gen2.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_art_templates.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_hashab_tables.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_lib_templates.py +0 -0
- {ipodsync-0.1.4 → ipodsync-0.1.6}/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.6
|
|
4
4
|
Summary: Upload music to an iPod nano 6G/7G without iTunes: SQLite library, hashAB signing, cover art (pure-Python).
|
|
5
5
|
Project-URL: Homepage, https://github.com/buldiei/ipodsync
|
|
6
6
|
Project-URL: Repository, https://github.com/buldiei/ipodsync
|
|
@@ -94,51 +94,53 @@ Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
|
|
|
94
94
|
ipodsync status # ready / no access / not connected
|
|
95
95
|
ipodsync list
|
|
96
96
|
ipodsync add "Song.mp3" # + cover auto
|
|
97
|
+
ipodsync add a.mp3 b.mp3 c.mp3 # several at once (one library write)
|
|
98
|
+
ipodsync add -f ~/Music/album # a whole folder (recursively)
|
|
97
99
|
ipodsync add "Song.mp3" --no-cover
|
|
98
100
|
ipodsync export ~/Music/ipod --by-album
|
|
99
101
|
ipodsync cover 123456789 --image cover.jpg
|
|
100
102
|
ipodsync rm 123456789 --delete-file
|
|
103
|
+
ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
|
|
101
104
|
```
|
|
102
105
|
|
|
106
|
+
Writes don't back up the library by default — pass `-b` / `--backup` to snapshot
|
|
107
|
+
`iTunes Library.itlp` to `~/ipod-backups` before editing.
|
|
108
|
+
|
|
103
109
|
The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mnt`
|
|
104
110
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
105
111
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
106
112
|
|
|
107
113
|
## Linux: mounting the iPod
|
|
108
114
|
|
|
109
|
-
On macOS, Finder mounts the iPod automatically
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
On macOS, Finder mounts the iPod automatically — you never need `--mount`, just run
|
|
116
|
+
`ipodsync add song.mp3`. A headless Linux box usually doesn't mount it: the device
|
|
117
|
+
shows up as a disk but stays unmounted, so `ipodsync` reports "iPod not found".
|
|
112
118
|
|
|
113
|
-
|
|
119
|
+
The easy way is **mount once, work, unmount once**:
|
|
114
120
|
|
|
115
121
|
```bash
|
|
116
|
-
|
|
122
|
+
ipodsync --mount # detect + mount the iPod (asks for sudo); stays mounted
|
|
123
|
+
ipodsync add song-1.mp3 # add as many tracks as you like…
|
|
124
|
+
ipodsync add song-2.mp3
|
|
125
|
+
ipodsync add -f ~/Music/album # …or a whole folder at once
|
|
126
|
+
ipodsync list
|
|
127
|
+
ipodsync --unmount # unmount when done — safe to unplug
|
|
117
128
|
```
|
|
118
129
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
ipodsync --mount list
|
|
124
|
-
```
|
|
130
|
+
`--mount` leaves the iPod at `/mnt/ipodsync`, which the following commands find on
|
|
131
|
+
their own. Pass `-b` to any write command to snapshot the library first
|
|
132
|
+
(`ipodsync -b add song.mp3`).
|
|
125
133
|
|
|
126
|
-
Or mount it
|
|
134
|
+
Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
|
|
127
135
|
|
|
128
136
|
```bash
|
|
129
|
-
sudo
|
|
130
|
-
|
|
131
|
-
sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
|
|
132
|
-
IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
137
|
+
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
138
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
133
139
|
sudo umount /mnt/ipod # before unplugging
|
|
134
140
|
```
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
```bash
|
|
140
|
-
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
141
|
-
```
|
|
142
|
+
Find the iPod's partition with `lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT` (an `hfsplus`
|
|
143
|
+
one, roughly the iPod's size — e.g. `sda2 14.7G hfsplus`).
|
|
142
144
|
|
|
143
145
|
Two catches:
|
|
144
146
|
|
|
@@ -62,51 +62,53 @@ Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
|
|
|
62
62
|
ipodsync status # ready / no access / not connected
|
|
63
63
|
ipodsync list
|
|
64
64
|
ipodsync add "Song.mp3" # + cover auto
|
|
65
|
+
ipodsync add a.mp3 b.mp3 c.mp3 # several at once (one library write)
|
|
66
|
+
ipodsync add -f ~/Music/album # a whole folder (recursively)
|
|
65
67
|
ipodsync add "Song.mp3" --no-cover
|
|
66
68
|
ipodsync export ~/Music/ipod --by-album
|
|
67
69
|
ipodsync cover 123456789 --image cover.jpg
|
|
68
70
|
ipodsync rm 123456789 --delete-file
|
|
71
|
+
ipodsync -b add "Song.mp3" # -b: back up the library first (off by default)
|
|
69
72
|
```
|
|
70
73
|
|
|
74
|
+
Writes don't back up the library by default — pass `-b` / `--backup` to snapshot
|
|
75
|
+
`iTunes Library.itlp` to `~/ipod-backups` before editing.
|
|
76
|
+
|
|
71
77
|
The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mnt`
|
|
72
78
|
(Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
|
|
73
79
|
`IPODSYNC_MOUNT=/path/to/mount`.
|
|
74
80
|
|
|
75
81
|
## Linux: mounting the iPod
|
|
76
82
|
|
|
77
|
-
On macOS, Finder mounts the iPod automatically
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
On macOS, Finder mounts the iPod automatically — you never need `--mount`, just run
|
|
84
|
+
`ipodsync add song.mp3`. A headless Linux box usually doesn't mount it: the device
|
|
85
|
+
shows up as a disk but stays unmounted, so `ipodsync` reports "iPod not found".
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
The easy way is **mount once, work, unmount once**:
|
|
82
88
|
|
|
83
89
|
```bash
|
|
84
|
-
|
|
90
|
+
ipodsync --mount # detect + mount the iPod (asks for sudo); stays mounted
|
|
91
|
+
ipodsync add song-1.mp3 # add as many tracks as you like…
|
|
92
|
+
ipodsync add song-2.mp3
|
|
93
|
+
ipodsync add -f ~/Music/album # …or a whole folder at once
|
|
94
|
+
ipodsync list
|
|
95
|
+
ipodsync --unmount # unmount when done — safe to unplug
|
|
85
96
|
```
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
ipodsync --mount list
|
|
92
|
-
```
|
|
98
|
+
`--mount` leaves the iPod at `/mnt/ipodsync`, which the following commands find on
|
|
99
|
+
their own. Pass `-b` to any write command to snapshot the library first
|
|
100
|
+
(`ipodsync -b add song.mp3`).
|
|
93
101
|
|
|
94
|
-
Or mount it
|
|
102
|
+
Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
|
|
95
103
|
|
|
96
104
|
```bash
|
|
97
|
-
sudo
|
|
98
|
-
|
|
99
|
-
sudo mount -t hfsplus -o ro /dev/sda2 /mnt/ipod
|
|
100
|
-
IPODSYNC_MOUNT=/mnt/ipod ipodsync list
|
|
105
|
+
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
106
|
+
IPODSYNC_MOUNT=/mnt/ipod ipodsync add song.mp3
|
|
101
107
|
sudo umount /mnt/ipod # before unplugging
|
|
102
108
|
```
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
sudo mount -t hfsplus -o rw,uid=$(id -u),gid=$(id -g) /dev/sda2 /mnt/ipod
|
|
109
|
-
```
|
|
110
|
+
Find the iPod's partition with `lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT` (an `hfsplus`
|
|
111
|
+
one, roughly the iPod's size — e.g. `sda2 14.7G hfsplus`).
|
|
110
112
|
|
|
111
113
|
Two catches:
|
|
112
114
|
|
|
@@ -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.6"
|
|
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"
|
|
@@ -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
|
|
|
@@ -173,34 +169,77 @@ def cmd_wait(args):
|
|
|
173
169
|
print(f"✅ iPod ready: {ipod.root}")
|
|
174
170
|
|
|
175
171
|
|
|
172
|
+
AUDIO_EXTS = {".mp3", ".m4a", ".aac", ".alac", ".mp4"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _collect_add_files(args) -> list[Path]:
|
|
176
|
+
"""Resolve the files to add from positional paths and/or --folder."""
|
|
177
|
+
files: list[Path] = [Path(f) for f in (args.file or [])]
|
|
178
|
+
if args.folder:
|
|
179
|
+
root = Path(args.folder)
|
|
180
|
+
if not root.is_dir():
|
|
181
|
+
raise SystemExit(f"⚠️ --folder: {root} is not a directory")
|
|
182
|
+
files += sorted(p for p in root.rglob("*")
|
|
183
|
+
if p.is_file() and p.suffix.lower() in AUDIO_EXTS)
|
|
184
|
+
# de-dup, keep order
|
|
185
|
+
seen, out = set(), []
|
|
186
|
+
for p in files:
|
|
187
|
+
rp = p.resolve()
|
|
188
|
+
if rp not in seen:
|
|
189
|
+
seen.add(rp)
|
|
190
|
+
out.append(p)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
176
194
|
def cmd_add(ipod, args):
|
|
177
|
-
"""Upload
|
|
195
|
+
"""Upload track(s) to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
|
|
196
|
+
files = _collect_add_files(args)
|
|
197
|
+
if not files:
|
|
198
|
+
raise SystemExit("⚠️ add: give a file path, or -f/--folder DIR")
|
|
199
|
+
if len(files) > 1 and (args.title or args.artist or args.album):
|
|
200
|
+
raise SystemExit("⚠️ --title/--artist/--album only apply to a single file")
|
|
201
|
+
|
|
178
202
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
179
203
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
180
|
-
backup =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
204
|
+
backup = _backup_library(itlp, stamp) if args.backup else None
|
|
205
|
+
art_dir = None if args.no_cover else ipod.control / "Artwork"
|
|
206
|
+
if art_dir is not None and args.backup:
|
|
207
|
+
_backup_artwork(ipod, stamp)
|
|
184
208
|
|
|
185
209
|
guid = read_firewire_guid(ipod.sysinfo_extended, ipod.sysinfo, mount=ipod.root)
|
|
186
|
-
|
|
187
|
-
print(f"File copied: {location}")
|
|
210
|
+
overrides = {"title": args.title, "artist": args.artist, "album": args.album}
|
|
188
211
|
|
|
212
|
+
# Edit one working copy of the library, add every file, then write it back once.
|
|
189
213
|
work = Path("/tmp") / f"itlp_add_{stamp}"
|
|
190
214
|
shutil.copytree(itlp, work)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
215
|
+
n = len(files)
|
|
216
|
+
added = 0
|
|
217
|
+
for i, src in enumerate(files, 1):
|
|
218
|
+
prefix = f"[{i}/{n}] " if n > 1 else ""
|
|
219
|
+
print(f"→ {prefix}copying \"{src.name}\" onto the iPod…")
|
|
220
|
+
location, abs_path = copy_audio_to_ipod(ipod, str(src))
|
|
221
|
+
pid = add_mp3_to_library(work, location, str(src), abs_path.stat().st_size,
|
|
222
|
+
guid, overrides=overrides if n == 1 else {},
|
|
223
|
+
artwork_dir=art_dir)
|
|
224
|
+
cover = "" if args.no_cover else " (+ cover art)"
|
|
225
|
+
print(f" ✓ {prefix}added \"{src.name}\"{cover} · pid {pid}")
|
|
226
|
+
added += 1
|
|
227
|
+
|
|
197
228
|
for name in ("Library.itdb", "Locations.itdb", "Dynamic.itdb",
|
|
198
229
|
"Extras.itdb", "Locations.itdb.cbk"):
|
|
199
230
|
shutil.copy2(work / name, itlp / name)
|
|
200
|
-
print(f"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
231
|
+
print(f"✓ Added {added} track{'s' if added != 1 else ''}. Eject the iPod, then check Songs.")
|
|
232
|
+
if backup:
|
|
233
|
+
print(f" Undo: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _backup_library(itlp: Path, stamp: str) -> Path:
|
|
237
|
+
"""Copy the whole .itlp to ~/ipod-backups (only when -b/--backup is given)."""
|
|
238
|
+
dst = Path.home() / "ipod-backups" / f"itlp-{stamp}"
|
|
239
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
shutil.copytree(itlp, dst)
|
|
241
|
+
print(f"→ backed up the library ({dst.name})")
|
|
242
|
+
return dst
|
|
204
243
|
|
|
205
244
|
|
|
206
245
|
def _backup_artwork(ipod, stamp: str) -> None:
|
|
@@ -208,8 +247,7 @@ def _backup_artwork(ipod, stamp: str) -> None:
|
|
|
208
247
|
art = ipod.control / "Artwork"
|
|
209
248
|
if art.exists():
|
|
210
249
|
dst = Path.home() / "ipod-backups" / f"Artwork-{stamp}"
|
|
211
|
-
shutil.copytree(art, dst)
|
|
212
|
-
print(f"Artwork backup: {dst}")
|
|
250
|
+
shutil.copytree(art, dst) # backed up alongside the library
|
|
213
251
|
|
|
214
252
|
|
|
215
253
|
def cmd_cover(ipod, args):
|
|
@@ -220,7 +258,8 @@ def cmd_cover(ipod, args):
|
|
|
220
258
|
|
|
221
259
|
itlp = ipod.itunes_dir / "iTunes Library.itlp"
|
|
222
260
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
223
|
-
|
|
261
|
+
if args.backup:
|
|
262
|
+
_backup_artwork(ipod, stamp)
|
|
224
263
|
|
|
225
264
|
if args.image:
|
|
226
265
|
cover = Path(args.image).read_bytes()
|
|
@@ -252,8 +291,7 @@ def cmd_cover(ipod, args):
|
|
|
252
291
|
finally:
|
|
253
292
|
lib.close()
|
|
254
293
|
shutil.copy2(work / "Library.itdb", itlp / "Library.itdb")
|
|
255
|
-
print(f"Cover attached to pid
|
|
256
|
-
f"album{'+' if album_pid else ' —'}). Eject iPod.")
|
|
294
|
+
print(f"✓ Cover attached to pid {args.pid}. Eject the iPod and check the album art.")
|
|
257
295
|
|
|
258
296
|
|
|
259
297
|
def main() -> int:
|
|
@@ -263,11 +301,17 @@ def main() -> int:
|
|
|
263
301
|
"The iPod mounts as a plain volume; ipodsync edits its SQLite library, "
|
|
264
302
|
"signs it (hashAB) and writes cover art.",
|
|
265
303
|
epilog="Set IPODSYNC_MOUNT=/path to point at the iPod explicitly. "
|
|
266
|
-
"
|
|
267
|
-
"`
|
|
304
|
+
"On Linux, `ipodsync --mount` mounts the iPod once (sudo) and leaves it "
|
|
305
|
+
"mounted, so `add`/`list`/... just work; `ipodsync --unmount` when done. "
|
|
306
|
+
"Run `ipodsync <command> -h` for per-command help.")
|
|
268
307
|
ap.add_argument("--mount", action="store_true",
|
|
269
|
-
help="Linux: mount the iPod
|
|
270
|
-
|
|
308
|
+
help="Linux: mount the iPod (sudo) and leave it mounted, then exit")
|
|
309
|
+
ap.add_argument("--unmount", "--umount", action="store_true", dest="unmount",
|
|
310
|
+
help="Linux: unmount the iPod mounted by --mount, then exit")
|
|
311
|
+
ap.add_argument("-b", "--backup", action="store_true",
|
|
312
|
+
help="back up the library to ~/ipod-backups before editing "
|
|
313
|
+
"(off by default)")
|
|
314
|
+
sub = ap.add_subparsers(dest="cmd", required=False, metavar="<command>")
|
|
271
315
|
|
|
272
316
|
sub.add_parser("list", help="show tracks on the iPod")
|
|
273
317
|
|
|
@@ -281,11 +325,12 @@ def main() -> int:
|
|
|
281
325
|
pr.add_argument("pid", type=int, help="track pid (see `list`)")
|
|
282
326
|
pr.add_argument("--delete-file", action="store_true", help="also delete the audio file")
|
|
283
327
|
|
|
284
|
-
pa = sub.add_parser("add", help="upload
|
|
285
|
-
pa.add_argument("file", help="path to the
|
|
286
|
-
pa.add_argument("--
|
|
287
|
-
pa.add_argument("--
|
|
288
|
-
pa.add_argument("--
|
|
328
|
+
pa = sub.add_parser("add", help="upload a track (or a whole folder); cover attached automatically")
|
|
329
|
+
pa.add_argument("file", nargs="*", help="path(s) to the audio file(s) to upload")
|
|
330
|
+
pa.add_argument("-f", "--folder", help="add every audio file in this folder (recursively)")
|
|
331
|
+
pa.add_argument("--title", help="override the title tag (single file only)")
|
|
332
|
+
pa.add_argument("--artist", help="override the artist tag (single file only)")
|
|
333
|
+
pa.add_argument("--album", help="override the album tag (single file only)")
|
|
289
334
|
pa.add_argument("--no-cover", action="store_true", help="don't attach the embedded cover")
|
|
290
335
|
|
|
291
336
|
sub.add_parser("status", help="report ready / no-access / not-connected")
|
|
@@ -312,22 +357,50 @@ def main() -> int:
|
|
|
312
357
|
|
|
313
358
|
args = ap.parse_args()
|
|
314
359
|
|
|
360
|
+
if args.unmount:
|
|
361
|
+
return _do_unmount()
|
|
315
362
|
if args.mount:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
363
|
+
rc = _do_mount()
|
|
364
|
+
if rc != 0 or args.cmd is None:
|
|
365
|
+
return rc # standalone: mounted, now exit
|
|
366
|
+
if args.cmd is None:
|
|
367
|
+
ap.print_help()
|
|
368
|
+
return 1
|
|
369
|
+
return _dispatch(args)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _do_mount() -> int:
|
|
373
|
+
"""Mount the iPod and leave it mounted for subsequent commands."""
|
|
374
|
+
from ipodsync.transport import mount_ipod
|
|
375
|
+
try:
|
|
376
|
+
mp, mounted_by_us = mount_ipod(rw=True)
|
|
377
|
+
except IPodNotFound as e:
|
|
378
|
+
print(f"⚠️ {e}")
|
|
379
|
+
return 2
|
|
380
|
+
if mounted_by_us:
|
|
381
|
+
print(f"✓ iPod mounted at {mp}")
|
|
382
|
+
else:
|
|
383
|
+
print(f"✓ iPod already mounted at {mp}")
|
|
384
|
+
print(" Now run e.g. `ipodsync add song.mp3`; `ipodsync --unmount` when done.")
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _do_unmount() -> int:
|
|
389
|
+
from ipodsync.transport import find_ipod, umount_ipod, MOUNTPOINT
|
|
390
|
+
try:
|
|
391
|
+
mp = str(find_ipod().root)
|
|
392
|
+
except IPodNotFound:
|
|
393
|
+
mp = MOUNTPOINT
|
|
394
|
+
umount_ipod(mp)
|
|
395
|
+
print(f"✓ iPod unmounted ({mp}) — safe to unplug.")
|
|
396
|
+
return 0
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _dispatch(args) -> int:
|
|
326
400
|
if args.cmd == "status":
|
|
327
401
|
return cmd_status(args)
|
|
328
402
|
if args.cmd == "wait":
|
|
329
403
|
return cmd_wait(args)
|
|
330
|
-
|
|
331
404
|
try:
|
|
332
405
|
ipod = find_ipod()
|
|
333
406
|
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])
|
|
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
|