diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..5d306a1 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -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 diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 58462ed..1b0426c 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,10 +1,9 @@ -name: Frontend CI Checks +name: Frontend CI on: push: branches: [main] pull_request: - branches: [main] defaults: run: @@ -89,3 +88,28 @@ jobs: - name: Run type 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 diff --git a/Makefile b/Makefile index 8f75517..e15ca60 100644 --- a/Makefile +++ b/Makefile @@ -2,20 +2,73 @@ SHELL := /bin/bash .DEFAULT_GOAL := help -ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) -BACKEND_DIR := $(ROOT_DIR)/backend +ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +BACKEND_DIR := $(ROOT_DIR)/backend 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 - @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): 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: lint + backend-lint: $(BACKEND_VENV_STAMP) ## Run backend Ruff checks cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check backend +# Backend: full test suite + 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 - 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: focused test targets 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) - 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 -backend-test-sync-generation: $(BACKEND_VENV_STAMP) ## Run MUS-19 sync generation counter tests - cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_sync_generation.py -v +test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests + $(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 +# Frontend: setup + frontend-install: ## Install frontend npm dependencies cd "$(FRONTEND_DIR)" && $(NPM) install frontend-build: ## Run frontend production 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 cd "$(FRONTEND_DIR)" && $(NPM) run format:check @@ -164,38 +232,48 @@ frontend-check: ## Run frontend type checks frontend-lint: ## Run frontend linting 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 -frontend-test-queuehelpers: ## Run queue helper regressions - cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts +frontend-test-server: ## Run frontend server-project tests only (no Playwright) + cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server -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: focused test targets 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 -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 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 +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 cd "$(ROOT_DIR)" && $(PYTHON) scripts/gen-project-map.py rebuild: ## Rebuild the application cd "$(ROOT_DIR)" && ./manage.sh --rebuild +# Aggregate targets + 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 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 diff --git a/backend/api/v1/routes/artists.py b/backend/api/v1/routes/artists.py index cc2c125..0ff48eb 100644 --- a/backend/api/v1/routes/artists.py +++ b/backend/api/v1/routes/artists.py @@ -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) diff --git a/backend/api/v1/routes/profile.py b/backend/api/v1/routes/profile.py index 54ca235..9e20400 100644 --- a/backend/api/v1/routes/profile.py +++ b/backend/api/v1/routes/profile.py @@ -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 diff --git a/backend/services/library_service.py b/backend/services/library_service.py index 1266cf3..cfe172c 100644 --- a/backend/services/library_service.py +++ b/backend/services/library_service.py @@ -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 diff --git a/backend/services/precache/orchestrator.py b/backend/services/precache/orchestrator.py index b44dca0..e720238 100644 --- a/backend/services/precache/orchestrator.py +++ b/backend/services/precache/orchestrator.py @@ -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)) diff --git a/backend/static_server.py b/backend/static_server.py index 928a7dd..630c481 100644 --- a/backend/static_server.py +++ b/backend/static_server.py @@ -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(): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0e116e2..3d10326 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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)) diff --git a/backend/tests/infrastructure/test_library_pagination.py b/backend/tests/infrastructure/test_library_pagination.py index 49234b9..f12ec8e 100644 --- a/backend/tests/infrastructure/test_library_pagination.py +++ b/backend/tests/infrastructure/test_library_pagination.py @@ -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 diff --git a/backend/tests/repositories/test_audiodb_repository.py b/backend/tests/repositories/test_audiodb_repository.py index a0ba902..34c9701 100644 --- a/backend/tests/repositories/test_audiodb_repository.py +++ b/backend/tests/repositories/test_audiodb_repository.py @@ -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") diff --git a/backend/tests/services/test_cache_stats_nonblocking.py b/backend/tests/services/test_cache_stats_nonblocking.py index ec83f9c..873bfc5 100644 --- a/backend/tests/services/test_cache_stats_nonblocking.py +++ b/backend/tests/services/test_cache_stats_nonblocking.py @@ -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() diff --git a/backend/tests/test_lidarr_skip_unconfigured.py b/backend/tests/test_lidarr_skip_unconfigured.py index aa2bd92..6b750ef 100644 --- a/backend/tests/test_lidarr_skip_unconfigured.py +++ b/backend/tests/test_lidarr_skip_unconfigured.py @@ -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") diff --git a/backend/tests/test_phase9_observability.py b/backend/tests/test_phase9_observability.py index 61bc1d6..a0d31bb 100644 --- a/backend/tests/test_phase9_observability.py +++ b/backend/tests/test_phase9_observability.py @@ -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 diff --git a/frontend/src/app.html b/frontend/src/app.html index f944dc6..072b832 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,7 +3,11 @@ - + + + + + diff --git a/frontend/src/lib/stores/player.spec.ts b/frontend/src/lib/stores/player.spec.ts index 3e6d893..f8288e5 100644 --- a/frontend/src/lib/stores/player.spec.ts +++ b/frontend/src/lib/stores/player.spec.ts @@ -41,7 +41,7 @@ vi.mock('$lib/player/createSource', () => ({ })); 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), reportStop: vi.fn(async () => true) })); @@ -730,6 +730,7 @@ describe('Jellyfin session lifecycle', () => { jellyfinApi = (await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi; + jellyfinApi.startSession.mockResolvedValue('ps-123'); mockApiGet.mockResolvedValue({ url: 'http://jf/Audio/1/stream?static=true', @@ -769,7 +770,7 @@ describe('Jellyfin session lifecycle', () => { playerStore.playQueue([makeJellyfinItem()]); 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 () => { @@ -837,6 +838,7 @@ describe('beforeunload beacon', () => { jellyfinApi = (await import('$lib/player/jellyfinPlaybackApi')) as unknown as typeof jellyfinApi; + jellyfinApi.startSession.mockResolvedValue('ps-beacon'); mockApiGet.mockResolvedValue({ 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 }); playerStore.playQueue([item]); await vi.advanceTimersByTimeAsync(0); - expect(playerStore.isSeekable).toBe(false); + expect(playerStore.isSeekable).toBe(true); }); }); diff --git a/frontend/static/android-chrome-192x192.png b/frontend/static/android-chrome-192x192.png new file mode 100644 index 0000000..73a7013 Binary files /dev/null and b/frontend/static/android-chrome-192x192.png differ diff --git a/frontend/static/android-chrome-512x512.png b/frontend/static/android-chrome-512x512.png new file mode 100644 index 0000000..a30fec8 Binary files /dev/null and b/frontend/static/android-chrome-512x512.png differ diff --git a/frontend/static/apple-touch-icon.png b/frontend/static/apple-touch-icon.png new file mode 100644 index 0000000..9e0de50 Binary files /dev/null and b/frontend/static/apple-touch-icon.png differ diff --git a/frontend/static/favicon-16x16.png b/frontend/static/favicon-16x16.png new file mode 100644 index 0000000..c8158b0 Binary files /dev/null and b/frontend/static/favicon-16x16.png differ diff --git a/frontend/static/favicon-32x32.png b/frontend/static/favicon-32x32.png new file mode 100644 index 0000000..5f36591 Binary files /dev/null and b/frontend/static/favicon-32x32.png differ diff --git a/frontend/static/favicon.ico b/frontend/static/favicon.ico new file mode 100644 index 0000000..b3268ee Binary files /dev/null and b/frontend/static/favicon.ico differ diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png deleted file mode 100644 index 05b49cd..0000000 Binary files a/frontend/static/favicon.png and /dev/null differ diff --git a/frontend/static/site.webmanifest b/frontend/static/site.webmanifest new file mode 100644 index 0000000..f018d50 --- /dev/null +++ b/frontend/static/site.webmanifest @@ -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" +}