Skip to content

Commit 3f23766

Browse files
Keep set membership across the analysis round trip
Staging a track for Platinum Notes / Mixed In Key moves it out of the folder it was curated in, and it returns renamed and relocated. As written, that silently dropped the track from its set or vibe playlist - the exact curation this tool exists to protect. Staging now captures the folder as a playlist first and records the playlists each staged track owed a place in. Filing it back out of 'Processed' re-adds it at its new location, then clears the record. Matching survives the round trip in both directions it can change: Platinum Notes appends _PN, and a track with no artist tag stages as 'Unknown Artist - Title', which reads back as a real artist. Entries therefore carry both an artist+title key and a title-only key, precise one tried first. Because matching is on tags rather than file content, a staged MP3 that returns as FLAC still rejoins its set - verified end to end with a real conversion. Also passes the full library into staging, without which a rebuilt playlist would have contained only the returned track instead of the whole set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4c67f5f commit 3f23766

5 files changed

Lines changed: 176 additions & 5 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ loop:
6060
3. Run that folder through Platinum Notes and Mixed In Key, saving into `Processed`.
6161
4. **Import → "Sort the 'Processed' folder"** files them by genre automatically.
6262

63+
Staging a track out of a set or vibe folder does **not** cost you that
64+
curation. Before anything moves, Sortero writes the folder out as a playlist
65+
and records which playlists each staged track belonged to. When the track is
66+
filed back out of `Processed`, it rejoins exactly those playlists at its new
67+
location. Matching is on artist and title, so it survives Platinum Notes
68+
renaming the file *and* changing its format — an MP3 that comes back as FLAC
69+
still lands back in its set.
70+
6371
## Streaming playlists
6472

6573
Paste a Spotify or TIDAL playlist link on the **Playlists** tab and Sortero

sortero/app.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -808,8 +808,13 @@ def stage(self):
808808
return
809809
root = self.app.root_dir.get()
810810

811+
all_recs = self.app.recs
812+
811813
def work(progress, log):
812-
return organize.stage_for_analysis(root, recs, log=log, progress=progress)
814+
# all_recs matters: the folder's *other* tracks are what the
815+
# playlist is rebuilt from before these ones leave.
816+
return organize.stage_for_analysis(root, recs, all_recs=all_recs,
817+
log=log, progress=progress)
813818

814819
def done(res):
815820
path, n = res

sortero/importer.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from .dupes import _sig, ident
1414
from .journal import Journal
1515
from .tagio import Track
16+
from . import membership
17+
from . import playlists as pl
1618

1719
TO_PROCESS = "To Be Processed"
1820
PROCESSED = "Processed"
@@ -122,6 +124,7 @@ def apply(root, results, move=True, clean_spam=True, log=print, progress=None):
122124
todo = [x for x in results if x["dest"]]
123125
total = len(todo) or 1
124126
n = 0
127+
restored = []
125128
for i, x in enumerate(todo):
126129
if progress and i % 10 == 0:
127130
progress(i, total)
@@ -151,10 +154,22 @@ def apply(root, results, move=True, clean_spam=True, log=print, progress=None):
151154
t.set(field, None)
152155
if ch and t.save():
153156
j.tagged(final, ch)
157+
158+
# If this track was staged out of a set/vibe folder, put it back
159+
# into the playlists it came from - now pointing at its new home.
160+
owed = membership.claim(x["rec"])
161+
for name in owed:
162+
pl.append(root, name, [final])
163+
if owed:
164+
restored.append(x["rec"])
165+
log(f" restored to {len(owed)} playlist(s): {os.path.basename(final)}")
154166
except Exception as e:
155167
log(f" ! {os.path.basename(src)}: {e}")
156168
if progress:
157169
progress(total, total)
170+
if restored:
171+
membership.release(restored)
172+
log(f"restored {len(restored)} tracks to their original playlists")
158173
path = j.save()
159174
log(f"imported {n} files | journal: {path}")
160175
return path, n

sortero/membership.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Remember which playlists a track belonged to while it is away being analysed.
2+
3+
Staging a track for Platinum Notes / Mixed In Key moves it out of the folder it
4+
was curated in, and it comes back renamed, in a different place. Without a
5+
record of where it came from, that track would quietly fall out of the set or
6+
vibe playlist it belonged to.
7+
8+
Matching has to survive the round trip, and the name is not stable: Platinum
9+
Notes appends '_PN', and a track with no artist tag is staged as
10+
'Unknown Artist - Title', which then reads back as a real artist. So each entry
11+
carries two keys - artist+title, and title alone - and a claim tries the precise
12+
one first.
13+
"""
14+
import json, os, re
15+
16+
from . import paths
17+
from .dupes import ident, norm_title
18+
19+
PENDING = "pending-membership.json"
20+
PLACEHOLDER = re.compile(r"(?i)^(unknown artist|unknown|various artists|va)$")
21+
22+
23+
def _file():
24+
return os.path.join(paths.data_dir(), PENDING)
25+
26+
27+
def _load():
28+
try:
29+
with open(_file()) as fh:
30+
data = json.load(fh)
31+
return data if isinstance(data, list) else []
32+
except (OSError, json.JSONDecodeError):
33+
return []
34+
35+
36+
def _save(data):
37+
with open(_file(), "w") as fh:
38+
json.dump(data, fh, indent=1)
39+
40+
41+
def _keys(rec):
42+
"""(precise key, loose key). The loose key ignores the artist entirely."""
43+
artist = rec.artist or ""
44+
precise = ident(rec) if not PLACEHOLDER.match(artist.strip()) else None
45+
loose = norm_title(rec.title or "") or None
46+
return precise, loose
47+
48+
49+
def remember(pairs):
50+
"""pairs: iterable of (Rec, [playlist name, ...])."""
51+
data = _load()
52+
for rec, names in pairs:
53+
if not names:
54+
continue
55+
precise, loose = _keys(rec)
56+
found = None
57+
for e in data:
58+
if (precise and e.get("ident") == precise) or \
59+
(loose and e.get("title") == loose):
60+
found = e
61+
break
62+
if found is None:
63+
found = {"ident": precise, "title": loose,
64+
"display": rec.display, "playlists": []}
65+
data.append(found)
66+
for n in names:
67+
if n not in found["playlists"]:
68+
found["playlists"].append(n)
69+
_save(data)
70+
return len(data)
71+
72+
73+
def claim(rec):
74+
"""Playlists this track owes a place in. Does not clear the entry."""
75+
precise, loose = _keys(rec)
76+
data = _load()
77+
if precise:
78+
for e in data:
79+
if e.get("ident") == precise:
80+
return e["playlists"]
81+
if loose:
82+
for e in data:
83+
if e.get("title") == loose:
84+
return e["playlists"]
85+
return []
86+
87+
88+
def release(recs):
89+
"""Forget the entries for these tracks, once they are back in playlists."""
90+
data = _load()
91+
drop = []
92+
for r in recs:
93+
precise, loose = _keys(r)
94+
for e in data:
95+
if (precise and e.get("ident") == precise) or \
96+
(loose and e.get("title") == loose):
97+
drop.append(id(e))
98+
_save([e for e in data if id(e) not in drop])
99+
100+
101+
def pending_count():
102+
return len(_load())
103+
104+
105+
def pending():
106+
return _load()

sortero/organize.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,11 @@ def playlists_from_current(root, recs):
276276
return dict(pls)
277277

278278

279-
def write_playlists(root, playlists, journal=None, dry=False):
279+
def write_playlists(root, playlists, journal=None, dry=False, min_tracks=2):
280280
out = os.path.join(root, PLAYLIST_DIR)
281281
written = []
282282
for name, paths in sorted(playlists.items()):
283-
if len(paths) < 2:
283+
if len(paths) < min_tracks:
284284
continue
285285
fp = os.path.join(out, f"{name}.m3u8")
286286
written.append(fp)
@@ -296,10 +296,47 @@ def write_playlists(root, playlists, journal=None, dry=False):
296296
return written
297297

298298

299-
def stage_for_analysis(root, recs, log=print, progress=None):
300-
"""Move chosen tracks into 'To Be Processed' for Platinum Notes / Mixed In Key."""
299+
def folder_playlist_name(rec):
300+
"""The playlist name a track's current folder maps to, or None at the root."""
301+
folder = os.path.dirname(rec.rel)
302+
if not folder:
303+
return None
304+
return safe(folder.replace(os.sep, " - "), 100)
305+
306+
307+
def stage_for_analysis(root, recs, all_recs=None, log=print, progress=None):
308+
"""Move chosen tracks into 'To Be Processed' for Platinum Notes / Mixed In Key.
309+
310+
Their current folder membership is written to playlists first and recorded
311+
as pending, so filing them back out of 'Processed' restores them to the
312+
sets and vibe playlists they were curated into.
313+
"""
314+
from . import membership
315+
301316
j = Journal("stage-for-analysis", root)
302317
dest_dir = os.path.join(root, "To Be Processed")
318+
319+
# Capture curation before anything moves.
320+
staged_paths = {r.path for r in recs}
321+
owed = [(r, [n]) for r in recs if (n := folder_playlist_name(r))]
322+
if owed:
323+
pool = all_recs if all_recs is not None else recs
324+
by_name = collections.defaultdict(list)
325+
for r in pool:
326+
if r.protected or r.path in staged_paths:
327+
continue
328+
n = folder_playlist_name(r)
329+
if n:
330+
by_name[n].append(r.path)
331+
affected = {n for _, names in owed for n in names}
332+
# min_tracks=1: keep even a nearly-empty playlist alive so the staged
333+
# track has something to rejoin.
334+
write_playlists(root, {n: by_name.get(n, []) for n in affected},
335+
journal=j, min_tracks=1)
336+
membership.remember(owed)
337+
log(f"recorded playlist membership for {len(owed)} tracks "
338+
f"across {len(affected)} playlists")
339+
303340
total = len(recs) or 1
304341
n = 0
305342
for i, r in enumerate(recs):

0 commit comments

Comments
 (0)