Skip to content

Commit ed1c695

Browse files
Testing mode with portable backups, and GPL-3.0-or-later
Testing mode groups a whole run of work into one restore point, for the first big reorganisation where undoing operations one at a time is not much comfort. A banner keeps count of what has changed, and the session is written continuously to a .bak file. Finishing goes one of two ways: keep everything, which deletes the combined backup while leaving individual operations undoable in History; or undo the session wholesale. The .bak is portable and self-contained, so it can also be loaded later, or on another machine, to reverse the work - tested by restoring from the file in a fresh process with no in-app session. The backup stores no audio, only the log: every move, and every tag change with its previous value. That is sufficient because none of these operations delete anything. Verified on a sandbox: folder structure restored exactly, every tag value back, and audio streams bit-identical by decode-and-compare. Files whose tags were edited are not byte-identical afterwards, because rewriting an ID3 tag rebuilds the container's padding and frame order; that is documented rather than papered over. Licensed GPL-3.0-or-later. mutagen is GPL-2.0-or-later and is linked directly for all tag I/O, so distributed builds must be GPL-compatible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cd0da05 commit ed1c695

6 files changed

Lines changed: 1085 additions & 3 deletions

File tree

LICENSE

Lines changed: 675 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Sortero
22

3+
[![License: GPL v3+](https://img.shields.io/badge/License-GPLv3+-blue.svg)](LICENSE)
4+
35
A macOS app for getting a DJ collection under control: one canonical copy of
46
every track, playlists that preserve your curation, clean tags, and an intake
57
lane for new music.
@@ -151,10 +153,40 @@ interpreter — see `.github/workflows/release.yml`.
151153
./.venv/bin/python run.py
152154
```
153155

156+
## Testing mode
157+
158+
For a first big reorganisation, turn on **Testing → Start Testing Session**.
159+
Everything you do from then on is recorded into a single restore point, saved
160+
continuously as a `.bak` file, and a banner keeps count of what has changed.
161+
162+
- **Keep All Changes** — make it permanent and delete the backup. Individual
163+
operations stay in History and can still be undone one at a time.
164+
- **Undo Everything in This Session** — put the collection back as it was.
165+
- **Save Backup As… / Load Backup and Undo…** — the `.bak` is portable and
166+
self-contained, so it can undo the work from a different machine or after
167+
reinstalling.
168+
169+
The backup holds no audio, only the log: every move, and every tag change with
170+
its previous value. That's enough to reverse everything, because none of these
171+
operations ever delete a file.
172+
173+
Reverting restores the folder structure exactly and puts every tag value back.
174+
Files whose tags were edited won't be byte-identical afterwards — rewriting an
175+
ID3 tag rebuilds the tag container's padding and frame order. The audio streams
176+
are bit-identical; verified with a decode-and-compare.
177+
154178
## Safety
155179

156180
- Dry-run previews on every destructive tab; nothing moves until you confirm.
157181
- Journals live alongside your other app data: `~/Library/Application Support/Sortero`
158182
on macOS, `%APPDATA%\\Sortero` on Windows, `$XDG_DATA_HOME/sortero` on Linux.
159183
- Duplicate removal is quarantine-only — Sortero never calls `unlink` on your music.
160-
- Back up before the first big reorganisation anyway.
184+
- Back up before the first big reorganisation anyway — or use Testing mode.
185+
186+
## License
187+
188+
GPL-3.0-or-later — see [LICENSE](LICENSE).
189+
190+
Sortero links [mutagen](https://mutagen.readthedocs.io/), which is GPL-2.0-or-later,
191+
so distributed builds have to be GPL-compatible. keyring is MIT, and PyInstaller
192+
is GPLv2 with a linking exception that does not constrain the bundled app.

sortero/app.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from tkinter import ttk, filedialog, messagebox
55

66
from . import (library, organize, dupes, fixtags, importer, journal, playlists,
7-
auth, paths, settings, updates, wizard)
7+
auth, paths, settings, updates, wizard, session)
88
from .common import human_size
99

1010
APP = "Sortero"
@@ -90,6 +90,7 @@ def __init__(self):
9090
self._build_menu()
9191
self._build_header()
9292
self._build_tabs()
93+
self._build_banner()
9394
self._build_footer()
9495
self.after(250, self._first_run)
9596

@@ -113,6 +114,19 @@ def _build_menu(self):
113114
command=lambda: paths.reveal(paths.data_dir()))
114115
menubar.add_cascade(label="File", menu=filem)
115116

117+
testm = tk.Menu(menubar, tearoff=0)
118+
testm.add_command(label="Start Testing Session…", command=self.testing_start)
119+
testm.add_separator()
120+
testm.add_command(label="Keep All Changes (delete backup)…",
121+
command=self.testing_commit)
122+
testm.add_command(label="Undo Everything in This Session…",
123+
command=self.testing_revert)
124+
testm.add_separator()
125+
testm.add_command(label="Save Backup As…", command=self.testing_export)
126+
testm.add_command(label="Load Backup and Undo…", command=self.testing_load)
127+
menubar.add_cascade(label="Testing", menu=testm)
128+
self.testm = testm
129+
116130
helpm = tk.Menu(menubar, tearoff=0, name="help")
117131
helpm.add_command(label="Setup Wizard…", command=self.run_wizard)
118132
helpm.add_separator()
@@ -131,6 +145,141 @@ def _build_menu(self):
131145
self.bind_all("<Command-r>" if paths.IS_MAC else "<Control-r>",
132146
lambda e: self.scan())
133147

148+
# -- testing mode ------------------------------------------------------
149+
def _build_banner(self):
150+
self.banner = tk.Frame(self, bg="#8a5a00")
151+
self.banner_label = tk.Label(self.banner, bg="#8a5a00", fg="white",
152+
font=("Helvetica", 12, "bold"), pady=6)
153+
self.banner_label.pack(side="left", padx=12)
154+
tk.Button(self.banner, text="Keep changes", command=self.testing_commit,
155+
highlightbackground="#8a5a00").pack(side="right", padx=6, pady=4)
156+
tk.Button(self.banner, text="Undo everything", command=self.testing_revert,
157+
highlightbackground="#8a5a00").pack(side="right", pady=4)
158+
self.refresh_banner()
159+
160+
def refresh_banner(self):
161+
sess = session.active()
162+
if not sess:
163+
self.banner.pack_forget()
164+
return
165+
s = session.summary(sess)
166+
self.banner_label.configure(
167+
text=f"TESTING MODE — {s['operations']} operations recorded "
168+
f"({s['moves']} moves, {s['tags']} tag edits). "
169+
f"Nothing is permanent until you keep it.")
170+
self.banner.pack(fill="x", before=self.nb)
171+
172+
def testing_start(self):
173+
d = self.require_root()
174+
if not d:
175+
return
176+
if session.active():
177+
messagebox.showinfo(APP, "A testing session is already running.")
178+
return
179+
if not messagebox.askyesno(
180+
APP, "Start a testing session?\n\n"
181+
"Everything you do from now on is recorded into one restore "
182+
"point, saved continuously as a .bak file. You can undo the "
183+
"whole lot in one go, or keep it all when you're happy."):
184+
return
185+
sess = session.start(d)
186+
session.export(sess)
187+
self.refresh_banner()
188+
self.log(f"testing session started: {sess['id']}")
189+
messagebox.showinfo(APP, "Testing mode is on.\n\nBackup: "
190+
f"{session.default_bak_path(sess)}")
191+
192+
def testing_commit(self):
193+
sess = session.active()
194+
if not sess:
195+
messagebox.showinfo(APP, "No testing session is running.")
196+
return
197+
s = session.summary(sess)
198+
if not messagebox.askyesno(
199+
APP, f"Keep all {s['operations']} operations "
200+
f"({s['moves']} moves, {s['tags']} tag edits)?\n\n"
201+
"The combined backup file is deleted. Individual operations "
202+
"stay in History and can still be undone one at a time."):
203+
return
204+
session.commit(sess, log=self.log)
205+
self.refresh_banner()
206+
self.tab_history.refresh()
207+
messagebox.showinfo(APP, "Changes kept. Testing mode is off.")
208+
209+
def testing_revert(self):
210+
sess = session.active()
211+
if not sess:
212+
messagebox.showinfo(APP, "No testing session is running.")
213+
return
214+
s = session.summary(sess)
215+
if not messagebox.askyesno(
216+
APP, f"Undo everything in this session?\n\n"
217+
f"{s['moves']} moves and {s['tags']} tag edits will be reversed, "
218+
"putting your collection back as it was when testing started."):
219+
return
220+
221+
def work(progress, log):
222+
return session.revert_active(log=log)
223+
224+
def done(res):
225+
ok, fail = res
226+
self.refresh_banner()
227+
self.tab_history.refresh()
228+
messagebox.showinfo(APP, f"Reverted {ok} operations."
229+
+ (f"\n{fail} failed." if fail else ""))
230+
self.scan()
231+
232+
self.task.run(work, done, "Undoing session")
233+
234+
def testing_export(self):
235+
sess = session.active()
236+
if not sess:
237+
messagebox.showinfo(APP, "No testing session is running.")
238+
return
239+
p = filedialog.asksaveasfilename(
240+
title="Save Sortero backup", defaultextension=".bak",
241+
initialfile=f"sortero-{sess['id']}.bak",
242+
filetypes=[("Sortero backup", "*.bak")])
243+
if p:
244+
session.export(sess, p)
245+
self.log(f"backup saved: {p}")
246+
messagebox.showinfo(APP, f"Backup saved to\n{p}")
247+
248+
def testing_load(self):
249+
p = filedialog.askopenfilename(title="Load a Sortero backup",
250+
filetypes=[("Sortero backup", "*.bak"),
251+
("All files", "*")])
252+
if not p:
253+
return
254+
try:
255+
data = session.load(p)
256+
except Exception as e:
257+
messagebox.showerror(APP, str(e))
258+
return
259+
d = session.describe(data)
260+
import time as _t
261+
when = _t.strftime("%Y-%m-%d %H:%M", _t.localtime(d["started"])) if d["started"] else "unknown"
262+
if not messagebox.askyesno(
263+
APP, f"Undo everything in this backup?\n\n"
264+
f"Recorded: {when}\nCollection: {d['root']}\n"
265+
f"{d['operations']} operations — {d['moves']} moves, "
266+
f"{d['tags']} tag edits.\n\n"
267+
"Files are moved back and tags restored to their old values."):
268+
return
269+
270+
def work(progress, log):
271+
return session.revert_backup(data, log=log)
272+
273+
def done(res):
274+
ok, fail = res
275+
self.refresh_banner()
276+
self.tab_history.refresh()
277+
messagebox.showinfo(APP, f"Reverted {ok} operations."
278+
+ (f"\n{fail} failed." if fail else ""))
279+
self.scan()
280+
281+
self.task.run(work, done, "Restoring from backup")
282+
134283
def _first_run(self):
135284
def after_wizard(root_dir):
136285
if root_dir:
@@ -252,6 +401,7 @@ def done(res):
252401
self.tab_import, self.tab_needs, self.tab_playlists):
253402
t.invalidate()
254403
self.log(f"Scanned {len(self.recs)} files in {d}")
404+
self.refresh_banner()
255405

256406
self.task.run(work, done, "Scanning library")
257407

sortero/journal.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ def save(self):
4242
json.dump({"id": self.id, "kind": self.kind, "root": self.root,
4343
"started": self.started, "finished": time.time(),
4444
"entries": self.entries}, fh, indent=1)
45+
# If testing mode is running, this operation joins its restore point.
46+
try:
47+
from . import session
48+
session.record(self.path)
49+
if session.active():
50+
session.export()
51+
except Exception:
52+
pass
4553
return self.path
4654

4755

0 commit comments

Comments
 (0)