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
|
listening_stats_worker = None
|
||||||
|
|
||||||
# --- Stats API Endpoints ---
|
# --- 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'])
|
@app.route('/api/stats/cached', methods=['GET'])
|
||||||
def stats_cached():
|
def stats_cached():
|
||||||
"""Get all pre-computed stats for a time range from cache. Instant response."""
|
"""Get all pre-computed stats for a time range from cache. Instant response."""
|
||||||
try:
|
try:
|
||||||
import json as _json
|
|
||||||
time_range = request.args.get('range', '7d')
|
time_range = request.args.get('range', '7d')
|
||||||
database = get_database()
|
data = _stats_queries.get_cached_stats(get_database(), fix_artist_image_url, time_range)
|
||||||
conn = database._get_connection()
|
return jsonify({'success': True, **data})
|
||||||
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,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -46849,8 +46821,7 @@ def stats_overview():
|
||||||
"""Get aggregate listening stats for a time range."""
|
"""Get aggregate listening stats for a time range."""
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', 'all')
|
time_range = request.args.get('range', 'all')
|
||||||
database = get_database()
|
data = _stats_queries.get_overview(get_database(), time_range)
|
||||||
data = database.get_listening_stats(time_range)
|
|
||||||
return jsonify({'success': True, **data})
|
return jsonify({'success': True, **data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46861,31 +46832,7 @@ def stats_top_artists():
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', 'all')
|
time_range = request.args.get('range', 'all')
|
||||||
limit = int(request.args.get('limit', 10))
|
limit = int(request.args.get('limit', 10))
|
||||||
database = get_database()
|
artists = _stats_queries.get_top_artists(get_database(), fix_artist_image_url, time_range, limit)
|
||||||
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
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'artists': artists})
|
return jsonify({'success': True, 'artists': artists})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46896,28 +46843,7 @@ def stats_top_albums():
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', 'all')
|
time_range = request.args.get('range', 'all')
|
||||||
limit = int(request.args.get('limit', 10))
|
limit = int(request.args.get('limit', 10))
|
||||||
database = get_database()
|
albums = _stats_queries.get_top_albums(get_database(), fix_artist_image_url, time_range, limit)
|
||||||
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
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'albums': albums})
|
return jsonify({'success': True, 'albums': albums})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46928,30 +46854,7 @@ def stats_top_tracks():
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', 'all')
|
time_range = request.args.get('range', 'all')
|
||||||
limit = int(request.args.get('limit', 10))
|
limit = int(request.args.get('limit', 10))
|
||||||
database = get_database()
|
tracks = _stats_queries.get_top_tracks(get_database(), fix_artist_image_url, time_range, limit)
|
||||||
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
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'tracks': tracks})
|
return jsonify({'success': True, 'tracks': tracks})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46962,8 +46865,7 @@ def stats_timeline():
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', '30d')
|
time_range = request.args.get('range', '30d')
|
||||||
granularity = request.args.get('granularity', 'day')
|
granularity = request.args.get('granularity', 'day')
|
||||||
database = get_database()
|
data = _stats_queries.get_timeline(get_database(), time_range, granularity)
|
||||||
data = database.get_listening_timeline(time_range, granularity)
|
|
||||||
return jsonify({'success': True, 'timeline': data})
|
return jsonify({'success': True, 'timeline': data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46973,8 +46875,7 @@ def stats_genres():
|
||||||
"""Get genre distribution by play count."""
|
"""Get genre distribution by play count."""
|
||||||
try:
|
try:
|
||||||
time_range = request.args.get('range', 'all')
|
time_range = request.args.get('range', 'all')
|
||||||
database = get_database()
|
data = _stats_queries.get_genres(get_database(), time_range)
|
||||||
data = database.get_genre_breakdown(time_range)
|
|
||||||
return jsonify({'success': True, 'genres': data})
|
return jsonify({'success': True, 'genres': data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46983,8 +46884,7 @@ def stats_genres():
|
||||||
def stats_library_health():
|
def stats_library_health():
|
||||||
"""Get library health metrics."""
|
"""Get library health metrics."""
|
||||||
try:
|
try:
|
||||||
database = get_database()
|
data = _stats_queries.get_library_health(get_database())
|
||||||
data = database.get_library_health()
|
|
||||||
return jsonify({'success': True, **data})
|
return jsonify({'success': True, **data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -46993,8 +46893,7 @@ def stats_library_health():
|
||||||
def stats_db_storage():
|
def stats_db_storage():
|
||||||
"""Get database storage breakdown by table."""
|
"""Get database storage breakdown by table."""
|
||||||
try:
|
try:
|
||||||
database = get_database()
|
data = _stats_queries.get_db_storage(get_database())
|
||||||
data = database.get_db_storage_stats()
|
|
||||||
return jsonify({'success': True, **data})
|
return jsonify({'success': True, **data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -47004,28 +46903,7 @@ def stats_recent():
|
||||||
"""Get recently played tracks."""
|
"""Get recently played tracks."""
|
||||||
try:
|
try:
|
||||||
limit = int(request.args.get('limit', 20))
|
limit = int(request.args.get('limit', 20))
|
||||||
database = get_database()
|
tracks = _stats_queries.get_recent_tracks(get_database(), limit)
|
||||||
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],
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'success': True, 'tracks': tracks})
|
return jsonify({'success': True, 'tracks': tracks})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -47040,41 +46918,10 @@ def stats_resolve_track():
|
||||||
if not title:
|
if not title:
|
||||||
return jsonify({'success': False, 'error': 'Title required'}), 400
|
return jsonify({'success': False, 'error': 'Title required'}), 400
|
||||||
|
|
||||||
database = get_database()
|
track = _stats_queries.resolve_track(get_database(), fix_artist_image_url, title, artist)
|
||||||
conn = database._get_connection()
|
if track is None:
|
||||||
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:
|
|
||||||
return jsonify({'success': False, 'error': 'Track not found in library'})
|
return jsonify({'success': False, 'error': 'Track not found in library'})
|
||||||
|
return jsonify({'success': True, 'track': track})
|
||||||
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],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -47084,22 +46931,7 @@ def listening_stats_sync():
|
||||||
try:
|
try:
|
||||||
if not listening_stats_worker:
|
if not listening_stats_worker:
|
||||||
return jsonify({'success': False, 'error': 'Listening stats worker not initialized'}), 400
|
return jsonify({'success': False, 'error': 'Listening stats worker not initialized'}), 400
|
||||||
|
_stats_queries.trigger_listening_sync(listening_stats_worker)
|
||||||
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()
|
|
||||||
return jsonify({'success': True, 'message': 'Sync started'})
|
return jsonify({'success': True, 'message': 'Sync started'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -47108,10 +46940,7 @@ def listening_stats_sync():
|
||||||
def listening_stats_status():
|
def listening_stats_status():
|
||||||
"""Get listening stats worker status."""
|
"""Get listening stats worker status."""
|
||||||
try:
|
try:
|
||||||
if listening_stats_worker is None:
|
return jsonify(_stats_queries.get_listening_status(listening_stats_worker))
|
||||||
return jsonify({'enabled': False, 'running': False, 'paused': False,
|
|
||||||
'idle': False, 'current_item': None, 'stats': {}})
|
|
||||||
return jsonify(listening_stats_worker.get_stats())
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
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: '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: '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: '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': [
|
'2.4.0': [
|
||||||
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---
|
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue