reel-sync 1.0.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.
- reel_sync-1.0.0/PKG-INFO +128 -0
- reel_sync-1.0.0/README.md +114 -0
- reel_sync-1.0.0/pyproject.toml +33 -0
- reel_sync-1.0.0/reel/__init__.py +2 -0
- reel_sync-1.0.0/reel/__main__.py +157 -0
- reel_sync-1.0.0/reel/branding.py +76 -0
- reel_sync-1.0.0/reel/classify.py +35 -0
- reel_sync-1.0.0/reel/config.py +92 -0
- reel_sync-1.0.0/reel/console.py +337 -0
- reel_sync-1.0.0/reel/device.py +132 -0
- reel_sync-1.0.0/reel/naming.py +69 -0
- reel_sync-1.0.0/reel/runner.py +141 -0
- reel_sync-1.0.0/reel/sync.py +180 -0
- reel_sync-1.0.0/reel/watch.py +117 -0
- reel_sync-1.0.0/reel_sync.egg-info/PKG-INFO +128 -0
- reel_sync-1.0.0/reel_sync.egg-info/SOURCES.txt +19 -0
- reel_sync-1.0.0/reel_sync.egg-info/dependency_links.txt +1 -0
- reel_sync-1.0.0/reel_sync.egg-info/entry_points.txt +2 -0
- reel_sync-1.0.0/reel_sync.egg-info/requires.txt +5 -0
- reel_sync-1.0.0/reel_sync.egg-info/top_level.txt +1 -0
- reel_sync-1.0.0/setup.cfg +4 -0
reel_sync-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reel-sync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fully-local sync tool for the Sony ICD-UX570 voice recorder
|
|
5
|
+
Author: Nicolas
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://pypi.org/project/reel-sync/
|
|
8
|
+
Keywords: sony,icd-ux570,voice-recorder,sync,dictaphone
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Requires-Dist: mutagen>=1.47
|
|
13
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
14
|
+
|
|
15
|
+
# REEL
|
|
16
|
+
|
|
17
|
+
**A fully-local, automatic sync tool for the Sony ICD-UX570.** Plug in the recorder, and Reel pulls every new recording onto your PC, names it by the moment you hit record, and files it into the right category — Interviews, Songs, Memes, Podcasts, Voice Notes. Re-plug the same stick and nothing copies twice. A clean terminal, a progress bar, done.
|
|
18
|
+
|
|
19
|
+
> Everything stays on your machine. The recorder is never wiped. The library is just folders — open it in Explorer any time.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What it does
|
|
24
|
+
|
|
25
|
+
- **Detects the recorder** the moment it mounts (by volume label *or* by spotting its `REC_FILE` folder — works even if the label changes).
|
|
26
|
+
- **Copies only what's new.** Each file is fingerprinted; already-synced recordings are skipped.
|
|
27
|
+
- **Names everything cleanly** — `2026-06-08_1432_reel-7F3A.mp3` (date-first, sorts forever).
|
|
28
|
+
- **Auto-categorises** using simple, tunable rules:
|
|
29
|
+
- device `MUSIC` folder → **Songs**, `PODCASTS` → **Podcasts**
|
|
30
|
+
- a filename keyword (`interview`, `meme`, …) wins if present
|
|
31
|
+
- otherwise by length: very short → **Memes**, very long → **Interviews**, else **Voice Notes**
|
|
32
|
+
- **Mirrors** to any extra drives you list (USB / cloud), so sync doubles as backup.
|
|
33
|
+
- **Runs automatically** in watch mode — leave it running and every plug-in just syncs.
|
|
34
|
+
|
|
35
|
+
Files land here:
|
|
36
|
+
```
|
|
37
|
+
Reel/
|
|
38
|
+
├── Interviews/2026/2026-06-08_1432_reel-7F3A.mp3
|
|
39
|
+
├── Songs/2026/2026-05-30_2011_reel-A1C9.mp3
|
|
40
|
+
├── Memes/2026/…
|
|
41
|
+
└── .reel/manifest.json ← dedup index (so nothing copies twice)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
Once it's published, anyone can install it from PyPI:
|
|
49
|
+
```
|
|
50
|
+
pip install reel-sync
|
|
51
|
+
```
|
|
52
|
+
(The package is `reel-sync`; the command you run is just `reel`.)
|
|
53
|
+
|
|
54
|
+
### From this folder (for development)
|
|
55
|
+
|
|
56
|
+
1. **Install Python 3.11+** (python.org → tick *Add Python to PATH*).
|
|
57
|
+
2. **Install Reel as a command** — open a terminal in this folder once:
|
|
58
|
+
```
|
|
59
|
+
pip install -e .
|
|
60
|
+
```
|
|
61
|
+
That puts a `reel` command on your PATH, so you can run it from **any** folder
|
|
62
|
+
(including `C:\Users\YOU>`), not just this one.
|
|
63
|
+
3. **Set your path** in `config.toml`:
|
|
64
|
+
```toml
|
|
65
|
+
[library]
|
|
66
|
+
sync_root = "C:/Users/YOURNAME/Reel"
|
|
67
|
+
```
|
|
68
|
+
Point `sync_root` at a cloud-synced folder (pCloud / OneDrive / Dropbox) and your off-site backup is automatic.
|
|
69
|
+
|
|
70
|
+
Reel finds this `config.toml` automatically wherever you run it from (it also
|
|
71
|
+
checks `./config.toml` and `~/.reel/config.toml` first). No config at all? It
|
|
72
|
+
falls back to sensible defaults (`~/Reel`).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Use it
|
|
77
|
+
|
|
78
|
+
You really only need two commands. Run them from anywhere — your home prompt is fine.
|
|
79
|
+
|
|
80
|
+
**First time** — run this, then plug in the recorder when it asks:
|
|
81
|
+
```
|
|
82
|
+
reel setup
|
|
83
|
+
```
|
|
84
|
+
A short welcome, then it waits for your ICD-UX570, does the big initial copy, and
|
|
85
|
+
**stays running as auto-sync** from there. (Run `reel setup` again later and it
|
|
86
|
+
just says *"Already set up :)"*.)
|
|
87
|
+
|
|
88
|
+
**Every day after** — start auto-sync and leave it running:
|
|
89
|
+
```
|
|
90
|
+
reel start
|
|
91
|
+
```
|
|
92
|
+
It does the initial sync, then keeps syncing automatically whenever you **drop a
|
|
93
|
+
file onto the recorder from your PC** — and it **stops by itself the moment you
|
|
94
|
+
unplug** the recorder. (Press **Ctrl + C** to stop sooner.)
|
|
95
|
+
|
|
96
|
+
That's the whole flow. The rest is optional:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
reel sync # a single one-off sync, then exit (rarely needed)
|
|
100
|
+
reel sort <path> # auto-filter a folder/files you drop in (or use the .bat)
|
|
101
|
+
reel status # what's in your library, per category
|
|
102
|
+
reel devices # is the recorder detected right now?
|
|
103
|
+
reel open # open the Reel library in Explorer
|
|
104
|
+
reel reset # clear first-time setup (to see the welcome again)
|
|
105
|
+
reel --theme dark start# dark terminal theme
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
(`python -m reel start` and `python run.py start` work identically.)
|
|
109
|
+
|
|
110
|
+
### Make it truly hands-off (optional)
|
|
111
|
+
Put a shortcut to `reel start` in your Startup folder (`Win+R` → `shell:startup`).
|
|
112
|
+
Reel will be running and watching from the moment you log in.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Branding
|
|
117
|
+
|
|
118
|
+
Light by default — colours tuned for a white terminal. Want dark? `theme = "dark"` in `config.toml`, or `--theme dark` on any command. Identity lives in `reel/branding.py`.
|
|
119
|
+
|
|
120
|
+
## Notes
|
|
121
|
+
|
|
122
|
+
- **Privacy:** nothing leaves your machine except the copies you put in your own mirror/cloud folders.
|
|
123
|
+
- **Safety:** `delete_after_sync = false` by default — Reel copies off the recorder and never deletes from it.
|
|
124
|
+
- **Coming later:** built-in playback, a podcast queue, smarter song-vs-voice detection. See `ROADMAP.md`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
*REEL v1.0.0 — plug in, walk away.*
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# REEL
|
|
2
|
+
|
|
3
|
+
**A fully-local, automatic sync tool for the Sony ICD-UX570.** Plug in the recorder, and Reel pulls every new recording onto your PC, names it by the moment you hit record, and files it into the right category — Interviews, Songs, Memes, Podcasts, Voice Notes. Re-plug the same stick and nothing copies twice. A clean terminal, a progress bar, done.
|
|
4
|
+
|
|
5
|
+
> Everything stays on your machine. The recorder is never wiped. The library is just folders — open it in Explorer any time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **Detects the recorder** the moment it mounts (by volume label *or* by spotting its `REC_FILE` folder — works even if the label changes).
|
|
12
|
+
- **Copies only what's new.** Each file is fingerprinted; already-synced recordings are skipped.
|
|
13
|
+
- **Names everything cleanly** — `2026-06-08_1432_reel-7F3A.mp3` (date-first, sorts forever).
|
|
14
|
+
- **Auto-categorises** using simple, tunable rules:
|
|
15
|
+
- device `MUSIC` folder → **Songs**, `PODCASTS` → **Podcasts**
|
|
16
|
+
- a filename keyword (`interview`, `meme`, …) wins if present
|
|
17
|
+
- otherwise by length: very short → **Memes**, very long → **Interviews**, else **Voice Notes**
|
|
18
|
+
- **Mirrors** to any extra drives you list (USB / cloud), so sync doubles as backup.
|
|
19
|
+
- **Runs automatically** in watch mode — leave it running and every plug-in just syncs.
|
|
20
|
+
|
|
21
|
+
Files land here:
|
|
22
|
+
```
|
|
23
|
+
Reel/
|
|
24
|
+
├── Interviews/2026/2026-06-08_1432_reel-7F3A.mp3
|
|
25
|
+
├── Songs/2026/2026-05-30_2011_reel-A1C9.mp3
|
|
26
|
+
├── Memes/2026/…
|
|
27
|
+
└── .reel/manifest.json ← dedup index (so nothing copies twice)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Once it's published, anyone can install it from PyPI:
|
|
35
|
+
```
|
|
36
|
+
pip install reel-sync
|
|
37
|
+
```
|
|
38
|
+
(The package is `reel-sync`; the command you run is just `reel`.)
|
|
39
|
+
|
|
40
|
+
### From this folder (for development)
|
|
41
|
+
|
|
42
|
+
1. **Install Python 3.11+** (python.org → tick *Add Python to PATH*).
|
|
43
|
+
2. **Install Reel as a command** — open a terminal in this folder once:
|
|
44
|
+
```
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
That puts a `reel` command on your PATH, so you can run it from **any** folder
|
|
48
|
+
(including `C:\Users\YOU>`), not just this one.
|
|
49
|
+
3. **Set your path** in `config.toml`:
|
|
50
|
+
```toml
|
|
51
|
+
[library]
|
|
52
|
+
sync_root = "C:/Users/YOURNAME/Reel"
|
|
53
|
+
```
|
|
54
|
+
Point `sync_root` at a cloud-synced folder (pCloud / OneDrive / Dropbox) and your off-site backup is automatic.
|
|
55
|
+
|
|
56
|
+
Reel finds this `config.toml` automatically wherever you run it from (it also
|
|
57
|
+
checks `./config.toml` and `~/.reel/config.toml` first). No config at all? It
|
|
58
|
+
falls back to sensible defaults (`~/Reel`).
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Use it
|
|
63
|
+
|
|
64
|
+
You really only need two commands. Run them from anywhere — your home prompt is fine.
|
|
65
|
+
|
|
66
|
+
**First time** — run this, then plug in the recorder when it asks:
|
|
67
|
+
```
|
|
68
|
+
reel setup
|
|
69
|
+
```
|
|
70
|
+
A short welcome, then it waits for your ICD-UX570, does the big initial copy, and
|
|
71
|
+
**stays running as auto-sync** from there. (Run `reel setup` again later and it
|
|
72
|
+
just says *"Already set up :)"*.)
|
|
73
|
+
|
|
74
|
+
**Every day after** — start auto-sync and leave it running:
|
|
75
|
+
```
|
|
76
|
+
reel start
|
|
77
|
+
```
|
|
78
|
+
It does the initial sync, then keeps syncing automatically whenever you **drop a
|
|
79
|
+
file onto the recorder from your PC** — and it **stops by itself the moment you
|
|
80
|
+
unplug** the recorder. (Press **Ctrl + C** to stop sooner.)
|
|
81
|
+
|
|
82
|
+
That's the whole flow. The rest is optional:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
reel sync # a single one-off sync, then exit (rarely needed)
|
|
86
|
+
reel sort <path> # auto-filter a folder/files you drop in (or use the .bat)
|
|
87
|
+
reel status # what's in your library, per category
|
|
88
|
+
reel devices # is the recorder detected right now?
|
|
89
|
+
reel open # open the Reel library in Explorer
|
|
90
|
+
reel reset # clear first-time setup (to see the welcome again)
|
|
91
|
+
reel --theme dark start# dark terminal theme
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
(`python -m reel start` and `python run.py start` work identically.)
|
|
95
|
+
|
|
96
|
+
### Make it truly hands-off (optional)
|
|
97
|
+
Put a shortcut to `reel start` in your Startup folder (`Win+R` → `shell:startup`).
|
|
98
|
+
Reel will be running and watching from the moment you log in.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Branding
|
|
103
|
+
|
|
104
|
+
Light by default — colours tuned for a white terminal. Want dark? `theme = "dark"` in `config.toml`, or `--theme dark` on any command. Identity lives in `reel/branding.py`.
|
|
105
|
+
|
|
106
|
+
## Notes
|
|
107
|
+
|
|
108
|
+
- **Privacy:** nothing leaves your machine except the copies you put in your own mirror/cloud folders.
|
|
109
|
+
- **Safety:** `delete_after_sync = false` by default — Reel copies off the recorder and never deletes from it.
|
|
110
|
+
- **Coming later:** built-in playback, a podcast queue, smarter song-vs-voice detection. See `ROADMAP.md`.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
*REEL v1.0.0 — plug in, walk away.*
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
# Published on PyPI as "reel-sync" (plain "reel" was taken). Friends install with
|
|
6
|
+
# `pip install reel-sync`; the import package and the command both stay `reel`.
|
|
7
|
+
[project]
|
|
8
|
+
name = "reel-sync"
|
|
9
|
+
dynamic = ["version"] # single-sourced from reel/__init__.py
|
|
10
|
+
description = "Fully-local sync tool for the Sony ICD-UX570 voice recorder"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
authors = [{ name = "Nicolas" }]
|
|
15
|
+
keywords = ["sony", "icd-ux570", "voice-recorder", "sync", "dictaphone"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"rich>=13.0",
|
|
18
|
+
"mutagen>=1.47",
|
|
19
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://pypi.org/project/reel-sync/"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
reel = "reel.__main__:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["reel"]
|
|
30
|
+
py-modules = []
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.dynamic]
|
|
33
|
+
version = { attr = "reel.__version__" }
|
|
@@ -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:]))
|
|
@@ -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
|
+
}
|
|
@@ -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"
|
|
@@ -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
|