reel-sync 1.0.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.
- reel/__init__.py +2 -0
- reel/__main__.py +157 -0
- reel/branding.py +76 -0
- reel/classify.py +35 -0
- reel/config.py +92 -0
- reel/console.py +337 -0
- reel/device.py +132 -0
- reel/naming.py +69 -0
- reel/runner.py +141 -0
- reel/sync.py +180 -0
- reel/watch.py +117 -0
- reel_sync-1.0.0.dist-info/METADATA +128 -0
- reel_sync-1.0.0.dist-info/RECORD +16 -0
- reel_sync-1.0.0.dist-info/WHEEL +5 -0
- reel_sync-1.0.0.dist-info/entry_points.txt +2 -0
- reel_sync-1.0.0.dist-info/top_level.txt +1 -0
reel/__init__.py
ADDED
reel/__main__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
__main__.py — the Reel command line.
|
|
3
|
+
|
|
4
|
+
reel setup first-time setup, then flows into auto-sync
|
|
5
|
+
reel start auto-sync: runs passively, syncs on any change
|
|
6
|
+
reel sync one-off sync right now (rarely needed)
|
|
7
|
+
reel sort <path>... run the same auto-filter on a folder/files (drag-drop)
|
|
8
|
+
reel status library summary (counts per category, size)
|
|
9
|
+
reel open open your Reel library in the file manager
|
|
10
|
+
reel devices list any recorders currently detected
|
|
11
|
+
reel reset clear first-time setup (to see the welcome again)
|
|
12
|
+
|
|
13
|
+
Global: --config path/to/config.toml --theme light|dark
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .config import load, resolve_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser():
|
|
25
|
+
# shared options accepted both before AND after the subcommand
|
|
26
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
27
|
+
common.add_argument("--config", default=argparse.SUPPRESS)
|
|
28
|
+
common.add_argument("--theme", choices=["light", "dark"], default=argparse.SUPPRESS)
|
|
29
|
+
|
|
30
|
+
p = argparse.ArgumentParser(prog="reel", parents=[common],
|
|
31
|
+
description="Reel — Sony ICD-UX570 sync")
|
|
32
|
+
sub = p.add_subparsers(dest="cmd") # optional: bare `reel` -> splash
|
|
33
|
+
sub.add_parser("setup", parents=[common])
|
|
34
|
+
sub.add_parser("start", parents=[common])
|
|
35
|
+
sub.add_parser("sync", parents=[common])
|
|
36
|
+
ps = sub.add_parser("sort", parents=[common]); ps.add_argument("paths", nargs="+")
|
|
37
|
+
sub.add_parser("status", parents=[common])
|
|
38
|
+
sub.add_parser("open", parents=[common])
|
|
39
|
+
sub.add_parser("devices", parents=[common])
|
|
40
|
+
sub.add_parser("reset", parents=[common])
|
|
41
|
+
return p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv=None):
|
|
45
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
46
|
+
|
|
47
|
+
# Friendly front doors, handled before argparse:
|
|
48
|
+
# reel -> welcome splash
|
|
49
|
+
# reel help / -h -> clean command reference
|
|
50
|
+
if not argv or argv[0] in ("help", "-h", "--help"):
|
|
51
|
+
from .console import Reel
|
|
52
|
+
con = Reel(theme=load(resolve_path(None)).theme)
|
|
53
|
+
con.splash() if not argv else con.help_screen()
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
args = build_parser().parse_args(argv)
|
|
57
|
+
cfg = load(resolve_path(getattr(args, "config", None)))
|
|
58
|
+
theme = getattr(args, "theme", None)
|
|
59
|
+
if theme:
|
|
60
|
+
cfg.theme = theme
|
|
61
|
+
|
|
62
|
+
from .console import Reel
|
|
63
|
+
con = Reel(theme=cfg.theme)
|
|
64
|
+
|
|
65
|
+
if not args.cmd:
|
|
66
|
+
con.splash()
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if args.cmd == "setup":
|
|
70
|
+
from .runner import first_setup
|
|
71
|
+
first_setup(cfg, con)
|
|
72
|
+
|
|
73
|
+
elif args.cmd == "start":
|
|
74
|
+
from .watch import auto_sync
|
|
75
|
+
from .runner import load_profile
|
|
76
|
+
con.logo()
|
|
77
|
+
con.greeting(load_profile(cfg).get("name"))
|
|
78
|
+
auto_sync(cfg, con)
|
|
79
|
+
|
|
80
|
+
elif args.cmd == "sync":
|
|
81
|
+
from .runner import sync_once
|
|
82
|
+
con.logo()
|
|
83
|
+
sync_once(cfg, con, sources=None)
|
|
84
|
+
|
|
85
|
+
elif args.cmd == "reset":
|
|
86
|
+
from .runner import reset_setup
|
|
87
|
+
if reset_setup(cfg):
|
|
88
|
+
con.ok("setup reset — run 'reel setup' to see the welcome again.")
|
|
89
|
+
else:
|
|
90
|
+
con.dim("nothing to reset — setup hasn't run yet.")
|
|
91
|
+
|
|
92
|
+
elif args.cmd == "sort":
|
|
93
|
+
from .runner import sync_once
|
|
94
|
+
con.logo()
|
|
95
|
+
paths = [Path(p) for p in args.paths]
|
|
96
|
+
missing = [str(p) for p in paths if not p.exists()]
|
|
97
|
+
if missing:
|
|
98
|
+
con.err("not found: " + ", ".join(missing))
|
|
99
|
+
return 1
|
|
100
|
+
sync_once(cfg, con, sources=paths)
|
|
101
|
+
|
|
102
|
+
elif args.cmd == "status":
|
|
103
|
+
_status(cfg, con)
|
|
104
|
+
|
|
105
|
+
elif args.cmd == "devices":
|
|
106
|
+
from . import device
|
|
107
|
+
devs = device.find_devices(cfg)
|
|
108
|
+
if not devs:
|
|
109
|
+
con.warn("no recorder detected.")
|
|
110
|
+
for d in devs:
|
|
111
|
+
con.ok(f"detected: {d}")
|
|
112
|
+
|
|
113
|
+
elif args.cmd == "open":
|
|
114
|
+
import os, subprocess
|
|
115
|
+
cfg.sync_root.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
if sys.platform == "win32":
|
|
117
|
+
os.startfile(cfg.sync_root) # noqa: S606
|
|
118
|
+
elif sys.platform == "darwin":
|
|
119
|
+
subprocess.run(["open", str(cfg.sync_root)])
|
|
120
|
+
else:
|
|
121
|
+
subprocess.run(["xdg-open", str(cfg.sync_root)])
|
|
122
|
+
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _status(cfg, con):
|
|
127
|
+
from . import sync, branding
|
|
128
|
+
manifest = sync.load_manifest(cfg)
|
|
129
|
+
by_cat, total_bytes = {}, 0
|
|
130
|
+
for entry in manifest.values():
|
|
131
|
+
by_cat[entry["category"]] = by_cat.get(entry["category"], 0) + 1
|
|
132
|
+
# size from disk (authoritative)
|
|
133
|
+
if cfg.sync_root.exists():
|
|
134
|
+
for p in cfg.sync_root.rglob("*"):
|
|
135
|
+
if p.is_file() and ".reel" not in p.parts:
|
|
136
|
+
try:
|
|
137
|
+
total_bytes += p.stat().st_size
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
lines = []
|
|
141
|
+
for cat in branding.CATEGORIES:
|
|
142
|
+
n = by_cat.get(cat, 0)
|
|
143
|
+
if n:
|
|
144
|
+
g = branding.CATEGORY_GLYPH.get(cat, "·")
|
|
145
|
+
col = branding.CATEGORY_COLOR.get(cat, "white")
|
|
146
|
+
lines.append(f" [{col}]{g}[/{col}] {cat:<12} {n}")
|
|
147
|
+
targets = ", ".join(str(t) for t in cfg.mirror_targets) or "(none)"
|
|
148
|
+
body = (f" library {cfg.sync_root}\n"
|
|
149
|
+
f" recorded {len(manifest)} files · {total_bytes/(1024*1024):.1f} MB\n"
|
|
150
|
+
f" mirrors {targets}\n")
|
|
151
|
+
if lines:
|
|
152
|
+
body += "\n" + "\n".join(lines)
|
|
153
|
+
con.panel("REEL status", body)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main(sys.argv[1:]))
|
reel/branding.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
branding.py — Reel's identity in one place.
|
|
3
|
+
|
|
4
|
+
Clean and modern: a calm wordmark, light by default, dark on request.
|
|
5
|
+
No CRT, no scanlines. Colours are chosen to read on a WHITE terminal first;
|
|
6
|
+
the dark theme just brightens them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
NAME = "REEL"
|
|
10
|
+
VERSION = "1.0.0"
|
|
11
|
+
TAGLINE = "voice-recorder sync"
|
|
12
|
+
|
|
13
|
+
# A small, tasteful wordmark. Printed once at the top of a run.
|
|
14
|
+
LOGO = r"""
|
|
15
|
+
___ ___ ___ _
|
|
16
|
+
| _ \ __| __| | · {tag}
|
|
17
|
+
| / _|| _|| |__
|
|
18
|
+
|_|_\___|___|____| {ver}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Big "Welcome" wordmark, shown on first-time setup.
|
|
22
|
+
WELCOME_ART = r"""
|
|
23
|
+
__ __ _
|
|
24
|
+
\ \ / /__| | ___ ___ _ __ ___ ___
|
|
25
|
+
\ \ /\ / / _ \ |/ __/ _ \| '_ ` _ \ / _ \
|
|
26
|
+
\ V V / __/ | (_| (_) | | | | | | __/
|
|
27
|
+
\_/\_/ \___|_|\___\___/|_| |_| |_|\___|
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Categories the sorter produces, with a stable display order + glyph.
|
|
31
|
+
CATEGORIES = ["Interviews", "Voice Notes", "Songs", "Podcasts", "Memes", "Other"]
|
|
32
|
+
CATEGORY_GLYPH = {
|
|
33
|
+
"Interviews": "▤",
|
|
34
|
+
"Voice Notes": "✎",
|
|
35
|
+
"Songs": "♪",
|
|
36
|
+
"Podcasts": "◉",
|
|
37
|
+
"Memes": "★",
|
|
38
|
+
"Other": "·",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Semantic styles per theme. Keys are used by console.py to build a rich Theme.
|
|
42
|
+
# Light = default (assumes a white/bright terminal background).
|
|
43
|
+
THEMES = {
|
|
44
|
+
"light": {
|
|
45
|
+
"brand": "bold #0b5fa5", # deep blue wordmark
|
|
46
|
+
"accent": "#0b5fa5",
|
|
47
|
+
"ok": "bold #1a7f37", # green
|
|
48
|
+
"warn": "bold #9a6700", # amber-brown
|
|
49
|
+
"err": "bold #cf222e", # red
|
|
50
|
+
"muted": "#6e7781", # grey
|
|
51
|
+
"value": "default", # the terminal's own fg — readable on any bg
|
|
52
|
+
"bar": "#0b5fa5",
|
|
53
|
+
"bar_bg": "#d0d7de",
|
|
54
|
+
},
|
|
55
|
+
"dark": {
|
|
56
|
+
"brand": "bold #4493f8",
|
|
57
|
+
"accent": "#4493f8",
|
|
58
|
+
"ok": "bold #3fb950",
|
|
59
|
+
"warn": "bold #d29922",
|
|
60
|
+
"err": "bold #f85149",
|
|
61
|
+
"muted": "#8b949e",
|
|
62
|
+
"value": "default",
|
|
63
|
+
"bar": "#4493f8",
|
|
64
|
+
"bar_bg": "#30363d",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Per-category accent (rich colour names / hex). Used in summaries.
|
|
69
|
+
CATEGORY_COLOR = {
|
|
70
|
+
"Interviews": "blue",
|
|
71
|
+
"Voice Notes": "cyan",
|
|
72
|
+
"Songs": "magenta",
|
|
73
|
+
"Podcasts": "green",
|
|
74
|
+
"Memes": "yellow",
|
|
75
|
+
"Other": "white",
|
|
76
|
+
}
|
reel/classify.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
classify.py — decide which category a recording belongs in. Rules, not AI.
|
|
3
|
+
|
|
4
|
+
Signal order (first hit wins):
|
|
5
|
+
1. Source folder on the device — MUSIC -> Songs, PODCASTS -> Podcasts.
|
|
6
|
+
2. Filename keyword — "interview", "song", "meme", "podcast", …
|
|
7
|
+
3. Duration — very short -> Memes, very long -> Interviews,
|
|
8
|
+
otherwise -> Voice Notes.
|
|
9
|
+
|
|
10
|
+
All thresholds and keywords live in config, so it's tunable without code edits.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def classify(*, source: str, filename: str, duration_sec: float | None, cfg) -> str:
|
|
16
|
+
src = (source or "").lower()
|
|
17
|
+
if src == "music":
|
|
18
|
+
return "Songs"
|
|
19
|
+
if src == "podcast":
|
|
20
|
+
return "Podcasts"
|
|
21
|
+
|
|
22
|
+
name = (filename or "").lower()
|
|
23
|
+
for kw, category in cfg.category_keywords.items():
|
|
24
|
+
if kw.lower() in name:
|
|
25
|
+
return category
|
|
26
|
+
|
|
27
|
+
d = duration_sec or 0
|
|
28
|
+
if d and d <= cfg.meme_max_sec:
|
|
29
|
+
return "Memes"
|
|
30
|
+
if d and d >= cfg.interview_min_sec:
|
|
31
|
+
return "Interviews"
|
|
32
|
+
if d:
|
|
33
|
+
return "Voice Notes"
|
|
34
|
+
# no duration available and no other signal
|
|
35
|
+
return "Voice Notes" if src == "voice" else "Other"
|
reel/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""config.py — set it once. Every value has a sensible default."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Config:
|
|
16
|
+
# where synced recordings land (your "Reel" library)
|
|
17
|
+
sync_root: Path = Path.home() / "Reel"
|
|
18
|
+
# optional extra drives to also receive a copy (USB / cloud-synced folder)
|
|
19
|
+
mirror_targets: list[Path] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
# how the ICD-UX570 shows up
|
|
22
|
+
device_labels: list[str] = field(
|
|
23
|
+
default_factory=lambda: ["IC RECORDER", "MEMORY CARD", "IC RECORDER MEMORY CARD"])
|
|
24
|
+
delete_from_device: bool = False # safe default: copy, never wipe the recorder
|
|
25
|
+
|
|
26
|
+
# categorisation thresholds (seconds)
|
|
27
|
+
meme_max_sec: int = 20 # <= this -> Memes
|
|
28
|
+
interview_min_sec: int = 480 # >= this -> Interviews
|
|
29
|
+
# filename keyword -> forced category (case-insensitive substring)
|
|
30
|
+
category_keywords: dict = field(default_factory=lambda: {
|
|
31
|
+
"interview": "Interviews",
|
|
32
|
+
"song": "Songs",
|
|
33
|
+
"meme": "Memes",
|
|
34
|
+
"podcast": "Podcasts",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# watch mode
|
|
38
|
+
watch_interval_sec: int = 4
|
|
39
|
+
close_on_unplug: bool = True # close the terminal window when the recorder is removed
|
|
40
|
+
|
|
41
|
+
# terminal theme: "light" (default) or "dark"
|
|
42
|
+
theme: str = "light"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def manifest_path(self) -> Path:
|
|
46
|
+
return self.sync_root / ".reel" / "manifest.json"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def profile_path(self) -> Path:
|
|
50
|
+
return self.sync_root / ".reel" / "profile.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_path(explicit: str | os.PathLike | None = None) -> Path | None:
|
|
54
|
+
"""Find config.toml wherever you run `reel` from. First match wins:
|
|
55
|
+
1. an explicit --config path
|
|
56
|
+
2. ./config.toml (the folder you're standing in)
|
|
57
|
+
3. ~/.reel/config.toml (a stable per-user spot)
|
|
58
|
+
4. config.toml next to the installed package (the project folder)
|
|
59
|
+
Returns None if none exist -> Reel runs on built-in defaults."""
|
|
60
|
+
candidates = []
|
|
61
|
+
if explicit:
|
|
62
|
+
candidates.append(Path(explicit).expanduser())
|
|
63
|
+
candidates.append(Path.cwd() / "config.toml")
|
|
64
|
+
candidates.append(Path.home() / ".reel" / "config.toml")
|
|
65
|
+
candidates.append(Path(__file__).resolve().parent.parent / "config.toml")
|
|
66
|
+
for c in candidates:
|
|
67
|
+
if c.exists():
|
|
68
|
+
return c
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load(path: str | os.PathLike | None) -> Config:
|
|
73
|
+
cfg = Config()
|
|
74
|
+
if path is None or not Path(path).exists():
|
|
75
|
+
return cfg
|
|
76
|
+
with open(path, "rb") as f:
|
|
77
|
+
data = tomllib.load(f)
|
|
78
|
+
|
|
79
|
+
def g(section, key, default):
|
|
80
|
+
return data.get(section, {}).get(key, default)
|
|
81
|
+
|
|
82
|
+
cfg.sync_root = Path(g("library", "sync_root", str(cfg.sync_root))).expanduser()
|
|
83
|
+
cfg.mirror_targets = [Path(x).expanduser() for x in g("library", "mirror_targets", [])]
|
|
84
|
+
cfg.device_labels = g("device", "labels", cfg.device_labels)
|
|
85
|
+
cfg.delete_from_device = bool(g("device", "delete_after_sync", False))
|
|
86
|
+
cfg.meme_max_sec = int(g("categories", "meme_max_sec", cfg.meme_max_sec))
|
|
87
|
+
cfg.interview_min_sec = int(g("categories", "interview_min_sec", cfg.interview_min_sec))
|
|
88
|
+
cfg.category_keywords = g("categories", "keywords", cfg.category_keywords)
|
|
89
|
+
cfg.watch_interval_sec = int(g("watch", "interval_sec", cfg.watch_interval_sec))
|
|
90
|
+
cfg.close_on_unplug = bool(g("watch", "close_on_unplug", cfg.close_on_unplug))
|
|
91
|
+
cfg.theme = g("ui", "theme", cfg.theme)
|
|
92
|
+
return cfg
|