Skip to content

Commit d65c1ce

Browse files
Cross-platform support, streaming playlists, and OAuth
Playlists tab: build an .m3u8 from a Spotify or TIDAL playlist link by matching each track against files already in the collection. Falls back to pasted 'Artist - Title' lines or a CSV export, which always work. Import tab gains an "add to playlist" picker, plus a button to rebuild folder playlists. OAuth 2.0 Authorization Code + PKCE for TIDAL and Spotify. Sign-in happens on the provider's own site, so no password ever reaches Sortero; tokens go to the OS credential store. Connecting also lifts Spotify's 50-track embed cap. Cross-platform: platform-appropriate config/data directories, an OS-agnostic credential store with a 0600 file fallback, a portable "reveal in file manager", and pure-Python .ico generation so Windows icons need no macOS tooling. build_app.py replaces the shell script and builds on all three platforms; --universal2 checks the interpreter is actually universal before invoking PyInstaller. CI builds macOS (universal2), Windows and Linux on a tag and attaches them to a GitHub release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ebbf2e7 commit d65c1ce

15 files changed

Lines changed: 1300 additions & 110 deletions

File tree

.github/workflows/release.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Build and release
2+
3+
# Push a tag like v0.1.0 to build all three platforms and attach them to a
4+
# GitHub release. Also runnable by hand from the Actions tab.
5+
on:
6+
push:
7+
tags: ["v*"]
8+
workflow_dispatch:
9+
inputs:
10+
tag:
11+
description: "Tag to build and release (e.g. v0.1.0)"
12+
required: true
13+
14+
permissions:
15+
contents: write
16+
17+
jobs:
18+
build:
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
include:
23+
- os: macos-14
24+
name: macOS-universal2
25+
args: "--universal2"
26+
- os: windows-latest
27+
name: windows-x86_64
28+
args: ""
29+
- os: ubuntu-22.04
30+
name: linux-x86_64
31+
args: ""
32+
runs-on: ${{ matrix.os }}
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
# setup-python ships the python.org macOS build, which is universal2 -
37+
# that is what makes the universal Mac app possible.
38+
- uses: actions/setup-python@v5
39+
with:
40+
python-version: "3.12"
41+
42+
- name: Install Tk (Linux)
43+
if: runner.os == 'Linux'
44+
run: sudo apt-get update && sudo apt-get install -y python3-tk
45+
46+
- name: Create venv and install deps
47+
shell: bash
48+
run: |
49+
python -m venv .venv
50+
if [ "$RUNNER_OS" = "Windows" ]; then PY=.venv/Scripts/python.exe; else PY=.venv/bin/python; fi
51+
$PY -m pip install -q --upgrade pip
52+
$PY -m pip install -q mutagen keyring pyinstaller
53+
54+
- name: Build
55+
shell: bash
56+
run: |
57+
if [ "$RUNNER_OS" = "Windows" ]; then PY=.venv/Scripts/python.exe; else PY=.venv/bin/python; fi
58+
$PY build/build_app.py ${{ matrix.args }}
59+
60+
- name: Verify macOS binary is universal
61+
if: runner.os == 'macOS'
62+
run: |
63+
BIN=build/dist/Sortero.app/Contents/MacOS/Sortero
64+
lipo -archs "$BIN"
65+
lipo -archs "$BIN" | grep -q x86_64 && lipo -archs "$BIN" | grep -q arm64 \
66+
|| { echo "not universal"; exit 1; }
67+
68+
- uses: actions/upload-artifact@v4
69+
with:
70+
name: Sortero-${{ matrix.name }}
71+
path: build/dist/*.zip
72+
if-no-files-found: error
73+
74+
release:
75+
needs: build
76+
runs-on: ubuntu-latest
77+
steps:
78+
- uses: actions/download-artifact@v4
79+
with:
80+
path: artifacts
81+
- name: Publish release
82+
uses: softprops/action-gh-release@v2
83+
with:
84+
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
85+
name: Sortero ${{ github.event.inputs.tag || github.ref_name }}
86+
generate_release_notes: true
87+
files: artifacts/**/*.zip

README.md

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ DJ Collection/
4747
| **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. |
4848
| **History** | Every operation, with one-click undo. |
4949

50+
## Streaming playlists
51+
52+
Paste a Spotify or TIDAL playlist link on the **Playlists** tab and Sortero
53+
matches each track against files you already own, then writes an `.m3u8`.
54+
55+
Connect an account (Playlists → Accounts) to read playlists of any length. It
56+
uses OAuth 2.0 with PKCE: you sign in on Spotify's or TIDAL's own website, so
57+
Sortero never sees your password, and the tokens are stored in your OS
58+
credential store. One-time setup is creating a free app at
59+
[developer.tidal.com](https://developer.tidal.com) or the
60+
[Spotify dashboard](https://developer.spotify.com/dashboard) and adding
61+
`http://127.0.0.1:8899/callback` as a redirect URI.
62+
63+
Without connecting, Spotify links fall back to a public preview capped at 50
64+
tracks, and TIDAL links need a connection. Pasting a tracklist as
65+
`Artist - Title` lines, or loading a CSV export, always works.
66+
5067
## Why energy ends up in Grouping
5168

5269
Mixed In Key writes `Cm - Energy 6` into the **comment** field. The key half
@@ -58,15 +75,39 @@ software can sort on. Sortero copies it to **Grouping** as `5A - Energy 6`
5875

5976
Build the app, then open `Sortero.app` and point it at your collection.
6077

78+
Sortero runs on **macOS, Windows and Linux**. Grab the build for your platform
79+
from the [Releases page](../../releases):
80+
81+
| Platform | Artifact |
82+
|---|---|
83+
| macOS (Intel + Apple Silicon) | `Sortero-macOS-universal2.zip` |
84+
| Windows | `Sortero-windows-x86_64.zip` |
85+
| Linux | `Sortero-linux-x86_64.zip` |
86+
87+
macOS builds are ad-hoc signed, not notarised, so the first launch needs
88+
right-click → Open.
89+
6190
### Building from source
6291

6392
```bash
6493
python3.12 -m venv .venv
65-
./.venv/bin/pip install -U pip mutagen pyinstaller
66-
./build/build_app.sh # -> build/dist/Sortero.app
94+
./.venv/bin/pip install -U pip mutagen keyring pyinstaller
95+
./.venv/bin/python build/build_app.py
96+
```
97+
98+
Requires Python 3.12 with Tk — `brew install python-tk@3.12` on macOS,
99+
`apt install python3-tk` on Debian/Ubuntu; the Windows installer includes it.
100+
101+
A **universal2** macOS build needs a universal2 Python (the python.org
102+
installer ships one; Homebrew's is single-architecture):
103+
104+
```bash
105+
./.venv/bin/python build/build_app.py --universal2
67106
```
68107

69-
Requires Python 3.12 with Tk (`brew install python-tk@3.12`).
108+
The script checks the interpreter first and tells you if it can't. Release
109+
builds run in GitHub Actions, where `setup-python` provides a universal2
110+
interpreter — see `.github/workflows/release.yml`.
70111

71112
### Running without building
72113

@@ -77,6 +118,7 @@ Requires Python 3.12 with Tk (`brew install python-tk@3.12`).
77118
## Safety
78119

79120
- Dry-run previews on every destructive tab; nothing moves until you confirm.
80-
- Journals in `~/Library/Application Support/Sortero/journals/`.
121+
- Journals live alongside your other app data: `~/Library/Application Support/Sortero`
122+
on macOS, `%APPDATA%\\Sortero` on Windows, `$XDG_DATA_HOME/sortero` on Linux.
81123
- Duplicate removal is quarantine-only — Sortero never calls `unlink` on your music.
82124
- Back up before the first big reorganisation anyway.

build/Sortero.icns

825 Bytes
Binary file not shown.

build/Sortero.ico

7.96 KB
Binary file not shown.

build/build_app.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
"""Build the Sortero desktop app for the platform you run this on.
3+
4+
python build/build_app.py [--universal2]
5+
6+
macOS -> build/dist/Sortero.app (+ a .zip you can hand to someone else)
7+
Windows -> build/dist/Sortero/Sortero.exe
8+
Linux -> build/dist/Sortero/Sortero
9+
10+
--universal2 (macOS) builds an Intel + Apple Silicon binary. It requires a
11+
universal2 Python - the python.org installer ships one; Homebrew's does not.
12+
The script checks first and tells you rather than failing deep inside
13+
PyInstaller.
14+
"""
15+
import argparse, os, shutil, subprocess, sys, zipfile
16+
17+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18+
BUILD = os.path.join(ROOT, "build")
19+
DIST = os.path.join(BUILD, "dist")
20+
WORK = os.path.join(BUILD, "work")
21+
IS_MAC, IS_WIN = sys.platform == "darwin", os.name == "nt"
22+
23+
24+
def venv_python():
25+
for cand in (os.path.join(ROOT, ".venv", "Scripts", "python.exe"),
26+
os.path.join(ROOT, ".venv", "bin", "python")):
27+
if os.path.exists(cand):
28+
return cand
29+
return sys.executable
30+
31+
32+
def mac_arches(py):
33+
try:
34+
out = subprocess.run(["lipo", "-archs", os.path.realpath(py)],
35+
capture_output=True, text=True).stdout
36+
return out.split()
37+
except Exception:
38+
return []
39+
40+
41+
def main():
42+
ap = argparse.ArgumentParser()
43+
ap.add_argument("--universal2", action="store_true",
44+
help="macOS: build a universal (Intel + Apple Silicon) binary")
45+
args = ap.parse_args()
46+
47+
py = venv_python()
48+
print(f"==> python: {py}")
49+
50+
print("==> generating icons")
51+
subprocess.run([py, os.path.join(BUILD, "gen_icon.py")], check=True)
52+
53+
icon = None
54+
if IS_MAC:
55+
icon = os.path.join(BUILD, "Sortero.icns")
56+
elif IS_WIN:
57+
icon = os.path.join(BUILD, "Sortero.ico")
58+
59+
for d in (DIST, WORK):
60+
shutil.rmtree(d, ignore_errors=True)
61+
62+
cmd = [py, "-m", "PyInstaller", "--noconfirm", "--clean", "--windowed",
63+
"--name", "Sortero",
64+
"--distpath", DIST, "--workpath", WORK, "--specpath", BUILD,
65+
"--hidden-import", "mutagen", "--collect-submodules", "mutagen",
66+
"--hidden-import", "keyring", "--collect-submodules", "keyring"]
67+
if icon and os.path.exists(icon):
68+
cmd += ["--icon", icon]
69+
if IS_MAC:
70+
cmd += ["--osx-bundle-identifier", "com.sortero.app"]
71+
if args.universal2:
72+
arches = mac_arches(py)
73+
if "x86_64" not in arches or "arm64" not in arches:
74+
sys.exit(
75+
f"\nCannot build universal2: this Python is {'/'.join(arches) or 'single-arch'}.\n"
76+
"PyInstaller can only produce a universal2 binary from a universal2 Python.\n"
77+
"Install one from https://www.python.org/downloads/macos/ (the installer is\n"
78+
"universal2), recreate the venv with it, then re-run with --universal2.\n")
79+
cmd += ["--target-arch", "universal2"]
80+
cmd.append(os.path.join(ROOT, "run.py"))
81+
82+
print("==> building")
83+
subprocess.run(cmd, check=True, cwd=ROOT)
84+
85+
if IS_MAC:
86+
app = os.path.join(DIST, "Sortero.app")
87+
if not os.path.isdir(app):
88+
sys.exit("build failed: no Sortero.app")
89+
# Ad-hoc signature so Gatekeeper will run it locally.
90+
subprocess.run(["codesign", "--force", "--deep", "--sign", "-", app],
91+
capture_output=True)
92+
arch = "-".join(mac_arches(os.path.join(app, "Contents", "MacOS", "Sortero"))) or "unknown"
93+
zip_path = os.path.join(DIST, f"Sortero-macOS-{arch}.zip")
94+
print("==> zipping (ditto preserves the bundle)")
95+
subprocess.run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent",
96+
app, zip_path], check=True)
97+
print(f"\nbuilt {app}\nzip: {zip_path} ({os.path.getsize(zip_path)/1e6:.1f} MB)")
98+
else:
99+
target = os.path.join(DIST, "Sortero")
100+
plat = "windows" if IS_WIN else "linux"
101+
zip_path = os.path.join(DIST, f"Sortero-{plat}-x86_64.zip")
102+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
103+
for dirpath, _, files in os.walk(target):
104+
for f in files:
105+
fp = os.path.join(dirpath, f)
106+
z.write(fp, os.path.relpath(fp, DIST))
107+
print(f"\nbuilt {target}\nzip: {zip_path}")
108+
109+
110+
if __name__ == "__main__":
111+
main()

build/build_app.sh

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)