Files
stemify/stemroller/player/library.py
T
ClericalErrorandClaude Opus 5.5 1845f03c41 StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems
- android/: Plex music player that plays per-stem mixes, with Android Auto support,
  a graphic + parametric (Equalizer APO-style) EQ, and offline downloads
- server/: stemserve (serves stems to the app) and stemd (separates the library on
  the server's GPU, with "Stem Now" priority requests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:17:17 -07:00

605 lines
24 KiB
Python

"""The library as a workbook: Plex data cached on disk, refreshed in the background, shown as sheets."""
import json
import os
import random
import re
import threading
import time
from PySide6.QtCore import Property, QObject, QTimer, Signal, Slot
from PySide6.QtGui import QGuiApplication
from . import config
from .plex import PlexError
from .sheets import ALBUM, ARTIST, PLAYLIST, SONG, Sheet, SheetModel, fmt, fmt_time, fold
BASE_TABS = ["Songs", "Artists", "Albums", "Playlists", "Stems", "Queue"]
_SONG_NAME = re.compile(r"^(?:.* - )?(?:\d{1,2}-)?(\d{1,3})\s*[-.]?\s+(.+)$")
class Library(QObject):
tabsChanged = Signal()
sheetChanged = Signal() # anything about the current sheet: data, sort, columns
sheetSwitched = Signal() # a different sheet is showing: selection and scroll start over
statusChanged = Signal()
toast = Signal(str)
_done = Signal(str, object) # worker results, delivered on the GUI thread
def __init__(self, cfg, plex, stems, parent=None):
super().__init__(parent)
self.cfg, self.plex, self.stems = cfg, plex, stems
self.player = None # set by the app; the queue sheet reads it
self.tracks, self.artists, self.albums, self.playlists = [], [], [], []
self._by_album, self._by_artist = {}, {}
self._tabs = [Sheet(t, "songs", []) for t in BASE_TABS]
self._tabs[1] = Sheet("Artists", "artists", [], ARTIST)
self._tabs[2] = Sheet("Albums", "albums", [], ALBUM)
self._tabs[3] = Sheet("Playlists", "playlists", [], PLAYLIST)
self._tabs[5] = Sheet("Queue", "queue", [])
self._current = 0
self.model = SheetModel(self)
self._status = ""
self._progress = -1.0
self._busy = False
self._done.connect(self._on_done)
self._cache = os.path.join(config.CACHE_DIR, "library.json")
self._load_cache()
self._rebuild()
self.model.set_sheet(self._tabs[0])
QTimer.singleShot(0, self.refresh)
# stemserve rescans every 30 s; pick up newly separated songs while open
self._stem_timer = QTimer(self, interval=120_000)
self._stem_timer.timeout.connect(self._refresh_stems)
self._stem_timer.start()
# ---- data ------------------------------------------------------------------------
def _load_cache(self):
try:
with open(self._cache) as f:
d = json.load(f)
if d.get("section") == self.cfg.get("musicSection"):
self.tracks, self.artists = d["tracks"], d["artists"]
self.albums, self.playlists = d["albums"], d["playlists"]
except (OSError, ValueError, KeyError):
pass
def _save_cache(self):
os.makedirs(config.CACHE_DIR, exist_ok=True)
tmp = self._cache + ".tmp"
with open(tmp, "w") as f:
json.dump({"section": self.cfg.get("musicSection"), "tracks": self.tracks, "artists": self.artists,
"albums": self.albums, "playlists": self.playlists}, f)
os.replace(tmp, self._cache)
def _work(self, tag, fn):
def run():
try:
self._done.emit(tag, fn())
except Exception as e: # noqa: BLE001 - shown in the status bar
self._done.emit(tag + ":error", e)
threading.Thread(target=run, daemon=True).start()
@Slot()
def refresh(self):
"""Data → Refresh All."""
self._refresh_stems()
if not self.plex.signed_in or not self.cfg.get("musicSection") or self._busy:
return
self._busy = True
self._set_status("Calculating: Plex library", 0.0)
def fetch():
def progress(n, total):
self._done.emit("progress", (n, total))
return {"tracks": self.plex.tracks(progress), "artists": self.plex.artists(),
"albums": self.plex.albums(), "playlists": self.plex.playlists()}
self._work("library", fetch)
def _refresh_stems(self):
self._work("stems", self.stems.refresh)
def _on_done(self, tag, result):
if tag == "pin":
self._poll_pin(result)
elif tag == "token":
self._signed_in(result)
elif tag == "sections":
self._sections(result)
elif tag == "progress":
n, total = result
self._set_status("Calculating: Plex library", n / total if total else 0.0)
elif tag == "library":
self._busy = False
self.tracks, self.artists = result["tracks"], result["artists"]
self.albums, self.playlists = result["albums"], result["playlists"]
self._rebuild()
self._set_status("", -1)
self._work("save", self._save_cache)
elif tag == "stems":
if result:
self._rebuild()
self.statusChanged.emit()
elif tag == "library:error":
self._busy = False
self._set_status("", -1)
self.toast.emit(str(result))
elif tag.endswith(":error"):
self.toast.emit(str(result))
for s in self._tabs:
if s.loading:
s.loading = False
self.sheetChanged.emit()
elif tag.startswith("playlist:"):
key = tag.split(":", 1)[1]
for s in self._tabs:
if s.sid == f"playlist:{key}":
s.rows, s.loading = self._song_rows(result), False
if s is self._tabs[self._current]:
self.model.set_sheet(s)
self.sheetChanged.emit()
elif tag == "playlists":
self.playlists = result
self._tabs[3].rows = self._playlist_rows()
if self._current == 3:
self.model.refresh()
def _stems_only(self):
"""Without Plex, the library is the stem server's index: artist/album/song from the folders."""
tracks, albums, artists = [], {}, {}
for e in self.stems.index.entries:
parts = e["key"].split("/")
artist = parts[0] if len(parts) > 2 else ""
album = parts[-2] if len(parts) > 1 else ""
m = _SONG_NAME.match(parts[-1])
title, index = (m.group(2), int(m.group(1))) if m else (parts[-1], 0)
ak, rk = "stem-album:" + "/".join(parts[:-1]), "stem-artist:" + artist
tracks.append({"key": "stem:" + e["key"], "title": title, "artist": artist, "albumArtist": artist,
"album": album, "albumKey": ak, "artistKey": rk, "year": 0, "index": index, "disc": 0,
"durationMs": int((e.get("duration") or 0) * 1000), "thumb": "", "partFile": "",
"partKey": "", "codec": "flac", "plays": 0, "addedAt": int(e.get("finished") or 0),
"stemKey": e["key"]})
albums.setdefault(ak, {"key": ak, "title": album, "artist": artist, "artistKey": rk, "year": 0,
"tracks": 0, "thumb": "", "addedAt": 0, "genre": ""})["tracks"] += 1
artists.setdefault(rk, {"key": rk, "title": artist, "sortTitle": artist, "albums": 0, "thumb": "", "genre": ""})
for a in albums.values():
artists[a["artistKey"]]["albums"] += 1
tracks.sort(key=lambda t: fold(t["title"]))
return tracks, sorted(artists.values(), key=lambda a: fold(a["title"])), \
sorted(albums.values(), key=lambda a: fold(a["title"]))
def _rebuild(self):
"""Derives stems flags, groupings and the base sheets from the raw lists."""
if not self.plex.signed_in or not self.cfg.get("musicSection"):
self.tracks, self.artists, self.albums = self._stems_only()
self.playlists = []
for t in self.tracks:
e = self._entry(t)
t["stemKey"] = e["key"] if e else ""
t["stems"] = len(e["stems"]) if e else 0
self._by_album, self._by_artist = {}, {}
for t in self.tracks:
self._by_album.setdefault(t["albumKey"], []).append(t)
self._by_artist.setdefault(t["artistKey"], []).append(t)
for lst in self._by_album.values():
lst.sort(key=lambda t: (t.get("disc") or 0, t.get("index") or 0))
for a in self.artists:
songs = self._by_artist.get(a["key"], [])
a["songs"] = len(songs)
a["stemSongs"] = sum(1 for t in songs if t["stems"])
for a in self.albums:
a["stemSongs"] = sum(1 for t in self._by_album.get(a["key"], []) if t["stems"])
self._tabs[0].rows = self._song_rows(self.tracks)
self._tabs[1].rows = [dict(a) for a in self.artists]
self._tabs[2].rows = [dict(a) for a in self.albums]
self._tabs[3].rows = self._playlist_rows()
self._tabs[4].rows = self._song_rows([t for t in self.tracks if t["stems"]])
for s in self._tabs[:5]:
if s.sort:
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
self.statusChanged.emit()
def _entry(self, t):
if t["key"].startswith("stem:"):
return self.stems.index.by_key.get(t.get("stemKey"))
return self.stems.find(t)
def _playlist_rows(self):
return [dict(p, kind="Smart" if p["smart"] else "Manual") for p in self.playlists]
def _song_rows(self, tracks):
rows = []
for t in tracks:
e = self._entry(t)
rows.append(dict(t, stemKey=e["key"] if e else "", stems=len(e["stems"]) if e else 0))
return rows
# ---- tabs -----------------------------------------------------------------------
def _get_tabs(self):
return [{"title": s.title, "base": i < len(BASE_TABS)} for i, s in enumerate(self._tabs)]
def _get_current(self):
return self._current
@Slot(int)
def setCurrent(self, i):
if 0 <= i < len(self._tabs) and i != self._current:
self._current = i
if self._tabs[i].layout == "queue":
self.update_queue()
self.sheetSwitched.emit()
self.model.set_sheet(self._tabs[i])
self.tabsChanged.emit()
self.sheetChanged.emit()
@Slot(int)
def closeTab(self, i):
if i >= len(BASE_TABS) and i < len(self._tabs):
del self._tabs[i]
cur = min(self._current, len(self._tabs) - 1) if self._current >= i else self._current
self._current = -1
self.tabsChanged.emit()
self.setCurrent(cur if cur != i or cur < len(self._tabs) else i - 1)
@Slot()
def back(self):
"""Backspace: closes the drilled-in sheet and returns to the one before it."""
if self._current >= len(BASE_TABS):
self.closeTab(self._current)
def _open(self, sheet):
"""Opens a drilled-in sheet after the current one, replacing what was to its right."""
for i, s in enumerate(self._tabs):
if s.sid == sheet.sid:
self._tabs[i] = sheet
self._current = -1
self.setCurrent(i)
return
cut = max(len(BASE_TABS), self._current + 1)
self._tabs = self._tabs[:cut] + [sheet]
self.tabsChanged.emit()
self.setCurrent(len(self._tabs) - 1)
@property
def sheet(self) -> Sheet:
return self._tabs[self._current]
# ---- rows -----------------------------------------------------------------------
@Slot(int, int)
def activate(self, row, mods=0):
"""Enter / double-click: play a song from here, or open an artist, album or playlist."""
s = self.sheet
if not (0 <= row < len(s.rows)):
return
kind, r = s.row_kind(row), s.rows[row]
if kind == SONG:
if s.layout == "queue":
self.player.skipTo(row)
return
songs = [x for i, x in enumerate(s.rows) if s.row_kind(i) == SONG]
self.player.play_tracks(songs, songs.index(r))
elif kind == ARTIST:
albums = sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0)
self._open(Sheet(r["title"], "albums", [dict(a) for a in albums], ALBUM, sid=f"artist:{r['key']}"))
elif kind == ALBUM:
self._open(Sheet(r["title"], "albumSongs", self._song_rows(self._by_album.get(r["key"], [])),
sid=f"album:{r['key']}"))
elif kind == PLAYLIST:
self._open(Sheet(r["title"], "songs", [], sid=f"playlist:{r['key']}", loading=True))
self._work(f"playlist:{r['key']}", lambda: self.plex.playlist_tracks(r["key"]))
def rows_for_play(self, r0, r1):
s = self.sheet
out = []
for i in range(max(0, r0), min(len(s.rows), r1 + 1)):
k, r = s.row_kind(i), s.rows[i]
if k == SONG:
out.append(r)
elif k == ALBUM:
out += self._song_rows(self._by_album.get(r["key"], []))
elif k == ARTIST:
for a in sorted((a for a in self.albums if a["artistKey"] == r["key"]), key=lambda a: a.get("year") or 0):
out += self._song_rows(self._by_album.get(a["key"], []))
return out
@Slot(int, int)
def queueRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.add_to_queue(tracks)
self.toast.emit(f"Added {len(tracks)} song{'s' if len(tracks) != 1 else ''} to the queue")
@Slot(int, int)
def playNextRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_next(tracks)
self.toast.emit(f"{len(tracks)} song{'s' if len(tracks) != 1 else ''} will play next")
@Slot(int, int)
def playRows(self, r0, r1):
tracks = self.rows_for_play(r0, r1)
if tracks:
self.player.play_tracks(tracks, 0)
@Slot()
def shuffleSheet(self):
"""AutoSum's spot on the ribbon: shuffle everything on this sheet."""
tracks = self.rows_for_play(0, len(self.sheet.rows) - 1)
if tracks:
self.player.play_tracks(tracks, random.randrange(len(tracks)), shuffle=True)
@Slot(int, int)
def removeRows(self, r0, r1):
s = self.sheet
if s.layout == "queue":
for i in range(min(r1, len(s.rows) - 1), r0 - 1, -1):
self.player.removeAt(i)
elif s.sid.startswith("playlist:"):
key = s.sid.split(":", 1)[1]
items = [s.rows[i] for i in range(r0, min(r1 + 1, len(s.rows)))]
def remove():
for it in items:
if it.get("playlistItemID"):
self.plex.remove_from_playlist(key, it["playlistItemID"])
return self.plex.playlist_tracks(key)
self._work(f"playlist:{key}", remove)
@Slot(str, int, int)
def newPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
if not title.strip() or not keys:
return
def make():
self.plex.create_playlist(title.strip(), keys)
return self.plex.playlists()
self._work("playlists", make)
self.toast.emit(f"Created playlist “{title.strip()}”")
@Slot(str, int, int)
def addToPlaylist(self, title, r0, r1):
keys = [t["key"] for t in self.rows_for_play(r0, r1)]
p = next((p for p in self.playlists if p["title"] == title and not p["smart"]), None)
if not keys or p is None:
return
def add():
self.plex.add_to_playlist(p["key"], keys)
return self.plex.playlists()
self._work("playlists", add)
self.toast.emit(f"Added {len(keys)} song{'s' if len(keys) != 1 else ''} to “{p['title']}”")
def _get_playlist_names(self):
return [p["title"] for p in self.playlists if not p["smart"]]
# ---- queue sheet ------------------------------------------------------------------
def update_queue(self):
q = self._tabs[5]
q.rows = [dict(self._song_rows([t])[0], pos=i + 1) for i, t in enumerate(self.player.queue)]
if self._current == 5:
self.model.refresh()
self.model.set_playing(self.player.current_key(), self.player.index)
# ---- search, sort ------------------------------------------------------------------
@Slot(str)
def search(self, text):
q = fold(text.strip())
if not q:
return
rows = [dict(a, _kind=ARTIST, type="Artist") for a in self.artists if q in fold(a["title"])][:50]
rows += [dict(a, _kind=ALBUM, type="Album", album="") for a in self.albums if q in fold(a["title"])][:100]
hits = [t for t in self.tracks if q in fold(t["title"])]
hits += [t for t in self.tracks if q not in fold(t["title"]) and (q in fold(t["artist"]) or q in fold(t["album"]))]
rows += [dict(r, type="Song") for r in self._song_rows(hits[:2000])]
self._open(Sheet(f"Search “{text.strip()}”", "search", rows, SONG, sid="search"))
@Slot(int)
def sortBy(self, col):
s = self.sheet
desc = bool(s.sort and s.sort[0] == col and not s.sort[1])
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@Slot(int, bool)
def sortTo(self, col, desc):
s = self.sheet
if 0 <= col < len(s.columns):
s.sort = (col, desc)
self._apply_sort(s)
self.model.refresh()
self.sheetChanged.emit()
@staticmethod
def _apply_sort(s):
col, desc = s.sort
key, _, _, kind = s.columns[col]
if kind == "text":
s.rows.sort(key=lambda r: fold(str(r.get(key) or "")), reverse=desc)
else:
s.rows.sort(key=lambda r: r.get(key) or 0, reverse=desc)
def _get_sort_col(self):
return self.sheet.sort[0] if self.sheet.sort else -1
def _get_sort_desc(self):
return bool(self.sheet.sort and self.sheet.sort[1])
# ---- formula bar, status bar --------------------------------------------------------
@Slot(int, int, result=str)
def formula(self, row, col):
"""What the formula bar shows for a cell: the value, or a formula for the row's action."""
s = self.sheet
if not (0 <= row < len(s.rows) and 0 <= col < len(s.columns)):
return ""
r = s.rows[row]
key, _, _, kind = s.columns[col]
if kind == "stems":
return f"=STEMS(\"{r.get('title', '')}\")" if r.get(key) else "=FALSE"
if kind == "time" and r.get(key):
return f"=TIME(0,{int(r[key] / 60000)},{int(r[key] / 1000) % 60})"
if key == "title":
k = s.row_kind(row)
fn = {SONG: "PLAY", ARTIST: "ARTIST", ALBUM: "ALBUM", PLAYLIST: "PLAYLIST"}[k]
q = r["title"].replace('"', '""')
return f'={fn}("{q}")'
v = r.get(key)
return "" if v in (None, "", 0) and kind != "int" else str(v)
@Slot(int, int, int, int)
def copyCells(self, r0, c0, r1, c1):
"""Ctrl+C: the selection as tab-separated text, like copying from Excel."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
lines = ["\t".join(fmt(s.rows[r].get(s.columns[c][0]), s.columns[c][3]) for c in range(c0, c1 + 1))
for r in range(r0, r1 + 1)]
QGuiApplication.clipboard().setText("\n".join(lines))
@Slot(int, int, int, int, result="QVariantMap")
def stats(self, r0, c0, r1, c1):
"""Average / Count / Sum for a selection, like Excel's status bar."""
s = self.sheet
r0, r1 = max(0, min(r0, r1)), min(len(s.rows) - 1, max(r0, r1))
c0, c1 = max(0, min(c0, c1)), min(len(s.columns) - 1, max(c0, c1))
cells, nums, kinds = 0, [], set()
for r in range(r0, r1 + 1):
row = s.rows[r]
for c in range(c0, c1 + 1):
key, _, _, kind = s.columns[c]
v = row.get(key)
if v in (None, "") or (kind != "int" and not v):
continue
cells += 1
if kind in ("int", "time", "year") and isinstance(v, (int, float)):
nums.append(v)
kinds.add(kind)
if cells < 2:
return {}
out = {"count": cells}
if nums:
is_time = kinds == {"time"}
total = sum(nums)
out["sum"] = fmt_time(total) if is_time else f"{total:,}"
avg = total / len(nums)
out["average"] = fmt_time(avg) if is_time else f"{avg:,.1f}".rstrip("0").rstrip(".")
return out
def _set_status(self, text, progress):
self._status, self._progress = text, progress
self.statusChanged.emit()
def _get_status(self):
return self._status
def _get_progress(self):
return self._progress
def _get_sheet_title(self):
return self.sheet.title
def _get_loading(self):
return self.sheet.loading
def _get_sheet_id(self):
return self.sheet.sid
def _get_layout(self):
return self.sheet.layout
def _get_columns(self):
return [{"title": c[1], "width": c[2], "kind": c[3]} for c in self.sheet.columns]
def _get_counts(self):
return {"songs": len(self.tracks), "stems": sum(1 for t in self.tracks if t.get("stems")),
"stemIndex": len(self.stems.index), "stemOnline": self.stems.online}
tabs = Property("QVariantList", _get_tabs, notify=tabsChanged)
current = Property(int, _get_current, setCurrent, notify=tabsChanged)
columns = Property("QVariantList", _get_columns, notify=sheetChanged)
sheetTitle = Property(str, _get_sheet_title, notify=sheetChanged)
layout = Property(str, _get_layout, notify=sheetChanged)
sheetId = Property(str, _get_sheet_id, notify=sheetChanged)
loading = Property(bool, _get_loading, notify=sheetChanged)
sortColumn = Property(int, _get_sort_col, notify=sheetChanged)
sortDescending = Property(bool, _get_sort_desc, notify=sheetChanged)
status = Property(str, _get_status, notify=statusChanged)
progress = Property(float, _get_progress, notify=statusChanged)
counts = Property("QVariantMap", _get_counts, notify=statusChanged)
playlistNames = Property("QVariantList", _get_playlist_names, notify=sheetChanged)
# ---- Plex sign-in (File → Account) ---------------------------------------------------
pinChanged = Signal()
def _get_pin(self):
return getattr(self, "_pin_code", "")
pin = Property(str, _get_pin, notify=pinChanged)
@Slot()
def signIn(self):
def start():
return self.plex.create_pin()
self._work("pin", start)
def _poll_pin(self, pin):
self._pin_code = pin.get("code", "")
self.pinChanged.emit()
pin_id = pin["id"]
def wait():
for _ in range(300):
time.sleep(2)
p = self.plex.check_pin(pin_id)
if p.get("authToken"):
return p["authToken"]
if getattr(self, "_pin_code", "") != pin.get("code"):
return None
return None
self._work("token", wait)
def _signed_in(self, token):
self._pin_code = ""
self.pinChanged.emit()
if not token:
return
url = self.cfg["plexUrl"]
def finish():
if not self.plex.probe(url, token):
raise PlexError(f"Signed in, but the server at {url} didn't answer")
self.cfg["plexToken"] = token
self.cfg["plexUser"] = self.plex.username(token)
secs = self.plex.music_sections()
return secs
self._work("sections", finish)
def _sections(self, secs):
if not secs:
self.toast.emit("No music library on that Plex server")
return
# The phone uses the "Music" library; prefer the one at Flacs if there are several.
pick = next((s for s in secs if any("Flacs" in l for l in s["locations"])), secs[0])
self.cfg["musicSection"] = pick["key"]
self.cfg["sectionLocations"] = pick["locations"]
config.save(self.cfg)
self.toast.emit(f"Connected to Plex — {pick['title']}")
self.statusChanged.emit()
self.refresh()
@Slot()
def cancelSignIn(self):
self._pin_code = ""
self.pinChanged.emit()
def _get_signed_in(self):
return self.plex.signed_in and bool(self.cfg.get("musicSection"))
signedIn = Property(bool, _get_signed_in, notify=statusChanged)