transmute-cli 0.2.3a0__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.
- transmute/__init__.py +1 -0
- transmute/__main__.py +4 -0
- transmute/app.py +1481 -0
- transmute/commands.py +321 -0
- transmute/config.py +21 -0
- transmute/downloader.py +190 -0
- transmute/enrich.py +865 -0
- transmute/history.py +858 -0
- transmute/iphone.py +247 -0
- transmute/keys.py +152 -0
- transmute/layout.py +125 -0
- transmute/main.py +38 -0
- transmute/settings.py +114 -0
- transmute/style.py +32 -0
- transmute/sync.py +532 -0
- transmute/widgets.py +86 -0
- transmute_cli-0.2.3a0.dist-info/METADATA +335 -0
- transmute_cli-0.2.3a0.dist-info/RECORD +21 -0
- transmute_cli-0.2.3a0.dist-info/WHEEL +4 -0
- transmute_cli-0.2.3a0.dist-info/entry_points.txt +2 -0
- transmute_cli-0.2.3a0.dist-info/licenses/LICENSE +674 -0
transmute/style.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Theme and user-facing text constants."""
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.styles import Style
|
|
4
|
+
|
|
5
|
+
STYLE = Style.from_dict(
|
|
6
|
+
{
|
|
7
|
+
"header.bar": "#22d3ee",
|
|
8
|
+
"header.title": "#22d3ee bold",
|
|
9
|
+
"header.desc": "#bbbbbb",
|
|
10
|
+
"subsection": "#7dd3fc",
|
|
11
|
+
"dim": "#777777",
|
|
12
|
+
"ok": "#4ade80",
|
|
13
|
+
"err": "#f87171",
|
|
14
|
+
"warn": "#facc15",
|
|
15
|
+
"accent": "#22d3ee",
|
|
16
|
+
"prompt": "#888888",
|
|
17
|
+
"placeholder": "#666666 italic",
|
|
18
|
+
"input.hint": "#666666",
|
|
19
|
+
"input.warn": "#facc15",
|
|
20
|
+
"rule": "#333333",
|
|
21
|
+
"toolbar": "#7dd3fc bg:#16303a",
|
|
22
|
+
"selected": "bg:#1b3a46",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
TAGLINE = "Convert YouTube or SoundCloud links into rich MP3s. Bring your own provider."
|
|
27
|
+
PLACEHOLDER = "Paste a YouTube or SoundCloud link"
|
|
28
|
+
HINT_PLACEHOLDER = "type a hint (low confidence)"
|
|
29
|
+
HELP_HINT = "/help for more commands"
|
|
30
|
+
|
|
31
|
+
HISTORY_SHOWN = 14
|
|
32
|
+
MESSAGES_SHOWN = 8
|
transmute/sync.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""UI-independent, additive synchronization of local MP3 files and Spotify Documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from contextlib import AbstractContextManager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from difflib import SequenceMatcher
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import BinaryIO, Literal, Protocol
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from .config import STATE_DIR
|
|
19
|
+
|
|
20
|
+
CHUNK_SIZE = 1024 * 1024
|
|
21
|
+
MAX_REASON = 240
|
|
22
|
+
_MANIFEST_VERSION = 1
|
|
23
|
+
_PARTIAL_RE = re.compile(r"(?:\.part(?:ial)?|\.tmp|\.temp)$", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SyncError(RuntimeError):
|
|
27
|
+
"""A bounded, actionable sync integration or validation error."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TrackFile:
|
|
32
|
+
relative_path: str
|
|
33
|
+
size: int
|
|
34
|
+
sha256: str
|
|
35
|
+
artist: str = ""
|
|
36
|
+
title: str = ""
|
|
37
|
+
duration: float | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SyncItem:
|
|
42
|
+
id: str
|
|
43
|
+
direction: Literal["to_phone", "to_computer", "matched"]
|
|
44
|
+
track: TrackFile
|
|
45
|
+
target_name: str
|
|
46
|
+
confidence: Literal["high", "low"]
|
|
47
|
+
reason: str = ""
|
|
48
|
+
duplicate_of: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SyncPlan:
|
|
53
|
+
device_id: str
|
|
54
|
+
device_name: str
|
|
55
|
+
out_dir: Path
|
|
56
|
+
items: tuple[SyncItem, ...]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SyncProgress:
|
|
61
|
+
phase: str
|
|
62
|
+
current: int = 0
|
|
63
|
+
total: int = 0
|
|
64
|
+
filename: str = ""
|
|
65
|
+
item_id: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class SyncResult:
|
|
70
|
+
item_id: str
|
|
71
|
+
status: Literal["done", "error", "cancelled"]
|
|
72
|
+
message: str = ""
|
|
73
|
+
target_name: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PhoneStore(Protocol):
|
|
77
|
+
device_id: str
|
|
78
|
+
device_name: str
|
|
79
|
+
|
|
80
|
+
def list_files(self) -> list[str]: ...
|
|
81
|
+
def exists(self, relative_path: str) -> bool: ...
|
|
82
|
+
def read_to(self, relative_path: str, sink: BinaryIO) -> None: ...
|
|
83
|
+
def write_from(self, relative_path: str, source: BinaryIO) -> None: ...
|
|
84
|
+
def publish(self, temporary_path: str, destination_path: str) -> None: ...
|
|
85
|
+
def remove(self, relative_path: str) -> None: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
PhoneFactory = Callable[[str | None], AbstractContextManager[PhoneStore]]
|
|
89
|
+
ProgressCallback = Callable[[SyncProgress], None]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _safe_relative(value: str) -> str:
|
|
93
|
+
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
94
|
+
raise SyncError("Phone returned an unsafe file path.")
|
|
95
|
+
path = PurePosixPath(value)
|
|
96
|
+
if path.is_absolute() or any(part in ("", ".", "..") for part in value.split("/")):
|
|
97
|
+
raise SyncError("Phone returned an unsafe file path.")
|
|
98
|
+
return path.as_posix()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _safe_basename(value: str) -> str:
|
|
102
|
+
name = PurePosixPath(value).name
|
|
103
|
+
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip(" .")
|
|
104
|
+
if not name or name.startswith(".") or _PARTIAL_RE.search(name):
|
|
105
|
+
raise SyncError("A sync filename is hidden or temporary and cannot be transferred.")
|
|
106
|
+
suffix = Path(name).suffix
|
|
107
|
+
stem = name[: -len(suffix)] if suffix else name
|
|
108
|
+
name = _utf8_prefix(stem, 240 - len(suffix.encode("utf-8"))) + suffix
|
|
109
|
+
return name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _utf8_prefix(value: str, max_bytes: int) -> str:
|
|
113
|
+
result: list[str] = []
|
|
114
|
+
used = 0
|
|
115
|
+
for char in value:
|
|
116
|
+
width = len(char.encode("utf-8"))
|
|
117
|
+
if used + width > max_bytes:
|
|
118
|
+
break
|
|
119
|
+
result.append(char)
|
|
120
|
+
used += width
|
|
121
|
+
return "".join(result)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _unique_target(base: str, occupied: set[str]) -> str:
|
|
125
|
+
safe_base = _safe_basename(base)
|
|
126
|
+
if safe_base.casefold() not in occupied:
|
|
127
|
+
return safe_base
|
|
128
|
+
suffix = Path(safe_base).suffix
|
|
129
|
+
stem = safe_base[: -len(suffix)] if suffix else safe_base
|
|
130
|
+
number = 2
|
|
131
|
+
while True:
|
|
132
|
+
marker = f" ({number})"
|
|
133
|
+
stem_limit = 240 - len((marker + suffix).encode("utf-8"))
|
|
134
|
+
candidate = _safe_basename(_utf8_prefix(stem, stem_limit) + marker + suffix)
|
|
135
|
+
if candidate.casefold() not in occupied:
|
|
136
|
+
return candidate
|
|
137
|
+
number += 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _hash_stream(stream: BinaryIO) -> tuple[str, int]:
|
|
141
|
+
digest = hashlib.sha256()
|
|
142
|
+
size = 0
|
|
143
|
+
while True:
|
|
144
|
+
block = stream.read(CHUNK_SIZE)
|
|
145
|
+
if not block:
|
|
146
|
+
break
|
|
147
|
+
digest.update(block)
|
|
148
|
+
size += len(block)
|
|
149
|
+
return digest.hexdigest(), size
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _metadata(path: Path, relative: str) -> TrackFile:
|
|
153
|
+
artist = title = ""
|
|
154
|
+
duration: float | None = None
|
|
155
|
+
try:
|
|
156
|
+
from mutagen import File as MutagenFile
|
|
157
|
+
|
|
158
|
+
audio = MutagenFile(path, easy=True)
|
|
159
|
+
if audio is not None:
|
|
160
|
+
artist = _first_tag(audio, "artist")
|
|
161
|
+
title = _first_tag(audio, "title")
|
|
162
|
+
raw_duration = getattr(getattr(audio, "info", None), "length", None)
|
|
163
|
+
duration = float(raw_duration) if raw_duration is not None else None
|
|
164
|
+
except Exception: # noqa: BLE001 - metadata parsing must not block byte copying.
|
|
165
|
+
pass
|
|
166
|
+
digest, size = _hash_file(path)
|
|
167
|
+
return TrackFile(relative, size, digest, artist[:160], title[:160], duration)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _metadata_stream(stream: BinaryIO) -> tuple[str, str, float | None]:
|
|
171
|
+
"""Read embedded tags from an already-open phone readback stream."""
|
|
172
|
+
artist = title = ""
|
|
173
|
+
duration: float | None = None
|
|
174
|
+
try:
|
|
175
|
+
from mutagen import File as MutagenFile
|
|
176
|
+
|
|
177
|
+
stream.seek(0)
|
|
178
|
+
audio = MutagenFile(stream, easy=True)
|
|
179
|
+
if audio is not None:
|
|
180
|
+
artist = _first_tag(audio, "artist")
|
|
181
|
+
title = _first_tag(audio, "title")
|
|
182
|
+
raw_duration = getattr(getattr(audio, "info", None), "length", None)
|
|
183
|
+
duration = float(raw_duration) if raw_duration is not None else None
|
|
184
|
+
except Exception: # noqa: BLE001 - unreadable embedded metadata does not block byte sync.
|
|
185
|
+
pass
|
|
186
|
+
finally:
|
|
187
|
+
stream.seek(0)
|
|
188
|
+
return artist[:160], title[:160], duration
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _first_tag(audio: object, key: str) -> str:
|
|
192
|
+
value = getattr(audio, "get", lambda _key: None)(key)
|
|
193
|
+
if isinstance(value, (list, tuple)):
|
|
194
|
+
value = value[0] if value else ""
|
|
195
|
+
return str(value or "").strip()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _hash_file(path: Path) -> tuple[str, int]:
|
|
199
|
+
with path.open("rb") as stream:
|
|
200
|
+
return _hash_stream(stream)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _phone_hash(phone: PhoneStore, relative_path: str) -> str:
|
|
204
|
+
sink = tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE)
|
|
205
|
+
try:
|
|
206
|
+
phone.read_to(relative_path, sink)
|
|
207
|
+
sink.seek(0)
|
|
208
|
+
return _hash_stream(sink)[0]
|
|
209
|
+
finally:
|
|
210
|
+
sink.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _eligible_path(path: Path, root: Path) -> bool:
|
|
214
|
+
try:
|
|
215
|
+
relative = path.relative_to(root)
|
|
216
|
+
if path.is_symlink() or not path.is_file():
|
|
217
|
+
return False
|
|
218
|
+
if any(part.startswith(".") for part in relative.parts):
|
|
219
|
+
return False
|
|
220
|
+
return path.suffix.lower() == ".mp3" and not _PARTIAL_RE.search(path.name)
|
|
221
|
+
except (OSError, ValueError):
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _fingerprint(text: str) -> str:
|
|
226
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _norm(text: str) -> str:
|
|
230
|
+
folded = text.casefold()
|
|
231
|
+
return " ".join("".join(char if char.isalnum() else " " for char in folded).split())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _near(a: TrackFile, b: TrackFile) -> bool:
|
|
235
|
+
left_artist = _norm(a.artist)
|
|
236
|
+
right_artist = _norm(b.artist)
|
|
237
|
+
left_title = _norm(a.title or Path(a.relative_path).stem)
|
|
238
|
+
right_title = _norm(b.title or Path(b.relative_path).stem)
|
|
239
|
+
artist_match = bool(left_artist and right_artist and left_artist == right_artist)
|
|
240
|
+
title_match = bool(left_title and right_title and left_title == right_title)
|
|
241
|
+
title_similarity = SequenceMatcher(None, left_title, right_title).ratio() if left_title and right_title else 0.0
|
|
242
|
+
left_filename = _norm(Path(a.relative_path).stem)
|
|
243
|
+
right_filename = _norm(Path(b.relative_path).stem)
|
|
244
|
+
filename_match = bool(left_filename and right_filename and left_filename == right_filename)
|
|
245
|
+
filename_similarity = SequenceMatcher(None, left_filename, right_filename).ratio() if left_filename and right_filename else 0.0
|
|
246
|
+
duration_match = (
|
|
247
|
+
a.duration is not None and b.duration is not None
|
|
248
|
+
and abs(a.duration - b.duration) <= 3
|
|
249
|
+
)
|
|
250
|
+
if artist_match and title_match:
|
|
251
|
+
return True
|
|
252
|
+
# Similarity only lowers transfer confidence. Require independent artist
|
|
253
|
+
# and duration evidence so distinct tracks by one artist remain selectable.
|
|
254
|
+
if artist_match and duration_match and title_similarity >= 0.82:
|
|
255
|
+
return True
|
|
256
|
+
return filename_match or (duration_match and filename_similarity >= 0.9)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _manifest_key(device_id: str, out_dir: Path) -> str:
|
|
260
|
+
return _fingerprint(f"{device_id}\0{out_dir.resolve()}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class SyncService:
|
|
264
|
+
"""Build read-only plans and transfer explicitly selected files."""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
phone_factory: PhoneFactory | None = None,
|
|
269
|
+
manifest_dir: Path | None = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
self._phone_factory = phone_factory
|
|
272
|
+
self.manifest_dir = Path(manifest_dir) if manifest_dir is not None else STATE_DIR / "sync"
|
|
273
|
+
|
|
274
|
+
def _open_phone(self, device_id: str | None = None) -> AbstractContextManager[PhoneStore]:
|
|
275
|
+
factory = self._phone_factory
|
|
276
|
+
if factory is None:
|
|
277
|
+
from .iphone import open_spotify
|
|
278
|
+
|
|
279
|
+
factory = open_spotify
|
|
280
|
+
return factory(device_id)
|
|
281
|
+
|
|
282
|
+
def preview(
|
|
283
|
+
self,
|
|
284
|
+
out_dir: Path,
|
|
285
|
+
excluded_paths: tuple[Path, ...] = (),
|
|
286
|
+
progress: ProgressCallback | None = None,
|
|
287
|
+
) -> SyncPlan:
|
|
288
|
+
root = Path(out_dir).expanduser().resolve()
|
|
289
|
+
if not root.is_dir():
|
|
290
|
+
raise SyncError("Output folder is unavailable; check /out and try again.")
|
|
291
|
+
excluded = {Path(p).expanduser().resolve() for p in excluded_paths}
|
|
292
|
+
local: list[TrackFile] = []
|
|
293
|
+
for path in sorted(root.rglob("*")):
|
|
294
|
+
if not _eligible_path(path, root) or path.resolve() in excluded:
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
before = path.stat()
|
|
298
|
+
track = _metadata(path, path.relative_to(root).as_posix())
|
|
299
|
+
after = path.stat()
|
|
300
|
+
if (before.st_size, before.st_mtime_ns, before.st_ino) != (after.st_size, after.st_mtime_ns, after.st_ino):
|
|
301
|
+
continue
|
|
302
|
+
local.append(track)
|
|
303
|
+
except OSError:
|
|
304
|
+
continue
|
|
305
|
+
with self._open_phone() as phone:
|
|
306
|
+
remote: list[TrackFile] = []
|
|
307
|
+
for index, raw_path in enumerate(phone.list_files(), 1):
|
|
308
|
+
rel = _safe_relative(raw_path)
|
|
309
|
+
if rel.startswith(".") or any(part.startswith(".") for part in rel.split("/")) or _PARTIAL_RE.search(rel):
|
|
310
|
+
continue
|
|
311
|
+
if not rel.lower().endswith(".mp3"):
|
|
312
|
+
continue
|
|
313
|
+
sink = tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE)
|
|
314
|
+
try:
|
|
315
|
+
phone.read_to(rel, sink)
|
|
316
|
+
sink.seek(0)
|
|
317
|
+
digest, size = _hash_stream(sink)
|
|
318
|
+
sink.seek(0)
|
|
319
|
+
artist, title, duration = _metadata_stream(sink)
|
|
320
|
+
finally:
|
|
321
|
+
sink.close()
|
|
322
|
+
remote.append(TrackFile(rel, size, digest, artist, title or Path(rel).stem, duration))
|
|
323
|
+
if progress:
|
|
324
|
+
progress(SyncProgress("scan_phone", index, 0, Path(rel).name))
|
|
325
|
+
items = _build_items(local, remote)
|
|
326
|
+
return SyncPlan(phone.device_id, phone.device_name, root, tuple(items))
|
|
327
|
+
|
|
328
|
+
def transfer(
|
|
329
|
+
self,
|
|
330
|
+
plan: SyncPlan,
|
|
331
|
+
selected_ids: set[str],
|
|
332
|
+
progress: ProgressCallback | None = None,
|
|
333
|
+
cancelled: Callable[[], bool] | None = None,
|
|
334
|
+
) -> tuple[SyncResult, ...]:
|
|
335
|
+
selected = [item for item in plan.items if item.id in selected_ids and item.direction != "matched"]
|
|
336
|
+
known = {item.id for item in plan.items if item.direction != "matched"}
|
|
337
|
+
if selected_ids - known:
|
|
338
|
+
raise SyncError("The sync selection is stale; preview the folders again.")
|
|
339
|
+
if not selected:
|
|
340
|
+
return ()
|
|
341
|
+
results: list[SyncResult] = []
|
|
342
|
+
with self._open_phone(plan.device_id) as phone:
|
|
343
|
+
if phone.device_id != plan.device_id:
|
|
344
|
+
raise SyncError("The connected phone changed; preview again before syncing.")
|
|
345
|
+
manifest = self._load_manifest(plan, phone.device_id)
|
|
346
|
+
for index, item in enumerate(selected, 1):
|
|
347
|
+
if cancelled and cancelled():
|
|
348
|
+
results.extend(SyncResult(rest.id, "cancelled", "Cancelled before transfer.", rest.target_name) for rest in selected[index - 1 :])
|
|
349
|
+
break
|
|
350
|
+
if progress:
|
|
351
|
+
progress(SyncProgress("transfer", index, len(selected), item.target_name, item.id))
|
|
352
|
+
try:
|
|
353
|
+
if item.direction == "to_phone":
|
|
354
|
+
result = self._to_phone(phone, plan.out_dir, item)
|
|
355
|
+
else:
|
|
356
|
+
result = self._to_computer(phone, plan.out_dir, item)
|
|
357
|
+
if result.status == "done":
|
|
358
|
+
manifest[item.id] = {"sha256": item.track.sha256, "target": result.target_name}
|
|
359
|
+
try:
|
|
360
|
+
self._save_manifest(plan, phone.device_id, manifest)
|
|
361
|
+
except OSError:
|
|
362
|
+
result = SyncResult(item.id, "error", "Copied, but sync history could not be saved; retry is safe.", result.target_name)
|
|
363
|
+
results.append(result)
|
|
364
|
+
except Exception as exc: # noqa: BLE001 - isolate device and filesystem failures per item.
|
|
365
|
+
results.append(SyncResult(item.id, "error", _bounded_error(exc), item.target_name))
|
|
366
|
+
return tuple(results)
|
|
367
|
+
|
|
368
|
+
def _to_phone(self, phone: PhoneStore, out_dir: Path, item: SyncItem) -> SyncResult:
|
|
369
|
+
source = _source_path(item, out_dir)
|
|
370
|
+
before = source.stat()
|
|
371
|
+
digest, size = _hash_file(source)
|
|
372
|
+
if digest != item.track.sha256 or size != item.track.size:
|
|
373
|
+
raise SyncError("Source changed after preview; preview the folders again.")
|
|
374
|
+
destination = item.target_name
|
|
375
|
+
if phone.exists(destination):
|
|
376
|
+
if _phone_hash(phone, destination) == item.track.sha256:
|
|
377
|
+
return SyncResult(item.id, "done", "Already present with identical content.", destination)
|
|
378
|
+
raise FileExistsError(destination)
|
|
379
|
+
temporary = f".transmute-sync-{uuid4().hex}.partial"
|
|
380
|
+
try:
|
|
381
|
+
with source.open("rb") as stream:
|
|
382
|
+
phone.write_from(temporary, stream)
|
|
383
|
+
if _phone_hash(phone, temporary) != item.track.sha256:
|
|
384
|
+
raise SyncError("Phone readback did not match; the file was not published.")
|
|
385
|
+
after = source.stat()
|
|
386
|
+
if (before.st_size, before.st_mtime_ns, before.st_ino) != (after.st_size, after.st_mtime_ns, after.st_ino):
|
|
387
|
+
raise SyncError("Source changed during transfer; preview again.")
|
|
388
|
+
try:
|
|
389
|
+
phone.publish(temporary, destination)
|
|
390
|
+
except FileExistsError:
|
|
391
|
+
if _phone_hash(phone, destination) == item.track.sha256:
|
|
392
|
+
return SyncResult(item.id, "done", "Already present with identical content.", destination)
|
|
393
|
+
raise
|
|
394
|
+
return SyncResult(item.id, "done", "Copied to phone.", destination)
|
|
395
|
+
finally:
|
|
396
|
+
try:
|
|
397
|
+
phone.remove(temporary)
|
|
398
|
+
except Exception: # noqa: BLE001 - cleanup failure must not mask transfer outcome.
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
def _to_computer(self, phone: PhoneStore, out_dir: Path, item: SyncItem) -> SyncResult:
|
|
402
|
+
source = _safe_relative(item.track.relative_path)
|
|
403
|
+
if not phone.exists(source):
|
|
404
|
+
raise SyncError("Phone source disappeared after preview; preview again.")
|
|
405
|
+
target = out_dir / item.target_name
|
|
406
|
+
if target.exists() or target.is_symlink():
|
|
407
|
+
if target.is_file() and not target.is_symlink() and _hash_file(target)[0] == item.track.sha256:
|
|
408
|
+
return SyncResult(item.id, "done", "Already present with identical content.", item.target_name)
|
|
409
|
+
raise FileExistsError(item.target_name)
|
|
410
|
+
descriptor, name = tempfile.mkstemp(prefix=".transmute-sync-", suffix=".partial", dir=out_dir)
|
|
411
|
+
os.close(descriptor)
|
|
412
|
+
temporary = Path(name)
|
|
413
|
+
try:
|
|
414
|
+
with temporary.open("wb") as sink:
|
|
415
|
+
phone.read_to(source, sink)
|
|
416
|
+
sink.flush()
|
|
417
|
+
os.fsync(sink.fileno())
|
|
418
|
+
digest, size = _hash_file(temporary)
|
|
419
|
+
if digest != item.track.sha256 or size != item.track.size:
|
|
420
|
+
raise SyncError("Phone readback did not match; the file was not published.")
|
|
421
|
+
if not phone.exists(source) or _phone_hash(phone, source) != item.track.sha256:
|
|
422
|
+
raise SyncError("Phone source changed during transfer; preview again.")
|
|
423
|
+
try:
|
|
424
|
+
os.link(temporary, target)
|
|
425
|
+
except FileExistsError:
|
|
426
|
+
if target.is_file() and _hash_file(target)[0] == item.track.sha256:
|
|
427
|
+
return SyncResult(item.id, "done", "Already present with identical content.", item.target_name)
|
|
428
|
+
raise
|
|
429
|
+
return SyncResult(item.id, "done", "Copied to computer.", item.target_name)
|
|
430
|
+
finally:
|
|
431
|
+
temporary.unlink(missing_ok=True)
|
|
432
|
+
|
|
433
|
+
def _manifest_path(self, plan: SyncPlan, device_id: str) -> Path:
|
|
434
|
+
return self.manifest_dir / f"{_manifest_key(device_id, plan.out_dir)}.json"
|
|
435
|
+
|
|
436
|
+
def _load_manifest(self, plan: SyncPlan, device_id: str) -> dict[str, dict[str, str]]:
|
|
437
|
+
path = self._manifest_path(plan, device_id)
|
|
438
|
+
try:
|
|
439
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
440
|
+
except FileNotFoundError:
|
|
441
|
+
return {}
|
|
442
|
+
except (OSError, json.JSONDecodeError):
|
|
443
|
+
return {}
|
|
444
|
+
if not isinstance(data, dict) or data.get("version") != _MANIFEST_VERSION or not isinstance(data.get("successes"), dict):
|
|
445
|
+
return {}
|
|
446
|
+
return {str(k): v for k, v in data["successes"].items() if isinstance(v, dict) and isinstance(v.get("sha256"), str)}
|
|
447
|
+
|
|
448
|
+
def _save_manifest(self, plan: SyncPlan, device_id: str, successes: dict[str, dict[str, str]]) -> None:
|
|
449
|
+
path = self._manifest_path(plan, device_id)
|
|
450
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
payload = json.dumps({"version": _MANIFEST_VERSION, "successes": successes}, sort_keys=True)
|
|
452
|
+
descriptor, name = tempfile.mkstemp(prefix=".sync-", suffix=".tmp", dir=path.parent)
|
|
453
|
+
tmp = Path(name)
|
|
454
|
+
try:
|
|
455
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
456
|
+
stream.write(payload)
|
|
457
|
+
stream.flush()
|
|
458
|
+
os.fsync(stream.fileno())
|
|
459
|
+
os.replace(tmp, path)
|
|
460
|
+
finally:
|
|
461
|
+
tmp.unlink(missing_ok=True)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _build_items(local: list[TrackFile], remote: list[TrackFile]) -> list[SyncItem]:
|
|
465
|
+
items: list[SyncItem] = []
|
|
466
|
+
matched_local: set[str] = set()
|
|
467
|
+
matched_remote: set[str] = set()
|
|
468
|
+
remote_by_hash: dict[str, TrackFile] = {}
|
|
469
|
+
for track in remote:
|
|
470
|
+
remote_by_hash.setdefault(track.sha256, track)
|
|
471
|
+
seen_exact: set[str] = set()
|
|
472
|
+
for track in local:
|
|
473
|
+
counterpart = remote_by_hash.get(track.sha256)
|
|
474
|
+
if counterpart:
|
|
475
|
+
matched_local.add(track.relative_path)
|
|
476
|
+
matched_remote.update(candidate.relative_path for candidate in remote if candidate.sha256 == track.sha256)
|
|
477
|
+
if track.sha256 not in seen_exact:
|
|
478
|
+
seen_exact.add(track.sha256)
|
|
479
|
+
items.append(_item("matched", track, Path(track.relative_path).name, "high", "Exact SHA256 match."))
|
|
480
|
+
for direction, sources, other, already in (
|
|
481
|
+
("to_phone", local, remote, matched_local),
|
|
482
|
+
("to_computer", remote, local, matched_remote),
|
|
483
|
+
):
|
|
484
|
+
other_hashes = {track.sha256 for track in other}
|
|
485
|
+
other_names = {_safe_basename(Path(track.relative_path).name).casefold() for track in other}
|
|
486
|
+
occupied = set(other_names)
|
|
487
|
+
for track in sources:
|
|
488
|
+
if track.relative_path in already or track.sha256 in other_hashes:
|
|
489
|
+
continue
|
|
490
|
+
similar = next((candidate for candidate in other if _near(track, candidate)), None)
|
|
491
|
+
name_conflict = Path(track.relative_path).name.casefold() in other_names
|
|
492
|
+
confidence: Literal["high", "low"] = "low" if similar or name_conflict else "high"
|
|
493
|
+
reason = "Possible duplicate based on metadata or filename." if similar else "Same filename has different content." if name_conflict else ""
|
|
494
|
+
base = _safe_basename(Path(track.relative_path).name)
|
|
495
|
+
target = _unique_target(base, occupied)
|
|
496
|
+
occupied.add(target.casefold())
|
|
497
|
+
if target != base:
|
|
498
|
+
reason = (reason + " Flattened filename collision.").strip()
|
|
499
|
+
confidence = "low"
|
|
500
|
+
items.append(_item(direction, track, target, confidence, reason))
|
|
501
|
+
return items
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _item(direction: Literal["to_phone", "to_computer", "matched"], track: TrackFile, target: str, confidence: Literal["high", "low"], reason: str) -> SyncItem:
|
|
505
|
+
stable_id = _fingerprint(f"{direction}\0{track.relative_path}\0{track.sha256}")[:20]
|
|
506
|
+
return SyncItem(stable_id, direction, track, _safe_basename(target), confidence, reason[:MAX_REASON])
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _bounded_error(exc: Exception) -> str:
|
|
510
|
+
if isinstance(exc, FileExistsError):
|
|
511
|
+
return "Destination already exists; no file was replaced. Preview again."
|
|
512
|
+
if isinstance(exc, SyncError):
|
|
513
|
+
return str(exc)[:MAX_REASON]
|
|
514
|
+
if isinstance(exc, OSError) and exc.errno in (28,):
|
|
515
|
+
return "Destination storage is full; free space and retry."
|
|
516
|
+
return "Transfer failed; check the phone connection and destination, then retry."
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _source_path(item: SyncItem, out_dir: Path) -> Path:
|
|
520
|
+
rel = _safe_relative(item.track.relative_path)
|
|
521
|
+
root = out_dir.resolve()
|
|
522
|
+
path = root
|
|
523
|
+
for part in rel.split("/"):
|
|
524
|
+
path = path / part
|
|
525
|
+
if path.is_symlink():
|
|
526
|
+
raise SyncError("Planned source is now a symlink; preview the folders again.")
|
|
527
|
+
resolved = path.resolve()
|
|
528
|
+
if not resolved.is_relative_to(root):
|
|
529
|
+
raise SyncError("Planned source path escaped the output folder; preview again.")
|
|
530
|
+
if not resolved.is_file():
|
|
531
|
+
raise SyncError("Planned source is no longer a regular file; preview the folders again.")
|
|
532
|
+
return resolved
|
transmute/widgets.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Reusable prompt_toolkit pieces with no app-state dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import NotImplementedType
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
11
|
+
from prompt_toolkit.formatted_text.base import AnyFormattedText
|
|
12
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
13
|
+
from prompt_toolkit.layout.processors import Processor, Transformation
|
|
14
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HomeDirCompleter(Completer):
|
|
18
|
+
"""Suggests existing directories under ~/ for the output-directory picker.
|
|
19
|
+
|
|
20
|
+
The picker holds only the path *relative to home* in the input buffer (the
|
|
21
|
+
"~/" is a fixed prompt prefix), so completions resolve against
|
|
22
|
+
``Path.home()``. `active` gates completion to the folder editor's input stage.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, active: Callable[[], bool]) -> None:
|
|
26
|
+
self.active = active
|
|
27
|
+
|
|
28
|
+
def get_completions(self, document, complete_event):
|
|
29
|
+
if not self.active():
|
|
30
|
+
return
|
|
31
|
+
head, _, tail = document.text_before_cursor.lstrip("/").rpartition("/")
|
|
32
|
+
base = Path.home() / head if head else Path.home()
|
|
33
|
+
try:
|
|
34
|
+
names = sorted(p.name for p in base.iterdir() if p.is_dir())
|
|
35
|
+
except OSError:
|
|
36
|
+
return
|
|
37
|
+
for name in names:
|
|
38
|
+
if name.startswith(tail):
|
|
39
|
+
yield Completion(name + "/", start_position=-len(tail))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Modal:
|
|
44
|
+
"""A temporary takeover of the bottom input line (reusable for any command).
|
|
45
|
+
|
|
46
|
+
on_submit receives the entered text; empty or untouched text means
|
|
47
|
+
"do nothing". Esc cancels without calling on_submit.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
prefix: str
|
|
51
|
+
placeholder: str
|
|
52
|
+
on_submit: Callable[[str], None]
|
|
53
|
+
initial: str = ""
|
|
54
|
+
hint: str = "enter applies · esc cancels"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PlaceholderProcessor(Processor):
|
|
58
|
+
def __init__(self, text: str | Callable[[], str]) -> None:
|
|
59
|
+
self.text = text
|
|
60
|
+
|
|
61
|
+
def apply_transformation(self, ti):
|
|
62
|
+
if ti.lineno == 0 and not ti.document.text:
|
|
63
|
+
text = self.text() if callable(self.text) else self.text
|
|
64
|
+
return Transformation([("class:placeholder", text)])
|
|
65
|
+
return Transformation(ti.fragments)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ScrollableHelpControl(FormattedTextControl):
|
|
69
|
+
"""Formatted help content that forwards wheel scrolling to its owner."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self, text: AnyFormattedText, on_scroll: Callable[[int], None], *, focusable: bool = False
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(text, focusable=focusable)
|
|
75
|
+
self.on_scroll = on_scroll
|
|
76
|
+
|
|
77
|
+
def mouse_handler(
|
|
78
|
+
self, mouse_event: MouseEvent
|
|
79
|
+
) -> None | NotImplementedType:
|
|
80
|
+
if mouse_event.event_type == MouseEventType.SCROLL_UP:
|
|
81
|
+
self.on_scroll(-3)
|
|
82
|
+
return None
|
|
83
|
+
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
|
84
|
+
self.on_scroll(3)
|
|
85
|
+
return None
|
|
86
|
+
return NotImplemented
|