mediasync 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.
mediasync/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """mediasync package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
mediasync/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from mediasync.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
mediasync/cli.py ADDED
@@ -0,0 +1,806 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ctypes
5
+ import json
6
+ import msvcrt
7
+ import os
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
14
+ from urllib.request import Request, urlopen
15
+
16
+ import dropbox
17
+ from dropbox.exceptions import ApiError, AuthError
18
+ from yt_dlp import YoutubeDL
19
+
20
+ from mediasync import __version__
21
+
22
+
23
+ MANIFEST_NAME = "mediasync.json"
24
+ STATE_DIR_NAME = ".mediasync"
25
+ CREDS_NAME = "credentials.json"
26
+ DROPBOX_KIND = "dropbox"
27
+ LINK_KIND = "link"
28
+ DEFAULT_TARGETS = [DROPBOX_KIND, LINK_KIND]
29
+ STD_OUTPUT_HANDLE = -11
30
+
31
+
32
+ class CONSOLE_CURSOR_INFO(ctypes.Structure):
33
+ _fields_ = [("dwSize", ctypes.c_int), ("bVisible", ctypes.c_bool)]
34
+
35
+
36
+ @dataclass
37
+ class RepoPaths:
38
+ root: Path
39
+ manifest: Path
40
+ state_dir: Path
41
+ credentials: Path
42
+
43
+
44
+ @dataclass
45
+ class LocalFile:
46
+ relative_path: str
47
+ absolute_path: Path
48
+ size: int
49
+ modified_at: datetime
50
+
51
+
52
+ @dataclass
53
+ class RemoteFile:
54
+ relative_path: str
55
+ remote_path: str
56
+ size: int
57
+ modified_at: datetime
58
+
59
+
60
+ @dataclass
61
+ class SyncAction:
62
+ direction: str
63
+ relative_path: str
64
+ source_kind: str
65
+ selected_target: str | None = None
66
+ remote_path: str | None = None
67
+ url: str | None = None
68
+
69
+
70
+ def build_parser() -> argparse.ArgumentParser:
71
+ parser = argparse.ArgumentParser(
72
+ prog="mediasync",
73
+ description="Synchronize media from multiple sources between devices.",
74
+ )
75
+ parser.add_argument(
76
+ "--version",
77
+ action="version",
78
+ version=f"%(prog)s {__version__}",
79
+ )
80
+
81
+ subparsers = parser.add_subparsers(dest="command")
82
+
83
+ init_parser = subparsers.add_parser("init", help="Initialize a mediasync pool.")
84
+ init_parser.add_argument(
85
+ "--project",
86
+ help="Project name to write into the local manifest. Defaults to the current directory name.",
87
+ )
88
+
89
+ return parser
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ parser = build_parser()
94
+ args = parser.parse_args(argv)
95
+
96
+ try:
97
+ if args.command == "init":
98
+ return cmd_init(args)
99
+
100
+ repo = find_repo(Path.cwd())
101
+ if repo is None:
102
+ print("No mediasync pool found in the current directory or its parents.")
103
+ parser.print_help()
104
+ return 1
105
+
106
+ return cmd_sync(repo)
107
+ except (EOFError, KeyboardInterrupt):
108
+ set_cursor_visibility(True)
109
+ print("\nCancelled.")
110
+ return 1
111
+ except RuntimeError as exc:
112
+ set_cursor_visibility(True)
113
+ print(str(exc), file=sys.stderr)
114
+ return 1
115
+
116
+
117
+ def cmd_init(args: argparse.Namespace) -> int:
118
+ root = Path.cwd()
119
+ repo = repo_paths(root)
120
+ repo.state_dir.mkdir(exist_ok=True)
121
+
122
+ if repo.manifest.exists():
123
+ manifest = load_manifest(repo.manifest)
124
+ else:
125
+ manifest = new_manifest(args.project or root.name)
126
+ save_manifest(repo.manifest, manifest)
127
+ print(f"Created {repo.manifest}")
128
+
129
+ changed = ensure_remote_origin(repo, manifest)
130
+ if changed:
131
+ print(f"Updated {repo.manifest}")
132
+
133
+ print(f"Initialized mediasync pool in {repo.root}")
134
+ return 0
135
+
136
+
137
+ def cmd_sync(repo: RepoPaths) -> int:
138
+ manifest = load_manifest(repo.manifest)
139
+ changed = ensure_remote_origin(repo, manifest)
140
+ if changed:
141
+ manifest = load_manifest(repo.manifest)
142
+
143
+ credentials = load_credentials(repo.credentials)
144
+ client = create_dropbox_client(credentials)
145
+ remote_manifest = fetch_remote_manifest(client, manifest)
146
+ if remote_manifest:
147
+ manifest = merge_manifests(repo, manifest, remote_manifest)
148
+
149
+ manifest_changed = ensure_manifest_defaults(manifest)
150
+ if manifest_changed:
151
+ save_manifest(repo.manifest, manifest)
152
+ sync_manifest_to_dropbox(repo, credentials, manifest["remote_origin"]["path"])
153
+
154
+ local_files = scan_local_files(repo)
155
+ remote_files = list_remote_dropbox_files(client, manifest)
156
+ actions = build_sync_actions(manifest, local_files, remote_files)
157
+
158
+ if not actions:
159
+ print("Up to date.")
160
+ return 0
161
+
162
+ approved_actions = run_sync_tui(actions, manifest)
163
+ if approved_actions is None:
164
+ return 1
165
+
166
+ applied = apply_sync_actions(repo, manifest, credentials, client, approved_actions)
167
+ if applied.manifest_changed:
168
+ save_manifest(repo.manifest, manifest)
169
+ sync_manifest_to_dropbox(repo, credentials, manifest["remote_origin"]["path"])
170
+
171
+ if not applied.performed:
172
+ print("Up to date.")
173
+ return 0
174
+
175
+ print(f"Applied {applied.performed} change(s).")
176
+ return 0
177
+
178
+
179
+ def find_repo(start: Path) -> RepoPaths | None:
180
+ current = start.resolve()
181
+ for candidate in (current, *current.parents):
182
+ if (candidate / MANIFEST_NAME).exists():
183
+ return repo_paths(candidate)
184
+ return None
185
+
186
+
187
+ def repo_paths(root: Path) -> RepoPaths:
188
+ resolved = root.resolve()
189
+ return RepoPaths(
190
+ root=resolved,
191
+ manifest=resolved / MANIFEST_NAME,
192
+ state_dir=resolved / STATE_DIR_NAME,
193
+ credentials=resolved / STATE_DIR_NAME / CREDS_NAME,
194
+ )
195
+
196
+
197
+ def new_manifest(project_name: str) -> dict[str, Any]:
198
+ return {
199
+ "version": 1,
200
+ "project": project_name,
201
+ "files": [],
202
+ }
203
+
204
+
205
+ def load_manifest(path: Path) -> dict[str, Any]:
206
+ with path.open("r", encoding="utf-8") as handle:
207
+ data = json.load(handle)
208
+ if not isinstance(data, dict):
209
+ raise ValueError(f"{path} must contain a JSON object.")
210
+ return data
211
+
212
+
213
+ def save_manifest(path: Path, manifest: dict[str, Any]) -> None:
214
+ with path.open("w", encoding="utf-8", newline="\n") as handle:
215
+ json.dump(manifest, handle, indent=2)
216
+ handle.write("\n")
217
+
218
+
219
+ def ensure_remote_origin(repo: RepoPaths, manifest: dict[str, Any]) -> bool:
220
+ if manifest.get("remote_origin"):
221
+ changed = ensure_manifest_defaults(manifest)
222
+ if changed:
223
+ save_manifest(repo.manifest, manifest)
224
+ return changed
225
+
226
+ target = choose_option("Choose a target type", [DROPBOX_KIND], label_map={DROPBOX_KIND: "Dropbox"})
227
+ if target != DROPBOX_KIND:
228
+ raise RuntimeError(f"Unsupported target type: {target}")
229
+
230
+ creds = prompt_dropbox_credentials()
231
+ repo.state_dir.mkdir(exist_ok=True)
232
+ save_credentials(repo.credentials, creds)
233
+
234
+ print("Uploading manifest to Dropbox...")
235
+ remote_origin = bootstrap_dropbox_manifest(repo, creds)
236
+ manifest["remote_origin"] = remote_origin
237
+ ensure_manifest_defaults(manifest)
238
+ save_manifest(repo.manifest, manifest)
239
+ sync_manifest_to_dropbox(repo, creds, remote_origin["path"])
240
+ return True
241
+
242
+
243
+ def ensure_manifest_defaults(manifest: dict[str, Any]) -> bool:
244
+ changed = False
245
+
246
+ if "files" not in manifest or not isinstance(manifest["files"], list):
247
+ manifest["files"] = []
248
+ changed = True
249
+
250
+ if "default_target" not in manifest:
251
+ remote_origin = manifest.get("remote_origin")
252
+ if remote_origin and remote_origin.get("kind") == DROPBOX_KIND and remote_origin.get("path"):
253
+ manifest["default_target"] = {
254
+ "kind": DROPBOX_KIND,
255
+ "path": str(Path(remote_origin["path"]).parent).replace("\\", "/"),
256
+ }
257
+ changed = True
258
+
259
+ return changed
260
+
261
+
262
+ def prompt_dropbox_credentials() -> dict[str, Any]:
263
+ print("Enter Dropbox credentials for the remote manifest.")
264
+ access_token = prompt_non_empty("Dropbox access token: ")
265
+ remote_folder = prompt_non_empty("Dropbox folder path (for example /Apps/mediasync/my-project): ")
266
+ return {
267
+ "dropbox": {
268
+ "access_token": access_token,
269
+ "folder_path": normalize_dropbox_path(remote_folder),
270
+ }
271
+ }
272
+
273
+
274
+ def prompt_non_empty(prompt: str) -> str:
275
+ while True:
276
+ value = input(prompt).strip()
277
+ if value:
278
+ return value
279
+ print("A value is required.")
280
+
281
+
282
+ def save_credentials(path: Path, credentials: dict[str, Any]) -> None:
283
+ with path.open("w", encoding="utf-8", newline="\n") as handle:
284
+ json.dump(credentials, handle, indent=2)
285
+ handle.write("\n")
286
+
287
+
288
+ def load_credentials(path: Path) -> dict[str, Any]:
289
+ with path.open("r", encoding="utf-8") as handle:
290
+ return json.load(handle)
291
+
292
+
293
+ def bootstrap_dropbox_manifest(repo: RepoPaths, credentials: dict[str, Any]) -> dict[str, str]:
294
+ dropbox_config = credentials["dropbox"]
295
+ client = create_dropbox_client(credentials)
296
+ validate_dropbox_auth(client)
297
+
298
+ remote_manifest_path = join_dropbox_path(dropbox_config["folder_path"], MANIFEST_NAME)
299
+ upload_file_to_dropbox(client, repo.manifest, remote_manifest_path)
300
+ shared_url = get_or_create_shared_link(client, remote_manifest_path)
301
+ direct_url = make_direct_url(shared_url)
302
+
303
+ return {
304
+ "kind": DROPBOX_KIND,
305
+ "path": remote_manifest_path,
306
+ "url": direct_url,
307
+ }
308
+
309
+
310
+ def create_dropbox_client(credentials: dict[str, Any]) -> dropbox.Dropbox:
311
+ token = credentials["dropbox"]["access_token"]
312
+ client = dropbox.Dropbox(oauth2_access_token=token)
313
+ validate_dropbox_auth(client)
314
+ return client
315
+
316
+
317
+ def fetch_remote_manifest(client: dropbox.Dropbox, manifest: dict[str, Any]) -> dict[str, Any] | None:
318
+ remote_origin = manifest.get("remote_origin")
319
+ if not remote_origin or remote_origin.get("kind") != DROPBOX_KIND:
320
+ return None
321
+
322
+ try:
323
+ _, response = client.files_download(remote_origin["path"])
324
+ except ApiError as exc:
325
+ raise RuntimeError(f"Failed to fetch remote manifest from {remote_origin['path']}.") from exc
326
+
327
+ payload = response.content.decode("utf-8")
328
+ data = json.loads(payload)
329
+ if not isinstance(data, dict):
330
+ raise RuntimeError("Remote manifest is not a JSON object.")
331
+ return data
332
+
333
+
334
+ def merge_manifests(repo: RepoPaths, local_manifest: dict[str, Any], remote_manifest: dict[str, Any]) -> dict[str, Any]:
335
+ if not remote_manifest.get("remote_origin") and local_manifest.get("remote_origin"):
336
+ remote_manifest["remote_origin"] = local_manifest["remote_origin"]
337
+
338
+ if "default_target" not in remote_manifest and "default_target" in local_manifest:
339
+ remote_manifest["default_target"] = local_manifest["default_target"]
340
+
341
+ save_manifest(repo.manifest, remote_manifest)
342
+ return remote_manifest
343
+
344
+
345
+ def list_remote_dropbox_files(client: dropbox.Dropbox, manifest: dict[str, Any]) -> dict[str, RemoteFile]:
346
+ folder_path = get_default_dropbox_folder(manifest)
347
+ results = client.files_list_folder(folder_path, recursive=True)
348
+ files: dict[str, RemoteFile] = {}
349
+
350
+ while True:
351
+ for entry in results.entries:
352
+ if isinstance(entry, dropbox.files.FileMetadata):
353
+ relative_path = dropbox_relative_to_local(folder_path, entry.path_display)
354
+ if relative_path == MANIFEST_NAME:
355
+ continue
356
+ files[relative_path] = RemoteFile(
357
+ relative_path=relative_path,
358
+ remote_path=entry.path_display,
359
+ size=entry.size,
360
+ modified_at=entry.server_modified,
361
+ )
362
+ if not results.has_more:
363
+ break
364
+ results = client.files_list_folder_continue(results.cursor)
365
+
366
+ return files
367
+
368
+
369
+ def scan_local_files(repo: RepoPaths) -> dict[str, LocalFile]:
370
+ files: dict[str, LocalFile] = {}
371
+ for path in repo.root.rglob("*"):
372
+ if not path.is_file():
373
+ continue
374
+ relative = path.relative_to(repo.root).as_posix()
375
+ if should_ignore_local_path(relative):
376
+ continue
377
+ stat = path.stat()
378
+ files[relative] = LocalFile(
379
+ relative_path=relative,
380
+ absolute_path=path,
381
+ size=stat.st_size,
382
+ modified_at=datetime.fromtimestamp(stat.st_mtime),
383
+ )
384
+ return files
385
+
386
+
387
+ def should_ignore_local_path(relative_path: str) -> bool:
388
+ if relative_path == MANIFEST_NAME:
389
+ return True
390
+ if relative_path.startswith(f"{STATE_DIR_NAME}/"):
391
+ return True
392
+ return False
393
+
394
+
395
+ def build_sync_actions(
396
+ manifest: dict[str, Any],
397
+ local_files: dict[str, LocalFile],
398
+ remote_files: dict[str, RemoteFile],
399
+ ) -> list[SyncAction]:
400
+ actions: list[SyncAction] = []
401
+ manifest_files = build_manifest_file_map(manifest)
402
+ all_paths = set(local_files) | set(remote_files) | set(manifest_files)
403
+
404
+ default_kind = get_default_target_kind(manifest)
405
+
406
+ for relative_path in sorted(all_paths):
407
+ local_file = local_files.get(relative_path)
408
+ remote_file = remote_files.get(relative_path)
409
+ file_entry = manifest_files.get(relative_path)
410
+ url_source = find_url_source(file_entry)
411
+
412
+ if local_file and remote_file:
413
+ if local_file.size != remote_file.size:
414
+ if local_file.modified_at >= remote_file.modified_at.replace(tzinfo=None):
415
+ actions.append(
416
+ SyncAction(
417
+ direction="upload",
418
+ relative_path=relative_path,
419
+ source_kind=DROPBOX_KIND,
420
+ selected_target=default_kind,
421
+ remote_path=remote_file.remote_path,
422
+ )
423
+ )
424
+ else:
425
+ actions.append(
426
+ SyncAction(
427
+ direction="download",
428
+ relative_path=relative_path,
429
+ source_kind=DROPBOX_KIND,
430
+ remote_path=remote_file.remote_path,
431
+ )
432
+ )
433
+ continue
434
+
435
+ if local_file and not remote_file:
436
+ if url_source and url_source.get("url"):
437
+ continue
438
+ actions.append(
439
+ SyncAction(
440
+ direction="upload",
441
+ relative_path=relative_path,
442
+ source_kind=DROPBOX_KIND,
443
+ selected_target=default_kind,
444
+ )
445
+ )
446
+ continue
447
+
448
+ if remote_file and not local_file:
449
+ actions.append(
450
+ SyncAction(
451
+ direction="download",
452
+ relative_path=relative_path,
453
+ source_kind=DROPBOX_KIND,
454
+ remote_path=remote_file.remote_path,
455
+ )
456
+ )
457
+ continue
458
+
459
+ if url_source and url_source.get("url"):
460
+ actions.append(
461
+ SyncAction(
462
+ direction="download",
463
+ relative_path=relative_path,
464
+ source_kind=LINK_KIND,
465
+ url=url_source["url"],
466
+ )
467
+ )
468
+
469
+ return actions
470
+
471
+
472
+ def build_manifest_file_map(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
473
+ file_map: dict[str, dict[str, Any]] = {}
474
+ for item in manifest.get("files", []):
475
+ if isinstance(item, dict) and isinstance(item.get("path"), str):
476
+ file_map[item["path"]] = item
477
+ return file_map
478
+
479
+
480
+ def find_url_source(file_entry: dict[str, Any] | None) -> dict[str, Any] | None:
481
+ if not file_entry:
482
+ return None
483
+ for source in file_entry.get("sources", []):
484
+ if source.get("kind") == "url" and source.get("url"):
485
+ return source
486
+ return None
487
+
488
+
489
+ def get_default_target_kind(manifest: dict[str, Any]) -> str:
490
+ default_target = manifest.get("default_target", {})
491
+ kind = default_target.get("kind")
492
+ return kind if kind in DEFAULT_TARGETS else DROPBOX_KIND
493
+
494
+
495
+ def get_default_dropbox_folder(manifest: dict[str, Any]) -> str:
496
+ default_target = manifest.get("default_target", {})
497
+ if default_target.get("kind") == DROPBOX_KIND and default_target.get("path"):
498
+ return normalize_dropbox_path(default_target["path"])
499
+
500
+ remote_origin = manifest.get("remote_origin")
501
+ if not remote_origin or remote_origin.get("kind") != DROPBOX_KIND:
502
+ raise RuntimeError("Only Dropbox remote origins are supported right now.")
503
+ return normalize_dropbox_path(str(Path(remote_origin["path"]).parent).replace("\\", "/"))
504
+
505
+
506
+ def run_sync_tui(actions: list[SyncAction], manifest: dict[str, Any]) -> list[SyncAction] | None:
507
+ selected = 0
508
+ label_map = {DROPBOX_KIND: "dropbox", LINK_KIND: "link"}
509
+
510
+ try:
511
+ set_cursor_visibility(False)
512
+ while True:
513
+ clear_screen()
514
+ print("mediasync\n")
515
+ print(invert("Start") if selected == 0 else "Start")
516
+ print()
517
+
518
+ for index, action in enumerate(actions, start=1):
519
+ label = render_action(action, manifest, label_map)
520
+ print(invert(label) if selected == index else label)
521
+
522
+ key = get_key()
523
+
524
+ if key == b"\xe0H":
525
+ selected = (selected - 1) % (len(actions) + 1)
526
+ elif key == b"\xe0P":
527
+ selected = (selected + 1) % (len(actions) + 1)
528
+ elif key == b"\r":
529
+ if selected == 0:
530
+ clear_screen()
531
+ return actions
532
+ selected_action = actions[selected - 1]
533
+ if selected_action.direction == "upload":
534
+ previous_target = selected_action.selected_target or get_default_target_kind(manifest)
535
+ chosen = choose_option(
536
+ f"Choose target for {selected_action.relative_path}",
537
+ DEFAULT_TARGETS,
538
+ label_map={DROPBOX_KIND: "Dropbox", LINK_KIND: "Link"},
539
+ )
540
+ if chosen == LINK_KIND:
541
+ url = prompt_optional_value(f"URL for {selected_action.relative_path}: ")
542
+ if url:
543
+ selected_action.selected_target = LINK_KIND
544
+ selected_action.url = url
545
+ else:
546
+ selected_action.selected_target = previous_target
547
+ else:
548
+ selected_action.selected_target = chosen
549
+ selected_action.url = None
550
+ elif key == b"\x1b":
551
+ clear_screen()
552
+ return None
553
+ finally:
554
+ set_cursor_visibility(True)
555
+
556
+
557
+ def render_action(action: SyncAction, manifest: dict[str, Any], label_map: dict[str, str]) -> str:
558
+ if action.direction == "upload":
559
+ target = action.selected_target or get_default_target_kind(manifest)
560
+ suffix = f": {label_map.get(target, target)}"
561
+ if target == LINK_KIND and action.url:
562
+ suffix = f"{suffix} [{action.url}]"
563
+ return f"\u2191 {action.relative_path}{suffix}"
564
+
565
+ source = "dropbox" if action.source_kind == DROPBOX_KIND else "link"
566
+ return f"\u2193 {action.relative_path} <- {source}"
567
+
568
+
569
+ @dataclass
570
+ class ApplyResult:
571
+ performed: int
572
+ manifest_changed: bool
573
+
574
+
575
+ def apply_sync_actions(
576
+ repo: RepoPaths,
577
+ manifest: dict[str, Any],
578
+ credentials: dict[str, Any],
579
+ client: dropbox.Dropbox,
580
+ actions: list[SyncAction],
581
+ ) -> ApplyResult:
582
+ performed = 0
583
+ manifest_changed = False
584
+
585
+ for action in actions:
586
+ destination = repo.root / Path(action.relative_path)
587
+ if action.direction == "upload":
588
+ target = action.selected_target or get_default_target_kind(manifest)
589
+ if target == DROPBOX_KIND:
590
+ remote_path = action.remote_path or join_dropbox_path(get_default_dropbox_folder(manifest), action.relative_path)
591
+ upload_file_to_dropbox(client, destination, remote_path)
592
+ performed += 1
593
+ elif target == LINK_KIND and action.url:
594
+ upsert_manifest_link_source(manifest, action.relative_path, action.url)
595
+ manifest_changed = True
596
+ performed += 1
597
+ else:
598
+ destination.parent.mkdir(parents=True, exist_ok=True)
599
+ if action.source_kind == DROPBOX_KIND and action.remote_path:
600
+ download_dropbox_file(client, action.remote_path, destination)
601
+ performed += 1
602
+ elif action.source_kind == LINK_KIND and action.url:
603
+ download_from_url(action.url, destination)
604
+ performed += 1
605
+
606
+ return ApplyResult(performed=performed, manifest_changed=manifest_changed)
607
+
608
+
609
+ def upsert_manifest_link_source(manifest: dict[str, Any], relative_path: str, url: str) -> None:
610
+ files = manifest.setdefault("files", [])
611
+ for item in files:
612
+ if item.get("path") == relative_path:
613
+ sources = item.setdefault("sources", [])
614
+ for source in sources:
615
+ if source.get("kind") == "url":
616
+ source["url"] = url
617
+ return
618
+ sources.append({"kind": "url", "url": url})
619
+ return
620
+
621
+ files.append(
622
+ {
623
+ "path": relative_path,
624
+ "sources": [{"kind": "url", "url": url}],
625
+ }
626
+ )
627
+
628
+
629
+ def download_dropbox_file(client: dropbox.Dropbox, remote_path: str, destination: Path) -> None:
630
+ try:
631
+ _, response = client.files_download(remote_path)
632
+ except ApiError as exc:
633
+ raise RuntimeError(f"Failed to download {remote_path} from Dropbox.") from exc
634
+
635
+ with destination.open("wb") as handle:
636
+ handle.write(response.content)
637
+
638
+
639
+ def download_from_url(url: str, destination: Path) -> None:
640
+ if is_youtube_url(url):
641
+ download_youtube(url, destination)
642
+ return
643
+
644
+ request = Request(url, headers={"User-Agent": "mediasync/0.1.0"})
645
+ with urlopen(request) as response:
646
+ content_type = response.headers.get_content_type()
647
+ if not is_direct_media_response(url, content_type):
648
+ raise RuntimeError(f"URL did not resolve to a direct media file: {url}")
649
+
650
+ with destination.open("wb") as handle:
651
+ while True:
652
+ chunk = response.read(1024 * 1024)
653
+ if not chunk:
654
+ break
655
+ handle.write(chunk)
656
+
657
+
658
+ def is_youtube_url(url: str) -> bool:
659
+ host = urlparse(url).netloc.lower()
660
+ return "youtube.com" in host or "youtu.be" in host
661
+
662
+
663
+ def download_youtube(url: str, destination: Path) -> None:
664
+ destination.parent.mkdir(parents=True, exist_ok=True)
665
+ options = {
666
+ "outtmpl": str(destination),
667
+ "quiet": True,
668
+ "noplaylist": True,
669
+ "overwrites": True,
670
+ }
671
+ with YoutubeDL(options) as downloader:
672
+ downloader.download([url])
673
+
674
+
675
+ def is_direct_media_response(url: str, content_type: str) -> bool:
676
+ if content_type.startswith("video/") or content_type.startswith("audio/"):
677
+ return True
678
+ path = urlparse(url).path.lower()
679
+ direct_exts = (".mp4", ".mov", ".mkv", ".webm", ".mp3", ".wav", ".m4a")
680
+ return path.endswith(direct_exts)
681
+
682
+
683
+ def sync_manifest_to_dropbox(repo: RepoPaths, credentials: dict[str, Any], remote_manifest_path: str) -> None:
684
+ client = create_dropbox_client(credentials)
685
+ upload_file_to_dropbox(client, repo.manifest, remote_manifest_path)
686
+
687
+
688
+ def upload_file_to_dropbox(client: dropbox.Dropbox, local_path: Path, remote_path: str) -> None:
689
+ with local_path.open("rb") as handle:
690
+ try:
691
+ client.files_upload(handle.read(), remote_path, mode=dropbox.files.WriteMode.overwrite)
692
+ except AuthError as exc:
693
+ raise RuntimeError("Dropbox token is missing required scopes. Regenerate it after enabling the scopes.") from exc
694
+ except ApiError as exc:
695
+ raise RuntimeError(f"Failed to upload {local_path} to Dropbox path {remote_path}.") from exc
696
+
697
+
698
+ def validate_dropbox_auth(client: dropbox.Dropbox) -> None:
699
+ try:
700
+ client.users_get_current_account()
701
+ except AuthError as exc:
702
+ raise RuntimeError("Dropbox authentication failed. Check the access token.") from exc
703
+
704
+
705
+ def get_or_create_shared_link(client: dropbox.Dropbox, remote_path: str) -> str:
706
+ try:
707
+ shared_link = client.sharing_create_shared_link_with_settings(remote_path)
708
+ return shared_link.url
709
+ except ApiError as exc:
710
+ if not is_shared_link_conflict(exc):
711
+ raise RuntimeError(f"Failed to create Dropbox shared link for {remote_path}.") from exc
712
+
713
+ links = client.sharing_list_shared_links(path=remote_path, direct_only=True).links
714
+ if not links:
715
+ raise RuntimeError(f"Dropbox reported an existing shared link conflict for {remote_path}, but no shared links were returned.")
716
+ return links[0].url
717
+
718
+
719
+ def is_shared_link_conflict(error: ApiError) -> bool:
720
+ return error.error.is_shared_link_already_exists()
721
+
722
+
723
+ def make_direct_url(shared_url: str) -> str:
724
+ parsed = urlparse(shared_url)
725
+ query = dict(parse_qsl(parsed.query, keep_blank_values=True))
726
+ query["raw"] = "1"
727
+ query.pop("dl", None)
728
+ return urlunparse(parsed._replace(query=urlencode(query)))
729
+
730
+
731
+ def normalize_dropbox_path(path: str) -> str:
732
+ cleaned = path.strip().replace("\\", "/")
733
+ if not cleaned.startswith("/"):
734
+ cleaned = f"/{cleaned}"
735
+ return cleaned.rstrip("/")
736
+
737
+
738
+ def join_dropbox_path(folder_path: str, name: str) -> str:
739
+ normalized_name = name.replace("\\", "/").lstrip("/")
740
+ return f"{normalize_dropbox_path(folder_path)}/{normalized_name}"
741
+
742
+
743
+ def dropbox_relative_to_local(folder_path: str, file_path: str) -> str:
744
+ prefix = normalize_dropbox_path(folder_path).rstrip("/")
745
+ relative = file_path[len(prefix):].lstrip("/")
746
+ return relative.replace("\\", "/")
747
+
748
+
749
+ def choose_option(title: str, options: list[str], label_map: dict[str, str] | None = None) -> str:
750
+ selected = 0
751
+ label_map = label_map or {}
752
+
753
+ try:
754
+ set_cursor_visibility(False)
755
+ while True:
756
+ clear_screen()
757
+ print(f"{title}\n")
758
+ for index, option in enumerate(options):
759
+ label = label_map.get(option, option)
760
+ print(invert(label) if index == selected else label)
761
+
762
+ key = get_key()
763
+ if key == b"\xe0H":
764
+ selected = (selected - 1) % len(options)
765
+ elif key == b"\xe0P":
766
+ selected = (selected + 1) % len(options)
767
+ elif key == b"\r":
768
+ clear_screen()
769
+ return options[selected]
770
+ elif key == b"\x1b":
771
+ raise RuntimeError("Cancelled.")
772
+ finally:
773
+ set_cursor_visibility(True)
774
+
775
+
776
+ def prompt_optional_value(prompt: str) -> str:
777
+ set_cursor_visibility(True)
778
+ try:
779
+ return input(prompt).strip()
780
+ finally:
781
+ set_cursor_visibility(False)
782
+
783
+
784
+ def clear_screen() -> None:
785
+ os.system("cls")
786
+
787
+
788
+ def invert(text: str) -> str:
789
+ return "\033[7m" + text + "\033[0m"
790
+
791
+
792
+ def get_key() -> bytes:
793
+ while True:
794
+ if msvcrt.kbhit():
795
+ key = msvcrt.getch()
796
+ if key == b"\xe0":
797
+ return b"\xe0" + msvcrt.getch()
798
+ return key
799
+
800
+
801
+ def set_cursor_visibility(visible: bool) -> None:
802
+ handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
803
+ cursor_info = CONSOLE_CURSOR_INFO()
804
+ ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(cursor_info))
805
+ cursor_info.bVisible = visible
806
+ ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(cursor_info))
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: mediasync
3
+ Version: 0.1.0
4
+ Summary: Synchronize media from multiple sources between devices
5
+ Author: Dart Spark
6
+ Project-URL: Homepage, https://pypi.org/project/mediasync/
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: dropbox
10
+ Requires-Dist: yt-dlp
11
+
12
+ # mediasync
13
+
14
+ Synchronize media from multiple sources between devices.
@@ -0,0 +1,8 @@
1
+ mediasync/__init__.py,sha256=paQIa4j4VxFjcsYvixYMH_HI759vgeFDHTaz6aWkmg4,75
2
+ mediasync/__main__.py,sha256=1cPsZ7fkdS8K0_pmOzzYj1Ju6zw_PTiSmfdYN3kYA1U,89
3
+ mediasync/cli.py,sha256=QtCBeejbUpN7Rvh80mn8sWP0cgYt7xCbrDojk0iobe4,27159
4
+ mediasync-0.1.0.dist-info/METADATA,sha256=rf0ymHE-w4_KAcMCEHc-aO_6inhwX91C2i6P4kmkIkk,390
5
+ mediasync-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ mediasync-0.1.0.dist-info/entry_points.txt,sha256=Z2Tc1OesCjnAiB0VQKzkaWK5aNf81KetJSD5CYA-mAk,54
7
+ mediasync-0.1.0.dist-info/top_level.txt,sha256=Tl5rRA8sTYdHq6mU15SOxSfr5NtUWZLir6BqrrHv8zM,10
8
+ mediasync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mediasync = mediasync.__main__:main
@@ -0,0 +1 @@
1
+ mediasync