mediasync 0.1.0__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.
- mediasync-0.1.0/PKG-INFO +14 -0
- mediasync-0.1.0/README.md +3 -0
- mediasync-0.1.0/pyproject.toml +29 -0
- mediasync-0.1.0/setup.cfg +4 -0
- mediasync-0.1.0/src/mediasync/__init__.py +5 -0
- mediasync-0.1.0/src/mediasync/__main__.py +5 -0
- mediasync-0.1.0/src/mediasync/cli.py +806 -0
- mediasync-0.1.0/src/mediasync.egg-info/PKG-INFO +14 -0
- mediasync-0.1.0/src/mediasync.egg-info/SOURCES.txt +11 -0
- mediasync-0.1.0/src/mediasync.egg-info/dependency_links.txt +1 -0
- mediasync-0.1.0/src/mediasync.egg-info/entry_points.txt +2 -0
- mediasync-0.1.0/src/mediasync.egg-info/requires.txt +2 -0
- mediasync-0.1.0/src/mediasync.egg-info/top_level.txt +1 -0
mediasync-0.1.0/PKG-INFO
ADDED
|
@@ -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,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mediasync"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Synchronize media from multiple sources between devices"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Dart Spark" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"dropbox",
|
|
16
|
+
"yt-dlp",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
mediasync = "mediasync.__main__:main"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://pypi.org/project/mediasync/"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
@@ -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,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/mediasync/__init__.py
|
|
4
|
+
src/mediasync/__main__.py
|
|
5
|
+
src/mediasync/cli.py
|
|
6
|
+
src/mediasync.egg-info/PKG-INFO
|
|
7
|
+
src/mediasync.egg-info/SOURCES.txt
|
|
8
|
+
src/mediasync.egg-info/dependency_links.txt
|
|
9
|
+
src/mediasync.egg-info/entry_points.txt
|
|
10
|
+
src/mediasync.egg-info/requires.txt
|
|
11
|
+
src/mediasync.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mediasync
|