mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
66 lines
2.4 KiB
66 lines
2.4 KiB
"""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
|