ipodsync 0.1.1__tar.gz → 0.1.3__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 (42) hide show
  1. {ipodsync-0.1.1 → ipodsync-0.1.3}/PKG-INFO +80 -4
  2. {ipodsync-0.1.1 → ipodsync-0.1.3}/README.md +74 -3
  3. {ipodsync-0.1.1 → ipodsync-0.1.3}/pyproject.toml +6 -1
  4. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/__init__.py +1 -1
  5. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/cli.py +65 -34
  6. ipodsync-0.1.3/src/ipodsync/sysinfo.py +118 -0
  7. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/transport.py +70 -4
  8. ipodsync-0.1.1/src/ipodsync/sysinfo.py +0 -50
  9. {ipodsync-0.1.1 → ipodsync-0.1.3}/.github/workflows/ci.yml +0 -0
  10. {ipodsync-0.1.1 → ipodsync-0.1.3}/.github/workflows/publish.yml +0 -0
  11. {ipodsync-0.1.1 → ipodsync-0.1.3}/.gitignore +0 -0
  12. {ipodsync-0.1.1 → ipodsync-0.1.3}/LICENSE +0 -0
  13. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/__main__.py +0 -0
  14. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_art_templates.py +0 -0
  15. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_gen.py +0 -0
  16. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_gen2.py +0 -0
  17. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_gen_rt.py +0 -0
  18. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_py.py +0 -0
  19. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_tables.bin +0 -0
  20. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_hashab_tables.py +0 -0
  21. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/_lib_templates.py +0 -0
  22. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/artwork_writer.py +0 -0
  23. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/cbk.py +0 -0
  24. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/export.py +0 -0
  25. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/hashab.py +0 -0
  26. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/importer.py +0 -0
  27. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/library.py +0 -0
  28. {ipodsync-0.1.1 → ipodsync-0.1.3}/src/ipodsync/models.py +0 -0
  29. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/data/ArtworkDB +0 -0
  30. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/data/Locations.itdb +0 -0
  31. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/data/Locations.itdb.cbk +0 -0
  32. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/data/hashab-traces.json +0 -0
  33. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/data/hashab-vectors.json +0 -0
  34. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/test_artwork_writer.py +0 -0
  35. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/test_cbk.py +0 -0
  36. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/test_hashab.py +0 -0
  37. {ipodsync-0.1.1 → ipodsync-0.1.3}/tests/test_hashab_port.py +0 -0
  38. {ipodsync-0.1.1 → ipodsync-0.1.3}/tools/build_gen2.py +0 -0
  39. {ipodsync-0.1.1 → ipodsync-0.1.3}/tools/gen_art_templates.py +0 -0
  40. {ipodsync-0.1.1 → ipodsync-0.1.3}/tools/gen_hashab_tables.py +0 -0
  41. {ipodsync-0.1.1 → ipodsync-0.1.3}/tools/gen_lib_templates.py +0 -0
  42. {ipodsync-0.1.1 → ipodsync-0.1.3}/tools/transpile_hashab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipodsync
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -17,6 +17,11 @@ Classifier: License :: OSI Approved :: MIT License
17
17
  Classifier: Operating System :: MacOS
18
18
  Classifier: Operating System :: POSIX :: Linux
19
19
  Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
20
25
  Classifier: Topic :: Multimedia :: Sound/Audio
21
26
  Requires-Python: >=3.9
22
27
  Requires-Dist: mutagen>=1.47
@@ -28,8 +33,8 @@ Description-Content-Type: text/markdown
28
33
  # ipodsync
29
34
 
30
35
  [![CI](https://github.com/buldiei/ipodsync/actions/workflows/ci.yml/badge.svg)](https://github.com/buldiei/ipodsync/actions/workflows/ci.yml)
31
- [![PyPI](https://img.shields.io/pypi/v/ipodsync.svg)](https://pypi.org/project/ipodsync/)
32
- [![Python](https://img.shields.io/pypi/pyversions/ipodsync.svg)](https://pypi.org/project/ipodsync/)
36
+ [![PyPI](https://img.shields.io/pypi/v/ipodsync.svg?v=2)](https://pypi.org/project/ipodsync/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/ipodsync.svg?v=2)](https://pypi.org/project/ipodsync/)
33
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
34
39
 
35
40
  Upload music to an **iPod nano 6G/7G** without iTunes/Apple Music — straight from
@@ -54,9 +59,29 @@ edits its SQLite library, signs it (hashAB) and **generates cover art itself**.
54
59
 
55
60
  ## Install
56
61
 
62
+ `ipodsync` is a command-line tool, so install it with **pipx** — it lands in an
63
+ isolated environment while the `ipodsync` command stays available everywhere:
64
+
65
+ ```bash
66
+ pipx install ipodsync
67
+ ```
68
+
69
+ No pipx yet? `brew install pipx` (macOS) or `sudo apt install pipx` (Debian/Ubuntu),
70
+ then `pipx ensurepath`.
71
+
72
+ <details>
73
+ <summary><code>pip install ipodsync</code> fails with <code>externally-managed-environment</code>?</summary>
74
+
75
+ On modern Debian/Ubuntu (PEP 668) the system Python is locked, so a plain
76
+ `pip install` into it is refused — that's an OS guard, not an ipodsync issue.
77
+ Use `pipx` (above), or a virtualenv:
78
+
57
79
  ```bash
58
- pipx install ipodsync # recommended (isolated), or: pip install ipodsync
80
+ python3 -m venv ~/.venvs/ipodsync
81
+ ~/.venvs/ipodsync/bin/pip install ipodsync
82
+ ~/.venvs/ipodsync/bin/ipodsync --help
59
83
  ```
84
+ </details>
60
85
 
61
86
  Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
62
87
 
@@ -79,6 +104,57 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
79
104
  (Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
80
105
  `IPODSYNC_MOUNT=/path/to/mount`.
81
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
+
82
158
  ## How it works
83
159
 
84
160
  - **Transport** — mass storage: files are written under `iPod_Control/`.
@@ -1,8 +1,8 @@
1
1
  # ipodsync
2
2
 
3
3
  [![CI](https://github.com/buldiei/ipodsync/actions/workflows/ci.yml/badge.svg)](https://github.com/buldiei/ipodsync/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/ipodsync.svg)](https://pypi.org/project/ipodsync/)
5
- [![Python](https://img.shields.io/pypi/pyversions/ipodsync.svg)](https://pypi.org/project/ipodsync/)
4
+ [![PyPI](https://img.shields.io/pypi/v/ipodsync.svg?v=2)](https://pypi.org/project/ipodsync/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/ipodsync.svg?v=2)](https://pypi.org/project/ipodsync/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
7
 
8
8
  Upload music to an **iPod nano 6G/7G** without iTunes/Apple Music — straight from
@@ -27,9 +27,29 @@ edits its SQLite library, signs it (hashAB) and **generates cover art itself**.
27
27
 
28
28
  ## Install
29
29
 
30
+ `ipodsync` is a command-line tool, so install it with **pipx** — it lands in an
31
+ isolated environment while the `ipodsync` command stays available everywhere:
32
+
33
+ ```bash
34
+ pipx install ipodsync
35
+ ```
36
+
37
+ No pipx yet? `brew install pipx` (macOS) or `sudo apt install pipx` (Debian/Ubuntu),
38
+ then `pipx ensurepath`.
39
+
40
+ <details>
41
+ <summary><code>pip install ipodsync</code> fails with <code>externally-managed-environment</code>?</summary>
42
+
43
+ On modern Debian/Ubuntu (PEP 668) the system Python is locked, so a plain
44
+ `pip install` into it is refused — that's an OS guard, not an ipodsync issue.
45
+ Use `pipx` (above), or a virtualenv:
46
+
30
47
  ```bash
31
- pipx install ipodsync # recommended (isolated), or: pip install ipodsync
48
+ python3 -m venv ~/.venvs/ipodsync
49
+ ~/.venvs/ipodsync/bin/pip install ipodsync
50
+ ~/.venvs/ipodsync/bin/ipodsync --help
32
51
  ```
52
+ </details>
33
53
 
34
54
  Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
35
55
 
@@ -52,6 +72,57 @@ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mn
52
72
  (Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
53
73
  `IPODSYNC_MOUNT=/path/to/mount`.
54
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
+
55
126
  ## How it works
56
127
 
57
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.1"
7
+ version = "0.1.3"
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"
@@ -19,6 +19,11 @@ classifiers = [
19
19
  "Operating System :: MacOS",
20
20
  "Operating System :: POSIX :: Linux",
21
21
  "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
22
27
  "Topic :: Multimedia :: Sound/Audio",
23
28
  ]
24
29
  dependencies = [
@@ -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.1"
16
+ __version__ = "0.1.3"
17
17
 
18
18
  __all__ = [
19
19
  "find_ipod",
@@ -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
 
@@ -256,41 +257,71 @@ def cmd_cover(ipod, args):
256
257
 
257
258
 
258
259
  def main() -> int:
259
- ap = argparse.ArgumentParser()
260
- sub = ap.add_subparsers(dest="cmd", required=True)
261
- sub.add_parser("list")
262
- pe = sub.add_parser("export")
263
- pe.add_argument("dest")
264
- pe.add_argument("--pid", type=int)
265
- pe.add_argument("--by-album", action="store_true")
266
- pe.add_argument("--no-tag", action="store_true")
267
- pr = sub.add_parser("rm")
268
- pr.add_argument("pid", type=int)
269
- pr.add_argument("--delete-file", action="store_true")
270
- pa = sub.add_parser("add")
271
- pa.add_argument("file", help="path to MP3")
272
- pa.add_argument("--title")
273
- pa.add_argument("--artist")
274
- pa.add_argument("--album")
275
- pa.add_argument("--no-cover", action="store_true",
276
- help="don't attach the embedded cover")
277
- sub.add_parser("status")
278
- pw = sub.add_parser("wait")
279
- pw.add_argument("--timeout", type=float, default=120)
280
- pw.add_argument("--interval", type=float, default=2)
281
-
282
- sub.add_parser("playlists")
283
- pc = sub.add_parser("pl-create"); pc.add_argument("name")
284
- ppa = sub.add_parser("pl-add")
285
- ppa.add_argument("playlist", type=int); ppa.add_argument("track", type=int, nargs="+")
286
- ppr = sub.add_parser("pl-rm")
287
- ppr.add_argument("playlist", type=int); ppr.add_argument("track", type=int)
288
- ppd = sub.add_parser("pl-del"); ppd.add_argument("playlist", type=int)
289
- pcv = sub.add_parser("cover")
290
- pcv.add_argument("pid", type=int)
260
+ ap = argparse.ArgumentParser(
261
+ prog="ipodsync",
262
+ description="Upload music to an iPod nano 6G/7G without iTunes (macOS + Linux). "
263
+ "The iPod mounts as a plain volume; ipodsync edits its SQLite library, "
264
+ "signs it (hashAB) and writes cover art.",
265
+ 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.")
268
+ 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>")
271
+
272
+ sub.add_parser("list", help="show tracks on the iPod")
273
+
274
+ pe = sub.add_parser("export", help="download tracks from the iPod (read-only)")
275
+ pe.add_argument("dest", help="destination directory")
276
+ pe.add_argument("--pid", type=int, help="export only this track (by pid)")
277
+ pe.add_argument("--by-album", action="store_true", help="lay out as Artist/Album/")
278
+ pe.add_argument("--no-tag", action="store_true", help="don't write ID3/MP4 tags")
279
+
280
+ pr = sub.add_parser("rm", help="remove a track from the iPod")
281
+ pr.add_argument("pid", type=int, help="track pid (see `list`)")
282
+ pr.add_argument("--delete-file", action="store_true", help="also delete the audio file")
283
+
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")
289
+ pa.add_argument("--no-cover", action="store_true", help="don't attach the embedded cover")
290
+
291
+ sub.add_parser("status", help="report ready / no-access / not-connected")
292
+
293
+ pw = sub.add_parser("wait", help="block until an iPod is ready")
294
+ pw.add_argument("--timeout", type=float, default=120, help="seconds to wait (0 = forever)")
295
+ pw.add_argument("--interval", type=float, default=2, help="poll interval, seconds")
296
+
297
+ sub.add_parser("playlists", help="list playlists")
298
+ pc = sub.add_parser("pl-create", help="create a playlist")
299
+ pc.add_argument("name", help="playlist name")
300
+ ppa = sub.add_parser("pl-add", help="add tracks to a playlist")
301
+ ppa.add_argument("playlist", type=int, help="playlist pid")
302
+ ppa.add_argument("track", type=int, nargs="+", help="track pid(s)")
303
+ ppr = sub.add_parser("pl-rm", help="remove a track from a playlist")
304
+ ppr.add_argument("playlist", type=int, help="playlist pid")
305
+ ppr.add_argument("track", type=int, help="track pid")
306
+ ppd = sub.add_parser("pl-del", help="delete a playlist")
307
+ ppd.add_argument("playlist", type=int, help="playlist pid")
308
+
309
+ pcv = sub.add_parser("cover", help="attach a cover to an existing track")
310
+ pcv.add_argument("pid", type=int, help="track pid")
291
311
  pcv.add_argument("--image", help="cover image file (otherwise the track's APIC is used)")
312
+
292
313
  args = ap.parse_args()
293
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
+
294
325
  # commands that don't require a ready iPod
295
326
  if args.cmd == "status":
296
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.")
@@ -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