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 ADDED
@@ -0,0 +1,2 @@
1
+ """Reel — a fully-local, automatic sync tool for the Sony ICD-UX570."""
2
+ __version__ = "1.0.0"
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