ipodsync 0.1.0__py3-none-any.whl

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/sysinfo.py ADDED
@@ -0,0 +1,50 @@
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
+ )
ipodsync/transport.py ADDED
@@ -0,0 +1,165 @@
1
+ """Transport: the iPod nano mounts as an ordinary mass-storage volume.
2
+
3
+ Uploading = writing files under ``iPod_Control/``. No WebUSB/MTP involved.
4
+ This module locates the device, hands out paths and tracks availability.
5
+
6
+ Discovery is cross-platform:
7
+ - macOS: ``/Volumes/*``
8
+ - Linux: ``/media/$USER/*``, ``/media/*``, ``/run/media/$USER/*``, ``/mnt/*``
9
+ - override: set ``IPODSYNC_MOUNT=/path/to/mount`` or pass ``mount=`` explicitly.
10
+ A candidate is an iPod if it contains ``iPod_Control/``.
11
+
12
+ Access note: on macOS the OS may return ``Operation not permitted`` (EPERM) for a
13
+ removable volume when the terminal lacks Full Disk Access (TCC). We tell that state
14
+ (NO_ACCESS) apart from "no iPod connected" (NOT_FOUND).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import time
21
+ from dataclasses import dataclass
22
+ from enum import Enum
23
+ from pathlib import Path
24
+
25
+
26
+ class IPodNotFound(RuntimeError):
27
+ pass
28
+
29
+
30
+ class Access(Enum):
31
+ READY = "ready" # volume mounted and iPod_Control/iTunes is readable
32
+ NO_ACCESS = "no_access" # EPERM — missing TCC permission (macOS Full Disk Access)
33
+ NOT_FOUND = "not_found" # no iPod connected
34
+
35
+
36
+ NO_ACCESS_HINT = (
37
+ "iPod is mounted ({vols}) but not accessible (macOS TCC).\n"
38
+ " Quick fix: eject -> unplug -> plug back in.\n"
39
+ " Permanent: System Settings -> Privacy & Security -> Full Disk Access -> "
40
+ "add your terminal (Terminal/iTerm) and enable it."
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class IPod:
46
+ """A mounted iPod. All paths are absolute."""
47
+ root: Path
48
+
49
+ @property
50
+ def control(self) -> Path:
51
+ return self.root / "iPod_Control"
52
+
53
+ @property
54
+ def itunes_dir(self) -> Path:
55
+ return self.control / "iTunes"
56
+
57
+ @property
58
+ def itunes_cdb(self) -> Path:
59
+ """Legacy mhbd named iTunesCDB (device regenerates it from SQLite)."""
60
+ return self.itunes_dir / "iTunesCDB"
61
+
62
+ @property
63
+ def music_dir(self) -> Path:
64
+ return self.control / "Music"
65
+
66
+ @property
67
+ def device_dir(self) -> Path:
68
+ return self.control / "Device"
69
+
70
+ @property
71
+ def sysinfo_extended(self) -> Path:
72
+ return self.device_dir / "SysInfoExtended"
73
+
74
+ @property
75
+ def sysinfo(self) -> Path:
76
+ return self.device_dir / "SysInfo"
77
+
78
+
79
+ def _candidate_roots() -> list[Path]:
80
+ """Possible mount points to probe, in priority order."""
81
+ env = os.environ.get("IPODSYNC_MOUNT")
82
+ if env:
83
+ return [Path(env)]
84
+ roots: list[Path] = []
85
+ if sys.platform == "darwin":
86
+ base_dirs = ["/Volumes"]
87
+ else: # linux and other posix
88
+ user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
89
+ base_dirs = [f"/media/{user}", "/media", f"/run/media/{user}", "/mnt"]
90
+ for base in base_dirs:
91
+ try:
92
+ roots += sorted(Path(base).iterdir())
93
+ except OSError:
94
+ continue
95
+ return roots
96
+
97
+
98
+ def _probe(vol: Path) -> str:
99
+ """'ready' — iPod_Control/iTunes readable; 'blocked' — EPERM; 'no' — not an iPod."""
100
+ marker = vol / "iPod_Control" / "iTunes"
101
+ try:
102
+ os.stat(marker)
103
+ return "ready"
104
+ except PermissionError:
105
+ return "blocked"
106
+ except OSError:
107
+ # no iPod_Control here — but the whole volume may be EPERM-locked
108
+ try:
109
+ os.stat(vol)
110
+ return "no"
111
+ except PermissionError:
112
+ return "blocked"
113
+ except OSError:
114
+ return "no"
115
+
116
+
117
+ def probe_ipods(name_hint: str = "iPod") -> tuple[Access, IPod | None, list[Path]]:
118
+ """Return (state, IPod|None, list of blocked volumes)."""
119
+ ready, blocked = [], []
120
+ for v in _candidate_roots():
121
+ s = _probe(v)
122
+ if s == "ready":
123
+ ready.append(v)
124
+ elif s == "blocked":
125
+ blocked.append(v)
126
+ if ready:
127
+ pick = (next((v for v in ready if v.name == name_hint), None)
128
+ or next((v for v in ready if name_hint.lower() in v.name.lower()), None)
129
+ or ready[0])
130
+ return Access.READY, IPod(pick), blocked
131
+ if blocked:
132
+ return Access.NO_ACCESS, None, blocked
133
+ return Access.NOT_FOUND, None, []
134
+
135
+
136
+ def find_ipod(name_hint: str = "iPod") -> IPod:
137
+ """Find a ready iPod. Raises with a helpful message on NO_ACCESS / NOT_FOUND."""
138
+ status, ipod, blocked = probe_ipods(name_hint)
139
+ if status is Access.READY:
140
+ return ipod
141
+ if status is Access.NO_ACCESS:
142
+ 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
+ )
147
+
148
+
149
+ def wait_for_ipod(timeout: float = 120, interval: float = 2,
150
+ name_hint: str = "iPod", on_wait=None) -> IPod:
151
+ """Wait until an iPod becomes READY. timeout=0 waits forever.
152
+
153
+ on_wait(status, blocked) is called on each waiting iteration (for logging).
154
+ """
155
+ start = time.monotonic()
156
+ while True:
157
+ status, ipod, blocked = probe_ipods(name_hint)
158
+ if status is Access.READY:
159
+ return ipod
160
+ if on_wait:
161
+ on_wait(status, blocked)
162
+ if timeout and (time.monotonic() - start) > timeout:
163
+ raise IPodNotFound(f"iPod did not become available within {timeout}s "
164
+ f"(last state: {status.value})")
165
+ time.sleep(interval)
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipodsync
3
+ Version: 0.1.0
4
+ Summary: Upload music to an iPod nano 6G/7G without iTunes: SQLite library, hashAB signing, cover art (pure-Python).
5
+ Project-URL: Homepage, https://github.com/buldiei/ipodsync
6
+ Project-URL: Repository, https://github.com/buldiei/ipodsync
7
+ Project-URL: Issues, https://github.com/buldiei/ipodsync/issues
8
+ Project-URL: LinkedIn, https://www.linkedin.com/in/alex-buldiei-b10a9a150/
9
+ Author-email: Alex Buldiei <abuldiei@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: artwork,ipod,itunes,music,nano,sync
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: Multimedia :: Sound/Audio
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: mutagen>=1.47
23
+ Requires-Dist: pillow>=10.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ipodsync
29
+
30
+ [![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/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
34
+
35
+ Upload music to an **iPod nano 6G/7G** without iTunes/Apple Music — straight from
36
+ the terminal, on macOS and Linux. The iPod mounts as a plain volume; `ipodsync`
37
+ edits its SQLite library, signs it (hashAB) and **generates cover art itself**.
38
+
39
+ > ⚠️ Alpha. Works on real hardware, but it writes to the player's binary database.
40
+ > **Keep backups** (the tool makes one before every edit) and don't open this iPod
41
+ > in iTunes.
42
+
43
+ ## Features
44
+
45
+ - `list` / `export` — inspect and download tracks from the device (with tags).
46
+ - `add` — upload an MP3, metadata from tags, **cover art attached automatically**
47
+ (from an embedded APIC/covr): shown both in Now Playing and in list/Albums views.
48
+ - `rm` — remove a track (+file), re-sign.
49
+ - playlists — `playlists` / `pl-create` / `pl-add` / `pl-rm` / `pl-del`.
50
+ - `cover` — attach a cover to an already-uploaded track.
51
+ - works on an **empty (wiped) library** too — no iTunes needed to initialize it.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pipx install ipodsync # recommended (isolated), or: pip install ipodsync
57
+ ```
58
+
59
+ Requires Python ≥ 3.9. Pure-Python — no compiler or native library needed.
60
+
61
+ > Note: uploading currently supports **MP3 only**. FLAC/AAC → ALAC transcoding
62
+ > (which will need `ffmpeg`) is on the roadmap and not implemented yet.
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ ipodsync status # ready / no access / not connected
68
+ ipodsync list
69
+ ipodsync add "Song.mp3" # + cover auto
70
+ ipodsync add "Song.mp3" --no-cover
71
+ ipodsync export ~/Music/ipod --by-album
72
+ ipodsync cover 123456789 --image cover.jpg
73
+ ipodsync rm 123456789 --delete-file
74
+ ```
75
+
76
+ The iPod is discovered under `/Volumes` (macOS) and `/media`, `/run/media`, `/mnt`
77
+ (Linux) by the presence of `iPod_Control/`. To point at it explicitly, set
78
+ `IPODSYNC_MOUNT=/path/to/mount`.
79
+
80
+ ## How it works
81
+
82
+ - **Transport** — mass storage: files are written under `iPod_Control/`.
83
+ - **Database** — SQLite `iTunes Library.itlp/*.itdb` (not `iTunesCDB`, which the
84
+ device regenerates on its own). Only `Locations.itdb` is hash-protected (`.cbk`).
85
+ - **hashAB** — anti-tamper `.cbk` signature (white-box AES). **Pure-Python** port of
86
+ [dstaley/hashab](https://github.com/dstaley/hashab) (public domain), 100/100 test
87
+ vectors. No compiler or native lib — the package installs everywhere.
88
+ - **Cover art** — pure-Python writer for `ArtworkDB` + `F<fmt>_1.ithmb` (RGB565 LE,
89
+ formats 1010/1013/1015/1016), appended incrementally.
90
+
91
+ ## Status / roadmap
92
+
93
+ | | Status |
94
+ |---|---|
95
+ | Upload / remove / export / playlists (MP3) | ✅ confirmed on nano 7G |
96
+ | Cover art (pure-Python) | ✅ confirmed on nano 7G |
97
+ | Empty-library bootstrap | ✅ |
98
+ | **Pure-Python hashAB** (no native lib, installs everywhere) | ✅ 100/100 vectors |
99
+ | Cross-platform device discovery (macOS + Linux) | ✅ |
100
+ | FLAC/AAC → ALAC | ⬜ |
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ git clone … && cd ipodsync
106
+ python -m venv .venv && . .venv/bin/activate
107
+ pip install -e ".[dev]"
108
+ pytest
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT (see [LICENSE](LICENSE)). The vendored hashAB algorithm is public domain.
114
+ Not affiliated with Apple; iPod and iTunes are trademarks of Apple.
@@ -0,0 +1,25 @@
1
+ ipodsync/__init__.py,sha256=REzF6I-MS8uPGE59UpRYrGQh79D388XzD2SY0PfZahE,728
2
+ ipodsync/__main__.py,sha256=Pf37VnDwFZ-NTChYi2r9XHtP1jxVecowifdRUeZ4CG4,114
3
+ ipodsync/_art_templates.py,sha256=R53aZ3UkypEaEawD7a1CO1rwc4fKn_L9duq5WsREcg0,4816
4
+ ipodsync/_hashab_gen.py,sha256=giyPzQlobsNUo4uf7mCfq1K2VM7pdZ3xzgPnKWsQw4E,162837
5
+ ipodsync/_hashab_gen2.py,sha256=9UcfJoJ9UbUO0eM_g9NvIaQzQUcoM8Q9yqzH22_cAtg,85793
6
+ ipodsync/_hashab_gen_rt.py,sha256=zvHYc1wNruCyMWoi7CX10HjL4RasXkoD76M8c-3FkNI,4829
7
+ ipodsync/_hashab_py.py,sha256=i6whCvzsHKGh3lo_WqmHmdyuGov5XM1xtc67CHMhzK4,11756
8
+ ipodsync/_hashab_tables.bin,sha256=PfbLKaUk-54FZjOq96VyZXrfX0j_8vIodKSbdPkJiUQ,443788
9
+ ipodsync/_hashab_tables.py,sha256=b5lVY59cGGsfZ70LDFdOyaAeX8SDcg3jjoNKifib9pk,1023
10
+ ipodsync/_lib_templates.py,sha256=tYy0e6VYr_acZ8e8qNDjVABhSGzjCrV2C2AR7CZGFuc,3167
11
+ ipodsync/artwork_writer.py,sha256=jfSON_D5L9G8jT_eGKCV7smn4kJTJFQehUouJ1M-cck,9986
12
+ ipodsync/cbk.py,sha256=GDqTKBagNvjdWzb0K24qPd1KcIPvS7JXbENuV7VCOho,1737
13
+ ipodsync/cli.py,sha256=DG8pG3go4hdfN7OKUpLeAveUmEhE72QdG6jdxhES0rM,11595
14
+ ipodsync/export.py,sha256=uhbPf4zZT_f1UeX-vD3SF149LNDis4wNvIB6CzynIZ8,3044
15
+ ipodsync/hashab.py,sha256=VDCopl-smX4dskISJoCW49MRbsxBOHCxI3XAJgZ8cSc,1555
16
+ ipodsync/importer.py,sha256=upYW2wG-x-9efgWUsI4c2_bX0_612yC6Nq7YUaK64vE,5122
17
+ ipodsync/library.py,sha256=Oat1MoKKzZ3HXMNYHd2hhRXTlORE5bNVpvec9i9WAVQ,16511
18
+ ipodsync/models.py,sha256=M6KlH_sKFLB0IOT6_emhO_N2qFl0F63stgt0MfnvJQw,855
19
+ ipodsync/sysinfo.py,sha256=eVqQouHUeQN1Ui3Pcxpjs2s_XC47eMI4TSTb29-9y_M,1486
20
+ ipodsync/transport.py,sha256=vNbomxeUUdruCfiaVA2XqWZNaWai7vEiLhSA8tli2kY,5361
21
+ ipodsync-0.1.0.dist-info/METADATA,sha256=KZJ1-pThmLNeixe3-_3CzCGv04HiBky12yFzSF7ArvI,4641
22
+ ipodsync-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
23
+ ipodsync-0.1.0.dist-info/entry_points.txt,sha256=vVHLPxHbzX23-SnQop2t_EYyOV4cQwAOeE2ecpiDfU8,47
24
+ ipodsync-0.1.0.dist-info/licenses/LICENSE,sha256=Fmqk7PBD9o10kvqvy5N1ojgf--SNLWkCovih-jM9HB4,1069
25
+ ipodsync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ipodsync = ipodsync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Buldiei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.