Skip to content

Commit ebbf2e7

Browse files
Sortero: a DJ collection organiser for macOS
Consolidates a scattered DJ library into one canonical copy per track while preserving curation as playlists rather than duplicated files. - Organise: files tracks to Tracks/<Genre>/Artist - Title, collapses duplicate copies into one file, and rewrites every existing folder as an .m3u8 that points at the survivor, so vibe folders imported from Spotify/Tidal and gig sets keep their curation without duplicating audio. - Tags: strips download-site spam from Genre/Comment, infers missing Artist/Title from filenames, normalises Genre, and promotes Mixed In Key energy out of the comment field into the sortable Grouping field. - Duplicates: exact (content-hashed) and likely (artist/title/version plus duration) matching that never groups distinct remixes together. - Import: routes analysed tracks to Tracks/<Genre> and unanalysed ones to 'To Be Processed'; a dedicated action files the 'Processed' folder. - History: every operation is journalled and reversible. 'To Be Processed' and 'Processed' are never reorganised. Nothing is deleted - extra copies move to _Quarantine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0 parents  commit ebbf2e7

18 files changed

Lines changed: 2231 additions & 0 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.venv/
2+
__pycache__/
3+
*.pyc
4+
build/dist/
5+
build/work/
6+
build/Sortero.iconset/
7+
build/Sortero.spec
8+
.DS_Store

README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Sortero
2+
3+
A macOS app for getting a DJ collection under control: one canonical copy of
4+
every track, playlists that preserve your curation, clean tags, and an intake
5+
lane for new music.
6+
7+
![tabs: Overview, Organise, Tags, Duplicates, Import, History](build/icon_1024.png)
8+
9+
## The idea
10+
11+
The consensus among working DJs is that the *filesystem* should be shallow and
12+
predictable, and the *software* should do the organising — crates and playlists
13+
reference a track, they don't copy it. Sortero applies that:
14+
15+
- **One file per track**, at `Tracks/<Genre>/Artist - Title.ext`
16+
- **Playlists as pointers.** Every folder you have today — Spotify/Tidal vibe
17+
imports, gig sets — becomes an `.m3u8` in `_Playlists/` pointing at that one
18+
file. A track curated into five vibes is five playlist entries and one file.
19+
- **Key, BPM and energy live in tags**, where rekordbox and Mixxx can sort them.
20+
- **Nothing is deleted.** Extra copies go to `_Quarantine/`. Every operation is
21+
journalled and reversible from the History tab.
22+
23+
## Layout it produces
24+
25+
```
26+
DJ Collection/
27+
Tracks/<Genre>/Artist - Title.ext canonical home for every track
28+
Sets/<Set Name>/ optional: gig folders kept as folders
29+
Albums/<Album>/ full releases, left intact
30+
Mixes/ recordings over 20 minutes
31+
_Playlists/*.m3u8 every folder you had, as a playlist
32+
_Quarantine/ duplicates and rejects, never deleted
33+
To Be Processed/ your Platinum Notes / Mixed In Key stage
34+
Processed/ analysed and ready to file
35+
```
36+
37+
`To Be Processed` and `Processed` are never reorganised.
38+
39+
## Tabs
40+
41+
| Tab | What it does |
42+
|---|---|
43+
| **Overview** | Track count, size, and what share has key+BPM, genre and energy. Lists what needs attention. |
44+
| **Organise** | Previews every move before anything happens. Collapses duplicates, writes playlists, files tracks by genre. |
45+
| **Tags** | Strips download-site spam from Genre/Comment, infers missing Artist/Title from filenames, normalises Genre, and promotes Mixed In Key energy into the sortable Grouping field. |
46+
| **Duplicates** | Exact (identical audio) and Likely (same artist/title/version, same length). Different remixes are never grouped. Extras move to `_Quarantine`. |
47+
| **Import** | Add files or folders. Analysed tracks go straight to `Tracks/<Genre>`; anything missing key/BPM lands in `To Be Processed`. Tracks already in the library are flagged, not copied. **"Sort the 'Processed' folder"** files everything you've already run through PN and MIK. |
48+
| **History** | Every operation, with one-click undo. |
49+
50+
## Why energy ends up in Grouping
51+
52+
Mixed In Key writes `Cm - Energy 6` into the **comment** field. The key half
53+
usually also reaches `TKEY`, but the energy half is stranded somewhere no DJ
54+
software can sort on. Sortero copies it to **Grouping** as `5A - Energy 6`
55+
(Camelot + energy), which rekordbox and Mixxx expose as a sortable column.
56+
57+
## Running it
58+
59+
Build the app, then open `Sortero.app` and point it at your collection.
60+
61+
### Building from source
62+
63+
```bash
64+
python3.12 -m venv .venv
65+
./.venv/bin/pip install -U pip mutagen pyinstaller
66+
./build/build_app.sh # -> build/dist/Sortero.app
67+
```
68+
69+
Requires Python 3.12 with Tk (`brew install python-tk@3.12`).
70+
71+
### Running without building
72+
73+
```bash
74+
./.venv/bin/python run.py
75+
```
76+
77+
## Safety
78+
79+
- Dry-run previews on every destructive tab; nothing moves until you confirm.
80+
- Journals in `~/Library/Application Support/Sortero/journals/`.
81+
- Duplicate removal is quarantine-only — Sortero never calls `unlink` on your music.
82+
- Back up before the first big reorganisation anyway.

build/Sortero.icns

105 KB
Binary file not shown.

build/build_app.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/bin/bash
2+
# Build Sortero.app. Run from the repo root: ./build/build_app.sh
3+
set -euo pipefail
4+
cd "$(dirname "$0")/.."
5+
6+
VENV=./.venv
7+
[ -d "$VENV" ] || { echo "no venv - see README"; exit 1; }
8+
9+
echo "==> regenerating icon"
10+
"$VENV/bin/python" build/gen_icon.py
11+
12+
echo "==> building app bundle"
13+
rm -rf build/dist build/work dist/Sortero.app
14+
# --specpath makes relative paths resolve against it, so pass the icon absolute
15+
ICON="$(cd build && pwd)/Sortero.icns"
16+
"$VENV/bin/pyinstaller" \
17+
--noconfirm --clean --windowed \
18+
--name Sortero \
19+
--icon "$ICON" \
20+
--osx-bundle-identifier com.sortero.app \
21+
--distpath build/dist \
22+
--workpath build/work \
23+
--specpath build \
24+
--hidden-import mutagen \
25+
--collect-submodules mutagen \
26+
run.py
27+
28+
APP="build/dist/Sortero.app"
29+
[ -d "$APP" ] || { echo "build failed"; exit 1; }
30+
31+
# Ad-hoc sign so macOS will run it locally without a Gatekeeper prompt loop.
32+
codesign --force --deep --sign - "$APP" 2>/dev/null || echo "(codesign skipped)"
33+
34+
echo "==> built $APP"
35+
du -sh "$APP"

build/gen_icon.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""Generate Sortero.icns with no third-party imaging dependencies.
3+
4+
Draws a rounded-square tile with three sorted bars (short -> long), which reads
5+
as both 'sort' and 'levels'. Writes a PNG by hand, then hands off to sips and
6+
iconutil to produce the .icns.
7+
"""
8+
import math, os, struct, subprocess, sys, zlib
9+
10+
S = 1024
11+
BG_TOP = (28, 30, 38)
12+
BG_BOT = (16, 17, 22)
13+
BARS = [
14+
(0.30, 0.42, (255, 92, 122)), # y-centre, width fraction, colour
15+
(0.50, 0.62, (255, 176, 74)),
16+
(0.70, 0.80, (86, 214, 178)),
17+
]
18+
CORNER = 0.22 * S
19+
BAR_H = 0.105 * S
20+
BAR_X = 0.13 * S
21+
22+
23+
def rounded_alpha(x, y, w, h, r):
24+
"""Coverage 0..1 of a rounded rect, sampled 2x2 for smooth edges."""
25+
acc = 0.0
26+
for dx in (0.25, 0.75):
27+
for dy in (0.25, 0.75):
28+
px, py = x + dx, y + dy
29+
cx = min(max(px, r), w - r)
30+
cy = min(max(py, r), h - r)
31+
d = math.hypot(px - cx, py - cy)
32+
if px < 0 or py < 0 or px > w or py > h:
33+
continue
34+
acc += 1.0 if d <= r else max(0.0, 1.0 - (d - r))
35+
return acc / 4.0
36+
37+
38+
def blend(dst, src, a):
39+
return tuple(int(round(d + (s - d) * a)) for d, s in zip(dst, src))
40+
41+
42+
def render():
43+
rows = []
44+
for y in range(S):
45+
t = y / (S - 1)
46+
bg = tuple(int(round(a + (b - a) * t)) for a, b in zip(BG_TOP, BG_BOT))
47+
row = bytearray()
48+
row.append(0) # PNG filter: none
49+
for x in range(S):
50+
a = rounded_alpha(x, y, S, S, CORNER)
51+
if a <= 0:
52+
row += bytes((0, 0, 0, 0))
53+
continue
54+
px = bg
55+
for cy_frac, w_frac, colour in BARS:
56+
cy = cy_frac * S
57+
if abs(y - cy) <= BAR_H / 2:
58+
bx0, bx1 = BAR_X, BAR_X + w_frac * (S - 2 * BAR_X)
59+
br = BAR_H / 2
60+
ba = rounded_alpha(x - bx0, y - (cy - BAR_H / 2),
61+
bx1 - bx0, BAR_H, br)
62+
if ba > 0:
63+
px = blend(px, colour, ba)
64+
row += bytes(px) + bytes((int(round(a * 255)),))
65+
rows.append(bytes(row))
66+
return b"".join(rows)
67+
68+
69+
def chunk(tag, data):
70+
return (struct.pack(">I", len(data)) + tag + data
71+
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
72+
73+
74+
def write_png(path, raw):
75+
ihdr = struct.pack(">IIBBBBB", S, S, 8, 6, 0, 0, 0) # 8-bit RGBA
76+
with open(path, "wb") as fh:
77+
fh.write(b"\x89PNG\r\n\x1a\n")
78+
fh.write(chunk(b"IHDR", ihdr))
79+
fh.write(chunk(b"IDAT", zlib.compress(raw, 9)))
80+
fh.write(chunk(b"IEND", b""))
81+
82+
83+
def main():
84+
out = os.path.dirname(os.path.abspath(__file__))
85+
master = os.path.join(out, "icon_1024.png")
86+
print("rendering…")
87+
write_png(master, render())
88+
89+
iconset = os.path.join(out, "Sortero.iconset")
90+
os.makedirs(iconset, exist_ok=True)
91+
for size in (16, 32, 64, 128, 256, 512, 1024):
92+
for scale, suffix in ((1, ""), (2, "@2x")):
93+
px = size * scale
94+
if px > 1024:
95+
continue
96+
name = f"icon_{size}x{size}{suffix}.png"
97+
subprocess.run(["sips", "-z", str(px), str(px), master,
98+
"--out", os.path.join(iconset, name)],
99+
check=True, capture_output=True)
100+
icns = os.path.join(out, "Sortero.icns")
101+
subprocess.run(["iconutil", "-c", "icns", iconset, "-o", icns], check=True)
102+
print("wrote", icns)
103+
104+
105+
if __name__ == "__main__":
106+
main()

build/icon_1024.png

18.6 KB
Loading

run.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env python3
2+
"""Dev entry point: python run.py"""
3+
from sortero.app import main
4+
5+
if __name__ == "__main__":
6+
main()

sortero/__init__.py

Whitespace-only changes.

sortero/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .app import main
2+
3+
if __name__ == "__main__":
4+
main()

0 commit comments

Comments
 (0)