Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ Tests: `tests/test_marked_plays.py`.

Tests: `tests/test_music_moods.py`.

**The player of every Plex play, and the moods of every artist.** Groundwork for the Wrap-Up's
rhythm clock and "phone after midnight", measured on the real server:
**The player of every Plex play.** Groundwork for the Wrap-Up's rhythm clock and "phone after
midnight", measured on the real server:
- **Player:** every Plex history event names its device, and `/devices` gives its name and
platform ("iPhone" / "iOS"). Nothing stored it.
- The sync and the history import now write `player` (new column) and `platform` for every
Expand All @@ -126,11 +126,6 @@ rhythm clock and "phone after midnight", measured on the real server:
title.
- On a copy of the NAS data, about 19,000 music plays got their player; 968 plays happened on a
phone between midnight and 5 am.
- **Artist moods:** Plex keeps moods and styles per artist ("Atmospheric, Melodic" / "Indie
Folk"), only in an artist's own metadata.
- A daily custodian job, `artist_tags`, reads them into the Plex music index, 300 artists a run,
the most-played first, each again after 90 days.
- About half of the artists have moods.

Tests: `tests/test_plex_players.py`.

Expand Down
10 changes: 1 addition & 9 deletions src/services/data_custodian.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,13 +423,6 @@ async def _run_plex_players(deep: bool = False, task=None) -> bool:
return await run_player_backfill(task=task)


async def _run_artist_tags(deep: bool = False, task=None) -> bool:
"""Every artist's Plex moods and styles into the music index, a budget a
run (services/plex_players). No model."""
from src.services.plex_players import run_artist_tags
return await run_artist_tags(task=task)


async def _run_music_tags(deep: bool = False, task=None) -> bool:
"""Plex's moods and styles on the music (services/music_tags). No model."""
from src.services.music_tags import run_music_tags
Expand Down Expand Up @@ -869,9 +862,8 @@ def _registry() -> list[Task]:
# track, for the wrap-up's moods by time of day. No model.
Task("music_tags", "Music moods and styles", 72.0, _run_music_tags, takes_task=True),
# The Wrap-Up's rhythm clock: the device of every play ("phone after
# midnight") and the moods of every artist. Plex only, no model.
# midnight"). Plex only, no model.
Task("plex_players", "Players of the Plex plays", 24.0, _run_plex_players, takes_task=True),
Task("artist_tags", "Artist moods and styles", 24.0, _run_artist_tags, takes_task=True),
# The Wrap-Up's taste river: nanogenres of titles, then their names.
Task("nanogenres", "Nanogenres", 24.0, _run_nanogenres, takes_task=True),
Task("nanogenre_names", "Nanogenre names", 24.0, _run_nanogenre_names,
Expand Down
6 changes: 0 additions & 6 deletions src/services/lyrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,6 @@ def _connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
for col, ddl in (("size_bytes", "INTEGER NOT NULL DEFAULT 0"), ("added_at", "INTEGER"), ("album_year", "INTEGER")):
if col not in have:
con.execute(f"ALTER TABLE track_lyrics ADD COLUMN {col} {ddl}")
# An artist's Plex moods and styles (services/plex_players, 2026-10-25):
# JSON lists; Plex keeps them per artist, not per track.
have = {r[1] for r in con.execute("PRAGMA table_info(plex_artists)")}
for col in ("moods", "styles", "genres", "country", "tags_at"):
if col not in have:
con.execute(f"ALTER TABLE plex_artists ADD COLUMN {col} TEXT")
con.commit()
return con

Expand Down
110 changes: 10 additions & 100 deletions src/services/plex_players.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
"""Curatarr — the player of every Plex play, and the moods of every artist.

Two things the Wrap-Up's rhythm clock and "phone after midnight" need, and
nothing stored (2026-10-25, measured on the real server):

* Every Plex history event names its device (``deviceID``); Plex's
``/devices`` gives that device's name and platform ("iPhone" / "iOS",
"Chromecast Google TV" / "Android"). The sync writes them from now on
(plex_sync.player_of); ``run_player_backfill`` fills the plays already
stored, once, from the whole history Plex still holds.
* Plex keeps moods and styles per ARTIST, not per track ("Atmospheric,
Melodic, Warm" / "Indie Folk"), and only in an artist's own metadata, not
in the listing. ``run_artist_tags`` reads them into the Plex music index
(``plex_artists`` in lyrics.db), the most-played first, a budget a run,
each artist again after 90 days.

No model in either.
"""Curatarr — the player of every Plex play.

What the Wrap-Up's "phone after midnight" needs, and nothing stored
(2026-10-25, measured on the real server): every Plex history event names
its device (``deviceID``); Plex's ``/devices`` gives that device's name and
platform ("iPhone" / "iOS", "Chromecast Google TV" / "Android"). The sync
writes them from now on (plex_sync.player_of); ``run_player_backfill``
fills the plays already stored, once, from the whole history Plex still
holds. No model. (An artist's moods come from services/music_tags.)
"""
import asyncio
import calendar
import json
import logging
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional

from src.database.connection import get_db_session
Expand All @@ -30,8 +20,6 @@
PLAYERS_DONE_KEY = "plex_players_backfilled"
_PAGE = 1000
_SAME_PLAY_S = 90
_TAGS_BUDGET = 300
_TAGS_MAX_AGE = timedelta(days=90)


def _plex():
Expand Down Expand Up @@ -127,81 +115,3 @@ async def run_player_backfill(task=None) -> bool:
filled, len(by_rk))
return True


# ── the moods and styles of every artist ────────────────────────────────────

def _due_artists(limit: int, now: Optional[datetime] = None, db_path=None) -> list:
"""Artist keys without tags, or with tags older than 90 days, the most
tracks first. Blocking."""
from src.services import lyrics
cutoff = ((now or datetime.utcnow()) - _TAGS_MAX_AGE).isoformat()
con = lyrics._connect(db_path)
try:
return [r[0] for r in con.execute(
"SELECT artist_key FROM plex_artists WHERE tracks > 0 AND (tags_at IS NULL OR tags_at < ?) "
"ORDER BY tracks DESC LIMIT ?", (cutoff, limit))]
finally:
con.close()


def save_artist_tags(tags: dict, now: Optional[datetime] = None, db_path=None) -> int:
"""tags: {artist key: {moods, styles, genres, country}}. Blocking."""
from src.services import lyrics
stamp = (now or datetime.utcnow()).isoformat()
con = lyrics._connect(db_path)
try:
for key, t in tags.items():
con.execute("UPDATE plex_artists SET moods=?, styles=?, genres=?, country=?, tags_at=? "
"WHERE artist_key=?",
(json.dumps(t.get("moods") or []), json.dumps(t.get("styles") or []),
json.dumps(t.get("genres") or []), t.get("country") or None, stamp, str(key)))
con.commit()
finally:
con.close()
return len(tags)


def tags_of(md: dict) -> dict:
"""The tags of one artist's Plex metadata."""
def names(k):
return [x.get("tag") for x in md.get(k) or [] if x.get("tag")]
return {"moods": names("Mood"), "styles": names("Style"), "genres": names("Genre"),
"country": (names("Country") or [None])[0]}


async def run_artist_tags(task=None) -> bool:
"""A budget of artists a run; True when none is due any more."""
import httpx
from src.database.runner import run_read, run_write
from src.services.http_client import tls_context
url, headers = _plex()
if not url:
return True
try:
keys = await run_read(_due_artists, _TAGS_BUDGET)
except Exception as e: # noqa: BLE001 - no music index yet
logger.debug("[artist-tags] no Plex music index: %s", e)
return True
if not keys:
return True
sem = asyncio.Semaphore(4)
tags: dict = {}

async with httpx.AsyncClient(verify=tls_context(), timeout=30) as client:
async def one(key):
async with sem:
try:
r = await client.get(f"{url}/library/metadata/{key}", headers=headers)
except Exception as e: # noqa: BLE001 - this artist waits for the next run
logger.debug("[artist-tags] %s: %s", key, e)
return
if r.status_code == 200:
md = ((r.json().get("MediaContainer") or {}).get("Metadata") or [{}])[0]
tags[key] = tags_of(md)
elif r.status_code == 404:
tags[key] = {} # gone from Plex: the index walk drops it; no retry storm
await asyncio.gather(*(one(k) for k in keys))
await run_write(save_artist_tags, tags)
with_moods = sum(1 for t in tags.values() if t.get("moods"))
logger.info("[artist-tags] %d artist(s) read, %d with moods", len(tags), with_moods)
return len(keys) < _TAGS_BUDGET
36 changes: 3 additions & 33 deletions tests/test_plex_players.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
"""The player of every Plex play, and the moods of every artist (2026-10-25).
"""The player of every Plex play (2026-10-25).

Measured on the real server:
- every history event names its device (deviceID; /devices: "iPhone" /
"iOS"); nothing stored it, so "phone after midnight" was impossible;
- most music events no longer carry a ratingKey (the track was re-added),
so a stored play is found by its second and title as well;
- Plex keeps moods and styles per artist, only in the artist's own metadata.

python tests/test_plex_players.py
"""
import calendar
import json
import sys
import tempfile
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
Expand All @@ -26,7 +23,6 @@

from src.database.models import Base, WatchHistoryEntry as W # noqa: E402
import src.services.plex_players as pp # noqa: E402
from src.services import lyrics # noqa: E402
from src.services import plex_sync as ps # noqa: E402

PASS = FAIL = 0
Expand Down Expand Up @@ -101,37 +97,11 @@ def fake_session():
check("the backfill runs once (a state key), the custodian holds it, no model",
pp.PLAYERS_DONE_KEY == "plex_players_backfilled")

# ── an artist's moods ───────────────────────────────────────────────────────
tmp = Path(tempfile.mkdtemp()) / "lyrics.db"
con = lyrics._connect(tmp)
for key, tracks, tags_at in (("a1", 50, None), ("a2", 80, "2026-01-01T00:00:00"),
("a3", 10, "2026-10-20T00:00:00"), ("a4", 0, None)):
con.execute("INSERT INTO plex_artists (artist_key, name, tracks, updated_at, tags_at) VALUES (?,?,?,?,?)",
(key, key, tracks, "x", tags_at))
con.commit()
con.close()
due = pp._due_artists(10, now=datetime(2026, 10, 25), db_path=tmp)
check("artists without tags, or with tags older than 90 days, the most tracks first; none without tracks",
due == ["a2", "a1"])
md = {"Mood": [{"tag": "Atmospheric"}, {"tag": "Melodic"}], "Style": [{"tag": "Indie Folk"}],
"Genre": [{"tag": "Folk"}], "Country": [{"tag": "Germany"}]}
t = pp.tags_of(md)
check("moods, styles, genres and the country of an artist's metadata",
t == {"moods": ["Atmospheric", "Melodic"], "styles": ["Indie Folk"], "genres": ["Folk"], "country": "Germany"})
pp.save_artist_tags({"a1": t}, now=datetime(2026, 10, 25), db_path=tmp)
con = lyrics._connect(tmp)
row = con.execute("SELECT moods, styles, country, tags_at FROM plex_artists WHERE artist_key='a1'").fetchone()
con.close()
check("...stored on the artist in the music index",
json.loads(row[0]) == ["Atmospheric", "Melodic"] and json.loads(row[1]) == ["Indie Folk"]
and row[2] == "Germany" and row[3].startswith("2026-10-25"))
check("...and not due again", pp._due_artists(10, now=datetime(2026, 10, 26), db_path=tmp) == ["a2"])

from src.services.data_custodian import _registry # noqa: E402

jobs = {t.job_id: t for t in _registry()}
check("both jobs run in the custodian without a model",
all(j in jobs and not jobs[j].needs_llm for j in ("plex_players", "artist_tags")))
check("the backfill runs in the custodian without a model; the artist moods are music_tags' alone",
"plex_players" in jobs and not jobs["plex_players"].needs_llm and "artist_tags" not in jobs)
conn = (ROOT / "src/database/connection.py").read_text(encoding="utf-8")
check("existing installs get the player column", '("watch_history", "player",' in conn)

Expand Down
Loading