favicon + ci workflow + makefile changes (#33)
* favicon + ci workflow + makefile changes * fix lint * run workflows on any branch/pr * fix workflow
@@ -0,0 +1,56 @@
|
|||||||
|
name: Backend CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
|
||||||
|
- name: Install Ruff
|
||||||
|
run: pip install ruff
|
||||||
|
|
||||||
|
- name: Run Ruff
|
||||||
|
run: ruff check backend
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./backend
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/requirements.txt
|
||||||
|
backend/requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
|
pip install -r requirements.txt -r requirements-dev.txt pytest pytest-asyncio
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: python -m pytest
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
name: Frontend CI Checks
|
name: Frontend CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -89,3 +88,28 @@ jobs:
|
|||||||
|
|
||||||
- name: Run type check
|
- name: Run type check
|
||||||
run: pnpm run check
|
run: pnpm run check
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '25'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests (server project)
|
||||||
|
run: pnpm exec vitest run --project server
|
||||||
|
|||||||
@@ -2,20 +2,73 @@ SHELL := /bin/bash
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
|
ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
|
||||||
BACKEND_DIR := $(ROOT_DIR)/backend
|
BACKEND_DIR := $(ROOT_DIR)/backend
|
||||||
FRONTEND_DIR := $(ROOT_DIR)/frontend
|
FRONTEND_DIR := $(ROOT_DIR)/frontend
|
||||||
BACKEND_VENV_DIR := $(BACKEND_DIR)/.venv
|
|
||||||
BACKEND_VENV_PYTHON := $(BACKEND_VENV_DIR)/bin/python
|
|
||||||
BACKEND_VENV_STAMP := $(BACKEND_VENV_DIR)/.deps-stamp
|
|
||||||
BACKEND_VIRTUALENV_ZIPAPP := $(BACKEND_DIR)/.virtualenv.pyz
|
|
||||||
PYTHON ?= python3
|
|
||||||
NPM ?= pnpm
|
|
||||||
|
|
||||||
.PHONY: help backend-venv backend-lint backend-test backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 backend-test-exception-handling backend-test-playlist backend-test-multidisc backend-test-performance backend-test-security backend-test-config-validation backend-test-home backend-test-home-genre backend-test-infra-hardening backend-test-library-pagination backend-test-search-top-result test-audiodb-all backend-test-artist-page backend-test-monitoring-cache frontend-install frontend-build frontend-format-check frontend-check frontend-lint frontend-test frontend-test-queuehelpers frontend-test-album-page frontend-test-playlist-detail frontend-test-audiodb-images frontend-browser-install project-map rebuild test check lint ci
|
BACKEND_VENV_DIR := $(BACKEND_DIR)/.venv
|
||||||
|
BACKEND_VENV_STAMP := $(BACKEND_VENV_DIR)/.deps-stamp
|
||||||
|
PYTEST := cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
|
NPM ?= pnpm
|
||||||
|
|
||||||
|
.PHONY: \
|
||||||
|
help \
|
||||||
|
backend-venv backend-lint backend-test \
|
||||||
|
backend-test-album-refresh \
|
||||||
|
backend-test-artist-lock \
|
||||||
|
backend-test-artist-monitoring \
|
||||||
|
backend-test-artist-page \
|
||||||
|
backend-test-audiodb \
|
||||||
|
backend-test-audiodb-parallel \
|
||||||
|
backend-test-audiodb-phase8 \
|
||||||
|
backend-test-audiodb-phase9 \
|
||||||
|
backend-test-audiodb-prewarm \
|
||||||
|
backend-test-audiodb-settings \
|
||||||
|
backend-test-cache-cleanup \
|
||||||
|
backend-test-config-validation \
|
||||||
|
backend-test-coverart-audiodb \
|
||||||
|
backend-test-dedup-cancellation \
|
||||||
|
backend-test-discovery-precache \
|
||||||
|
backend-test-exception-handling \
|
||||||
|
backend-test-home \
|
||||||
|
backend-test-home-genre \
|
||||||
|
backend-test-infra-hardening \
|
||||||
|
backend-test-jellyfin-proxy \
|
||||||
|
backend-test-library-pagination \
|
||||||
|
backend-test-lidarr-url \
|
||||||
|
backend-test-local-files-fallback \
|
||||||
|
backend-test-monitoring-cache \
|
||||||
|
backend-test-multidisc \
|
||||||
|
backend-test-mus15-status-race \
|
||||||
|
backend-test-performance \
|
||||||
|
backend-test-playlist \
|
||||||
|
backend-test-request-queue \
|
||||||
|
backend-test-request-service \
|
||||||
|
backend-test-search-top-result \
|
||||||
|
backend-test-security \
|
||||||
|
backend-test-sync-coordinator \
|
||||||
|
backend-test-sync-generation \
|
||||||
|
backend-test-sync-resume \
|
||||||
|
backend-test-sync-watchdog \
|
||||||
|
test-audiodb-all test-mus14-all test-sync-all \
|
||||||
|
frontend-install frontend-build frontend-browser-install \
|
||||||
|
frontend-format-check frontend-check frontend-lint frontend-test frontend-test-server \
|
||||||
|
frontend-test-album-page \
|
||||||
|
frontend-test-audiodb-images \
|
||||||
|
frontend-test-monitored-artists \
|
||||||
|
frontend-test-playlist-detail \
|
||||||
|
frontend-test-queuehelpers \
|
||||||
|
project-map rebuild \
|
||||||
|
test tests check lint ci
|
||||||
|
|
||||||
|
# Help
|
||||||
|
|
||||||
help: ## Show available targets
|
help: ## Show available targets
|
||||||
@grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-26s %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-34s %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# Backend: virtualenv
|
||||||
|
|
||||||
$(BACKEND_VENV_DIR):
|
$(BACKEND_VENV_DIR):
|
||||||
cd "$(BACKEND_DIR)" && test -f .virtualenv.pyz || curl -fsSLo .virtualenv.pyz https://bootstrap.pypa.io/virtualenv.pyz
|
cd "$(BACKEND_DIR)" && test -f .virtualenv.pyz || curl -fsSLo .virtualenv.pyz https://bootstrap.pypa.io/virtualenv.pyz
|
||||||
@@ -28,133 +81,148 @@ $(BACKEND_VENV_STAMP): $(BACKEND_DIR)/requirements.txt $(BACKEND_DIR)/requiremen
|
|||||||
|
|
||||||
backend-venv: $(BACKEND_VENV_STAMP) ## Create or refresh the backend virtualenv
|
backend-venv: $(BACKEND_VENV_STAMP) ## Create or refresh the backend virtualenv
|
||||||
|
|
||||||
|
# Backend: lint
|
||||||
|
|
||||||
backend-lint: $(BACKEND_VENV_STAMP) ## Run backend Ruff checks
|
backend-lint: $(BACKEND_VENV_STAMP) ## Run backend Ruff checks
|
||||||
cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check backend
|
cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check backend
|
||||||
|
|
||||||
|
# Backend: full test suite
|
||||||
|
|
||||||
backend-test: $(BACKEND_VENV_STAMP) ## Run all backend tests
|
backend-test: $(BACKEND_VENV_STAMP) ## Run all backend tests
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest
|
$(PYTEST)
|
||||||
|
|
||||||
backend-test-audiodb: $(BACKEND_VENV_STAMP) ## Run focused AudioDB backend tests
|
# Backend: focused test targets
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_audiodb_repository.py tests/infrastructure/test_disk_metadata_cache.py tests/services/test_audiodb_image_service.py tests/services/test_artist_audiodb_population.py tests/services/test_album_audiodb_population.py tests/services/test_audiodb_detail_flows.py tests/services/test_search_audiodb_overlay.py
|
|
||||||
|
|
||||||
backend-test-audiodb-prewarm: $(BACKEND_VENV_STAMP) ## Run AudioDB prewarm tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_audiodb_prewarm.py tests/services/test_audiodb_sweep.py tests/services/test_audiodb_browse_queue.py tests/services/test_audiodb_fallback_gating.py tests/services/test_preferences_generic_settings.py
|
|
||||||
|
|
||||||
backend-test-coverart-audiodb: $(BACKEND_VENV_STAMP) ## Run AudioDB coverart provider tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_coverart_album_fetcher.py tests/repositories/test_coverart_audiodb_provider.py tests/repositories/test_coverart_repository_memory_cache.py tests/services/test_audiodb_byte_caching_integration.py
|
|
||||||
|
|
||||||
backend-test-audiodb-settings: $(BACKEND_VENV_STAMP) ## Run AudioDB settings tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_audiodb_settings.py tests/test_advanced_settings_roundtrip.py tests/routes/test_settings_audiodb_key.py
|
|
||||||
|
|
||||||
backend-test-audiodb-phase8: $(BACKEND_VENV_STAMP) ## Run AudioDB cross-cutting tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_audiodb_models.py tests/test_audiodb_schema_contracts.py tests/services/test_audiodb_byte_caching_integration.py tests/services/test_audiodb_url_only_integration.py tests/services/test_audiodb_fallback_integration.py tests/services/test_audiodb_negative_cache_expiry.py tests/test_audiodb_killswitch.py tests/test_advanced_settings_roundtrip.py
|
|
||||||
|
|
||||||
backend-test-audiodb-phase9: $(BACKEND_VENV_STAMP) ## Run AudioDB observability tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_phase9_observability.py
|
|
||||||
|
|
||||||
backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py
|
|
||||||
|
|
||||||
backend-test-playlist: $(BACKEND_VENV_STAMP) ## Run playlist tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_playlist_service.py tests/services/test_playlist_source_resolution.py tests/repositories/test_playlist_repository.py tests/routes/test_playlist_routes.py
|
|
||||||
|
|
||||||
backend-test-multidisc: $(BACKEND_VENV_STAMP) ## Run multi-disc album tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_album_utils.py tests/services/test_album_service.py tests/infrastructure/test_cache_layer_followups.py
|
|
||||||
|
|
||||||
backend-test-performance: $(BACKEND_VENV_STAMP) ## Run performance regression tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_album_singleflight.py tests/services/test_artist_singleflight.py tests/services/test_genre_batch_parallel.py tests/services/test_cache_stats_nonblocking.py tests/services/test_settings_cache_invalidation.py tests/services/test_discover_enrich_singleflight.py
|
|
||||||
|
|
||||||
backend-test-security: $(BACKEND_VENV_STAMP) ## Run security regression tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_rate_limiter_middleware.py tests/test_url_validation.py tests/test_error_leakage.py
|
|
||||||
|
|
||||||
backend-test-config-validation: $(BACKEND_VENV_STAMP) ## Run config validation tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_config_validation.py
|
|
||||||
|
|
||||||
backend-test-home: $(BACKEND_VENV_STAMP) ## Run home page backend tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_home_service.py tests/routes/test_home_routes.py
|
|
||||||
|
|
||||||
backend-test-home-genre: $(BACKEND_VENV_STAMP) ## Run home genre decoupling tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_home_genre_decoupling.py
|
|
||||||
|
|
||||||
backend-test-infra-hardening: $(BACKEND_VENV_STAMP) ## Run infrastructure hardening tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_circuit_breaker_sync.py tests/infrastructure/test_disk_cache_periodic.py tests/infrastructure/test_retry_non_breaking.py
|
|
||||||
|
|
||||||
backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery precache tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_discovery_precache_progress.py tests/services/test_discovery_precache_lock.py tests/infrastructure/test_retry_non_breaking.py -v
|
|
||||||
|
|
||||||
backend-test-dedup-cancellation: $(BACKEND_VENV_STAMP) ## Run deduplicator cancellation tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_dedup_cancellation.py tests/infrastructure/test_disconnect.py -v
|
|
||||||
|
|
||||||
backend-test-library-pagination: $(BACKEND_VENV_STAMP) ## Run library pagination tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_library_pagination.py -v
|
|
||||||
|
|
||||||
backend-test-search-top-result: $(BACKEND_VENV_STAMP) ## Run search top result detection tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_search_top_result.py -v
|
|
||||||
|
|
||||||
backend-test-cache-cleanup: $(BACKEND_VENV_STAMP) ## Run cache cleanup tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_cache_cleanup.py -v
|
|
||||||
|
|
||||||
backend-test-lidarr-url: $(BACKEND_VENV_STAMP) ## Run dynamic Lidarr URL resolution tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_lidarr_url_dynamic.py -v
|
|
||||||
|
|
||||||
backend-test-sync-coordinator: $(BACKEND_VENV_STAMP) ## Run sync coordinator tests (cooldown, dedup)
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_coordinator.py -v
|
|
||||||
|
|
||||||
backend-test-local-files-fallback: $(BACKEND_VENV_STAMP) ## Run local files stale-while-error fallback tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_local_files_fallback.py -v
|
|
||||||
|
|
||||||
backend-test-jellyfin-proxy: $(BACKEND_VENV_STAMP) ## Run Jellyfin stream proxy tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_stream_routes.py -v
|
|
||||||
|
|
||||||
backend-test-sync-watchdog: $(BACKEND_VENV_STAMP) ## Run adaptive watchdog timeout tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_watchdog.py -v
|
|
||||||
|
|
||||||
backend-test-sync-resume: $(BACKEND_VENV_STAMP) ## Run sync resume-on-failure tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_resume.py -v
|
|
||||||
|
|
||||||
backend-test-audiodb-parallel: $(BACKEND_VENV_STAMP) ## Run AudioDB parallel prewarm tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_audiodb_parallel.py -v
|
|
||||||
|
|
||||||
backend-test-request-queue: $(BACKEND_VENV_STAMP) ## Run MUS-14 request queue tests (dedup, cancel, concurrency)
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_request_queue_mus14.py tests/infrastructure/test_queue_persistence.py -v
|
|
||||||
|
|
||||||
backend-test-artist-lock: $(BACKEND_VENV_STAMP) ## Run MUS-14 per-artist lock tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_album_artist_lock.py -v
|
|
||||||
|
|
||||||
backend-test-request-service: $(BACKEND_VENV_STAMP) ## Run request service tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_request_service.py -v
|
|
||||||
|
|
||||||
test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_lidarr_library_cache.py -v
|
|
||||||
|
|
||||||
backend-test-mus15-status-race: $(BACKEND_VENV_STAMP) ## Run MUS-15 status race condition tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_mus15_status_race.py -v
|
|
||||||
|
|
||||||
backend-test-artist-monitoring: $(BACKEND_VENV_STAMP) ## Run MUS-15B artist monitoring tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_artist_monitoring.py -v
|
|
||||||
|
|
||||||
backend-test-monitoring-cache: $(BACKEND_VENV_STAMP) ## Run artist monitoring cache/flag refresh tests
|
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_refresh_library_flags.py tests/test_queue_disk_invalidation.py tests/services/test_artist_utils_tags.py -v
|
|
||||||
|
|
||||||
backend-test-album-refresh: $(BACKEND_VENV_STAMP) ## Run album refresh endpoint tests
|
backend-test-album-refresh: $(BACKEND_VENV_STAMP) ## Run album refresh endpoint tests
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_album_refresh.py tests/services/test_navidrome_cache_invalidation.py -v
|
$(PYTEST) tests/routes/test_album_refresh.py tests/services/test_navidrome_cache_invalidation.py -v
|
||||||
|
|
||||||
|
backend-test-artist-lock: $(BACKEND_VENV_STAMP) ## Run MUS-14 per-artist lock tests
|
||||||
|
$(PYTEST) tests/repositories/test_album_artist_lock.py -v
|
||||||
|
|
||||||
|
backend-test-artist-monitoring: $(BACKEND_VENV_STAMP) ## Run MUS-15B artist monitoring tests
|
||||||
|
$(PYTEST) tests/test_artist_monitoring.py -v
|
||||||
|
|
||||||
backend-test-artist-page: $(BACKEND_VENV_STAMP) ## Run artist page latency tests (basic route, releases, Last.fm fast path)
|
backend-test-artist-page: $(BACKEND_VENV_STAMP) ## Run artist page latency tests (basic route, releases, Last.fm fast path)
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_artist_basic_route.py tests/routes/test_artist_releases_route.py tests/services/test_artist_basic_info.py tests/services/test_top_albums_lastfm_fast.py -v
|
$(PYTEST) tests/routes/test_artist_basic_route.py tests/routes/test_artist_releases_route.py tests/services/test_artist_basic_info.py tests/services/test_top_albums_lastfm_fast.py -v
|
||||||
|
|
||||||
|
backend-test-audiodb: $(BACKEND_VENV_STAMP) ## Run focused AudioDB backend tests
|
||||||
|
$(PYTEST) tests/repositories/test_audiodb_repository.py tests/infrastructure/test_disk_metadata_cache.py tests/services/test_audiodb_image_service.py tests/services/test_artist_audiodb_population.py tests/services/test_album_audiodb_population.py tests/services/test_audiodb_detail_flows.py tests/services/test_search_audiodb_overlay.py
|
||||||
|
|
||||||
|
backend-test-audiodb-parallel: $(BACKEND_VENV_STAMP) ## Run AudioDB parallel prewarm tests
|
||||||
|
$(PYTEST) tests/test_audiodb_parallel.py -v
|
||||||
|
|
||||||
|
backend-test-audiodb-phase8: $(BACKEND_VENV_STAMP) ## Run AudioDB cross-cutting tests
|
||||||
|
$(PYTEST) tests/repositories/test_audiodb_models.py tests/test_audiodb_schema_contracts.py tests/services/test_audiodb_byte_caching_integration.py tests/services/test_audiodb_url_only_integration.py tests/services/test_audiodb_fallback_integration.py tests/services/test_audiodb_negative_cache_expiry.py tests/test_audiodb_killswitch.py tests/test_advanced_settings_roundtrip.py
|
||||||
|
|
||||||
|
backend-test-audiodb-phase9: $(BACKEND_VENV_STAMP) ## Run AudioDB observability tests
|
||||||
|
$(PYTEST) tests/test_phase9_observability.py
|
||||||
|
|
||||||
|
backend-test-audiodb-prewarm: $(BACKEND_VENV_STAMP) ## Run AudioDB prewarm tests
|
||||||
|
$(PYTEST) tests/services/test_audiodb_prewarm.py tests/services/test_audiodb_sweep.py tests/services/test_audiodb_browse_queue.py tests/services/test_audiodb_fallback_gating.py tests/services/test_preferences_generic_settings.py
|
||||||
|
|
||||||
|
backend-test-audiodb-settings: $(BACKEND_VENV_STAMP) ## Run AudioDB settings tests
|
||||||
|
$(PYTEST) tests/test_audiodb_settings.py tests/test_advanced_settings_roundtrip.py tests/routes/test_settings_audiodb_key.py
|
||||||
|
|
||||||
|
backend-test-cache-cleanup: $(BACKEND_VENV_STAMP) ## Run cache cleanup tests
|
||||||
|
$(PYTEST) tests/test_cache_cleanup.py -v
|
||||||
|
|
||||||
|
backend-test-config-validation: $(BACKEND_VENV_STAMP) ## Run config validation tests
|
||||||
|
$(PYTEST) tests/test_config_validation.py
|
||||||
|
|
||||||
|
backend-test-coverart-audiodb: $(BACKEND_VENV_STAMP) ## Run AudioDB coverart provider tests
|
||||||
|
$(PYTEST) tests/repositories/test_coverart_album_fetcher.py tests/repositories/test_coverart_audiodb_provider.py tests/repositories/test_coverart_repository_memory_cache.py tests/services/test_audiodb_byte_caching_integration.py
|
||||||
|
|
||||||
|
backend-test-dedup-cancellation: $(BACKEND_VENV_STAMP) ## Run deduplicator cancellation tests
|
||||||
|
$(PYTEST) tests/infrastructure/test_dedup_cancellation.py tests/infrastructure/test_disconnect.py -v
|
||||||
|
|
||||||
|
backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery precache tests
|
||||||
|
$(PYTEST) tests/services/test_discovery_precache_progress.py tests/services/test_discovery_precache_lock.py tests/infrastructure/test_retry_non_breaking.py -v
|
||||||
|
|
||||||
|
backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions
|
||||||
|
$(PYTEST) tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py
|
||||||
|
|
||||||
|
backend-test-home: $(BACKEND_VENV_STAMP) ## Run home page backend tests
|
||||||
|
$(PYTEST) tests/services/test_home_service.py tests/routes/test_home_routes.py
|
||||||
|
|
||||||
|
backend-test-home-genre: $(BACKEND_VENV_STAMP) ## Run home genre decoupling tests
|
||||||
|
$(PYTEST) tests/services/test_home_genre_decoupling.py
|
||||||
|
|
||||||
|
backend-test-infra-hardening: $(BACKEND_VENV_STAMP) ## Run infrastructure hardening tests
|
||||||
|
$(PYTEST) tests/infrastructure/test_circuit_breaker_sync.py tests/infrastructure/test_disk_cache_periodic.py tests/infrastructure/test_retry_non_breaking.py
|
||||||
|
|
||||||
|
backend-test-jellyfin-proxy: $(BACKEND_VENV_STAMP) ## Run Jellyfin stream proxy tests
|
||||||
|
$(PYTEST) tests/routes/test_stream_routes.py -v
|
||||||
|
|
||||||
|
backend-test-library-pagination: $(BACKEND_VENV_STAMP) ## Run library pagination tests
|
||||||
|
$(PYTEST) tests/infrastructure/test_library_pagination.py -v
|
||||||
|
|
||||||
|
backend-test-lidarr-url: $(BACKEND_VENV_STAMP) ## Run dynamic Lidarr URL resolution tests
|
||||||
|
$(PYTEST) tests/test_lidarr_url_dynamic.py -v
|
||||||
|
|
||||||
|
backend-test-local-files-fallback: $(BACKEND_VENV_STAMP) ## Run local files stale-while-error fallback tests
|
||||||
|
$(PYTEST) tests/test_local_files_fallback.py -v
|
||||||
|
|
||||||
|
backend-test-monitoring-cache: $(BACKEND_VENV_STAMP) ## Run artist monitoring cache/flag refresh tests
|
||||||
|
$(PYTEST) tests/services/test_refresh_library_flags.py tests/test_queue_disk_invalidation.py tests/services/test_artist_utils_tags.py -v
|
||||||
|
|
||||||
|
backend-test-multidisc: $(BACKEND_VENV_STAMP) ## Run multi-disc album tests
|
||||||
|
$(PYTEST) tests/services/test_album_utils.py tests/services/test_album_service.py tests/infrastructure/test_cache_layer_followups.py
|
||||||
|
|
||||||
|
backend-test-mus15-status-race: $(BACKEND_VENV_STAMP) ## Run MUS-15 status race condition tests
|
||||||
|
$(PYTEST) tests/test_mus15_status_race.py -v
|
||||||
|
|
||||||
|
backend-test-performance: $(BACKEND_VENV_STAMP) ## Run performance regression tests
|
||||||
|
$(PYTEST) tests/services/test_album_singleflight.py tests/services/test_artist_singleflight.py tests/services/test_genre_batch_parallel.py tests/services/test_cache_stats_nonblocking.py tests/services/test_settings_cache_invalidation.py tests/services/test_discover_enrich_singleflight.py
|
||||||
|
|
||||||
|
backend-test-playlist: $(BACKEND_VENV_STAMP) ## Run playlist tests
|
||||||
|
$(PYTEST) tests/services/test_playlist_service.py tests/services/test_playlist_source_resolution.py tests/repositories/test_playlist_repository.py tests/routes/test_playlist_routes.py
|
||||||
|
|
||||||
|
backend-test-request-queue: $(BACKEND_VENV_STAMP) ## Run MUS-14 request queue tests (dedup, cancel, concurrency)
|
||||||
|
$(PYTEST) tests/infrastructure/test_request_queue_mus14.py tests/infrastructure/test_queue_persistence.py -v
|
||||||
|
|
||||||
|
backend-test-request-service: $(BACKEND_VENV_STAMP) ## Run request service tests
|
||||||
|
$(PYTEST) tests/services/test_request_service.py -v
|
||||||
|
|
||||||
|
backend-test-search-top-result: $(BACKEND_VENV_STAMP) ## Run search top result detection tests
|
||||||
|
$(PYTEST) tests/services/test_search_top_result.py -v
|
||||||
|
|
||||||
|
backend-test-security: $(BACKEND_VENV_STAMP) ## Run security regression tests
|
||||||
|
$(PYTEST) tests/test_rate_limiter_middleware.py tests/test_url_validation.py tests/test_error_leakage.py
|
||||||
|
|
||||||
|
backend-test-sync-coordinator: $(BACKEND_VENV_STAMP) ## Run sync coordinator tests (cooldown, dedup)
|
||||||
|
$(PYTEST) tests/test_sync_coordinator.py -v
|
||||||
|
|
||||||
|
backend-test-sync-generation: $(BACKEND_VENV_STAMP) ## Run MUS-19 sync generation counter tests
|
||||||
|
$(PYTEST) tests/test_sync_generation.py -v
|
||||||
|
|
||||||
|
backend-test-sync-resume: $(BACKEND_VENV_STAMP) ## Run sync resume-on-failure tests
|
||||||
|
$(PYTEST) tests/test_sync_resume.py -v
|
||||||
|
|
||||||
|
backend-test-sync-watchdog: $(BACKEND_VENV_STAMP) ## Run adaptive watchdog timeout tests
|
||||||
|
$(PYTEST) tests/test_sync_watchdog.py -v
|
||||||
|
|
||||||
|
# Backend: aggregate test targets
|
||||||
|
|
||||||
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
|
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
|
||||||
|
|
||||||
backend-test-sync-generation: $(BACKEND_VENV_STAMP) ## Run MUS-19 sync generation counter tests
|
test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests
|
||||||
cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_generation.py -v
|
$(PYTEST) tests/repositories/test_lidarr_library_cache.py -v
|
||||||
|
|
||||||
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel backend-test-sync-generation ## Run all sync robustness tests
|
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel backend-test-sync-generation ## Run all sync robustness tests
|
||||||
|
|
||||||
|
# Frontend: setup
|
||||||
|
|
||||||
frontend-install: ## Install frontend npm dependencies
|
frontend-install: ## Install frontend npm dependencies
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) install
|
cd "$(FRONTEND_DIR)" && $(NPM) install
|
||||||
|
|
||||||
frontend-build: ## Run frontend production build
|
frontend-build: ## Run frontend production build
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) run build
|
cd "$(FRONTEND_DIR)" && $(NPM) run build
|
||||||
|
|
||||||
|
frontend-browser-install: ## Install Playwright Chromium for browser tests
|
||||||
|
cd "$(FRONTEND_DIR)" && $(NPM) exec playwright install chromium
|
||||||
|
|
||||||
|
# Frontend: lint & checks
|
||||||
|
|
||||||
frontend-format-check: ## Run frontend formatting checks
|
frontend-format-check: ## Run frontend formatting checks
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) run format:check
|
cd "$(FRONTEND_DIR)" && $(NPM) run format:check
|
||||||
|
|
||||||
@@ -164,38 +232,48 @@ frontend-check: ## Run frontend type checks
|
|||||||
frontend-lint: ## Run frontend linting
|
frontend-lint: ## Run frontend linting
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) run lint
|
cd "$(FRONTEND_DIR)" && $(NPM) run lint
|
||||||
|
|
||||||
frontend-test: ## Run the frontend vitest suite
|
# Frontend: full test suite
|
||||||
|
|
||||||
|
frontend-test: ## Run the frontend vitest suite (all projects, needs Playwright)
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) run test
|
cd "$(FRONTEND_DIR)" && $(NPM) run test
|
||||||
|
|
||||||
frontend-test-queuehelpers: ## Run queue helper regressions
|
frontend-test-server: ## Run frontend server-project tests only (no Playwright)
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server
|
||||||
|
|
||||||
frontend-test-monitored-artists: ## Run pending monitored artist store tests
|
# Frontend: focused test targets
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/stores/monitoredArtists.spec.ts
|
|
||||||
|
|
||||||
frontend-test-album-page: ## Run the album page browser test
|
frontend-test-album-page: ## Run the album page browser test
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/album/[id]/page.svelte.spec.ts
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/album/[id]/page.svelte.spec.ts
|
||||||
|
|
||||||
frontend-test-playlist-detail: ## Run playlist page browser tests
|
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/playlists/[id]/page.svelte.spec.ts
|
|
||||||
|
|
||||||
frontend-browser-install: ## Install Playwright Chromium for browser tests
|
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec playwright install chromium
|
|
||||||
|
|
||||||
frontend-test-audiodb-images: ## Run AudioDB image tests
|
frontend-test-audiodb-images: ## Run AudioDB image tests
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/utils/imageSuffix.spec.ts
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/utils/imageSuffix.spec.ts
|
||||||
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/lib/components/BaseImage.svelte.spec.ts
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/lib/components/BaseImage.svelte.spec.ts
|
||||||
|
|
||||||
|
frontend-test-monitored-artists: ## Run pending monitored artist store tests
|
||||||
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/stores/monitoredArtists.spec.ts
|
||||||
|
|
||||||
|
frontend-test-playlist-detail: ## Run playlist page browser tests
|
||||||
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/playlists/[id]/page.svelte.spec.ts
|
||||||
|
|
||||||
|
frontend-test-queuehelpers: ## Run queue helper regressions
|
||||||
|
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
|
||||||
project-map: ## Refresh the project map block
|
project-map: ## Refresh the project map block
|
||||||
cd "$(ROOT_DIR)" && $(PYTHON) scripts/gen-project-map.py
|
cd "$(ROOT_DIR)" && $(PYTHON) scripts/gen-project-map.py
|
||||||
|
|
||||||
rebuild: ## Rebuild the application
|
rebuild: ## Rebuild the application
|
||||||
cd "$(ROOT_DIR)" && ./manage.sh --rebuild
|
cd "$(ROOT_DIR)" && ./manage.sh --rebuild
|
||||||
|
|
||||||
|
# Aggregate targets
|
||||||
|
|
||||||
test: backend-test frontend-test ## Run backend and frontend tests
|
test: backend-test frontend-test ## Run backend and frontend tests
|
||||||
|
|
||||||
|
tests: test ## Alias for 'test'
|
||||||
|
|
||||||
check: backend-test frontend-check ## Run backend tests and frontend type checks
|
check: backend-test frontend-check ## Run backend tests and frontend type checks
|
||||||
|
|
||||||
lint: backend-lint frontend-lint ## Run linting targets
|
lint: backend-lint frontend-lint ## Run linting targets
|
||||||
|
|
||||||
ci: backend-test backend-lint frontend-check frontend-lint frontend-test ## Run the local CI checks
|
ci: backend-test backend-lint frontend-check frontend-lint frontend-format-check frontend-test-server ## Run the local CI checks
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ async def get_artist_monitoring_status(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await artist_service.get_artist_monitoring_status(artist_id)
|
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)
|
logger.debug("Failed to fetch monitoring status for %s", artist_id, exc_info=True)
|
||||||
return ArtistMonitoringStatus(in_lidarr=False, monitored=False, auto_download=False)
|
return ArtistMonitoringStatus(in_lidarr=False, monitored=False, auto_download=False)
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async def get_profile(
|
|||||||
try:
|
try:
|
||||||
s = await jellyfin_service.get_stats()
|
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)
|
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)
|
logger.warning("Failed to fetch Jellyfin stats for profile: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ async def get_profile(
|
|||||||
try:
|
try:
|
||||||
s = await local_service.get_storage_stats()
|
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)
|
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)
|
logger.warning("Failed to fetch Local Files stats for profile: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ async def get_profile(
|
|||||||
try:
|
try:
|
||||||
s = await navidrome_service.get_stats()
|
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)
|
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)
|
logger.warning("Failed to fetch Navidrome stats for profile: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ class LibraryService:
|
|||||||
if future is not None and future.done() and not future.cancelled():
|
if future is not None and future.done() and not future.cancelled():
|
||||||
try:
|
try:
|
||||||
future.exception()
|
future.exception()
|
||||||
except BaseException:
|
except BaseException: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
except (ExternalServiceError, CircuitOpenError):
|
except (ExternalServiceError, CircuitOpenError):
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class LibraryPrecacheService:
|
|||||||
if asyncio.current_task().cancelling() > 0:
|
if asyncio.current_task().cancelling() > 0:
|
||||||
raise # outer task cancelled; propagate
|
raise # outer task cancelled; propagate
|
||||||
# inner task exited cleanly after cancel
|
# 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")
|
logger.warning("Precache task did not exit within 15s of cancel")
|
||||||
await status_service.complete_sync(str(exc))
|
await status_service.complete_sync(str(exc))
|
||||||
raise ExternalServiceError(str(exc))
|
raise ExternalServiceError(str(exc))
|
||||||
|
|||||||
@@ -50,6 +50,36 @@ def mount_frontend(app: FastAPI):
|
|||||||
return FileResponse(logo)
|
return FileResponse(logo)
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
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("/")
|
@app.get("/")
|
||||||
async def serve_root():
|
async def serve_root():
|
||||||
if index_html.exists():
|
if index_html.exists():
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for LibraryDB paginated query methods."""
|
"""Tests for LibraryDB paginated query methods."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -55,83 +54,74 @@ async def _seed(db: LibraryDB, n_albums: int = 100, n_artists: int = 20) -> None
|
|||||||
# --- Album pagination ---
|
# --- Album pagination ---
|
||||||
|
|
||||||
|
|
||||||
def test_albums_basic_pagination(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_basic_pagination(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=10, offset=0)
|
items, total = await db.get_albums_paginated(limit=10, offset=0)
|
||||||
)
|
|
||||||
assert total == 100
|
assert total == 100
|
||||||
assert len(items) == 10
|
assert len(items) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_albums_offset_beyond_total(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_offset_beyond_total(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=10, offset=200)
|
items, total = await db.get_albums_paginated(limit=10, offset=200)
|
||||||
)
|
|
||||||
assert total == 100
|
assert total == 100
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_albums_last_partial_page(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_last_partial_page(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=30, offset=90)
|
items, total = await db.get_albums_paginated(limit=30, offset=90)
|
||||||
)
|
|
||||||
assert total == 100
|
assert total == 100
|
||||||
assert len(items) == 10
|
assert len(items) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_albums_sort_by_title_asc(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_sort_by_title_asc(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="asc")
|
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="asc")
|
||||||
)
|
|
||||||
titles = [i.get("title", "") for i in items]
|
titles = [i.get("title", "") for i in items]
|
||||||
assert titles == sorted(titles, key=str.casefold)
|
assert titles == sorted(titles, key=str.casefold)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_sort_by_title_desc(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_sort_by_title_desc(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="desc")
|
items, _ = await db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="desc")
|
||||||
)
|
|
||||||
titles = [i.get("title", "") for i in items]
|
titles = [i.get("title", "") for i in items]
|
||||||
assert titles == sorted(titles, key=str.casefold, reverse=True)
|
assert titles == sorted(titles, key=str.casefold, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_sort_by_year(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_sort_by_year(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, sort_by="year", sort_order="desc")
|
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]
|
years = [i.get("year", 0) or 0 for i in items]
|
||||||
assert years == sorted(years, reverse=True)
|
assert years == sorted(years, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_sort_by_date_added(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_sort_by_date_added(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, sort_by="date_added", sort_order="desc")
|
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]
|
dates = [i.get("date_added", 0) or 0 for i in items]
|
||||||
assert dates == sorted(dates, reverse=True)
|
assert dates == sorted(dates, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_search_by_title(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_search_by_title(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="Album A")
|
items, total = await db.get_albums_paginated(limit=100, offset=0, search="Album A")
|
||||||
)
|
|
||||||
assert total > 0
|
assert total > 0
|
||||||
assert all("Album A" in i.get("title", "") for i in items)
|
assert all("Album A" in i.get("title", "") for i in items)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_search_by_artist(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_search_by_artist(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="Artist A")
|
items, total = await db.get_albums_paginated(limit=100, offset=0, search="Artist A")
|
||||||
)
|
|
||||||
assert total > 0
|
assert total > 0
|
||||||
assert all(
|
assert all(
|
||||||
"Artist A" in i.get("artist_name", "") or "Artist A" in i.get("title", "")
|
"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):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_search_no_results(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
|
items, total = await db.get_albums_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
|
||||||
)
|
|
||||||
assert total == 0
|
assert total == 0
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_albums_search_case_insensitive(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_search_case_insensitive(db: LibraryDB):
|
||||||
items_upper, total_upper = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="ALBUM A")
|
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")
|
||||||
items_lower, total_lower = asyncio.get_event_loop().run_until_complete(
|
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="album a")
|
|
||||||
)
|
|
||||||
assert total_upper == total_lower
|
assert total_upper == total_lower
|
||||||
assert len(items_upper) == len(items_lower)
|
assert len(items_upper) == len(items_lower)
|
||||||
|
|
||||||
|
|
||||||
def test_albums_search_escapes_like_metacharacters(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_search_escapes_like_metacharacters(db: LibraryDB):
|
||||||
items_pct, total_pct = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="100%")
|
items_pct, total_pct = await db.get_albums_paginated(limit=100, offset=0, search="100%")
|
||||||
)
|
|
||||||
assert total_pct == 0
|
assert total_pct == 0
|
||||||
assert len(items_pct) == 0
|
assert len(items_pct) == 0
|
||||||
items_under, total_under = asyncio.get_event_loop().run_until_complete(
|
items_under, total_under = await db.get_albums_paginated(limit=100, offset=0, search="Album_A")
|
||||||
db.get_albums_paginated(limit=100, offset=0, search="Album_A")
|
|
||||||
)
|
|
||||||
assert total_under == 0
|
assert total_under == 0
|
||||||
|
|
||||||
|
|
||||||
def test_artists_search_escapes_like_metacharacters(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_search_escapes_like_metacharacters(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=100, offset=0, search="Artist%B")
|
items, total = await db.get_artists_paginated(limit=100, offset=0, search="Artist%B")
|
||||||
)
|
|
||||||
assert total == 0
|
assert total == 0
|
||||||
|
|
||||||
|
|
||||||
def test_albums_invalid_sort_falls_back(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_albums_invalid_sort_falls_back(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_albums_paginated(limit=10, offset=0, sort_by="nonexistent")
|
items, total = await db.get_albums_paginated(limit=10, offset=0, sort_by="nonexistent")
|
||||||
)
|
|
||||||
assert total == 100
|
assert total == 100
|
||||||
assert len(items) == 10
|
assert len(items) == 10
|
||||||
|
|
||||||
|
|
||||||
def test_albums_empty_library(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
async def test_albums_empty_library(db: LibraryDB):
|
||||||
db.get_albums_paginated(limit=10, offset=0)
|
items, total = await db.get_albums_paginated(limit=10, offset=0)
|
||||||
)
|
|
||||||
assert total == 0
|
assert total == 0
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
@@ -201,64 +181,57 @@ def test_albums_empty_library(db: LibraryDB):
|
|||||||
# --- Artist pagination ---
|
# --- Artist pagination ---
|
||||||
|
|
||||||
|
|
||||||
def test_artists_basic_pagination(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_basic_pagination(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=5, offset=0)
|
items, total = await db.get_artists_paginated(limit=5, offset=0)
|
||||||
)
|
|
||||||
assert total == 20
|
assert total == 20
|
||||||
assert len(items) == 5
|
assert len(items) == 5
|
||||||
|
|
||||||
|
|
||||||
def test_artists_offset_beyond_total(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_offset_beyond_total(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=10, offset=50)
|
items, total = await db.get_artists_paginated(limit=10, offset=50)
|
||||||
)
|
|
||||||
assert total == 20
|
assert total == 20
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_artists_sort_by_name_asc(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_sort_by_name_asc(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=20, offset=0, sort_by="name", sort_order="asc")
|
items, _ = await db.get_artists_paginated(limit=20, offset=0, sort_by="name", sort_order="asc")
|
||||||
)
|
|
||||||
names = [i.get("name", "") for i in items]
|
names = [i.get("name", "") for i in items]
|
||||||
assert names == sorted(names, key=str.casefold)
|
assert names == sorted(names, key=str.casefold)
|
||||||
|
|
||||||
|
|
||||||
def test_artists_sort_by_album_count_desc(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_sort_by_album_count_desc(db: LibraryDB):
|
||||||
items, _ = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=20, offset=0, sort_by="album_count", sort_order="desc")
|
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]
|
counts = [i.get("album_count", 0) for i in items]
|
||||||
assert counts == sorted(counts, reverse=True)
|
assert counts == sorted(counts, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def test_artists_search(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_search(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=20, offset=0, search="Artist B")
|
items, total = await db.get_artists_paginated(limit=20, offset=0, search="Artist B")
|
||||||
)
|
|
||||||
assert total > 0
|
assert total > 0
|
||||||
assert all("Artist B" in i.get("name", "") for i in items)
|
assert all("Artist B" in i.get("name", "") for i in items)
|
||||||
|
|
||||||
|
|
||||||
def test_artists_search_no_results(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db))
|
async def test_artists_search_no_results(db: LibraryDB):
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
await _seed(db)
|
||||||
db.get_artists_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
|
items, total = await db.get_artists_paginated(limit=10, offset=0, search="zzz_no_match_zzz")
|
||||||
)
|
|
||||||
assert total == 0
|
assert total == 0
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_artists_empty_library(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
async def test_artists_empty_library(db: LibraryDB):
|
||||||
db.get_artists_paginated(limit=10, offset=0)
|
items, total = await db.get_artists_paginated(limit=10, offset=0)
|
||||||
)
|
|
||||||
assert total == 0
|
assert total == 0
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
|
|
||||||
@@ -266,16 +239,15 @@ def test_artists_empty_library(db: LibraryDB):
|
|||||||
# --- Pagination consistency (no duplicates/missing across pages) ---
|
# --- Pagination consistency (no duplicates/missing across pages) ---
|
||||||
|
|
||||||
|
|
||||||
def test_albums_pagination_no_duplicates(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=50))
|
async def test_albums_pagination_no_duplicates(db: LibraryDB):
|
||||||
|
await _seed(db, n_albums=50)
|
||||||
all_mbids: list[str] = []
|
all_mbids: list[str] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
page_size = 10
|
page_size = 10
|
||||||
while True:
|
while True:
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
items, total = await db.get_albums_paginated(
|
||||||
db.get_albums_paginated(
|
limit=page_size, offset=offset, sort_by="title", sort_order="asc"
|
||||||
limit=page_size, offset=offset, sort_by="title", sort_order="asc"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
break
|
break
|
||||||
@@ -285,16 +257,15 @@ def test_albums_pagination_no_duplicates(db: LibraryDB):
|
|||||||
assert len(set(all_mbids)) == 50
|
assert len(set(all_mbids)) == 50
|
||||||
|
|
||||||
|
|
||||||
def test_artists_pagination_no_duplicates(db: LibraryDB):
|
@pytest.mark.asyncio
|
||||||
asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=10, n_artists=30))
|
async def test_artists_pagination_no_duplicates(db: LibraryDB):
|
||||||
|
await _seed(db, n_albums=10, n_artists=30)
|
||||||
all_mbids: list[str] = []
|
all_mbids: list[str] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
page_size = 7
|
page_size = 7
|
||||||
while True:
|
while True:
|
||||||
items, total = asyncio.get_event_loop().run_until_complete(
|
items, total = await db.get_artists_paginated(
|
||||||
db.get_artists_paginated(
|
limit=page_size, offset=offset, sort_by="name", sort_order="asc"
|
||||||
limit=page_size, offset=offset, sort_by="name", sort_order="asc"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ async def test_request_429(caplog):
|
|||||||
with caplog.at_level("WARNING"), pytest.raises(RateLimitedError):
|
with caplog.at_level("WARNING"), pytest.raises(RateLimitedError):
|
||||||
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
|
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
|
||||||
assert repo._client.get.call_count == 3
|
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]
|
ratelimit_logs = [r.message for r in caplog.records if "audiodb.ratelimit" in r.message]
|
||||||
assert len(ratelimit_logs) == 3
|
assert len(ratelimit_logs) == 3
|
||||||
assert all("retry_after_s=60" in msg for msg in ratelimit_logs)
|
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)
|
response = _mock_response(429)
|
||||||
repo._client.get = AsyncMock(return_value=response)
|
repo._client.get = AsyncMock(return_value=response)
|
||||||
|
|
||||||
for _ in range(2):
|
for _ in range(5):
|
||||||
with pytest.raises(RateLimitedError):
|
with pytest.raises(RateLimitedError):
|
||||||
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
|
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)
|
repo._client.get = AsyncMock(return_value=fail_resp)
|
||||||
|
|
||||||
with caplog.at_level("INFO"):
|
with caplog.at_level("INFO"):
|
||||||
for _ in range(2):
|
for _ in range(5):
|
||||||
with pytest.raises(ExternalServiceError):
|
with pytest.raises(ExternalServiceError):
|
||||||
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
|
await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234")
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ class TestCacheStatsNonblocking:
|
|||||||
return fake_du
|
return fake_du
|
||||||
return fake_find
|
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.shutil.which", return_value="/usr/bin/du"), \
|
||||||
patch("services.cache_service.asyncio.to_thread", side_effect=mock_to_thread) as mock_tt:
|
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.exists.return_value = True
|
||||||
mock_dir.__str__ = lambda s: "/app/cache/covers"
|
mock_dir.__str__ = lambda s: "/app/cache/covers"
|
||||||
|
mock_get_dir.return_value = mock_dir
|
||||||
|
|
||||||
stats = await svc.get_stats()
|
stats = await svc.get_stats()
|
||||||
|
|
||||||
@@ -67,8 +69,10 @@ class TestCacheStatsNonblocking:
|
|||||||
"""Second call within TTL returns cached stats without subprocess."""
|
"""Second call within TTL returns cached stats without subprocess."""
|
||||||
svc = _make_service()
|
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_dir.exists.return_value = False
|
||||||
|
mock_get_dir.return_value = mock_dir
|
||||||
|
|
||||||
stats1 = await svc.get_stats()
|
stats1 = await svc.get_stats()
|
||||||
stats2 = await svc.get_stats()
|
stats2 = await svc.get_stats()
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class TestRequestServiceSkipsLidarr:
|
|||||||
|
|
||||||
svc = RequestService(lidarr, MagicMock(), MagicMock())
|
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")
|
await svc.request_album("test-mbid")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -372,8 +372,10 @@ class TestCacheStatsAudioDBWiring:
|
|||||||
)
|
)
|
||||||
svc._stats_cache_ttl = 0
|
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_dir.exists.return_value = False
|
||||||
|
mock_get_dir.return_value = mock_dir
|
||||||
stats = await svc.get_stats()
|
stats = await svc.get_stats()
|
||||||
|
|
||||||
assert stats.disk_audiodb_artist_count == 15
|
assert stats.disk_audiodb_artist_count == 15
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" type="image/x-icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://coverartarchive.org" />
|
<link rel="preconnect" href="https://coverartarchive.org" />
|
||||||
<link rel="preconnect" href="https://upload.wikimedia.org" />
|
<link rel="preconnect" href="https://upload.wikimedia.org" />
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ vi.mock('$lib/player/createSource', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/player/jellyfinPlaybackApi', () => ({
|
vi.mock('$lib/player/jellyfinPlaybackApi', () => ({
|
||||||
startSession: vi.fn(async (_itemId: string, playSessionId?: string) => playSessionId ?? ''),
|
startSession: vi.fn(async () => 'mock-session'),
|
||||||
reportProgress: vi.fn(async () => true),
|
reportProgress: vi.fn(async () => true),
|
||||||
reportStop: vi.fn(async () => true)
|
reportStop: vi.fn(async () => true)
|
||||||
}));
|
}));
|
||||||
@@ -730,6 +730,7 @@ describe('Jellyfin session lifecycle', () => {
|
|||||||
|
|
||||||
jellyfinApi =
|
jellyfinApi =
|
||||||
(await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi;
|
(await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi;
|
||||||
|
jellyfinApi.startSession.mockResolvedValue('ps-123');
|
||||||
|
|
||||||
mockApiGet.mockResolvedValue({
|
mockApiGet.mockResolvedValue({
|
||||||
url: 'http://jf/Audio/1/stream?static=true',
|
url: 'http://jf/Audio/1/stream?static=true',
|
||||||
@@ -769,7 +770,7 @@ describe('Jellyfin session lifecycle', () => {
|
|||||||
playerStore.playQueue([makeJellyfinItem()]);
|
playerStore.playQueue([makeJellyfinItem()]);
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
expect(jellyfinApi.startSession).toHaveBeenCalledWith('jf-1', 'ps-123');
|
expect(jellyfinApi.startSession).toHaveBeenCalledWith('jf-1', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls reportStop when switching tracks', async () => {
|
it('calls reportStop when switching tracks', async () => {
|
||||||
@@ -837,6 +838,7 @@ describe('beforeunload beacon', () => {
|
|||||||
|
|
||||||
jellyfinApi =
|
jellyfinApi =
|
||||||
(await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi;
|
(await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi;
|
||||||
|
jellyfinApi.startSession.mockResolvedValue('ps-beacon');
|
||||||
|
|
||||||
mockApiGet.mockResolvedValue({
|
mockApiGet.mockResolvedValue({
|
||||||
url: 'http://jf/Audio/1/stream?static=true',
|
url: 'http://jf/Audio/1/stream?static=true',
|
||||||
@@ -965,11 +967,11 @@ describe('non-seekable state propagation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets isSeekable to false when Jellyfin returns seekable: false', async () => {
|
it('sets isSeekable to true for Jellyfin streams', async () => {
|
||||||
const item = makeItem({ sourceType: 'jellyfin', trackSourceId: 'jf-ns', streamUrl: undefined });
|
const item = makeItem({ sourceType: 'jellyfin', trackSourceId: 'jf-ns', streamUrl: undefined });
|
||||||
playerStore.playQueue([item]);
|
playerStore.playQueue([item]);
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
expect(playerStore.isSeekable).toBe(false);
|
expect(playerStore.isSeekable).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 797 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "MusicSeerr",
|
||||||
|
"short_name": "MusicSeerr",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#1d232a",
|
||||||
|
"background_color": "#1d232a",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||