favicon + ci workflow + makefile changes (#33)

* favicon + ci workflow + makefile changes

* fix lint

* run workflows on any branch/pr

* fix workflow
This commit is contained in:
Harvey
2026-04-08 02:06:17 +01:00
committed by GitHub
parent df779c9e6d
commit 31e0412232
24 changed files with 469 additions and 275 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ async def get_artist_monitoring_status(
)
try:
return await artist_service.get_artist_monitoring_status(artist_id)
except Exception:
except Exception: # noqa: BLE001
logger.debug("Failed to fetch monitoring status for %s", artist_id, exc_info=True)
return ArtistMonitoringStatus(in_lidarr=False, monitored=False, auto_download=False)
+3 -3
View File
@@ -87,7 +87,7 @@ async def get_profile(
try:
s = await jellyfin_service.get_stats()
return LibraryStats(source="Jellyfin", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.warning("Failed to fetch Jellyfin stats for profile: %s", e)
return None
@@ -97,7 +97,7 @@ async def get_profile(
try:
s = await local_service.get_storage_stats()
return LibraryStats(source="Local Files", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists, total_size_bytes=s.total_size_bytes, total_size_human=s.total_size_human)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.warning("Failed to fetch Local Files stats for profile: %s", e)
return None
@@ -107,7 +107,7 @@ async def get_profile(
try:
s = await navidrome_service.get_stats()
return LibraryStats(source="Navidrome", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.warning("Failed to fetch Navidrome stats for profile: %s", e)
return None
+1 -1
View File
@@ -463,7 +463,7 @@ class LibraryService:
if future is not None and future.done() and not future.cancelled():
try:
future.exception()
except BaseException:
except BaseException: # noqa: BLE001
pass
except (ExternalServiceError, CircuitOpenError):
raise
+1 -1
View File
@@ -103,7 +103,7 @@ class LibraryPrecacheService:
if asyncio.current_task().cancelling() > 0:
raise # outer task cancelled; propagate
# inner task exited cleanly after cancel
except (asyncio.TimeoutError, Exception):
except (asyncio.TimeoutError, Exception): # noqa: BLE001
logger.warning("Precache task did not exit within 15s of cancel")
await status_service.complete_sync(str(exc))
raise ExternalServiceError(str(exc))
+30
View File
@@ -50,6 +50,36 @@ def mount_frontend(app: FastAPI):
return FileResponse(logo)
raise HTTPException(status_code=404, detail="Not found")
@app.get("/favicon.ico")
async def serve_favicon_ico():
if icon := resolve_asset("favicon.ico"):
return FileResponse(icon, media_type="image/x-icon", headers={"Cache-Control": "public, max-age=604800"})
raise HTTPException(status_code=404, detail="Not found")
@app.get("/favicon-{size}.png")
async def serve_favicon_png(size: str):
if icon := resolve_asset(f"favicon-{size}.png"):
return FileResponse(icon, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
raise HTTPException(status_code=404, detail="Not found")
@app.get("/apple-touch-icon.png")
async def serve_apple_touch_icon():
if icon := resolve_asset("apple-touch-icon.png"):
return FileResponse(icon, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
raise HTTPException(status_code=404, detail="Not found")
@app.get("/android-chrome-{size}.png")
async def serve_android_chrome(size: str):
if icon := resolve_asset(f"android-chrome-{size}.png"):
return FileResponse(icon, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
raise HTTPException(status_code=404, detail="Not found")
@app.get("/site.webmanifest")
async def serve_webmanifest():
if manifest := resolve_asset("site.webmanifest"):
return FileResponse(manifest, media_type="application/manifest+json", headers={"Cache-Control": "public, max-age=604800"})
raise HTTPException(status_code=404, detail="Not found")
@app.get("/")
async def serve_root():
if index_html.exists():
+4
View File
@@ -1,4 +1,8 @@
import os
import sys
import tempfile
from pathlib import Path
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -1,6 +1,5 @@
"""Tests for LibraryDB paginated query methods."""
import asyncio
import threading
from pathlib import Path
@@ -55,83 +54,74 @@ async def _seed(db: LibraryDB, n_albums: int = 100, n_artists: int = 20) -> None
# --- Album pagination ---
def test_albums_basic_pagination(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=10, offset=0)
)
@pytest.mark.asyncio
async def test_albums_basic_pagination(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=10, offset=0)
assert total == 100
assert len(items) == 10
def test_albums_offset_beyond_total(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=10, offset=200)
)
@pytest.mark.asyncio
async def test_albums_offset_beyond_total(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=10, offset=200)
assert total == 100
assert len(items) == 0
def test_albums_last_partial_page(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=30, offset=90)
)
@pytest.mark.asyncio
async def test_albums_last_partial_page(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=30, offset=90)
assert total == 100
assert len(items) == 10
def test_albums_sort_by_title_asc(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="asc")
)
@pytest.mark.asyncio
async def test_albums_sort_by_title_asc(db: LibraryDB):
await _seed(db)
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="asc")
titles = [i.get("title", "") for i in items]
assert titles == sorted(titles, key=str.casefold)
def test_albums_sort_by_title_desc(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="desc")
)
@pytest.mark.asyncio
async def test_albums_sort_by_title_desc(db: LibraryDB):
await _seed(db)
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="desc")
titles = [i.get("title", "") for i in items]
assert titles == sorted(titles, key=str.casefold, reverse=True)
def test_albums_sort_by_year(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, sort_by="year", sort_order="desc")
)
@pytest.mark.asyncio
async def test_albums_sort_by_year(db: LibraryDB):
await _seed(db)
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="year", sort_order="desc")
years = [i.get("year", 0) or 0 for i in items]
assert years == sorted(years, reverse=True)
def test_albums_sort_by_date_added(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, sort_by="date_added", sort_order="desc")
)
@pytest.mark.asyncio
async def test_albums_sort_by_date_added(db: LibraryDB):
await _seed(db)
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="date_added", sort_order="desc")
dates = [i.get("date_added", 0) or 0 for i in items]
assert dates == sorted(dates, reverse=True)
def test_albums_search_by_title(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="Album A")
)
@pytest.mark.asyncio
async def test_albums_search_by_title(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=100, offset=0, search="Album A")
assert total > 0
assert all("Album A" in i.get("title", "") for i in items)
def test_albums_search_by_artist(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="Artist A")
)
@pytest.mark.asyncio
async def test_albums_search_by_artist(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=100, offset=0, search="Artist A")
assert total > 0
assert all(
"Artist A" in i.get("artist_name", "") or "Artist A" in i.get("title", "")
@@ -139,61 +129,51 @@ def test_albums_search_by_artist(db: LibraryDB):
)
def test_albums_search_no_results(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
)
@pytest.mark.asyncio
async def test_albums_search_no_results(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
assert total == 0
assert len(items) == 0
def test_albums_search_case_insensitive(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items_upper, total_upper = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="ALBUM A")
)
items_lower, total_lower = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="album a")
)
@pytest.mark.asyncio
async def test_albums_search_case_insensitive(db: LibraryDB):
await _seed(db)
items_upper, total_upper = await db.get_albums_paginated(limit=100, offset=0, search="ALBUM A")
items_lower, total_lower = await db.get_albums_paginated(limit=100, offset=0, search="album a")
assert total_upper == total_lower
assert len(items_upper) == len(items_lower)
def test_albums_search_escapes_like_metacharacters(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items_pct, total_pct = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="100%")
)
@pytest.mark.asyncio
async def test_albums_search_escapes_like_metacharacters(db: LibraryDB):
await _seed(db)
items_pct, total_pct = await db.get_albums_paginated(limit=100, offset=0, search="100%")
assert total_pct == 0
assert len(items_pct) == 0
items_under, total_under = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=100, offset=0, search="Album_A")
)
items_under, total_under = await db.get_albums_paginated(limit=100, offset=0, search="Album_A")
assert total_under == 0
def test_artists_search_escapes_like_metacharacters(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=100, offset=0, search="Artist%B")
)
@pytest.mark.asyncio
async def test_artists_search_escapes_like_metacharacters(db: LibraryDB):
await _seed(db)
items, total = await db.get_artists_paginated(limit=100, offset=0, search="Artist%B")
assert total == 0
def test_albums_invalid_sort_falls_back(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=10, offset=0, sort_by="nonexistent")
)
@pytest.mark.asyncio
async def test_albums_invalid_sort_falls_back(db: LibraryDB):
await _seed(db)
items, total = await db.get_albums_paginated(limit=10, offset=0, sort_by="nonexistent")
assert total == 100
assert len(items) == 10
def test_albums_empty_library(db: LibraryDB):
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(limit=10, offset=0)
)
@pytest.mark.asyncio
async def test_albums_empty_library(db: LibraryDB):
items, total = await db.get_albums_paginated(limit=10, offset=0)
assert total == 0
assert len(items) == 0
@@ -201,64 +181,57 @@ def test_albums_empty_library(db: LibraryDB):
# --- Artist pagination ---
def test_artists_basic_pagination(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=5, offset=0)
)
@pytest.mark.asyncio
async def test_artists_basic_pagination(db: LibraryDB):
await _seed(db)
items, total = await db.get_artists_paginated(limit=5, offset=0)
assert total == 20
assert len(items) == 5
def test_artists_offset_beyond_total(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=10, offset=50)
)
@pytest.mark.asyncio
async def test_artists_offset_beyond_total(db: LibraryDB):
await _seed(db)
items, total = await db.get_artists_paginated(limit=10, offset=50)
assert total == 20
assert len(items) == 0
def test_artists_sort_by_name_asc(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=20, offset=0, sort_by="name", sort_order="asc")
)
@pytest.mark.asyncio
async def test_artists_sort_by_name_asc(db: LibraryDB):
await _seed(db)
items, _ = await db.get_artists_paginated(limit=20, offset=0, sort_by="name", sort_order="asc")
names = [i.get("name", "") for i in items]
assert names == sorted(names, key=str.casefold)
def test_artists_sort_by_album_count_desc(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, _ = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=20, offset=0, sort_by="album_count", sort_order="desc")
)
@pytest.mark.asyncio
async def test_artists_sort_by_album_count_desc(db: LibraryDB):
await _seed(db)
items, _ = await db.get_artists_paginated(limit=20, offset=0, sort_by="album_count", sort_order="desc")
counts = [i.get("album_count", 0) for i in items]
assert counts == sorted(counts, reverse=True)
def test_artists_search(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=20, offset=0, search="Artist B")
)
@pytest.mark.asyncio
async def test_artists_search(db: LibraryDB):
await _seed(db)
items, total = await db.get_artists_paginated(limit=20, offset=0, search="Artist B")
assert total > 0
assert all("Artist B" in i.get("name", "") for i in items)
def test_artists_search_no_results(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db))
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
)
@pytest.mark.asyncio
async def test_artists_search_no_results(db: LibraryDB):
await _seed(db)
items, total = await db.get_artists_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
assert total == 0
assert len(items) == 0
def test_artists_empty_library(db: LibraryDB):
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(limit=10, offset=0)
)
@pytest.mark.asyncio
async def test_artists_empty_library(db: LibraryDB):
items, total = await db.get_artists_paginated(limit=10, offset=0)
assert total == 0
assert len(items) == 0
@@ -266,16 +239,15 @@ def test_artists_empty_library(db: LibraryDB):
# --- Pagination consistency (no duplicates/missing across pages) ---
def test_albums_pagination_no_duplicates(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=50))
@pytest.mark.asyncio
async def test_albums_pagination_no_duplicates(db: LibraryDB):
await _seed(db, n_albums=50)
all_mbids: list[str] = []
offset = 0
page_size = 10
while True:
items, total = asyncio.get_event_loop().run_until_complete(
db.get_albums_paginated(
limit=page_size, offset=offset, sort_by="title", sort_order="asc"
)
items, total = await db.get_albums_paginated(
limit=page_size, offset=offset, sort_by="title", sort_order="asc"
)
if not items:
break
@@ -285,16 +257,15 @@ def test_albums_pagination_no_duplicates(db: LibraryDB):
assert len(set(all_mbids)) == 50
def test_artists_pagination_no_duplicates(db: LibraryDB):
asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=10, n_artists=30))
@pytest.mark.asyncio
async def test_artists_pagination_no_duplicates(db: LibraryDB):
await _seed(db, n_albums=10, n_artists=30)
all_mbids: list[str] = []
offset = 0
page_size = 7
while True:
items, total = asyncio.get_event_loop().run_until_complete(
db.get_artists_paginated(
limit=page_size, offset=offset, sort_by="name", sort_order="asc"
)
items, total = await db.get_artists_paginated(
limit=page_size, offset=offset, sort_by="name", sort_order="asc"
)
if not items:
break
@@ -356,7 +356,7 @@ async def test_request_429(caplog):
with caplog.at_level("WARNING"), pytest.raises(RateLimitedError):
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
assert repo._client.get.call_count == 3
assert _audiodb_circuit_breaker.failure_count == 3
assert _audiodb_circuit_breaker.failure_count == 1
ratelimit_logs = [r.message for r in caplog.records if "audiodb.ratelimit" in r.message]
assert len(ratelimit_logs) == 3
assert all("retry_after_s=60" in msg for msg in ratelimit_logs)
@@ -625,7 +625,7 @@ async def test_rate_limit_failures_open_circuit_and_short_circuit_next_lookup():
response = _mock_response(429)
repo._client.get = AsyncMock(return_value=response)
for _ in range(2):
for _ in range(5):
with pytest.raises(RateLimitedError):
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
@@ -646,7 +646,7 @@ async def test_audiodb_specific_circuit_state_change_logs(caplog):
repo._client.get = AsyncMock(return_value=fail_resp)
with caplog.at_level("INFO"):
for _ in range(2):
for _ in range(5):
with pytest.raises(ExternalServiceError):
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
@@ -50,11 +50,13 @@ class TestCacheStatsNonblocking:
return fake_du
return fake_find
with patch("services.cache_service.CACHE_DIR") as mock_dir, \
with patch("services.cache_service.get_covers_cache_dir") as mock_get_dir, \
patch("services.cache_service.shutil.which", return_value="/usr/bin/du"), \
patch("services.cache_service.asyncio.to_thread", side_effect=mock_to_thread) as mock_tt:
mock_dir = MagicMock()
mock_dir.exists.return_value = True
mock_dir.__str__ = lambda s: "/app/cache/covers"
mock_get_dir.return_value = mock_dir
stats = await svc.get_stats()
@@ -67,8 +69,10 @@ class TestCacheStatsNonblocking:
"""Second call within TTL returns cached stats without subprocess."""
svc = _make_service()
with patch("services.cache_service.CACHE_DIR") as mock_dir:
with patch("services.cache_service.get_covers_cache_dir") as mock_get_dir:
mock_dir = MagicMock()
mock_dir.exists.return_value = False
mock_get_dir.return_value = mock_dir
stats1 = await svc.get_stats()
stats2 = await svc.get_stats()
@@ -146,7 +146,7 @@ class TestRequestServiceSkipsLidarr:
svc = RequestService(lidarr, MagicMock(), MagicMock())
with pytest.raises(ExternalServiceError, match="not configured"):
with pytest.raises(ExternalServiceError, match="isn.t configured"):
await svc.request_album("test-mbid")
+3 -1
View File
@@ -372,8 +372,10 @@ class TestCacheStatsAudioDBWiring:
)
svc._stats_cache_ttl = 0
with patch("services.cache_service.CACHE_DIR") as mock_dir:
with patch("services.cache_service.get_covers_cache_dir") as mock_get_dir:
mock_dir = MagicMock()
mock_dir.exists.return_value = False
mock_get_dir.return_value = mock_dir
stats = await svc.get_stats()
assert stats.disk_audiodb_artist_count == 15