diff --git a/core/playback/__init__.py b/core/playback/__init__.py new file mode 100644 index 00000000..a199965a --- /dev/null +++ b/core/playback/__init__.py @@ -0,0 +1 @@ +"""Playback helpers (web-player play logging).""" diff --git a/core/playback/play_log.py b/core/playback/play_log.py new file mode 100644 index 00000000..8b2387a1 --- /dev/null +++ b/core/playback/play_log.py @@ -0,0 +1,65 @@ +"""Build a listening-history event from a SoulSync web-player play. + +Pure, DB-agnostic. ``listening_history`` is otherwise populated only from the +media server (Plex/Jellyfin/Navidrome) by ``listening_stats_worker``; this lets +the WEB PLAYER record its own plays too, so "recently played" and the Phase-2 +smart-radio recency signal reflect what was actually played in SoulSync. + +Kept as a pure function so it's unit-testable without a DB or Flask: it +normalizes the player's track payload into the exact event shape +``MusicDatabase.insert_listening_events`` expects. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +# Marks rows that came from the SoulSync web player (vs a media server), so they +# can be distinguished in queries / dedup if ever needed. +WEB_PLAYER_SOURCE = "soulsync_web" + + +def build_play_event(track: Dict[str, Any], played_at: str, + duration_ms: int = 0) -> Optional[Dict[str, Any]]: + """Normalize a player track payload into a listening_history event. + + ``played_at`` MUST be supplied by the caller (an ISO timestamp string) — + this module never reads the clock, so it stays pure/testable. Returns None + when there's nothing worth logging (no title), so callers can skip cleanly. + + The event shape matches insert_listening_events(): + track_id, title, artist, album, played_at, duration_ms, server_source, + db_track_id. + """ + if not isinstance(track, dict): + return None + title = (track.get("title") or "").strip() + if not title: + return None + + # db_track_id is the local tracks.id when it's a real library track (a + # plain integer id). Streamed/search results may carry a composite id — + # keep it only when it's a clean int so the FK-ish join stays valid. + raw_id = track.get("id") + db_track_id = int(raw_id) if _is_int_like(raw_id) else None + + return { + "track_id": str(raw_id) if raw_id is not None else None, + "title": title, + "artist": (track.get("artist") or "").strip(), + "album": (track.get("album") or "").strip(), + "played_at": played_at, + "duration_ms": int(duration_ms) if _is_int_like(duration_ms) else 0, + "server_source": WEB_PLAYER_SOURCE, + "db_track_id": db_track_id, + } + + +def _is_int_like(v: Any) -> bool: + if isinstance(v, bool): + return False + if isinstance(v, int): + return True + if isinstance(v, str): + return v.isdigit() + return False diff --git a/database/music_database.py b/database/music_database.py index 4e1439fc..4e443253 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -3654,6 +3654,36 @@ class MusicDatabase: if conn: conn.close() + def record_web_player_play(self, event): + """Record a single SoulSync web-player play: insert the listening_history + row AND bump tracks.play_count / last_played for the smart-radio recency + signal. ``event`` is the dict from core.playback.play_log.build_play_event. + + Returns True if the history row was newly inserted. + """ + if not event: + return False + inserted = self.insert_listening_events([event]) + db_id = event.get('db_track_id') + if db_id is not None: + conn = None + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE tracks + SET play_count = COALESCE(play_count, 0) + 1, + last_played = ? + WHERE id = ? + """, (event.get('played_at'), db_id)) + conn.commit() + except Exception as e: + logger.error(f"Error bumping play_count for track {db_id}: {e}") + finally: + if conn: + conn.close() + return inserted > 0 + def update_track_play_counts(self, counts): """Update play_count and last_played on the tracks table. diff --git a/tests/playback/__init__.py b/tests/playback/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/playback/test_play_log.py b/tests/playback/test_play_log.py new file mode 100644 index 00000000..5f78aaa9 --- /dev/null +++ b/tests/playback/test_play_log.py @@ -0,0 +1,75 @@ +"""Tests for core.playback.play_log.build_play_event.""" + +from __future__ import annotations + +from core.playback.play_log import WEB_PLAYER_SOURCE, build_play_event + +TS = "2026-05-30T12:00:00Z" + + +def test_library_track_full_event(): + ev = build_play_event( + {"id": 4321, "title": "DtMF", "artist": "Bad Bunny", "album": "DeBÍ"}, + TS, duration_ms=237000, + ) + assert ev == { + "track_id": "4321", + "title": "DtMF", + "artist": "Bad Bunny", + "album": "DeBÍ", + "played_at": TS, + "duration_ms": 237000, + "server_source": WEB_PLAYER_SOURCE, + "db_track_id": 4321, + } + + +def test_int_string_id_is_db_track_id(): + ev = build_play_event({"id": "99", "title": "X"}, TS) + assert ev["db_track_id"] == 99 + assert ev["track_id"] == "99" + + +def test_composite_id_not_used_as_db_track_id(): + # Streamed/search results can carry a composite id like "user||file" — + # must NOT become db_track_id (would corrupt the int FK join). + ev = build_play_event({"id": "peer||song.flac", "title": "Streamed"}, TS) + assert ev["db_track_id"] is None + assert ev["track_id"] == "peer||song.flac" + + +def test_missing_title_returns_none(): + assert build_play_event({"id": 1, "artist": "A"}, TS) is None + assert build_play_event({"title": " "}, TS) is None + + +def test_non_dict_returns_none(): + assert build_play_event(None, TS) is None + assert build_play_event("nope", TS) is None + + +def test_missing_fields_default_to_empty(): + ev = build_play_event({"title": "Solo"}, TS) + assert ev["artist"] == "" + assert ev["album"] == "" + assert ev["duration_ms"] == 0 + assert ev["db_track_id"] is None + assert ev["track_id"] is None + + +def test_bad_duration_is_zero(): + ev = build_play_event({"id": 1, "title": "T"}, TS, duration_ms="not-a-number") + assert ev["duration_ms"] == 0 + + +def test_bool_id_not_treated_as_int(): + # True is an int in Python — must not slip through as a track id. + ev = build_play_event({"id": True, "title": "T"}, TS) + assert ev["db_track_id"] is None + + +def test_caller_supplies_timestamp_pure(): + # The module never reads the clock — same input → same output. + a = build_play_event({"id": 1, "title": "T"}, TS) + b = build_play_event({"id": 1, "title": "T"}, TS) + assert a == b diff --git a/web_server.py b/web_server.py index 0c305810..03894895 100644 --- a/web_server.py +++ b/web_server.py @@ -10650,6 +10650,30 @@ def library_play_track(): logger.error(f"Error playing library track: {e}") return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/log-play', methods=['POST']) +def library_log_play(): + """Record a SoulSync web-player play into listening_history + bump the + track's play_count/last_played (feeds 'recently played' and smart radio). + + Fire-and-forget from the frontend ~10s into a track. Best-effort: a logging + failure never affects playback, so we always return 200-ish. + """ + try: + from core.playback.play_log import build_play_event + data = request.get_json(silent=True) or {} + track = data.get('track') or data + played_at = datetime.now().isoformat() + duration_ms = data.get('duration_ms', 0) + event = build_play_event(track, played_at, duration_ms) + if not event: + return jsonify({"success": False, "skipped": True}), 200 + get_database().record_web_player_play(event) + return jsonify({"success": True}) + except Exception as e: + logger.debug(f"log-play failed (non-fatal): {e}") + return jsonify({"success": False, "error": str(e)}), 200 + _enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz', 'discogs')} @app.route('/api/library/enrich', methods=['POST']) diff --git a/webui/static/media-player.js b/webui/static/media-player.js index b030f509..8b488992 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -18,6 +18,7 @@ function initializeMediaPlayer() { audioPlayer.addEventListener('timeupdate', updateAudioProgress); audioPlayer.addEventListener('timeupdate', npCrossfadeTick); audioPlayer.addEventListener('timeupdate', npThrottledPositionState); + audioPlayer.addEventListener('timeupdate', npMaybeLogPlay); audioPlayer.addEventListener('ended', onAudioEnded); audioPlayer.addEventListener('error', onAudioError); audioPlayer.addEventListener('loadstart', onAudioLoadStart); @@ -122,6 +123,7 @@ function _stripSourceIdPrefix(value) { function setTrackInfo(track) { currentTrack = track; + npPlayLogged = false; // new track — allow one play-log once it's heard a bit const trackTitleElement = document.getElementById('track-title'); const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track'; @@ -3135,6 +3137,30 @@ function initMediaSession() { } catch (e) { /* some browsers don't support seekto — handlers above still work */ } } +// Log a SoulSync play once a track has been heard ~10s (the standard "counts +// as a play" threshold) — feeds 'recently played' + smart-radio recency. +let npPlayLogged = false; +function npMaybeLogPlay() { + if (npPlayLogged || !currentTrack || !audioPlayer) return; + if (!isFinite(audioPlayer.currentTime) || audioPlayer.currentTime < 10) return; + npPlayLogged = true; // set first so a slow request can't double-fire + try { + fetch('/api/library/log-play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track: { + id: currentTrack.id, + title: currentTrack.title, + artist: currentTrack.artist, + album: currentTrack.album, + }, + duration_ms: Math.round((audioPlayer.duration || 0) * 1000), + }), + }).catch(() => {}); // fire-and-forget; logging must never affect playback + } catch (e) { /* non-fatal */ } +} + // timeupdate fires ~4x/s; only push position to the OS ~1x/s. let _npPosStateLast = 0; function npThrottledPositionState() {