Player: log SoulSync web-player plays (recently-played + smart-radio recency)
listening_history was populated ONLY from the media server; the web player recorded nothing. Now a play heard ~10s logs to listening_history AND bumps tracks.play_count/last_played — so the existing 'recently played' query reflects actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real data. - core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from player payload -> listening_history event shape. Caller supplies the timestamp (stays pure). Composite/streamed ids never become the int db_track_id; bool ids rejected; missing title -> skip. 9 unit tests. - MusicDatabase.record_web_player_play(): inserts the history row + increments play_count/last_played for the library track in one call. - /api/library/log-play: thin endpoint, server-side timestamp, best-effort (logging failure never 500s / never affects playback). - Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s threshold (flag reset in setTrackInfo, set-before-fetch so it can't double-fire), fully fire-and-forget. Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws) so it's a thin straightforward insert+update. JS + web_server parse clean.
This commit is contained in:
parent
f9bc96bd90
commit
bf2a2ca928
7 changed files with 221 additions and 0 deletions
1
core/playback/__init__.py
Normal file
1
core/playback/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Playback helpers (web-player play logging)."""
|
||||||
65
core/playback/play_log.py
Normal file
65
core/playback/play_log.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -3654,6 +3654,36 @@ class MusicDatabase:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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):
|
def update_track_play_counts(self, counts):
|
||||||
"""Update play_count and last_played on the tracks table.
|
"""Update play_count and last_played on the tracks table.
|
||||||
|
|
||||||
|
|
|
||||||
0
tests/playback/__init__.py
Normal file
0
tests/playback/__init__.py
Normal file
75
tests/playback/test_play_log.py
Normal file
75
tests/playback/test_play_log.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -10650,6 +10650,30 @@ def library_play_track():
|
||||||
logger.error(f"Error playing library track: {e}")
|
logger.error(f"Error playing library track: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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')}
|
_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'])
|
@app.route('/api/library/enrich', methods=['POST'])
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ function initializeMediaPlayer() {
|
||||||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||||||
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
||||||
audioPlayer.addEventListener('timeupdate', npThrottledPositionState);
|
audioPlayer.addEventListener('timeupdate', npThrottledPositionState);
|
||||||
|
audioPlayer.addEventListener('timeupdate', npMaybeLogPlay);
|
||||||
audioPlayer.addEventListener('ended', onAudioEnded);
|
audioPlayer.addEventListener('ended', onAudioEnded);
|
||||||
audioPlayer.addEventListener('error', onAudioError);
|
audioPlayer.addEventListener('error', onAudioError);
|
||||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||||
|
|
@ -122,6 +123,7 @@ function _stripSourceIdPrefix(value) {
|
||||||
|
|
||||||
function setTrackInfo(track) {
|
function setTrackInfo(track) {
|
||||||
currentTrack = track;
|
currentTrack = track;
|
||||||
|
npPlayLogged = false; // new track — allow one play-log once it's heard a bit
|
||||||
|
|
||||||
const trackTitleElement = document.getElementById('track-title');
|
const trackTitleElement = document.getElementById('track-title');
|
||||||
const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track';
|
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 */ }
|
} 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.
|
// timeupdate fires ~4x/s; only push position to the OS ~1x/s.
|
||||||
let _npPosStateLast = 0;
|
let _npPosStateLast = 0;
|
||||||
function npThrottledPositionState() {
|
function npThrottledPositionState() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue