Lift /api/stats/* and /api/listening-stats/* into core/stats/
Stats route logic moves into core/stats/queries.py as pure-ish functions that take dependencies (database, image-url fixer, listening worker) as arguments. The 13 route handlers in web_server.py shrink to thin parse-args / jsonify wrappers. What moved to core/stats/queries.py: - stats_cached: 3-key metadata cache lookup + image url fix-up - stats_overview / timeline / genres / library_health / db_storage - stats_top_artists / top_albums / top_tracks: top-N + DB enrichment - stats_recent: listening_history readback - stats_resolve_track: title+artist -> file_path lookup for playback - listening_stats_sync: spawns daemon thread that runs worker._poll - listening_stats_status: stats payload, with None-worker fallback shape No behavior change. Same response shapes, same error handling, same silent-except on per-row enrichment failure. fix_artist_image_url stays in web_server.py and is passed through as a callback so we don't have to lift its config_manager / media-server dependencies in this PR. Adds tests/stats/test_stats_queries.py — 27 tests covering happy paths, edge cases, image-url plumbing, worker glue. Ruff clean. 694 tests pass (was 667 + 27 new).
This commit is contained in:
parent
c4626ae503
commit
f51b75da7e
6 changed files with 731 additions and 191 deletions
1
core/stats/__init__.py
Normal file
1
core/stats/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Stats API helpers package."""
|
||||
276
core/stats/queries.py
Normal file
276
core/stats/queries.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Stats API query helpers.
|
||||
|
||||
Lifted from web_server.py /api/stats/* and /api/listening-stats/* routes.
|
||||
Pure-ish functions: take dependencies as args, return data dicts/lists. Route
|
||||
handlers stay in web_server.py and are responsible for request parsing,
|
||||
jsonify, and error responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ImageUrlFixer = Callable[[Optional[str]], Optional[str]]
|
||||
|
||||
|
||||
def get_cached_stats(database, image_url_fixer: ImageUrlFixer, time_range: str) -> dict:
|
||||
"""Read pre-computed stats cache for a time range. Instant response."""
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = ?", (f'stats_cache_{time_range}',))
|
||||
row = cursor.fetchone()
|
||||
data = json.loads(row[0]) if row and row[0] else {}
|
||||
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_recent'")
|
||||
row = cursor.fetchone()
|
||||
recent = json.loads(row[0]) if row and row[0] else []
|
||||
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_health'")
|
||||
row = cursor.fetchone()
|
||||
health = json.loads(row[0]) if row and row[0] else {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
for item in (data.get('top_artists') or []) + (data.get('top_albums') or []) + (data.get('top_tracks') or []):
|
||||
if item.get('image_url'):
|
||||
item['image_url'] = image_url_fixer(item['image_url'])
|
||||
|
||||
return {
|
||||
'cached': True,
|
||||
**data,
|
||||
'recent': recent,
|
||||
'health': health,
|
||||
}
|
||||
|
||||
|
||||
def get_overview(database, time_range: str) -> dict:
|
||||
"""Aggregate listening stats for a time range."""
|
||||
return database.get_listening_stats(time_range)
|
||||
|
||||
|
||||
def get_top_artists(database, image_url_fixer: ImageUrlFixer, time_range: str, limit: int) -> list[dict]:
|
||||
"""Top artists by play count, enriched with image / Last.fm stats / soul_id."""
|
||||
artists = database.get_top_artists(time_range, limit)
|
||||
|
||||
for artist in artists:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT thumb_url, id, lastfm_listeners, lastfm_playcount, soul_id
|
||||
FROM artists
|
||||
WHERE LOWER(name) = LOWER(?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(artist['name'],),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
artist['image_url'] = image_url_fixer(row[0]) if row[0] else None
|
||||
artist['id'] = row[1]
|
||||
artist['global_listeners'] = row[2]
|
||||
artist['global_playcount'] = row[3]
|
||||
artist['soul_id'] = row[4]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return artists
|
||||
|
||||
|
||||
def get_top_albums(database, image_url_fixer: ImageUrlFixer, time_range: str, limit: int) -> list[dict]:
|
||||
"""Top albums by play count, enriched with album thumb."""
|
||||
albums = database.get_top_albums(time_range, limit)
|
||||
|
||||
for album in albums:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT al.thumb_url, al.id, al.artist_id FROM albums al
|
||||
WHERE LOWER(al.title) = LOWER(?) AND al.thumb_url IS NOT NULL AND al.thumb_url != ''
|
||||
LIMIT 1
|
||||
""",
|
||||
(album['name'],),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
album['image_url'] = image_url_fixer(row[0]) if row[0] else None
|
||||
album['id'] = row[1]
|
||||
album['artist_id'] = row[2]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return albums
|
||||
|
||||
|
||||
def get_top_tracks(database, image_url_fixer: ImageUrlFixer, time_range: str, limit: int) -> list[dict]:
|
||||
"""Top tracks by play count, enriched with album thumb."""
|
||||
tracks = database.get_top_tracks(time_range, limit)
|
||||
|
||||
for track in tracks:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT al.thumb_url, t.id, t.artist_id FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(track['name'], track['artist']),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
track['image_url'] = image_url_fixer(row[0]) if row[0] else None
|
||||
track['id'] = row[1]
|
||||
track['artist_id'] = row[2]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return tracks
|
||||
|
||||
|
||||
def get_timeline(database, time_range: str, granularity: str) -> Any:
|
||||
"""Play count per time period for chart rendering."""
|
||||
return database.get_listening_timeline(time_range, granularity)
|
||||
|
||||
|
||||
def get_genres(database, time_range: str) -> Any:
|
||||
"""Genre distribution by play count."""
|
||||
return database.get_genre_breakdown(time_range)
|
||||
|
||||
|
||||
def get_library_health(database) -> dict:
|
||||
"""Library health metrics."""
|
||||
return database.get_library_health()
|
||||
|
||||
|
||||
def get_db_storage(database) -> dict:
|
||||
"""Database storage breakdown by table."""
|
||||
return database.get_db_storage_stats()
|
||||
|
||||
|
||||
def get_recent_tracks(database, limit: int) -> list[dict]:
|
||||
"""Recently played tracks from listening_history."""
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT title, artist, album, played_at, duration_ms
|
||||
FROM listening_history
|
||||
ORDER BY played_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
'title': row[0],
|
||||
'artist': row[1],
|
||||
'album': row[2],
|
||||
'played_at': row[3],
|
||||
'duration_ms': row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def resolve_track(database, image_url_fixer: ImageUrlFixer, title: str, artist: str) -> Optional[dict]:
|
||||
"""Resolve a track by title+artist to its file_path / metadata. Returns None if not found."""
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT t.id, t.title, t.file_path, t.bitrate, t.duration,
|
||||
ar.name as artist_name, al.title as album_title,
|
||||
al.thumb_url, t.artist_id, t.album_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
AND t.file_path IS NOT NULL AND t.file_path != ''
|
||||
LIMIT 1
|
||||
""",
|
||||
(title.strip(), artist.strip()),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row[0],
|
||||
'title': row[1],
|
||||
'file_path': row[2],
|
||||
'bitrate': row[3],
|
||||
'duration': row[4],
|
||||
'artist_name': row[5],
|
||||
'album_title': row[6],
|
||||
'image_url': image_url_fixer(row[7]) if row[7] else None,
|
||||
'artist_id': row[8],
|
||||
'album_id': row[9],
|
||||
}
|
||||
|
||||
|
||||
def trigger_listening_sync(worker) -> None:
|
||||
"""Spawn a daemon thread that runs the worker's poll loop once.
|
||||
|
||||
Caller is responsible for verifying worker is not None before calling.
|
||||
"""
|
||||
def _do_sync():
|
||||
try:
|
||||
logger.info("[Stats Sync] Starting manual poll...")
|
||||
worker._poll()
|
||||
worker.stats['polls_completed'] += 1
|
||||
worker.stats['last_poll'] = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
logger.info("[Stats Sync] Manual poll completed")
|
||||
except Exception as e:
|
||||
logger.error(f"[Stats Sync] Manual poll failed: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"Manual stats sync failed: {e}")
|
||||
|
||||
threading.Thread(target=_do_sync, daemon=True).start()
|
||||
|
||||
|
||||
def get_listening_status(worker) -> dict:
|
||||
"""Worker status dict. Returns disabled-state shape if worker is None."""
|
||||
if worker is None:
|
||||
return {
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'idle': False,
|
||||
'current_item': None,
|
||||
'stats': {},
|
||||
}
|
||||
return worker.get_stats()
|
||||
0
tests/stats/__init__.py
Normal file
0
tests/stats/__init__.py
Normal file
433
tests/stats/test_stats_queries.py
Normal file
433
tests/stats/test_stats_queries.py
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
"""Tests for core/stats/queries.py — lifted from web_server.py /api/stats/* routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.stats import queries
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return MusicDatabase(str(tmp_path / "music.db"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fix_url():
|
||||
"""Image-url fixer stub: prefixes inputs to make calls observable."""
|
||||
return lambda u: f"FIXED::{u}" if u else None
|
||||
|
||||
|
||||
_id_counter = {'n': 0}
|
||||
|
||||
|
||||
def _next_id(prefix):
|
||||
_id_counter['n'] += 1
|
||||
return f"{prefix}-{_id_counter['n']}"
|
||||
|
||||
|
||||
def _seed_artist(db, name, thumb=None, lastfm_listeners=None, lastfm_playcount=None, soul_id=None):
|
||||
aid = _next_id('art')
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO artists (id, name, thumb_url, lastfm_listeners, lastfm_playcount, soul_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(aid, name, thumb, lastfm_listeners, lastfm_playcount, soul_id),
|
||||
)
|
||||
conn.commit()
|
||||
return aid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_album(db, artist_id, title, thumb=None):
|
||||
alb = _next_id('alb')
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO albums (id, artist_id, title, thumb_url) VALUES (?, ?, ?, ?)",
|
||||
(alb, artist_id, title, thumb),
|
||||
)
|
||||
conn.commit()
|
||||
return alb
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_track(db, album_id, artist_id, title, file_path=None, bitrate=None, duration=None):
|
||||
tid = _next_id('trk')
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO tracks (id, album_id, artist_id, title, file_path, bitrate, duration) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(tid, album_id, artist_id, title, file_path, bitrate, duration),
|
||||
)
|
||||
conn.commit()
|
||||
return tid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_history(db, title, artist, album, played_at, duration_ms=180000):
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO listening_history (title, artist, album, played_at, duration_ms) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(title, artist, album, played_at, duration_ms),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_metadata(db, key, value):
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
|
||||
(key, json.dumps(value)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_recent_tracks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_recent_tracks_orders_by_played_at_desc(db):
|
||||
_seed_history(db, "Old", "A", "Album", "2026-01-01 00:00:00")
|
||||
_seed_history(db, "Newest", "A", "Album", "2026-04-01 00:00:00")
|
||||
_seed_history(db, "Mid", "A", "Album", "2026-02-15 00:00:00")
|
||||
|
||||
rows = queries.get_recent_tracks(db, limit=10)
|
||||
titles = [r['title'] for r in rows]
|
||||
|
||||
assert titles == ["Newest", "Mid", "Old"]
|
||||
|
||||
|
||||
def test_get_recent_tracks_respects_limit(db):
|
||||
for i in range(5):
|
||||
_seed_history(db, f"T{i}", "A", "Album", f"2026-04-0{i + 1} 00:00:00")
|
||||
rows = queries.get_recent_tracks(db, limit=2)
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
def test_get_recent_tracks_empty_returns_empty(db):
|
||||
rows = queries.get_recent_tracks(db, limit=10)
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_get_recent_tracks_returns_full_shape(db):
|
||||
_seed_history(db, "Money", "Pink Floyd", "DSOTM", "2026-04-01 00:00:00", duration_ms=383000)
|
||||
rows = queries.get_recent_tracks(db, limit=1)
|
||||
assert rows == [{
|
||||
'title': "Money",
|
||||
'artist': "Pink Floyd",
|
||||
'album': "DSOTM",
|
||||
'played_at': "2026-04-01 00:00:00",
|
||||
'duration_ms': 383000,
|
||||
}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_track
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_track_returns_full_metadata(db, fix_url):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM", thumb="local://thumb.jpg")
|
||||
_seed_track(db, alb, aid, "Money", file_path="/music/money.flac", bitrate=1411, duration=383000)
|
||||
|
||||
result = queries.resolve_track(db, fix_url, "Money", "Pink Floyd")
|
||||
assert result['title'] == "Money"
|
||||
assert result['file_path'] == "/music/money.flac"
|
||||
assert result['bitrate'] == 1411
|
||||
assert result['duration'] == 383000
|
||||
assert result['artist_name'] == "Pink Floyd"
|
||||
assert result['album_title'] == "DSOTM"
|
||||
assert result['image_url'] == "FIXED::local://thumb.jpg"
|
||||
assert result['album_id'] == alb
|
||||
assert result['artist_id'] == aid
|
||||
|
||||
|
||||
def test_resolve_track_case_insensitive_match(db, fix_url):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM")
|
||||
_seed_track(db, alb, aid, "Money", file_path="/music/x.flac")
|
||||
|
||||
result = queries.resolve_track(db, fix_url, "money", "pink floyd")
|
||||
assert result is not None
|
||||
assert result['title'] == "Money"
|
||||
|
||||
|
||||
def test_resolve_track_returns_none_when_no_file_path(db, fix_url):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM")
|
||||
_seed_track(db, alb, aid, "Money", file_path=None)
|
||||
|
||||
result = queries.resolve_track(db, fix_url, "Money", "Pink Floyd")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_track_returns_none_when_file_path_empty(db, fix_url):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM")
|
||||
_seed_track(db, alb, aid, "Money", file_path="")
|
||||
|
||||
result = queries.resolve_track(db, fix_url, "Money", "Pink Floyd")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_track_strips_whitespace(db, fix_url):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM")
|
||||
_seed_track(db, alb, aid, "Money", file_path="/x.flac")
|
||||
|
||||
result = queries.resolve_track(db, fix_url, " Money ", " Pink Floyd ")
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_top_artists / get_top_albums / get_top_tracks — enrichment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_top_artists_enriches_with_artist_table_columns(db, fix_url, monkeypatch):
|
||||
aid = _seed_artist(
|
||||
db, "Pink Floyd", thumb="local://pf.jpg",
|
||||
lastfm_listeners=5000000, lastfm_playcount=100000000, soul_id="soul-pf",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(db, "get_top_artists", lambda tr, lim: [{'name': 'Pink Floyd', 'play_count': 42}])
|
||||
|
||||
result = queries.get_top_artists(db, fix_url, time_range='all', limit=10)
|
||||
assert result[0]['name'] == 'Pink Floyd'
|
||||
assert result[0]['image_url'] == 'FIXED::local://pf.jpg'
|
||||
assert result[0]['id'] == aid
|
||||
assert result[0]['global_listeners'] == 5000000
|
||||
assert result[0]['global_playcount'] == 100000000
|
||||
assert result[0]['soul_id'] == 'soul-pf'
|
||||
|
||||
|
||||
def test_get_top_artists_no_match_leaves_record_unenriched(db, fix_url, monkeypatch):
|
||||
monkeypatch.setattr(db, "get_top_artists", lambda tr, lim: [{'name': 'Unknown', 'play_count': 1}])
|
||||
result = queries.get_top_artists(db, fix_url, time_range='all', limit=10)
|
||||
assert result == [{'name': 'Unknown', 'play_count': 1}]
|
||||
|
||||
|
||||
def test_get_top_albums_enriches_with_album_thumb(db, fix_url, monkeypatch):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM", thumb="local://album.jpg")
|
||||
|
||||
monkeypatch.setattr(db, "get_top_albums", lambda tr, lim: [{'name': 'DSOTM', 'play_count': 5}])
|
||||
|
||||
result = queries.get_top_albums(db, fix_url, time_range='all', limit=10)
|
||||
assert result[0]['image_url'] == 'FIXED::local://album.jpg'
|
||||
assert result[0]['id'] == alb
|
||||
assert result[0]['artist_id'] == aid
|
||||
|
||||
|
||||
def test_get_top_albums_skips_empty_thumb(db, fix_url, monkeypatch):
|
||||
aid = _seed_artist(db, "X")
|
||||
_seed_album(db, aid, "Empty", thumb="")
|
||||
monkeypatch.setattr(db, "get_top_albums", lambda tr, lim: [{'name': 'Empty', 'play_count': 1}])
|
||||
|
||||
result = queries.get_top_albums(db, fix_url, time_range='all', limit=10)
|
||||
assert 'image_url' not in result[0]
|
||||
|
||||
|
||||
def test_get_top_tracks_enriches_with_album_thumb(db, fix_url, monkeypatch):
|
||||
aid = _seed_artist(db, "Pink Floyd")
|
||||
alb = _seed_album(db, aid, "DSOTM", thumb="local://thumb.jpg")
|
||||
tid = _seed_track(db, alb, aid, "Money")
|
||||
|
||||
monkeypatch.setattr(db, "get_top_tracks", lambda tr, lim: [{'name': 'Money', 'artist': 'Pink Floyd'}])
|
||||
|
||||
result = queries.get_top_tracks(db, fix_url, time_range='all', limit=10)
|
||||
assert result[0]['image_url'] == 'FIXED::local://thumb.jpg'
|
||||
assert result[0]['id'] == tid
|
||||
assert result[0]['artist_id'] == aid
|
||||
|
||||
|
||||
def test_get_top_tracks_unmatched_record_passed_through(db, fix_url, monkeypatch):
|
||||
monkeypatch.setattr(db, "get_top_tracks", lambda tr, lim: [{'name': 'Phantom', 'artist': 'Nobody'}])
|
||||
result = queries.get_top_tracks(db, fix_url, time_range='all', limit=10)
|
||||
assert result == [{'name': 'Phantom', 'artist': 'Nobody'}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_cached_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_cached_stats_reads_three_metadata_keys(db, fix_url):
|
||||
_seed_metadata(db, 'stats_cache_7d', {
|
||||
'top_artists': [{'name': 'PF', 'image_url': 'local://a.jpg'}],
|
||||
'top_albums': [{'name': 'DSOTM'}],
|
||||
'top_tracks': [{'name': 'Money', 'image_url': 'local://t.jpg'}],
|
||||
'overview': {'plays': 100},
|
||||
})
|
||||
_seed_metadata(db, 'stats_cache_recent', [{'title': 'Money'}])
|
||||
_seed_metadata(db, 'stats_cache_health', {'orphan_tracks': 0})
|
||||
|
||||
result = queries.get_cached_stats(db, fix_url, '7d')
|
||||
|
||||
assert result['cached'] is True
|
||||
assert result['top_artists'][0]['image_url'] == 'FIXED::local://a.jpg'
|
||||
assert result['top_tracks'][0]['image_url'] == 'FIXED::local://t.jpg'
|
||||
assert result['overview'] == {'plays': 100}
|
||||
assert result['recent'] == [{'title': 'Money'}]
|
||||
assert result['health'] == {'orphan_tracks': 0}
|
||||
|
||||
|
||||
def test_get_cached_stats_missing_keys_return_empty_defaults(db, fix_url):
|
||||
result = queries.get_cached_stats(db, fix_url, '30d')
|
||||
assert result['cached'] is True
|
||||
assert result['recent'] == []
|
||||
assert result['health'] == {}
|
||||
|
||||
|
||||
def test_get_cached_stats_skips_image_fix_when_no_url(db, fix_url):
|
||||
_seed_metadata(db, 'stats_cache_7d', {
|
||||
'top_artists': [{'name': 'PF'}],
|
||||
})
|
||||
result = queries.get_cached_stats(db, fix_url, '7d')
|
||||
assert 'image_url' not in result['top_artists'][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pass-through helpers — verify they delegate to the right DB method
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_overview_delegates_to_db(monkeypatch):
|
||||
sentinel = object()
|
||||
called = {}
|
||||
|
||||
class _DB:
|
||||
def get_listening_stats(self, time_range):
|
||||
called['arg'] = time_range
|
||||
return sentinel
|
||||
|
||||
assert queries.get_overview(_DB(), '7d') is sentinel
|
||||
assert called['arg'] == '7d'
|
||||
|
||||
|
||||
def test_get_timeline_delegates_to_db():
|
||||
called = {}
|
||||
|
||||
class _DB:
|
||||
def get_listening_timeline(self, time_range, granularity):
|
||||
called['args'] = (time_range, granularity)
|
||||
return ['data']
|
||||
|
||||
assert queries.get_timeline(_DB(), '30d', 'week') == ['data']
|
||||
assert called['args'] == ('30d', 'week')
|
||||
|
||||
|
||||
def test_get_genres_delegates_to_db():
|
||||
called = {}
|
||||
|
||||
class _DB:
|
||||
def get_genre_breakdown(self, time_range):
|
||||
called['arg'] = time_range
|
||||
return [{'genre': 'rock'}]
|
||||
|
||||
assert queries.get_genres(_DB(), 'all') == [{'genre': 'rock'}]
|
||||
assert called['arg'] == 'all'
|
||||
|
||||
|
||||
def test_get_library_health_delegates_to_db():
|
||||
class _DB:
|
||||
def get_library_health(self):
|
||||
return {'orphan_tracks': 5}
|
||||
|
||||
assert queries.get_library_health(_DB()) == {'orphan_tracks': 5}
|
||||
|
||||
|
||||
def test_get_db_storage_delegates_to_db():
|
||||
class _DB:
|
||||
def get_db_storage_stats(self):
|
||||
return {'total_mb': 42}
|
||||
|
||||
assert queries.get_db_storage(_DB()) == {'total_mb': 42}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listening worker glue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_listening_status_handles_none_worker():
|
||||
result = queries.get_listening_status(None)
|
||||
assert result == {
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'idle': False,
|
||||
'current_item': None,
|
||||
'stats': {},
|
||||
}
|
||||
|
||||
|
||||
def test_get_listening_status_delegates_to_worker():
|
||||
class _Worker:
|
||||
def get_stats(self):
|
||||
return {'enabled': True, 'running': True, 'stats': {'polls_completed': 42}}
|
||||
|
||||
result = queries.get_listening_status(_Worker())
|
||||
assert result['enabled'] is True
|
||||
assert result['stats']['polls_completed'] == 42
|
||||
|
||||
|
||||
def test_trigger_listening_sync_runs_worker_poll_in_thread():
|
||||
poll_called = []
|
||||
stats_dict = {'polls_completed': 0, 'last_poll': None}
|
||||
|
||||
class _Worker:
|
||||
stats = stats_dict
|
||||
|
||||
def _poll(self):
|
||||
poll_called.append(True)
|
||||
|
||||
queries.trigger_listening_sync(_Worker())
|
||||
|
||||
# Wait briefly for thread to run
|
||||
import time as _time
|
||||
for _ in range(50):
|
||||
if poll_called:
|
||||
break
|
||||
_time.sleep(0.01)
|
||||
|
||||
assert poll_called == [True]
|
||||
assert stats_dict['polls_completed'] == 1
|
||||
assert stats_dict['last_poll'] is not None
|
||||
|
||||
|
||||
def test_trigger_listening_sync_swallows_worker_errors():
|
||||
class _BrokenWorker:
|
||||
stats = {'polls_completed': 0, 'last_poll': None}
|
||||
|
||||
def _poll(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Should NOT raise — error is caught + logged inside the thread
|
||||
queries.trigger_listening_sync(_BrokenWorker())
|
||||
|
||||
import time as _time
|
||||
_time.sleep(0.1) # give thread time to crash
|
||||
# Counter not incremented because exception was raised before increment
|
||||
assert _BrokenWorker.stats['polls_completed'] == 0
|
||||
211
web_server.py
211
web_server.py
|
|
@ -46801,46 +46801,18 @@ except Exception as e:
|
|||
listening_stats_worker = None
|
||||
|
||||
# --- Stats API Endpoints ---
|
||||
# Logic lives in core/stats/queries.py — these routes are thin handlers.
|
||||
|
||||
from core.stats import queries as _stats_queries
|
||||
|
||||
|
||||
@app.route('/api/stats/cached', methods=['GET'])
|
||||
def stats_cached():
|
||||
"""Get all pre-computed stats for a time range from cache. Instant response."""
|
||||
try:
|
||||
import json as _json
|
||||
time_range = request.args.get('range', '7d')
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get time-range-specific cache
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = ?", (f'stats_cache_{time_range}',))
|
||||
row = cursor.fetchone()
|
||||
data = _json.loads(row[0]) if row and row[0] else {}
|
||||
|
||||
# Get recent plays cache
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_recent'")
|
||||
row = cursor.fetchone()
|
||||
recent = _json.loads(row[0]) if row and row[0] else []
|
||||
|
||||
# Get library health cache
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'stats_cache_health'")
|
||||
row = cursor.fetchone()
|
||||
health = _json.loads(row[0]) if row and row[0] else {}
|
||||
|
||||
conn.close()
|
||||
|
||||
# Fix image URLs (stored as relative paths, need server prefix)
|
||||
for item in (data.get('top_artists') or []) + (data.get('top_albums') or []) + (data.get('top_tracks') or []):
|
||||
if item.get('image_url'):
|
||||
item['image_url'] = fix_artist_image_url(item['image_url'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'cached': True,
|
||||
**data,
|
||||
'recent': recent,
|
||||
'health': health,
|
||||
})
|
||||
data = _stats_queries.get_cached_stats(get_database(), fix_artist_image_url, time_range)
|
||||
return jsonify({'success': True, **data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
|
@ -46849,8 +46821,7 @@ def stats_overview():
|
|||
"""Get aggregate listening stats for a time range."""
|
||||
try:
|
||||
time_range = request.args.get('range', 'all')
|
||||
database = get_database()
|
||||
data = database.get_listening_stats(time_range)
|
||||
data = _stats_queries.get_overview(get_database(), time_range)
|
||||
return jsonify({'success': True, **data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46861,31 +46832,7 @@ def stats_top_artists():
|
|||
try:
|
||||
time_range = request.args.get('range', 'all')
|
||||
limit = int(request.args.get('limit', 10))
|
||||
database = get_database()
|
||||
artists = database.get_top_artists(time_range, limit)
|
||||
|
||||
# Enrich with images, Last.fm stats, and soul_id from the library
|
||||
for artist in artists:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT thumb_url, id, lastfm_listeners, lastfm_playcount, soul_id
|
||||
FROM artists
|
||||
WHERE LOWER(name) = LOWER(?)
|
||||
LIMIT 1
|
||||
""", (artist['name'],))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
artist['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
|
||||
artist['id'] = row[1]
|
||||
artist['global_listeners'] = row[2]
|
||||
artist['global_playcount'] = row[3]
|
||||
artist['soul_id'] = row[4]
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
artists = _stats_queries.get_top_artists(get_database(), fix_artist_image_url, time_range, limit)
|
||||
return jsonify({'success': True, 'artists': artists})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46896,28 +46843,7 @@ def stats_top_albums():
|
|||
try:
|
||||
time_range = request.args.get('range', 'all')
|
||||
limit = int(request.args.get('limit', 10))
|
||||
database = get_database()
|
||||
albums = database.get_top_albums(time_range, limit)
|
||||
|
||||
# Enrich with images
|
||||
for album in albums:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT al.thumb_url, al.id, al.artist_id FROM albums al
|
||||
WHERE LOWER(al.title) = LOWER(?) AND al.thumb_url IS NOT NULL AND al.thumb_url != ''
|
||||
LIMIT 1
|
||||
""", (album['name'],))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
album['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
|
||||
album['id'] = row[1]
|
||||
album['artist_id'] = row[2]
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
albums = _stats_queries.get_top_albums(get_database(), fix_artist_image_url, time_range, limit)
|
||||
return jsonify({'success': True, 'albums': albums})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46928,30 +46854,7 @@ def stats_top_tracks():
|
|||
try:
|
||||
time_range = request.args.get('range', 'all')
|
||||
limit = int(request.args.get('limit', 10))
|
||||
database = get_database()
|
||||
tracks = database.get_top_tracks(time_range, limit)
|
||||
|
||||
# Enrich with album images
|
||||
for track in tracks:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT al.thumb_url, t.id, t.artist_id FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
LIMIT 1
|
||||
""", (track['name'], track['artist']))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
track['image_url'] = fix_artist_image_url(row[0]) if row[0] else None
|
||||
track['id'] = row[1]
|
||||
track['artist_id'] = row[2]
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tracks = _stats_queries.get_top_tracks(get_database(), fix_artist_image_url, time_range, limit)
|
||||
return jsonify({'success': True, 'tracks': tracks})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46962,8 +46865,7 @@ def stats_timeline():
|
|||
try:
|
||||
time_range = request.args.get('range', '30d')
|
||||
granularity = request.args.get('granularity', 'day')
|
||||
database = get_database()
|
||||
data = database.get_listening_timeline(time_range, granularity)
|
||||
data = _stats_queries.get_timeline(get_database(), time_range, granularity)
|
||||
return jsonify({'success': True, 'timeline': data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46973,8 +46875,7 @@ def stats_genres():
|
|||
"""Get genre distribution by play count."""
|
||||
try:
|
||||
time_range = request.args.get('range', 'all')
|
||||
database = get_database()
|
||||
data = database.get_genre_breakdown(time_range)
|
||||
data = _stats_queries.get_genres(get_database(), time_range)
|
||||
return jsonify({'success': True, 'genres': data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46983,8 +46884,7 @@ def stats_genres():
|
|||
def stats_library_health():
|
||||
"""Get library health metrics."""
|
||||
try:
|
||||
database = get_database()
|
||||
data = database.get_library_health()
|
||||
data = _stats_queries.get_library_health(get_database())
|
||||
return jsonify({'success': True, **data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -46993,8 +46893,7 @@ def stats_library_health():
|
|||
def stats_db_storage():
|
||||
"""Get database storage breakdown by table."""
|
||||
try:
|
||||
database = get_database()
|
||||
data = database.get_db_storage_stats()
|
||||
data = _stats_queries.get_db_storage(get_database())
|
||||
return jsonify({'success': True, **data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -47004,28 +46903,7 @@ def stats_recent():
|
|||
"""Get recently played tracks."""
|
||||
try:
|
||||
limit = int(request.args.get('limit', 20))
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT title, artist, album, played_at, duration_ms
|
||||
FROM listening_history
|
||||
ORDER BY played_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
tracks = []
|
||||
for row in rows:
|
||||
tracks.append({
|
||||
'title': row[0],
|
||||
'artist': row[1],
|
||||
'album': row[2],
|
||||
'played_at': row[3],
|
||||
'duration_ms': row[4],
|
||||
})
|
||||
|
||||
tracks = _stats_queries.get_recent_tracks(get_database(), limit)
|
||||
return jsonify({'success': True, 'tracks': tracks})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -47040,41 +46918,10 @@ def stats_resolve_track():
|
|||
if not title:
|
||||
return jsonify({'success': False, 'error': 'Title required'}), 400
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, t.file_path, t.bitrate, t.duration,
|
||||
ar.name as artist_name, al.title as album_title,
|
||||
al.thumb_url, t.artist_id, t.album_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE LOWER(t.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
AND t.file_path IS NOT NULL AND t.file_path != ''
|
||||
LIMIT 1
|
||||
""", (title.strip(), artist.strip()))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
track = _stats_queries.resolve_track(get_database(), fix_artist_image_url, title, artist)
|
||||
if track is None:
|
||||
return jsonify({'success': False, 'error': 'Track not found in library'})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'track': {
|
||||
'id': row[0],
|
||||
'title': row[1],
|
||||
'file_path': row[2],
|
||||
'bitrate': row[3],
|
||||
'duration': row[4],
|
||||
'artist_name': row[5],
|
||||
'album_title': row[6],
|
||||
'image_url': fix_artist_image_url(row[7]) if row[7] else None,
|
||||
'artist_id': row[8],
|
||||
'album_id': row[9],
|
||||
}
|
||||
})
|
||||
return jsonify({'success': True, 'track': track})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
|
@ -47084,22 +46931,7 @@ def listening_stats_sync():
|
|||
try:
|
||||
if not listening_stats_worker:
|
||||
return jsonify({'success': False, 'error': 'Listening stats worker not initialized'}), 400
|
||||
|
||||
import threading
|
||||
def _do_sync():
|
||||
try:
|
||||
logger.info("[Stats Sync] Starting manual poll...")
|
||||
listening_stats_worker._poll()
|
||||
listening_stats_worker.stats['polls_completed'] += 1
|
||||
listening_stats_worker.stats['last_poll'] = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
logger.info("[Stats Sync] Manual poll completed")
|
||||
except Exception as e:
|
||||
logger.error(f"[Stats Sync] Manual poll failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"Manual stats sync failed: {e}")
|
||||
|
||||
threading.Thread(target=_do_sync, daemon=True).start()
|
||||
_stats_queries.trigger_listening_sync(listening_stats_worker)
|
||||
return jsonify({'success': True, 'message': 'Sync started'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -47108,10 +46940,7 @@ def listening_stats_sync():
|
|||
def listening_stats_status():
|
||||
"""Get listening stats worker status."""
|
||||
try:
|
||||
if listening_stats_worker is None:
|
||||
return jsonify({'enabled': False, 'running': False, 'paused': False,
|
||||
'idle': False, 'current_item': None, 'stats': {}})
|
||||
return jsonify(listening_stats_worker.get_stats())
|
||||
return jsonify(_stats_queries.get_listening_status(listening_stats_worker))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
|
|
|||
|
|
@ -3449,6 +3449,7 @@ const WHATS_NEW = {
|
|||
{ title: 'Settings Endpoints: Admin-Only', desc: 'the /api/settings endpoints (read, write, log-level, config-status, verify) had no auth gate — any logged-in profile could read or change service tokens, oauth secrets, api keys. now admin-only. single-admin setups (no multi-profile config) work transparently as before.', page: 'settings' },
|
||||
{ title: 'Browser Caching for Static Assets + Discover Pages', desc: 'static assets (js/css/icons) now get a 1-year browser cache instead of revalidating on every page load. safe because the existing ?v=static_v cache-bust query changes every server restart, so deploys still ship live. discover pages (hero, similar artists, recent releases, deep cuts, etc.) now cache 5 minutes browser-side so toggling between sections doesn\'t re-fetch everything. faster repeat loads, fewer round-trips.', page: 'discover' },
|
||||
{ title: 'Service Worker for Cover Art + Installable PWA', desc: 'cover art used to re-fetch from the cdn on every library / discover page visit. now a service worker caches images locally — second visit serves art instantly from disk, no network hit. also added a pwa manifest so soulsync can be installed to home screen / desktop as a standalone app (chrome / edge / safari → install soulsync). cache versioned so future strategy changes invalidate cleanly.' },
|
||||
{ title: 'Stats Endpoints Lifted to core/stats', desc: 'internal — moved /api/stats/* and /api/listening-stats/* logic out of web_server.py into core/stats/queries.py with full test coverage. no behavior change. step toward breaking up the web_server.py monolith.' },
|
||||
],
|
||||
'2.4.0': [
|
||||
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue