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.
Files changed (41) hide show
  1. {ipodsync-0.1.4 → ipodsync-0.1.6}/PKG-INFO +25 -23
  2. {ipodsync-0.1.4 → ipodsync-0.1.6}/README.md +24 -22
  3. {ipodsync-0.1.4 → ipodsync-0.1.6}/pyproject.toml +1 -1
  4. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/__init__.py +1 -1
  5. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/cli.py +135 -62
  6. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/transport.py +23 -13
  7. {ipodsync-0.1.4 → ipodsync-0.1.6}/.github/workflows/ci.yml +0 -0
  8. {ipodsync-0.1.4 → ipodsync-0.1.6}/.github/workflows/publish.yml +0 -0
  9. {ipodsync-0.1.4 → ipodsync-0.1.6}/.gitignore +0 -0
  10. {ipodsync-0.1.4 → ipodsync-0.1.6}/LICENSE +0 -0
  11. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/__main__.py +0 -0
  12. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_art_templates.py +0 -0
  13. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen.py +0 -0
  14. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen2.py +0 -0
  15. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_gen_rt.py +0 -0
  16. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_py.py +0 -0
  17. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_tables.bin +0 -0
  18. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_hashab_tables.py +0 -0
  19. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/_lib_templates.py +0 -0
  20. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/artwork_writer.py +0 -0
  21. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/cbk.py +0 -0
  22. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/export.py +0 -0
  23. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/hashab.py +0 -0
  24. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/importer.py +0 -0
  25. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/library.py +0 -0
  26. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/models.py +0 -0
  27. {ipodsync-0.1.4 → ipodsync-0.1.6}/src/ipodsync/sysinfo.py +0 -0
  28. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/ArtworkDB +0 -0
  29. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/Locations.itdb +0 -0
  30. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/Locations.itdb.cbk +0 -0
  31. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/hashab-traces.json +0 -0
  32. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/data/hashab-vectors.json +0 -0
  33. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_artwork_writer.py +0 -0
  34. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_cbk.py +0 -0
  35. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_hashab.py +0 -0
  36. {ipodsync-0.1.4 → ipodsync-0.1.6}/tests/test_hashab_port.py +0 -0
  37. {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/build_gen2.py +0 -0
  38. {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_art_templates.py +0 -0
  39. {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_hashab_tables.py +0 -0
  40. {ipodsync-0.1.4 → ipodsync-0.1.6}/tools/gen_lib_templates.py +0 -0
  41. {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.4
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. 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.
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
- Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
119
+ The easy way is **mount once, work, unmount once**:
114
120
 
115
121
  ```bash
116
- lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
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
- 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
- ```
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 yourself:
134
+ Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
127
135
 
128
136
  ```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
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
- **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
+ 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. 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.
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
- Find the iPod's partition (an `hfsplus` one, roughly the iPod's size):
87
+ The easy way is **mount once, work, unmount once**:
82
88
 
83
89
  ```bash
84
- lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT # e.g. sda2 14.7G hfsplus
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
- 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
- ```
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 yourself:
102
+ Or manage the mount yourself and point `IPODSYNC_MOUNT` at it:
95
103
 
96
104
  ```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
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
- **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
+ 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.4"
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"
@@ -13,7 +13,7 @@ from .library import ItlpLibrary
13
13
  from .models import Track
14
14
  from .transport import IPod, IPodNotFound, find_ipod
15
15
 
16
- __version__ = "0.1.4"
16
+ __version__ = "0.1.6"
17
17
 
18
18
  __all__ = [
19
19
  "find_ipod",
@@ -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 for the device. add/rm/cover write to the database (they back up .itlp).
11
- Browsing the iPod in Finder/Music is fine; just don't let Apple's software auto-sync it,
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 = Path.home() / "ipod-backups" / f"itlp-{stamp}"
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={args.pid} (file: {loc or '—'}"
88
- f"{', deleted' if args.delete_file else ''}). Eject iPod.")
89
- print(f"Rollback: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
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
- """Back up .itlp, edit a copy via fn(lib), copy Library/Dynamic.itdb back.
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
- backup = Path.home() / "ipod-backups" / f"itlp-{stamp}"
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(f".itlp backup: {backup}. Eject iPod.")
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 an MP3 to the iPod: audio -> Fxx/ (onto the device), .itlp -> edit a copy -> back."""
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 = Path.home() / "ipod-backups" / f"itlp-{stamp}"
181
- backup.parent.mkdir(parents=True, exist_ok=True)
182
- shutil.copytree(itlp, backup)
183
- print(f".itlp backup: {backup}")
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
- location, abs_path = copy_audio_to_ipod(ipod, args.file)
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
- overrides = {"title": args.title, "artist": args.artist, "album": args.album}
192
- art_dir = None if args.no_cover else ipod.control / "Artwork"
193
- if art_dir is not None:
194
- _backup_artwork(ipod, stamp)
195
- pid = add_mp3_to_library(work, location, args.file, abs_path.stat().st_size,
196
- guid, overrides=overrides, artwork_dir=art_dir)
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"Track added pid={pid}"
201
- f"{'' if args.no_cover else ' (cover attached if one was embedded)'}."
202
- " Eject iPod and check Songs.")
203
- print(f"Rollback: rm -rf '{itlp}' && cp -r '{backup}' '{itlp}'")
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
- _backup_artwork(ipod, stamp)
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={args.pid} (cache_id={image_id}, "
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
- "add/rm/cover back up the library before editing. Run "
267
- "`ipodsync <command> -h` for per-command help.")
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 first via udisksctl (read-only)")
270
- sub = ap.add_subparsers(dest="cmd", required=True, metavar="<command>")
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 an MP3 (cover attached automatically)")
285
- pa.add_argument("file", help="path to the MP3 to upload")
286
- pa.add_argument("--title", help="override the title tag")
287
- pa.add_argument("--artist", help="override the artist tag")
288
- pa.add_argument("--album", help="override the album tag")
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
- 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
-
325
- # commands that don't require a ready iPod
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
- def mount_ipod() -> str:
212
- """Mount the iPod via udisksctl (Linux, no root). Returns the mountpoint.
211
+ MOUNTPOINT = "/mnt/ipodsync"
213
212
 
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).
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
- 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.")
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