diff --git a/.env.example b/.env.example index 2fb02e8..191a20e 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,12 @@ # Uncomment to override stored setting. -#SPOTIFY_CLIENT_ID= -#SPOTIFY_CLIENT_SECRET= +#LASTFM_API_KEY= APP_ENV=prod APP_SECRET=changeme # APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting DEFAULT_URI=http://localhost:8085 -ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by Spotify. +ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by external APIs. +JWT_PASSPHRASE=changeme DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite. # DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8 diff --git a/README.md b/README.md index 09f7ed8..98d19aa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tonehaus — Music Ratings (Symfony 7) -Discover albums via Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap. +Discover albums via Last.fm, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap. ## Quick start (Docker Compose) @@ -23,10 +23,10 @@ docker compose up -d --build docker compose exec tonehaus php bin/console app:promote-admin you@example.com ``` -4) Configure Spotify +4) Configure Last.fm -- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or -- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +- Go to `http://localhost:8085/admin/settings` and enter your Last.fm API key, or +- Set env var in `.env`: `LASTFM_API_KEY` 5) (Optional) Seed demo data @@ -42,11 +42,14 @@ Notes: ## Features -- Spotify search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count) +- Last.fm-backed album search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count) +- REST API under `/api/v1` for albums/reviews with JSON responses and proper HTTP verbs +- JWT authentication for protected API endpoints (`/api/v1/auth/login`) via LexikJWTAuthenticationBundle +- REST response handling via FOSRestBundle - Album page with details, tracklist, reviews list, and inline new review (logged-in) - Auth modal (Login/Sign up) with remember‑me; no separate pages - Role-based access: authors manage their own reviews; moderators/admins can moderate content -- Admin Site Settings: manage Spotify credentials and public registration toggle +- Admin Site Settings: manage Last.fm API key and public registration toggle - User Dashboard: profile updates and password change - Light/Dark theme toggle (cookie-backed) @@ -55,7 +58,7 @@ Notes: - Setup and configuration: `docs/setup.md` - Feature overview: `docs/features.md` - Authentication and users: `docs/auth-and-users.md` -- Spotify integration: `docs/spotify-integration.md` +- Last.fm integration: `docs/lastfm-integration.md` - Reviews and albums: `docs/reviews-and-albums.md` - Admin & site settings: `docs/admin-and-settings.md` - Troubleshooting: `docs/troubleshooting.md` @@ -65,7 +68,8 @@ Notes: ## Environment overview - `APP_ENV` (dev|prod), `APP_SECRET` (required) -- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +- `LASTFM_API_KEY` +- `JWT_PASSPHRASE` (used for JWT private key encryption) - `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env - `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres) - `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`) @@ -74,4 +78,3 @@ Notes: ## License MIT - diff --git a/assets/app.js b/assets/app.js index 1f60f76..a5f9eac 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,5 +1 @@ -// Placeholder front-end entrypoint to satisfy importmap & asset mapper. -// You can add real JS behavior here; for now we only register a noop. - -console.debug('Tonehaus assets initialized'); - +// Intentionally empty importmap entrypoint. diff --git a/composer.json b/composer.json index 69a9bda..b804df1 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,9 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "friendsofsymfony/rest-bundle": "^3.8", + "guzzlehttp/guzzle": "^7.9", + "lexik/jwt-authentication-bundle": "^3.1", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-migrations-bundle": "^3.5", diff --git a/config/bundles.php b/config/bundles.php index 0457f99..fe4d42d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,6 +2,8 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + FOS\RestBundle\FOSRestBundle::class => ['all' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], diff --git a/config/packages/fos_rest.yaml b/config/packages/fos_rest.yaml new file mode 100644 index 0000000..90e0685 --- /dev/null +++ b/config/packages/fos_rest.yaml @@ -0,0 +1,10 @@ +fos_rest: + format_listener: + rules: + - { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] } + - { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] } + body_listener: true + view: + view_response_listener: force + formats: + json: true diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..f47472f --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,5 @@ +lexik_jwt_authentication: + secret_key: '%kernel.project_dir%/config/jwt/private.pem' + public_key: '%kernel.project_dir%/config/jwt/public.pem' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 3600 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 1bbb43b..3dbb77a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -15,6 +15,21 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + api_login: + pattern: ^/api/v1/auth/login$ + stateless: true + provider: app_user_provider + json_login: + check_path: /api/v1/auth/login + username_path: email + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + api: + pattern: ^/api + stateless: true + provider: app_user_provider + jwt: ~ main: lazy: true provider: app_user_provider @@ -48,6 +63,11 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/reviews$, roles: ROLE_USER, methods: [POST] } + - { path: ^/api/v1/reviews/[0-9]+$, roles: ROLE_USER, methods: [PUT, PATCH, DELETE] } + - { path: ^/api/v1/albums/ingest$, roles: ROLE_USER, methods: [POST] } + - { path: ^/api, roles: PUBLIC_ACCESS } - { path: ^/admin/settings, roles: ROLE_ADMIN } - { path: ^/admin/users, roles: ROLE_MODERATOR } - { path: ^/admin/dashboard, roles: ROLE_MODERATOR } diff --git a/config/routes/lexik_jwt_authentication.yaml b/config/routes/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..e96fcad --- /dev/null +++ b/config/routes/lexik_jwt_authentication.yaml @@ -0,0 +1,2 @@ +api_login_check: + path: /api/v1/auth/login diff --git a/config/services.yaml b/config/services.yaml index 0ed7f49..89a2d3c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -20,10 +20,14 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones - App\Service\SpotifyClient: + GuzzleHttp\Client: arguments: - $clientId: '%env(default::SPOTIFY_CLIENT_ID)%' - $clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%' + $config: { timeout: 10.0, connect_timeout: 5.0, http_errors: false } + + App\Service\LastFmClient: + arguments: + $httpClient: '@GuzzleHttp\Client' + $apiKey: '%env(default::LASTFM_API_KEY)%' App\Service\UploadStorage: arguments: @@ -33,3 +37,7 @@ services: App\Controller\AlbumController: arguments: $searchLimit: '%album_search_limit%' + + App\Controller\Api\V1\AlbumApiController: + arguments: + $searchLimit: '%album_search_limit%' diff --git a/docker-compose.yml b/docker-compose.yml index 2cbb482..87c096f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,12 @@ services: tonehaus: - image: git.ntbx.io/boris/tonehaus:latest-arm64 + image: tonehaus:local + build: + context: . + dockerfile: docker/php/Dockerfile + target: prod + args: + APP_ENV: prod container_name: tonehaus restart: unless-stopped volumes: @@ -18,4 +24,4 @@ services: volumes: sqlite_data: - uploads: \ No newline at end of file + uploads: diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index c224c97..616917a 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -12,6 +12,7 @@ WORKDIR /var/www/html RUN apk add --no-cache \ bash \ git \ + openssl \ unzip \ icu-dev \ libpng-dev \ @@ -133,4 +134,4 @@ EXPOSE 8080 ENTRYPOINT ["/entrypoint.sh"] CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"] - \ No newline at end of file + diff --git a/docker/prod/entrypoint.sh b/docker/prod/entrypoint.sh index f7a5ec0..cde11a8 100755 --- a/docker/prod/entrypoint.sh +++ b/docker/prod/entrypoint.sh @@ -8,6 +8,39 @@ require_app_secret() { fi } +ensure_jwt_keys() { + if [ ! -f bin/console ]; then + return + fi + + mkdir -p config/jwt + + if [ -f config/jwt/private.pem ] && [ -f config/jwt/public.pem ]; then + return + fi + + if [ -z "${JWT_PASSPHRASE:-}" ]; then + JWT_PASSPHRASE="${APP_SECRET}" + fi + + openssl genpkey \ + -out config/jwt/private.pem \ + -algorithm rsa \ + -pkeyopt rsa_keygen_bits:4096 \ + -aes-256-cbc \ + -pass pass:"${JWT_PASSPHRASE}" + + openssl pkey \ + -in config/jwt/private.pem \ + -passin pass:"${JWT_PASSPHRASE}" \ + -pubout \ + -out config/jwt/public.pem + + chmod 600 config/jwt/private.pem + chmod 644 config/jwt/public.pem + chown -R www-data:www-data config/jwt +} + install_runtime() { if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then return @@ -25,6 +58,7 @@ install_runtime if [ -f bin/console ]; then require_app_secret + ensure_jwt_keys fi if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then @@ -48,4 +82,3 @@ if [ -f bin/console ]; then fi exec "$@" - diff --git a/docker/prod/nginx.conf b/docker/prod/nginx.conf index 806c2be..8587310 100644 --- a/docker/prod/nginx.conf +++ b/docker/prod/nginx.conf @@ -23,6 +23,14 @@ server { return 200 "OK"; } + location ^/api/v1 { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + internal; + } + error_log /var/log/nginx/error.log; access_log /var/log/nginx/access.log; } diff --git a/docs/admin-and-settings.md b/docs/admin-and-settings.md index 6ba43b4..a73e08f 100644 --- a/docs/admin-and-settings.md +++ b/docs/admin-and-settings.md @@ -23,7 +23,7 @@ ## Site settings (ROLE_ADMIN) - URL: `/admin/settings` -- Form persists Spotify Client ID/Secret in the DB (no restart needed). +- Form persists Last.fm API key in the DB (no restart needed). - Toggle “Allow self-service registration” to pause public sign-ups while keeping `/admin/users` creation available to staff. - The setting syncs with the `APP_ALLOW_REGISTRATION` environment variable each time Symfony boots (change the env value and restart to enforce). UI changes persist while the process runs. - CSRF + role guards prevent unauthorized updates. @@ -44,5 +44,5 @@ docker compose exec php php bin/console app:promote-moderator user@example.com ## Useful tips - Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable. -- Changing Spotify credentials in settings is effective immediately; no restart is required. +- Changing Last.fm credentials in settings is effective immediately; no restart is required. - Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation. diff --git a/docs/architecture.md b/docs/architecture.md index 0af0c4a..160af28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,19 +6,19 @@ This project follows a conventional Symfony architecture with clear separation o - **Classes** - **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`). - - **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `SpotifyClient`, `SpotifyMetadataRefresher`). + - **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `LastFmClient`, `LastFmMetadataRefresher`). - **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details. - **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`). - **Methods** - - Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllSpotifyAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`). - - Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isSpotifyConfigured()`). + - Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllExternalAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`). + - Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isExternalConfigured()`). - Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc. - **Variables & parameters** - - Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$spotifyAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties). - - Booleans read naturally (`$isEnabled`, `$shouldQuerySpotify`, `$needsSync`). - - Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`). + - Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$lastfmAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties). + - Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`). + - Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`). - **Files & namespaces** - File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`). @@ -27,10 +27,10 @@ This project follows a conventional Symfony architecture with clear separation o These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting. ## High-level flow -1. Visitors search for albums (Spotify) and view an album page +1. Visitors search for albums (Last.fm) and view an album page 2. Logged‑in users can write, edit, and delete reviews 3. Moderators and admins can moderate content and manage users -4. Admins configure site settings (Spotify credentials, registration toggle) +4. Admins configure site settings (Last.fm credentials, registration toggle) ## Layers & components @@ -45,7 +45,7 @@ These conventions should be followed for all new PHP code and when refactoring e - `User` — authentication principal and roles - `Album`, `AlbumTrack` — normalized album metadata and track list - `Review` — user‑authored review with rating and timestamps -- `Setting` — key/value store for site configuration (e.g., Spotify credentials) +- `Setting` — key/value store for site configuration (e.g., Last.fm credentials) ### Repositories (`src/Repository/*`) - Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users) @@ -55,10 +55,10 @@ These conventions should be followed for all new PHP code and when refactoring e - Leverage Symfony validation constraints for robust server‑side validation ### Services (`src/Service/*`) -- `SpotifyClient` — Client Credentials token management (cached) and API calls -- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data +- `LastFmClient` — Last.fm API integration with caching +- `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data - `CatalogResetService` — admin action to reset/sync catalog state safely -- `ImageStorage` — avatar uploads and related image handling +- `UploadStorage` — avatar/album image uploads and related file handling - `RegistrationToggle` — DB‑backed registration flag with env override ### Security (`config/packages/security.yaml`, `src/Security/*`) @@ -86,5 +86,3 @@ These conventions should be followed for all new PHP code and when refactoring e ## Testing & tooling - PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage - Web Profiler enabled in dev - - diff --git a/docs/deployment.md b/docs/deployment.md index 2f18b5d..301755a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -18,8 +18,7 @@ docker run -d \ -p 8080:8080 \ -e APP_ENV=prod \ -e APP_SECRET=change_me \ - -e SPOTIFY_CLIENT_ID=your_client_id \ - -e SPOTIFY_CLIENT_SECRET=your_client_secret \ + -e LASTFM_API_KEY=your_api_key \ tonehaus-app:latest ``` @@ -51,7 +50,7 @@ docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interactio ## Environment variables - `APP_ENV` (`prod` recommended in production) - `APP_SECRET` (required; random string) -- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +- `LASTFM_API_KEY` - `APP_ALLOW_REGISTRATION` (env override for public registration) - `DATABASE_DRIVER` (`sqlite` default, or `postgres`) - `DATABASE_URL` (when using Postgres) @@ -67,4 +66,3 @@ docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interactio - Build then run a new container alongside the old one, switch traffic at the proxy - Keep SQLite on a named volume, or use Postgres for shared state across replicas - diff --git a/docs/features.md b/docs/features.md index 40398a7..95a3552 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,7 +1,7 @@ # Features ## Albums & Reviews -- Spotify album search with advanced filters (album, artist, year range) +- Last.fm album search with advanced filters (album, artist, year range) - Album page: cover art, metadata, full tracklist (when available) - Reviews list (newest first) and inline new review form (logged-in) - Rating slider (1–10) with live badge @@ -16,7 +16,7 @@ ## Administration - Dashboard: latest reviews/albums and key counts (moderator+) - Users: create/delete users, promote/demote moderators (admin constraints) -- Settings: manage Spotify credentials, toggle public registration (admin) +- Settings: manage Last.fm credentials, toggle public registration (admin) ## Design & UX - Responsive Bootstrap UI diff --git a/docs/lastfm-integration.md b/docs/lastfm-integration.md new file mode 100644 index 0000000..bb8306f --- /dev/null +++ b/docs/lastfm-integration.md @@ -0,0 +1,26 @@ +# Last.fm Integration + +## Credentials +- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required). +- Env fallback: `LASTFM_API_KEY`. + +## API Client +- `src/Service/LastFmClient.php` (uses Guzzle) + - `searchAlbums(query, limit)` + - `getAlbum(id)` / `getAlbums([ids])` + - `getAlbumWithTracks(id)` + - `getAlbumTracks(id)` + +## Caching & Limits +- Last.fm responses are cached in Symfony cache pools to reduce repeated calls. +- Search/detail payloads are normalized to one internal album shape before persistence. + +## Ingestion Flow +1. Search Last.fm +2. Resolve tags/genres via `LastFmTagResolver` +3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()` +4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()` + +## Admin Settings +- Set the Last.fm API key in `/admin/settings`. +- Key changes apply immediately. diff --git a/docs/reviews-and-albums.md b/docs/reviews-and-albums.md index 21776e4..c69fdaf 100644 --- a/docs/reviews-and-albums.md +++ b/docs/reviews-and-albums.md @@ -2,7 +2,7 @@ ## Album page - Artwork, metadata, average rating, and review count -- Full Spotify tracklist when available +- Full Last.fm tracklist when available - Reviews list (newest first) - Inline new review form for logged‑in users diff --git a/docs/setup.md b/docs/setup.md index 2d749d1..d8ff7c1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -2,7 +2,7 @@ ## Prerequisites - Docker + Docker Compose -- Spotify Developer account (Client ID/Secret) +- Last.fm API key - A unique `APP_SECRET` value in your environment (for prod builds) ## 1) Start the stack @@ -18,15 +18,19 @@ Health: `http://localhost:8085/healthz` docker compose exec tonehaus php bin/console app:promote-admin you@example.com ``` -## 3) Configure Spotify -- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB) +## 3) Configure Last.fm +- Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB) - Env fallback (in `.env` or your shell): ```bash -SPOTIFY_CLIENT_ID=your_client_id -SPOTIFY_CLIENT_SECRET=your_client_secret +LASTFM_API_KEY=your_api_key ``` -## 4) (Optional) Seed demo data +## 4) JWT auth keys +- API login endpoint: `POST /api/v1/auth/login` +- JWT keys are generated automatically on container start into `config/jwt/`. +- Set `JWT_PASSPHRASE` in `.env` to your production secret. + +## 5) (Optional) Seed demo data ```bash docker compose exec tonehaus php bin/console app:seed-demo-users --count=50 docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users @@ -44,7 +48,8 @@ docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-perce ## Environment variables - `APP_ENV=dev|prod` - `APP_SECRET=` -- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` +- `LASTFM_API_KEY` +- `JWT_PASSPHRASE=` - `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting) - `DATABASE_DRIVER=sqlite|postgres` - `DATABASE_SQLITE_PATH` (optional) diff --git a/docs/spotify-integration.md b/docs/spotify-integration.md deleted file mode 100644 index 68d3bcd..0000000 --- a/docs/spotify-integration.md +++ /dev/null @@ -1,30 +0,0 @@ -# Spotify Integration - -## Credentials -- Preferred: Manage in `/admin/settings` (persisted in DB; no restart required) -- Env fallback: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` - -## API Client -- `src/Service/SpotifyClient.php` - - Client Credentials token fetch with caching - - `searchAlbums(q, limit)` — album search endpoint - - `getAlbum(id)` / `getAlbums([ids])` — metadata fetch - - `getAlbumWithTracks(id)` — metadata + hydrated tracklist - - `getAlbumTracks(id)` — raw paginated tracks (when needed) - -### Caching & Rate Limits -- Access tokens are cached until expiry to avoid unnecessary auth calls. -- Downstream requests should be mindful of Spotify rate limits; user actions are debounced in the UI and server calls are focused on album/track data needed by the current page. - -## Advanced search syntax -- Fielded queries are composed as: - - `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY` - - Optional free text is appended to the query -- Examples: - - `album:"in rainbows" artist:"radiohead"` - - `year:1999-2004 post rock` - -## Admin settings -- Update credentials in `/admin/settings` -- Settings are stored in the database; `APP_ENV` reload or container restart is not required - diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0ec2dc7..ccae075 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,9 +29,8 @@ postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8 ``` -## Spotify errors -- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`. -- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container. +## Last.fm errors +- Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`. ## ARM64 Build @@ -43,4 +42,4 @@ sudo docker buildx build \ -f docker/php/Dockerfile \ . \ --load -``` \ No newline at end of file +``` diff --git a/migrations/Version20260227120500.php b/migrations/Version20260227120500.php new file mode 100644 index 0000000..bec485a --- /dev/null +++ b/migrations/Version20260227120500.php @@ -0,0 +1,120 @@ +shouldAddColumn($schema, 'albums', 'external_id')) { + $this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL'); + } + + if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) { + $this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL'); + } + + if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) { + $this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL'); + } + + if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) { + $this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL'); + } + + if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) { + $this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL'); + } + + if ($this->hasColumn($schema, 'albums', 'source')) { + $this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'"); + if (!$this->isSqlite()) { + $this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'"); + } + } + + if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) { + $this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL'); + } + + if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) { + $this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)'); + } + + if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) { + $this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)'); + } + } + + public function down(Schema $schema): void + { + if ($this->isSqlite()) { + // SQLite column drops require table rebuilds; keep schema forward-only here. + return; + } + + if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) { + $this->addSql('DROP INDEX uniq_users_oauth_identity'); + } + + if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) { + $this->addSql('DROP INDEX uniq_album_external_id'); + } + + if ($this->hasColumn($schema, 'users', 'oauth_id')) { + $this->addSql('ALTER TABLE users DROP COLUMN oauth_id'); + } + + if ($this->hasColumn($schema, 'users', 'oauth_provider')) { + $this->addSql('ALTER TABLE users DROP COLUMN oauth_provider'); + } + + if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) { + $this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id'); + } + + if ($this->hasColumn($schema, 'albums', 'external_id')) { + $this->addSql('ALTER TABLE albums DROP COLUMN external_id'); + } + + if ($this->hasColumn($schema, 'albums', 'source')) { + $this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'"); + $this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'"); + } + } + + private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool + { + return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName); + } + + private function hasTable(Schema $schema, string $tableName): bool + { + return $schema->hasTable($tableName); + } + + private function hasColumn(Schema $schema, string $tableName, string $columnName): bool + { + return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName); + } + + private function indexExists(Schema $schema, string $tableName, string $indexName): bool + { + return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName); + } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 33e3bdc..9c9fcbb 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -5,7 +5,7 @@ namespace App\Controller\Admin; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; use App\Repository\UserRepository; -use App\Service\SpotifyMetadataRefresher; +use App\Service\LastFmMetadataRefresher; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\Request; @@ -53,22 +53,22 @@ class DashboardController extends AbstractController ]); } - #[Route('/admin/dashboard/refresh-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])] - public function refreshSpotify( + #[Route('/admin/dashboard/refresh-external', name: 'admin_dashboard_refresh_external', methods: ['POST'])] + public function refreshExternal( Request $request, - SpotifyMetadataRefresher $refresher + LastFmMetadataRefresher $refresher ): Response { $token = (string) $request->request->get('_token'); - if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) { + if (!$this->isCsrfTokenValid('dashboard_refresh_external', $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } // Refresh runs synchronously; keep user feedback short so the POST remains snappy. - $updated = $refresher->refreshAllSpotifyAlbums(); + $updated = $refresher->refreshAllExternalAlbums(); if ($updated === 0) { - $this->addFlash('info', 'No Spotify albums needed refresh or none are saved.'); + $this->addFlash('info', 'No external albums needed refresh or none are saved.'); } else { - $this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated)); + $this->addFlash('success', sprintf('Refreshed %d external albums from Last.fm.', $updated)); } return $this->redirectToRoute('admin_dashboard'); diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php index 39814af..b796588 100644 --- a/src/Controller/Admin/SettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -62,21 +62,19 @@ class SettingsController extends AbstractController ]; /** - * Displays and persists Spotify credential settings. + * Displays and persists Last.fm credential settings. */ #[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])] public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): Response { $form = $this->createForm(SiteSettingsType::class); - $form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID')); - $form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET')); + $form->get('LASTFM_API_KEY')->setData($settings->getValue('LASTFM_API_KEY')); $registrationOverride = $registrationToggle->getEnvOverride(); $form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData()); - $settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData()); + $settings->setValue('LASTFM_API_KEY', (string) $form->get('LASTFM_API_KEY')->getData()); if ($registrationOverride === null) { // Persist only when the flag is not locked by APP_ALLOW_REGISTRATION. $registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData()); @@ -168,4 +166,4 @@ class SettingsController extends AbstractController } return $options; } -} \ No newline at end of file +} diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index 9b001a0..d59d849 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -13,8 +13,8 @@ use App\Repository\AlbumTrackRepository; use App\Repository\ReviewRepository; use App\Service\AlbumSearchService; use App\Service\UploadStorage; -use App\Service\SpotifyClient; -use App\Service\SpotifyGenreResolver; +use App\Service\LastFmClient; +use App\Service\LastFmTagResolver; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -32,13 +32,13 @@ class AlbumController extends AbstractController public function __construct( private readonly UploadStorage $uploadStorage, private readonly AlbumSearchService $albumSearch, - private readonly SpotifyGenreResolver $genreResolver, + private readonly LastFmTagResolver $genreResolver, private readonly int $searchLimit = 20 ) { } /** - * Searches Spotify plus local albums and decorates results with review stats. + * Searches external plus local albums and decorates results with review stats. */ #[Route('/', name: 'album_search', methods: ['GET'])] public function search(Request $request): Response @@ -57,7 +57,7 @@ class AlbumController extends AbstractController 'stats' => $result->stats, 'savedIds' => $result->savedIds, 'source' => $criteria->source, - 'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(), + 'externalConfigured' => $this->albumSearch->isExternalConfigured(), ]); } @@ -94,25 +94,26 @@ class AlbumController extends AbstractController * Renders a detailed album view plus inline review form. */ #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] - public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response + public function show(string $id, Request $request, LastFmClient $lastFmClient, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $albumEntity = $this->findAlbum($id, $albumRepo); $isSaved = $albumEntity !== null; if (!$albumEntity) { - // Album has never been saved locally, so hydrate it via Spotify before rendering. - $spotifyAlbum = $spotify->getAlbumWithTracks($id); - if ($spotifyAlbum === null) { + // Album has never been saved locally, so hydrate it via Last.fm before rendering. + $externalAlbum = $lastFmClient->getAlbumWithTracks($id); + if ($externalAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); + $albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo); $em->flush(); } else { - if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) { + if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $lastFmClient)) { // Track sync mutated the entity: persist before we build template arrays. $em->flush(); } } $albumCard = $albumEntity->toTemplateArray(); + $albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $lastFmClient); $canManage = $this->canManageAlbum($albumEntity); $trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray()); @@ -156,23 +157,23 @@ class AlbumController extends AbstractController } /** - * Saves a Spotify album locally for quicker access. + * Saves an external album locally for quicker access. */ #[IsGranted('ROLE_USER')] #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] - public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response + public function save(string $id, Request $request, LastFmClient $lastFmClient, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } - $existing = $albumRepo->findOneBySpotifyId($id); + $existing = $albumRepo->findOneByExternalId($id); if (!$existing) { - $spotifyAlbum = $spotify->getAlbumWithTracks($id); - if ($spotifyAlbum === null) { + $externalAlbum = $lastFmClient->getAlbumWithTracks($id); + if ($externalAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); + $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo); $em->flush(); $this->addFlash('success', 'Album saved.'); } else { @@ -278,7 +279,7 @@ class AlbumController extends AbstractController } /** - * Looks up an album by either local or Spotify identifier. + * Looks up an album by either local or external identifier. */ private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album { @@ -287,7 +288,7 @@ class AlbumController extends AbstractController return $local; } - return $albumRepo->findOneBySpotifyId($id); + return $albumRepo->findOneByExternalId($id); } /** @@ -338,18 +339,18 @@ class AlbumController extends AbstractController } /** - * @param array $spotifyAlbum + * @param array $externalAlbum */ - private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album + private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album { - // Bring genres along when we persist Spotify albums so templates can display them immediately. - $genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); - $albumId = (string) ($spotifyAlbum['id'] ?? ''); - $album = $albumRepo->upsertFromSpotifyAlbum( - $spotifyAlbum, + // Bring genres along when we persist external albums so templates can display them immediately. + $genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]); + $albumId = (string) ($externalAlbum['id'] ?? ''); + $album = $albumRepo->upsertFromExternalAlbum( + $externalAlbum, $albumId !== '' ? ($genresMap[$albumId] ?? []) : [] ); - $tracks = $spotifyAlbum['tracks']['items'] ?? []; + $tracks = $externalAlbum['tracks']['items'] ?? []; if (is_array($tracks) && $tracks !== []) { $trackRepo->replaceAlbumTracks($album, $tracks); $album->setTotalTracks(count($tracks)); @@ -357,33 +358,33 @@ class AlbumController extends AbstractController return $album; } - private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool + private function syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, LastFmClient $lastFmClient): bool { - if ($album->getSource() !== 'spotify') { + if ($album->getSource() !== 'external') { return false; } - $spotifyId = $album->getSpotifyId(); - if ($spotifyId === null) { + $externalId = $album->getExternalId(); + if ($externalId === null) { return false; } $storedCount = $album->getTracks()->count(); $needsSync = $storedCount === 0; if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) { - // Spotify track counts do not match what we have stored; re-sync to avoid stale data. + // external track counts do not match what we have stored; re-sync to avoid stale data. $needsSync = true; } if (!$needsSync) { return false; } - $spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId); - if ($spotifyAlbum === null) { + $externalAlbum = $lastFmClient->getAlbumWithTracks($externalId); + if ($externalAlbum === null) { return false; } - // Rehydrate genres during syncs as well, in case Spotify has updated the metadata. - $genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); - $albumGenres = $genresMap[$spotifyId] ?? []; - $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres); - $tracks = $spotifyAlbum['tracks']['items'] ?? []; + // Rehydrate genres during syncs as well, in case source metadata has updated. + $genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]); + $albumGenres = $genresMap[$externalId] ?? []; + $albumRepo->upsertFromExternalAlbum($externalAlbum, $albumGenres); + $tracks = $externalAlbum['tracks']['items'] ?? []; if (!is_array($tracks) || $tracks === []) { return false; } @@ -392,6 +393,32 @@ class AlbumController extends AbstractController return true; } + /** + * @param array $payload + * @return array + */ + private function hydrateExternalAlbumImages(array $payload, Album $album, LastFmClient $lastFmClient): array + { + if ($album->getSource() !== 'external') { + return $payload; + } + + $externalId = trim((string) $album->getExternalId()); + if ($externalId === '') { + return $payload; + } + + $fresh = $lastFmClient->getAlbumWithTracks($externalId); + if (!is_array($fresh)) { + return $payload; + } + + $images = $fresh['images'] ?? null; + if (is_array($images) && $images !== []) { + $payload['images'] = $images; + } + + return $payload; + } + } - - diff --git a/src/Controller/Api/V1/AlbumApiController.php b/src/Controller/Api/V1/AlbumApiController.php new file mode 100644 index 0000000..2841d2d --- /dev/null +++ b/src/Controller/Api/V1/AlbumApiController.php @@ -0,0 +1,231 @@ +searchLimit); + $result = $this->albumSearchService->search($criteria); + + return $this->handleView($this->view([ + 'meta' => [ + 'source' => $criteria->source, + 'limit' => $criteria->limit, + 'query' => $criteria->query, + ], + 'data' => $result->albums, + 'stats' => $result->stats, + 'saved_ids' => $result->savedIds, + ], Response::HTTP_OK)); + } + + #[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])] + #[IsGranted('ROLE_USER')] + public function ingest(Request $request): Response + { + $payload = $this->readJsonPayload($request); + if ($payload === null) { + return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + } + + $externalId = trim((string) ($payload['external_id'] ?? '')); + if ($externalId === '') { + $query = trim((string) ($payload['query'] ?? '')); + if ($query === '') { + $artist = trim((string) ($payload['artist'] ?? '')); + $album = trim((string) ($payload['album'] ?? '')); + $query = trim($artist . ' ' . $album); + } + if ($query !== '') { + $search = $this->lastFmClient->searchAlbums($query, 1); + $externalId = trim((string) ($search['albums']['items'][0]['id'] ?? '')); + } + } + + if ($externalId === '') { + return $this->handleView($this->view(['error' => 'external_id_or_query_required'], Response::HTTP_BAD_REQUEST)); + } + + $externalAlbum = $this->lastFmClient->getAlbumWithTracks($externalId); + if (!is_array($externalAlbum)) { + return $this->handleView($this->view(['error' => 'external_album_not_found'], Response::HTTP_NOT_FOUND)); + } + + $album = $this->persistExternalAlbumPayload($externalAlbum); + $this->entityManager->flush(); + $payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album, $externalAlbum); + + return $this->handleView($this->view([ + 'data' => $payload, + 'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()), + ], Response::HTTP_CREATED)); + } + + #[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])] + public function show(string $id): Response + { + $album = $this->findAlbum($id); + if (!$album instanceof Album) { + $externalAlbum = $this->lastFmClient->getAlbumWithTracks($id); + if (!is_array($externalAlbum)) { + return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + } + $album = $this->persistExternalAlbumPayload($externalAlbum); + $this->entityManager->flush(); + } + + $albumId = $album->getSource() === 'user' ? (string) $album->getLocalId() : (string) $album->getExternalId(); + $stats = $album->getSource() === 'user' + ? $this->reviewRepository->getAggregatesForAlbumEntityIds([(int) $album->getId()]) + : $this->reviewRepository->getAggregatesForAlbumIds([$albumId]); + $payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album); + + return $this->handleView($this->view([ + 'data' => $payload, + 'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()), + 'stats' => $album->getSource() === 'user' + ? ($stats[(int) $album->getId()] ?? ['count' => 0, 'avg' => 0.0]) + : ($stats[$albumId] ?? ['count' => 0, 'avg' => 0.0]), + ], Response::HTTP_OK)); + } + + #[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])] + public function reviews(string $id): Response + { + $album = $this->findAlbum($id); + if (!$album instanceof Album) { + return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + } + + $reviews = $this->reviewRepository->findBy(['album' => $album], ['createdAt' => 'DESC']); + return $this->handleView($this->view([ + 'data' => array_map(static fn($review) => [ + 'id' => $review->getId(), + 'title' => $review->getTitle(), + 'content' => $review->getContent(), + 'rating' => $review->getRating(), + 'author' => [ + 'id' => $review->getAuthor()?->getId(), + 'display_name' => $review->getAuthor()?->getDisplayName(), + ], + 'created_at' => $review->getCreatedAt()?->format(DATE_ATOM), + 'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM), + ], $reviews), + ], Response::HTTP_OK)); + } + + private function findAlbum(string $id): ?Album + { + $local = $this->albumRepository->findOneByLocalId($id); + if ($local instanceof Album) { + return $local; + } + + return $this->albumRepository->findOneByExternalId($id); + } + + /** + * @param array $externalAlbum + */ + private function persistExternalAlbumPayload(array $externalAlbum): Album + { + $genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]); + $albumId = (string) ($externalAlbum['id'] ?? ''); + + $album = $this->albumRepository->upsertFromExternalAlbum( + $externalAlbum, + $albumId !== '' ? ($genresMap[$albumId] ?? []) : [] + ); + + $tracks = $externalAlbum['tracks']['items'] ?? []; + if (is_array($tracks) && $tracks !== []) { + $this->trackRepository->replaceAlbumTracks($album, $tracks); + $album->setTotalTracks(count($tracks)); + } + + return $album; + } + + /** + * @return array|null + */ + private function readJsonPayload(Request $request): ?array + { + $content = trim($request->getContent()); + if ($content === '') { + return []; + } + + try { + $decoded = $request->toArray(); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $payload + * @param array|null $externalAlbum + * @return array + */ + private function hydrateExternalAlbumImages(array $payload, Album $album, ?array $externalAlbum = null): array + { + if ($album->getSource() !== 'external') { + return $payload; + } + + $fresh = $externalAlbum; + if ($fresh === null) { + $externalId = trim((string) $album->getExternalId()); + if ($externalId === '') { + return $payload; + } + + $resolved = $this->lastFmClient->getAlbumWithTracks($externalId); + if (!is_array($resolved)) { + return $payload; + } + $fresh = $resolved; + } + + $images = $fresh['images'] ?? null; + if (is_array($images) && $images !== []) { + $payload['images'] = $images; + } + + return $payload; + } +} diff --git a/src/Controller/Api/V1/ApiIndexController.php b/src/Controller/Api/V1/ApiIndexController.php new file mode 100644 index 0000000..1c2af08 --- /dev/null +++ b/src/Controller/Api/V1/ApiIndexController.php @@ -0,0 +1,39 @@ +handleView($this->view([ + 'name' => 'Tonehaus API', + 'version' => 'v1', + 'endpoints' => [ + 'auth' => [ + 'POST /api/v1/auth/login', + ], + 'albums' => [ + 'GET /api/v1/albums', + 'POST /api/v1/albums/ingest (JWT)', + 'GET /api/v1/albums/{id}', + 'GET /api/v1/albums/{id}/reviews', + ], + 'reviews' => [ + 'GET /api/v1/reviews', + 'GET /api/v1/reviews/{id}', + 'POST /api/v1/reviews (JWT)', + 'PUT /api/v1/reviews/{id} (JWT)', + 'PATCH /api/v1/reviews/{id} (JWT)', + 'DELETE /api/v1/reviews/{id} (JWT)', + ], + ], + ], Response::HTTP_OK)); + } +} diff --git a/src/Controller/Api/V1/ReviewApiController.php b/src/Controller/Api/V1/ReviewApiController.php new file mode 100644 index 0000000..8d4a5e5 --- /dev/null +++ b/src/Controller/Api/V1/ReviewApiController.php @@ -0,0 +1,203 @@ +query->get('limit', 50))); + $reviews = $this->reviewRepository->findLatest($limit); + + return $this->handleView($this->view([ + 'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews), + ], Response::HTTP_OK)); + } + + #[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] + public function show(Review $review): Response + { + return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); + } + + #[Route('', name: 'api_v1_review_create', methods: ['POST'])] + #[IsGranted('ROLE_USER')] + public function create(Request $request): Response + { + $payload = $this->readJsonPayload($request); + if ($payload === null) { + return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + } + + $albumId = trim((string) ($payload['album_id'] ?? '')); + $album = $this->resolveAlbum($albumId); + if (!$album instanceof Album) { + return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + } + + $title = trim((string) ($payload['title'] ?? '')); + $content = trim((string) ($payload['content'] ?? '')); + $rating = (int) ($payload['rating'] ?? 0); + + if ($title === '' || $content === '' || $rating < 1 || $rating > 10) { + return $this->handleView($this->view(['error' => 'title_content_rating_required'], Response::HTTP_BAD_REQUEST)); + } + + if (mb_strlen($content) < 20) { + return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); + } + + $author = $this->getUser(); + if (!$author instanceof User) { + return $this->handleView($this->view(['error' => 'authentication_required'], Response::HTTP_UNAUTHORIZED)); + } + + $review = new Review(); + $review->setAlbum($album); + $review->setAuthor($author); + $review->setTitle($title); + $review->setContent($content); + $review->setRating($rating); + + $this->entityManager->persist($review); + $this->entityManager->flush(); + + return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_CREATED)); + } + + #[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])] + #[IsGranted('ROLE_USER')] + public function update(Review $review, Request $request): Response + { + $this->denyAccessUnlessGranted('REVIEW_EDIT', $review); + + $payload = $this->readJsonPayload($request); + if ($payload === null) { + return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + } + + if (array_key_exists('title', $payload)) { + $title = trim((string) $payload['title']); + if ($title === '') { + return $this->handleView($this->view(['error' => 'title_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); + } + $review->setTitle($title); + } + + if (array_key_exists('content', $payload)) { + $content = trim((string) $payload['content']); + if ($content === '') { + return $this->handleView($this->view(['error' => 'content_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); + } + if (mb_strlen($content) < 20) { + return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); + } + $review->setContent($content); + } + + if (array_key_exists('rating', $payload)) { + $rating = (int) $payload['rating']; + if ($rating < 1 || $rating > 10) { + return $this->handleView($this->view(['error' => 'rating_out_of_range'], Response::HTTP_BAD_REQUEST)); + } + $review->setRating($rating); + } + + $this->entityManager->flush(); + + return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); + } + + #[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] + #[IsGranted('ROLE_USER')] + public function delete(Review $review): Response + { + $this->denyAccessUnlessGranted('REVIEW_DELETE', $review); + + $this->entityManager->remove($review); + $this->entityManager->flush(); + + return $this->handleView($this->view(null, Response::HTTP_NO_CONTENT)); + } + + /** + * @return array + */ + private function serializeReview(Review $review): array + { + $album = $review->getAlbum(); + + return [ + 'id' => $review->getId(), + 'title' => $review->getTitle(), + 'content' => $review->getContent(), + 'rating' => $review->getRating(), + 'created_at' => $review->getCreatedAt()?->format(DATE_ATOM), + 'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM), + 'author' => [ + 'id' => $review->getAuthor()?->getId(), + 'email' => $review->getAuthor()?->getEmail(), + 'display_name' => $review->getAuthor()?->getDisplayName(), + ], + 'album' => [ + 'id' => $album?->getSource() === 'user' ? $album?->getLocalId() : $album?->getExternalId(), + 'name' => $album?->getName(), + 'source' => $album?->getSource(), + ], + ]; + } + + private function resolveAlbum(string $albumId): ?Album + { + if ($albumId === '') { + return null; + } + + $local = $this->albumRepository->findOneByLocalId($albumId); + if ($local instanceof Album) { + return $local; + } + + return $this->albumRepository->findOneByExternalId($albumId); + } + + /** + * @return array|null + */ + private function readJsonPayload(Request $request): ?array + { + $content = trim($request->getContent()); + if ($content === '') { + return []; + } + + try { + $decoded = $request->toArray(); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/src/Dto/AlbumSearchCriteria.php b/src/Dto/AlbumSearchCriteria.php index 5616273..8963a86 100644 --- a/src/Dto/AlbumSearchCriteria.php +++ b/src/Dto/AlbumSearchCriteria.php @@ -18,7 +18,7 @@ final class AlbumSearchCriteria /** Explicit artist filter supplied via the advanced panel. */ public readonly string $artist; - /** Genre substring to match within stored Spotify/user genres. */ + /** Genre substring to match within stored external/user genres. */ public readonly string $genre; /** Lower bound (inclusive) of the release year filter, if any. */ @@ -27,7 +27,7 @@ final class AlbumSearchCriteria /** Upper bound (inclusive) of the release year filter, if any. */ public readonly ?int $yearTo; - /** Requested source scope: `all`, `spotify`, or `user`. */ + /** Requested source scope: `all`, `external`, or `user`. */ public readonly string $source; /** Maximum number of results the search should return. */ @@ -49,7 +49,7 @@ final class AlbumSearchCriteria $this->genre = $genre; $this->yearFrom = $yearFrom; $this->yearTo = $yearTo; - $this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all'; + $this->source = in_array($source, ['all', 'external', 'user'], true) ? $source : 'all'; $this->limit = max(1, $limit); } @@ -71,11 +71,11 @@ final class AlbumSearchCriteria } /** - * Determines whether the search should include Spotify-sourced albums. + * Determines whether the search should include externally sourced albums. */ - public function shouldUseSpotify(): bool + public function shouldUseExternal(): bool { - return $this->source === 'all' || $this->source === 'spotify'; + return $this->source === 'all' || $this->source === 'external'; } /** @@ -101,7 +101,6 @@ final class AlbumSearchCriteria private static function normalizeSource(string $source): string { $source = strtolower(trim($source)); - return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all'; + return in_array($source, ['all', 'external', 'user'], true) ? $source : 'all'; } } - diff --git a/src/Dto/AlbumSearchResult.php b/src/Dto/AlbumSearchResult.php index 6cba2db..40f0480 100644 --- a/src/Dto/AlbumSearchResult.php +++ b/src/Dto/AlbumSearchResult.php @@ -5,7 +5,7 @@ namespace App\Dto; /** * AlbumSearchResult is the value object returned by AlbumSearchService. * It keeps the original criteria alongside the merged album payloads, - * per-album review stats, and a list of Spotify IDs stored locally. + * per-album review stats, and a list of externally sourced IDs stored locally. */ final class AlbumSearchResult { @@ -21,9 +21,8 @@ final class AlbumSearchResult public readonly array $albums, /** Per-album review aggregates keyed by album ID. */ public readonly array $stats, - /** List of Spotify IDs saved locally for quick lookup. */ + /** List of externally sourced IDs saved locally for quick lookup. */ public readonly array $savedIds ) { } } - diff --git a/src/Entity/Album.php b/src/Entity/Album.php index 3dc03db..81922d2 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * Album aggregates Spotify or user-submitted metadata persisted in the catalog. + * Album aggregates external or user-submitted metadata persisted in the catalog. */ #[ORM\Entity(repositoryClass: AlbumRepository::class)] #[ORM\Table(name: 'albums')] @@ -27,17 +27,17 @@ class Album #[ORM\Column(type: 'integer')] private ?int $id = null; - // For Spotify-sourced albums; null for user-created + // For externally sourced albums; null for user-created #[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)] - private ?string $spotifyId = null; + private ?string $externalId = null; - // Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify + // Public identifier for user-created albums (e.g., "u_abc123"); null for external #[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)] private ?string $localId = null; - // 'spotify' or 'user' + // 'external' or 'user' #[ORM\Column(type: 'string', length: 16)] - private string $source = 'spotify'; + private string $source = 'external'; #[ORM\Column(type: 'string', length: 255)] #[Assert\NotBlank] @@ -55,7 +55,7 @@ class Album #[ORM\Column(type: 'json')] private array $genres = []; - // Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD + // Stored as provided by the source: YYYY or YYYY-MM or YYYY-MM-DD #[ORM\Column(type: 'string', length: 20, nullable: true)] private ?string $releaseDate = null; @@ -115,19 +115,19 @@ class Album } /** - * Gets the Spotify album identifier when sourced from Spotify. + * Gets the external album identifier when sourced externally. */ - public function getSpotifyId(): ?string + public function getExternalId(): ?string { - return $this->spotifyId; + return $this->externalId; } /** - * Stores the Spotify album identifier. + * Stores the external album identifier. */ - public function setSpotifyId(?string $spotifyId): void + public function setExternalId(?string $externalId): void { - $this->spotifyId = $spotifyId; + $this->externalId = $externalId; } /** @@ -147,7 +147,7 @@ class Album } /** - * Returns the album source flag ("spotify" or "user"). + * Returns the album source flag ("external" or "user"). */ public function getSource(): string { @@ -155,7 +155,7 @@ class Album } /** - * Sets the album source flag ("spotify" or "user"). + * Sets the album source flag ("external" or "user"). */ public function setSource(string $source): void { @@ -264,7 +264,7 @@ class Album } /** - * Returns an external link (defaults to Spotify). + * Returns an external link. */ public function getExternalUrl(): ?string { @@ -335,9 +335,11 @@ class Album public function toTemplateArray(): array { $images = []; - $imageUrl = $this->coverUrl; + $imageUrl = null; if ($this->source === 'user' && $this->coverImagePath) { $imageUrl = $this->coverImagePath; + } elseif ($this->source !== 'external') { + $imageUrl = $this->coverUrl; } if ($imageUrl) { $images = [ @@ -347,11 +349,10 @@ class Album } $artists = array_map(static fn(string $n) => ['name' => $n], $this->artists); $external = $this->externalUrl; - if ($external === null && $this->source === 'spotify' && $this->spotifyId) { - $external = 'https://open.spotify.com/album/' . $this->spotifyId; - } - $publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId; - $genres = array_slice($this->genres, 0, 5); + $publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->externalId; + $genres = $this->source === 'external' + ? $this->normalizeTopGenreForOutput($this->genres) + : array_slice($this->genres, 0, 5); return [ 'id' => $publicId, @@ -361,11 +362,27 @@ class Album 'genres' => $genres, 'release_date' => $this->releaseDate, 'total_tracks' => $this->totalTracks, - 'external_urls' => [ 'spotify' => $external ], + 'external_urls' => ['external' => $external], 'source' => $this->source, ]; } + /** + * @param list $genres + * @return list + */ + private function normalizeTopGenreForOutput(array $genres): array + { + foreach ($genres as $genre) { + $value = trim((string) $genre); + if ($value !== '') { + return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')]; + } + } + + return []; + } + /** * @return Collection */ @@ -389,5 +406,3 @@ class Album } } } - - diff --git a/src/Entity/AlbumTrack.php b/src/Entity/AlbumTrack.php index ed18e67..4cd6ee6 100644 --- a/src/Entity/AlbumTrack.php +++ b/src/Entity/AlbumTrack.php @@ -6,7 +6,7 @@ use App\Repository\AlbumTrackRepository; use Doctrine\ORM\Mapping as ORM; /** - * AlbumTrack persists individual tracks fetched from Spotify. + * AlbumTrack persists individual tracks fetched from External. */ #[ORM\Entity(repositoryClass: AlbumTrackRepository::class)] #[ORM\Table(name: 'album_tracks')] @@ -23,7 +23,7 @@ class AlbumTrack private ?Album $album = null; #[ORM\Column(type: 'string', length: 64, nullable: true)] - private ?string $spotifyTrackId = null; + private ?string $externalTrackId = null; #[ORM\Column(type: 'integer')] private int $discNumber = 1; @@ -55,14 +55,14 @@ class AlbumTrack $this->album = $album; } - public function getSpotifyTrackId(): ?string + public function getExternalTrackId(): ?string { - return $this->spotifyTrackId; + return $this->externalTrackId; } - public function setSpotifyTrackId(?string $spotifyTrackId): void + public function setExternalTrackId(?string $externalTrackId): void { - $this->spotifyTrackId = $spotifyTrackId; + $this->externalTrackId = $externalTrackId; } public function getDiscNumber(): int diff --git a/src/Entity/User.php b/src/Entity/User.php index cae2a06..a07dbb1 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -46,6 +46,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'string', length: 255, nullable: true)] private ?string $profileImagePath = null; + #[ORM\Column(type: 'string', length: 50, nullable: true)] + private ?string $oauthProvider = null; + + #[ORM\Column(type: 'string', length: 190, nullable: true)] + private ?string $oauthId = null; + /** * Returns the database identifier. */ @@ -164,6 +170,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { $this->profileImagePath = $profileImagePath; } + + public function getOauthProvider(): ?string + { + return $this->oauthProvider; + } + + public function setOauthProvider(?string $oauthProvider): void + { + $this->oauthProvider = $oauthProvider === null ? null : strtolower(trim($oauthProvider)); + } + + public function getOauthId(): ?string + { + return $this->oauthId; + } + + public function setOauthId(?string $oauthId): void + { + $this->oauthId = $oauthId === null ? null : trim($oauthId); + } } - diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php index 936068e..383acec 100644 --- a/src/Form/SiteSettingsType.php +++ b/src/Form/SiteSettingsType.php @@ -9,24 +9,19 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** - * SiteSettingsType exposes toggles for operations staff (Spotify creds, registration). + * SiteSettingsType exposes toggles for operations staff (Last.fm key, registration). */ class SiteSettingsType extends AbstractType { /** - * Exposes Spotify credential inputs for administrators. + * Exposes Last.fm credential inputs for administrators. */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('SPOTIFY_CLIENT_ID', TextType::class, [ + ->add('LASTFM_API_KEY', TextType::class, [ 'required' => false, - 'label' => 'Spotify Client ID', - 'mapped' => false, - ]) - ->add('SPOTIFY_CLIENT_SECRET', TextType::class, [ - 'required' => false, - 'label' => 'Spotify Client Secret', + 'label' => 'Last.fm API key', 'mapped' => false, ]) ->add('REGISTRATION_ENABLED', CheckboxType::class, [ @@ -45,4 +40,3 @@ class SiteSettingsType extends AbstractType } } - diff --git a/src/Repository/AlbumRepository.php b/src/Repository/AlbumRepository.php index dfff745..423a44f 100644 --- a/src/Repository/AlbumRepository.php +++ b/src/Repository/AlbumRepository.php @@ -25,11 +25,11 @@ class AlbumRepository extends ServiceEntityRepository } /** - * Finds one album by Spotify identifier. + * Finds one album by External identifier. */ - public function findOneBySpotifyId(string $spotifyId): ?Album + public function findOneByExternalId(string $externalId): ?Album { - return $this->findOneBy(['spotifyId' => $spotifyId]); + return $this->findOneBy(['externalId' => $externalId]); } /** @@ -41,48 +41,48 @@ class AlbumRepository extends ServiceEntityRepository } /** - * Returns albums keyed by Spotify identifiers for quick lookup. + * Returns albums keyed by External identifiers for quick lookup. * - * @param list $spotifyIds + * @param list $externalIds * @return array */ - public function findBySpotifyIdsKeyed(array $spotifyIds): array + public function findByExternalIdsKeyed(array $externalIds): array { - if ($spotifyIds === []) { + if ($externalIds === []) { return []; } $rows = $this->createQueryBuilder('a') - ->where('a.spotifyId IN (:ids)') - ->setParameter('ids', $spotifyIds) + ->where('a.externalId IN (:ids)') + ->setParameter('ids', $externalIds) ->getQuery() ->getResult(); $out = []; foreach ($rows as $row) { - if ($row instanceof Album && $row->getSpotifyId() !== null) { - $out[$row->getSpotifyId()] = $row; + if ($row instanceof Album && $row->getExternalId() !== null) { + $out[$row->getExternalId()] = $row; } } return $out; } /** - * Returns all stored Spotify album IDs so background jobs can iterate over them. + * Returns all stored External album IDs so background jobs can iterate over them. * * @return list */ - public function findAllSpotifyIds(): array + public function findAllExternalIds(): array { $rows = $this->createQueryBuilder('a') - ->select('a.spotifyId') + ->select('a.externalId') ->where('a.source = :src') - ->andWhere('a.spotifyId IS NOT NULL') - ->setParameter('src', 'spotify') + ->andWhere('a.externalId IS NOT NULL') + ->setParameter('src', 'external') ->getQuery() ->getScalarResult(); $ids = []; foreach ($rows as $row) { - $sid = (string) ($row['spotifyId'] ?? ''); + $sid = (string) ($row['externalId'] ?? ''); if ($sid !== '') { $ids[] = $sid; } @@ -124,15 +124,15 @@ class AlbumRepository extends ServiceEntityRepository } /** - * Filters persisted Spotify albums before falling back to the API. + * Filters persisted External albums before falling back to the API. * * @return list */ - public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array + public function searchExternalAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') - ->setParameter('src', 'spotify') + ->setParameter('src', 'external') ->setMaxResults($limit * 2); if ($freeText !== null && $freeText !== '') { $qb->andWhere('LOWER(a.name) LIKE :qName') @@ -156,43 +156,36 @@ class AlbumRepository extends ServiceEntityRepository } /** - * Upserts data from a Spotify album payload and keeps DB entities in sync. + * Upserts data from an external album payload and keeps DB entities in sync. * - * @param array $spotifyAlbum Raw Spotify album payload. - * @param list $resolvedGenres Optional, precomputed genres (typically from artist lookups). + * @param array $externalAlbum Raw external album payload. + * @param list $resolvedGenres Optional, precomputed genres. */ - public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album + public function upsertFromExternalAlbum(array $externalAlbum, array $resolvedGenres = []): Album { - $spotifyId = (string) ($spotifyAlbum['id'] ?? ''); - $name = (string) ($spotifyAlbum['name'] ?? ''); - $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? []))); - $rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($spotifyAlbum['genres'] ?? []); - $genres = array_values(array_filter(array_map(static fn($g) => trim((string) $g), $rawGenres), static fn($g) => $g !== '')); - $releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null; - $totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0); - - $images = (array) ($spotifyAlbum['images'] ?? []); - $coverUrl = null; - if (isset($images[1]['url'])) { - $coverUrl = (string) $images[1]['url']; - } elseif (isset($images[0]['url'])) { - $coverUrl = (string) $images[0]['url']; - } + $externalId = (string) ($externalAlbum['id'] ?? ''); + $name = (string) ($externalAlbum['name'] ?? ''); + $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($externalAlbum['artists'] ?? []))); + $rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($externalAlbum['genres'] ?? []); + $genres = $this->normalizeTopGenre($rawGenres); + $releaseDate = isset($externalAlbum['release_date']) ? (string) $externalAlbum['release_date'] : null; + $totalTracks = (int) ($externalAlbum['total_tracks'] ?? 0); $external = null; - if (isset($spotifyAlbum['external_urls']['spotify'])) { - $external = (string) $spotifyAlbum['external_urls']['spotify']; + if (isset($externalAlbum['external_urls']['external'])) { + $external = (string) $externalAlbum['external_urls']['external']; } $em = $this->getEntityManager(); - $album = $this->findOneBy(['spotifyId' => $spotifyId]) ?? new Album(); - $album->setSource('spotify'); - $album->setSpotifyId($spotifyId); + $album = $this->findOneBy(['externalId' => $externalId]) ?? new Album(); + $album->setSource('external'); + $album->setExternalId($externalId); $album->setName($name); $album->setArtists($artists); $album->setGenres($genres); $album->setReleaseDate($releaseDate); $album->setTotalTracks($totalTracks); - $album->setCoverUrl($coverUrl); + // External cover URLs should be fetched live from Last.fm at response time. + $album->setCoverUrl(null); $album->setExternalUrl($external); $em->persist($album); return $album; @@ -266,6 +259,22 @@ class AlbumRepository extends ServiceEntityRepository } return mb_strtolower($trimmed); } + + /** + * @param array $rawGenres + * @return list + */ + private function normalizeTopGenre(array $rawGenres): array + { + foreach ($rawGenres as $genre) { + $value = trim((string) $genre); + if ($value === '') { + continue; + } + + return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')]; + } + + return []; + } } - - diff --git a/src/Repository/AlbumTrackRepository.php b/src/Repository/AlbumTrackRepository.php index a3d553a..a83fe32 100644 --- a/src/Repository/AlbumTrackRepository.php +++ b/src/Repository/AlbumTrackRepository.php @@ -23,7 +23,7 @@ class AlbumTrackRepository extends ServiceEntityRepository } /** - * Replaces an album's stored tracklist with the provided Spotify payload. + * Replaces an album's stored tracklist with the provided External payload. * * @param list> $trackPayloads */ @@ -47,10 +47,10 @@ class AlbumTrackRepository extends ServiceEntityRepository $track = new AlbumTrack(); $track->setAlbum($album); - $track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null)); + $track->setExternalTrackId($this->stringOrNull($payload['id'] ?? null)); $disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1); $trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position); - // Some Spotify payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique. + // Some External payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique. $trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber); $track->setDiscNumber($disc); $track->setTrackNumber($trackNumber); @@ -65,7 +65,7 @@ class AlbumTrackRepository extends ServiceEntityRepository } /** - * Trims user/Spotify data to a nullable string, collapsing empty values to null. + * Trims user/External data to a nullable string, collapsing empty values to null. */ private function stringOrNull(mixed $value): ?string { diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php index 504c543..f8a11c8 100644 --- a/src/Repository/ReviewRepository.php +++ b/src/Repository/ReviewRepository.php @@ -36,7 +36,7 @@ class ReviewRepository extends ServiceEntityRepository } /** - * Aggregates review counts and averages for Spotify IDs. + * Aggregates review counts and averages for External IDs. * * @param list $albumIds * @return array @@ -49,10 +49,10 @@ class ReviewRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('r') ->innerJoin('r.album', 'a') - ->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating') - ->where('a.spotifyId IN (:ids)') + ->select('a.externalId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating') + ->where('a.externalId IN (:ids)') ->setParameter('ids', $albumIds) - ->groupBy('a.spotifyId'); + ->groupBy('a.externalId'); $rows = $qb->getQuery()->getArrayResult(); $out = []; diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 167243b..875f0d4 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -35,6 +35,20 @@ class UserRepository extends ServiceEntityRepository ->getOneOrNullResult(); } + /** + * Retrieves a user by OAuth provider and provider user ID. + */ + public function findOneByOauthIdentity(string $provider, string $oauthId): ?User + { + return $this->createQueryBuilder('u') + ->andWhere('u.oauthProvider = :provider') + ->andWhere('u.oauthId = :oauthId') + ->setParameter('provider', strtolower($provider)) + ->setParameter('oauthId', $oauthId) + ->getQuery() + ->getOneOrNullResult(); + } + /** * Returns every user with aggregated album/review counts. * @@ -66,4 +80,3 @@ class UserRepository extends ServiceEntityRepository } } - diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php index 24111e1..7d7b35e 100644 --- a/src/Service/AlbumSearchService.php +++ b/src/Service/AlbumSearchService.php @@ -7,110 +7,90 @@ use App\Dto\AlbumSearchResult; use App\Entity\Album; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; -use App\Service\SpotifyGenreResolver; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** - * AlbumSearchService pulls albums from Spotify and the local catalog, + * AlbumSearchService pulls albums from Last.fm and the local catalog, * adds review stats, and hands the UI one uniform structure to render. */ class AlbumSearchService { public function __construct( - private readonly SpotifyClient $spotifyClient, + private readonly LastFmClient $lastFmClient, private readonly AlbumRepository $albumRepository, private readonly ReviewRepository $reviewRepository, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, - private readonly SpotifyGenreResolver $genreResolver, + private readonly LastFmTagResolver $tagResolver, ) { } /** - * Runs the end-to-end search flow: figures out which sources to query, merges the payloads, - * and returns review stats plus saved IDs so templates get everything in one package. + * Runs the end-to-end search flow and returns review stats plus saved IDs. */ public function search(AlbumSearchCriteria $criteria): AlbumSearchResult { - $spotifyQuery = $this->buildSpotifySearchQuery($criteria); - $hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery); - // Spotify only gets pinged when callers explicitly enable it and we actually have - // something to ask for (bare "all" requests would otherwise waste API calls). - $shouldQuerySpotify = $criteria->shouldUseSpotify() - && ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify'); + $externalQuery = $this->buildExternalSearchQuery($criteria); + $hasUserFilters = $this->hasUserFilters($criteria, $externalQuery); + + $shouldQueryExternal = $criteria->shouldUseExternal() + && ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external'); $stats = []; $savedIds = []; - $spotifyPayloads = []; + $externalPayloads = []; $userPayloads = []; - if ($shouldQuerySpotify) { - // Try to reuse cached Spotify albums and only hit the API if we still need more. - $spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery); - $spotifyPayloads = $spotifyData['payloads']; - $stats = $this->mergeStats($stats, $spotifyData['stats']); - $savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']); + if ($shouldQueryExternal) { + $externalData = $this->resolveExternalAlbums($criteria, $externalQuery); + $externalPayloads = $externalData['payloads']; + $stats = $this->mergeStats($stats, $externalData['stats']); + $savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']); } if ($criteria->shouldUseUserCatalog() && $hasUserFilters) { - // Skip the user query unless at least one meaningful filter is present. $userData = $this->resolveUserAlbums($criteria); $userPayloads = $userData['payloads']; $stats = $this->mergeStats($stats, $userData['stats']); } - $albums = $this->composeAlbumList($criteria->source, $userPayloads, $spotifyPayloads, $criteria->limit); + $albums = $this->composeAlbumList($criteria->source, $userPayloads, $externalPayloads, $criteria->limit); return new AlbumSearchResult($criteria, $albums, $stats, $savedIds); } - - /** - * Indicates whether Spotify credentials are configured. - */ - public function isSpotifyConfigured(): bool - { - return $this->spotifyClient->isConfigured(); - } /** - * Turns structured filters into Spotify's free-form query syntax. + * Indicates whether Last.fm credentials are configured. */ - private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string + public function isExternalConfigured(): bool + { + return $this->lastFmClient->isConfigured(); + } + + private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string { $parts = []; - if ($criteria->albumName !== '') { - $parts[] = 'album:' . $criteria->albumName; - } - if ($criteria->artist !== '') { - $parts[] = 'artist:' . $criteria->artist; - } - if ($criteria->yearFrom !== null || $criteria->yearTo !== null) { - if ($criteria->yearFrom !== null && $criteria->yearTo !== null && $criteria->yearTo >= $criteria->yearFrom) { - $parts[] = 'year:' . $criteria->yearFrom . '-' . $criteria->yearTo; - } else { - $year = $criteria->yearFrom ?? $criteria->yearTo; - if ($year !== null) { - $parts[] = 'year:' . $year; - } - } - } if ($criteria->query !== '') { $parts[] = $criteria->query; } - return implode(' ', $parts); + if ($criteria->albumName !== '') { + $parts[] = $criteria->albumName; + } + if ($criteria->artist !== '') { + $parts[] = $criteria->artist; + } + + return trim(implode(' ', $parts)); } - /** - * Quick gate to tell if it's worth running the user catalog query. - */ - private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool + private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool { if ($criteria->source === 'user') { return true; } - return $spotifyQuery !== '' + return $externalQuery !== '' || $criteria->albumName !== '' || $criteria->artist !== '' || $criteria->genre !== '' @@ -119,13 +99,11 @@ class AlbumSearchService } /** - * Looks up cached Spotify albums and, if needed, tops them off with fresh API data. - * * @return array{payloads:array>,stats:array,savedIds:array} */ - private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array + private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery): array { - $storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums( + $storedExternalAlbums = $this->albumRepository->searchExternalAlbums( $criteria->query, $criteria->albumName, $criteria->artist, @@ -134,100 +112,92 @@ class AlbumSearchService $criteria->yearTo ?? 0, $criteria->limit ); - $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedSpotifyAlbums); + + $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums); $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre); - $storedIds = $this->collectSpotifyIds($storedSpotifyAlbums); + $storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo); + $storedPayloads = $this->hydrateExternalImages($storedPayloads); + + $storedIds = $this->collectExternalIds($storedExternalAlbums); $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : []; - $savedIds = $storedIds; - $shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit; - - if (!$shouldFetchFromSpotify) { + $shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit; + if (!$shouldFetchFromExternal) { return [ 'payloads' => array_slice($storedPayloads, 0, $criteria->limit), 'stats' => $stats, - 'savedIds' => $savedIds, + 'savedIds' => $storedIds, ]; } - // Mix cached payloads with just enough fresh API data to satisfy the limit. - // This has the consequence of preferring cached data, but reduces API calls. - $apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads); + $apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery); $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre); - $payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); - $stats = $this->mergeStats($stats, $apiPayloads['stats']); - $savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']); + $filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo); - return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds]; + return [ + 'payloads' => $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit), + 'stats' => $this->mergeStats($stats, $apiPayloads['stats']), + 'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']), + ]; } /** - * Calls Spotify search/albums endpoints, syncs what we find, and returns normalized payloads, stats, and IDs. - * - * @param array> $storedPayloads * @return array{payloads:array>,stats:array,savedIds:array} */ - private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array + private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array { - $result = $this->spotifyClient->searchAlbums($spotifyQuery, $criteria->limit); - $searchItems = $result['albums']['items'] ?? []; + $result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit); + $searchItems = (array) ($result['albums']['items'] ?? []); + $this->logger->info('Album search results received', [ - 'query' => $spotifyQuery, - 'items' => is_countable($searchItems) ? count($searchItems) : 0, + 'query' => $externalQuery, + 'items' => count($searchItems), ]); - if (!$searchItems) { + if ($searchItems === []) { return ['payloads' => [], 'stats' => [], 'savedIds' => []]; } - $ids = $this->extractSpotifyIds($searchItems); + $ids = $this->extractExternalIds($searchItems); if ($ids === []) { return ['payloads' => [], 'stats' => [], 'savedIds' => []]; } - $full = $this->spotifyClient->getAlbums($ids); - $albumsPayload = is_array($full) ? ($full['albums'] ?? []) : []; - if ($albumsPayload === [] && $searchItems !== []) { + $full = $this->lastFmClient->getAlbums($ids); + $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; + if ($albumsPayload === []) { $albumsPayload = $searchItems; - $this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]); } - // Resolve genres up-front (either from album payloads or artist fallbacks) so we store richer data. - $genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload); + $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload); - $upserted = 0; foreach ($albumsPayload as $payload) { - // Persist each album so the next search can reuse it without another API call. $albumId = (string) ($payload['id'] ?? ''); - $this->albumRepository->upsertFromSpotifyAlbum( + $this->albumRepository->upsertFromExternalAlbum( (array) $payload, $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] ); - $upserted++; } - $this->entityManager->flush(); - $this->logger->info('Albums upserted to DB', ['upserted' => $upserted]); - $existing = $this->albumRepository->findBySpotifyIdsKeyed($ids); + $this->entityManager->flush(); + + $existing = $this->albumRepository->findByExternalIdsKeyed($ids); $payloads = []; - foreach ($ids as $sid) { - if (isset($existing[$sid])) { - $payloads[] = $existing[$sid]->toTemplateArray(); + foreach ($ids as $externalId) { + if (isset($existing[$externalId])) { + $payloads[] = $existing[$externalId]->toTemplateArray(); } } - - $stats = $this->reviewRepository->getAggregatesForAlbumIds($ids); + $payloads = $this->hydrateExternalImages($payloads); return [ 'payloads' => $payloads, - 'stats' => $stats, + 'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids), 'savedIds' => array_keys($existing), ]; } /** - * Searches user-created albums and attaches review aggregates keyed to each payload. - * * @return array{payloads:array>,stats:array} */ private function resolveUserAlbums(AlbumSearchCriteria $criteria): array @@ -241,23 +211,21 @@ class AlbumSearchService $criteria->yearTo ?? 0, $criteria->limit ); + if ($userAlbums === []) { return ['payloads' => [], 'stats' => []]; } $entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums)); $userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds); - $payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums); return [ - 'payloads' => $payloads, + 'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums), 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats), ]; } /** - * Re-keys entity-based stats to the local IDs that templates actually use. - * * @param list $userAlbums * @param array $userStats * @return array @@ -269,56 +237,52 @@ class AlbumSearchService $entityId = (int) $album->getId(); $localId = (string) $album->getLocalId(); if ($localId !== '' && isset($userStats[$entityId])) { - // Templates never see entity IDs, so stats must be re-keyed to the user-facing local IDs. $mapped[$localId] = $userStats[$entityId]; } } + return $mapped; } /** - * Chooses which payload list to return based on the requested source preference. - * * @param array> $userPayloads - * @param array> $spotifyPayloads + * @param array> $externalPayloads * @return array> */ - private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array + private function composeAlbumList(string $source, array $userPayloads, array $externalPayloads, int $limit): array { if ($source === 'user') { return array_slice($userPayloads, 0, $limit); } - if ($source === 'spotify') { - return array_slice($spotifyPayloads, 0, $limit); + if ($source === 'external') { + return array_slice($externalPayloads, 0, $limit); } - return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit); + + return array_slice(array_merge($userPayloads, $externalPayloads), 0, $limit); } /** - * Collects Spotify IDs from hydrated album entities while skipping blanks. - * * @param list $albums * @return list */ - private function collectSpotifyIds(array $albums): array + private function collectExternalIds(array $albums): array { $ids = []; foreach ($albums as $album) { - $sid = (string) $album->getSpotifyId(); - if ($sid !== '') { - $ids[] = $sid; + $externalId = (string) $album->getExternalId(); + if ($externalId !== '') { + $ids[] = $externalId; } } + return array_values(array_unique($ids)); } /** - * Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls. - * * @param array $searchItems * @return list */ - private function extractSpotifyIds(array $searchItems): array + private function extractExternalIds(array $searchItems): array { $ids = []; foreach ($searchItems as $item) { @@ -327,12 +291,11 @@ class AlbumSearchService $ids[] = $id; } } + return array_values(array_unique($ids)); } /** - * Overwrites stats with the newest aggregates from the update set. - * * @param array $current * @param array $updates * @return array @@ -342,12 +305,11 @@ class AlbumSearchService foreach ($updates as $key => $value) { $current[$key] = $value; } + return $current; } /** - * Adds newly saved Spotify IDs while tossing blanks and duplicates. - * * @param list $current * @param list $updates * @return list @@ -359,8 +321,6 @@ class AlbumSearchService } /** - * Combines payload lists without duplicates, preferring primary items and honoring the limit. - * * @param array> $primary * @param array> $secondary * @return array> @@ -369,29 +329,32 @@ class AlbumSearchService { $seen = []; $merged = []; + foreach ($primary as $payload) { - $merged[] = $payload; - if (isset($payload['id'])) { - $seen[$payload['id']] = true; + $id = (string) ($payload['id'] ?? ''); + if ($id !== '') { + $seen[$id] = true; } + $merged[] = $payload; if (count($merged) >= $limit) { return array_slice($merged, 0, $limit); } } + foreach ($secondary as $payload) { - $id = $payload['id'] ?? null; - if ($id !== null && isset($seen[$id])) { + $id = (string) ($payload['id'] ?? ''); + if ($id !== '' && isset($seen[$id])) { continue; } - // Secondary payloads often duplicate the primary list; skip anything we've already emitted. $merged[] = $payload; - if ($id !== null) { + if ($id !== '') { $seen[$id] = true; } if (count($merged) >= $limit) { break; } } + return array_slice($merged, 0, $limit); } @@ -401,12 +364,12 @@ class AlbumSearchService */ private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array { - $genreNeedle = trim($genreNeedle); - if ($genreNeedle === '') { + $needle = trim(mb_strtolower($genreNeedle)); + if ($needle === '') { return $payloads; } - $needle = mb_strtolower($genreNeedle); - return array_values(array_filter($payloads, static function(array $payload) use ($needle): bool { + + return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool { $genres = $payload['genres'] ?? []; if (!is_array($genres)) { return false; @@ -419,5 +382,84 @@ class AlbumSearchService return false; })); } -} + /** + * @param array> $payloads + * @return array> + */ + private function filterPayloadsByYear(array $payloads, ?int $yearFrom, ?int $yearTo): array + { + if ($yearFrom === null && $yearTo === null) { + return $payloads; + } + + return array_values(array_filter($payloads, static function (array $payload) use ($yearFrom, $yearTo): bool { + $releaseDate = trim((string) ($payload['release_date'] ?? '')); + if (!preg_match('/^\d{4}/', $releaseDate, $matches)) { + return false; + } + $year = (int) $matches[0]; + if ($yearFrom !== null && $year < $yearFrom) { + return false; + } + if ($yearTo !== null && $year > $yearTo) { + return false; + } + return true; + })); + } + + /** + * @param array> $payloads + * @return array> + */ + private function hydrateExternalImages(array $payloads): array + { + $externalIds = []; + foreach ($payloads as $payload) { + if ((string) ($payload['source'] ?? '') !== 'external') { + continue; + } + $id = trim((string) ($payload['id'] ?? '')); + if ($id !== '') { + $externalIds[] = $id; + } + } + + if ($externalIds === []) { + return $payloads; + } + + $full = $this->lastFmClient->getAlbums(array_values(array_unique($externalIds))); + $albums = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; + if ($albums === []) { + return $payloads; + } + + $imagesByAlbum = []; + foreach ($albums as $album) { + if (!is_array($album)) { + continue; + } + $id = trim((string) ($album['id'] ?? '')); + $images = $album['images'] ?? null; + if ($id === '' || !is_array($images) || $images === []) { + continue; + } + $imagesByAlbum[$id] = $images; + } + + if ($imagesByAlbum === []) { + return $payloads; + } + + foreach ($payloads as $index => $payload) { + $id = trim((string) ($payload['id'] ?? '')); + if ($id !== '' && isset($imagesByAlbum[$id])) { + $payloads[$index]['images'] = $imagesByAlbum[$id]; + } + } + + return $payloads; + } +} diff --git a/src/Service/LastFmClient.php b/src/Service/LastFmClient.php new file mode 100644 index 0000000..e856569 --- /dev/null +++ b/src/Service/LastFmClient.php @@ -0,0 +1,443 @@ + 5, + 'extralarge' => 4, + 'large' => 3, + 'medium' => 2, + 'small' => 1, + ]; + + public function __construct( + private readonly ClientInterface $httpClient, + private readonly CacheInterface $cache, + private readonly ?string $apiKey, + private readonly SettingRepository $settings, + ) { + } + + /** + * Search Last.fm albums by query string. + * + * @return array{albums:array{items:list>}} + */ + public function searchAlbums(string $query, int $limit = 12): array + { + $apiKey = $this->getApiKey(); + if ($apiKey === null || trim($query) === '') { + return ['albums' => ['items' => []]]; + } + + try { + $payload = $this->sendRequest([ + 'method' => 'album.search', + 'album' => $query, + 'limit' => max(1, $limit), + ], 600); + } catch (\Throwable) { + return ['albums' => ['items' => []]]; + } + + $rawAlbums = $payload['results']['albummatches']['album'] ?? []; + $items = []; + foreach ($this->normalizeList($rawAlbums) as $album) { + $normalized = $this->normalizeSearchAlbum((array) $album); + if ($normalized !== null) { + $items[] = $normalized; + } + if (count($items) >= $limit) { + break; + } + } + + return ['albums' => ['items' => $items]]; + } + + /** + * Fetches album metadata plus tracklist from Last.fm. + * + * @return array|null + */ + public function getAlbumWithTracks(string $albumId): ?array + { + return $this->getAlbum($albumId); + } + + /** + * Fetch a single album by normalized external identifier. + * + * @return array|null + */ + public function getAlbum(string $albumId): ?array + { + $apiKey = $this->getApiKey(); + if ($apiKey === null) { + return null; + } + + $lookup = $this->parseAlbumIdentifier($albumId); + if ($lookup === null) { + return null; + } + + $query = ['method' => 'album.getinfo']; + if (isset($lookup['mbid'])) { + $query['mbid'] = (string) $lookup['mbid']; + } else { + $artist = trim((string) ($lookup['artist'] ?? '')); + $name = trim((string) ($lookup['album'] ?? '')); + if ($artist === '' || $name === '') { + return null; + } + $query['artist'] = $artist; + $query['album'] = $name; + } + + try { + $payload = $this->sendRequest($query, 1800); + } catch (\Throwable) { + return null; + } + + $album = $payload['album'] ?? null; + if (!is_array($album)) { + return null; + } + + return $this->normalizeDetailedAlbum($album, $albumId); + } + + /** + * Fetch multiple albums in sequence. + * + * @param list $albumIds + * @return array{albums:list>}|null + */ + public function getAlbums(array $albumIds): ?array + { + if ($albumIds === []) { + return ['albums' => []]; + } + if ($this->getApiKey() === null) { + return null; + } + + $albums = []; + foreach ($albumIds as $albumId) { + $album = $this->getAlbumWithTracks((string) $albumId); + if ($album !== null) { + $albums[] = $album; + } + } + + return ['albums' => $albums]; + } + + /** + * @return list> + */ + public function getAlbumTracks(string $albumId): array + { + $album = $this->getAlbumWithTracks($albumId); + if ($album === null) { + return []; + } + + $items = $album['tracks']['items'] ?? []; + return is_array($items) ? array_values($items) : []; + } + + /** + * Returns true when Last.fm API key is available from DB or environment. + */ + public function isConfigured(): bool + { + return $this->getApiKey() !== null; + } + + /** + * @param array $query + * @return array + */ + private function sendRequest(array $query, int $cacheTtlSeconds = 0): array + { + $apiKey = $this->getApiKey(); + if ($apiKey === null) { + return []; + } + + $query['api_key'] = $apiKey; + $query['format'] = 'json'; + $query['autocorrect'] = 1; + + $request = function () use ($query): array { + $response = $this->httpClient->request('GET', self::API_BASE_URL, ['query' => $query]); + $decoded = json_decode((string) $response->getBody(), true); + return is_array($decoded) ? $decoded : []; + }; + + if ($cacheTtlSeconds > 0) { + $cacheKey = 'lastfm_resp_' . sha1(json_encode($query)); + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { + $item->expiresAfter($cacheTtlSeconds); + return $request(); + }); + } + + return $request(); + } + + private function getApiKey(): ?string + { + $key = trim((string) $this->settings->getValue('LASTFM_API_KEY', $this->apiKey ?? '')); + return $key === '' ? null : $key; + } + + /** + * @param mixed $value + * @return list> + */ + private function normalizeList(mixed $value): array + { + if (is_array($value) && array_is_list($value)) { + return array_values(array_filter($value, static fn($row) => is_array($row))); + } + + if (is_array($value) && $value !== []) { + return [array_filter($value, static fn($row) => true)]; + } + + return []; + } + + /** + * @param array $rawAlbum + * @return array|null + */ + private function normalizeSearchAlbum(array $rawAlbum): ?array + { + $name = trim((string) ($rawAlbum['name'] ?? '')); + $artist = trim((string) ($rawAlbum['artist'] ?? '')); + if ($name === '' || $artist === '') { + return null; + } + + $mbid = trim((string) ($rawAlbum['mbid'] ?? '')); + $id = $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name); + + return [ + 'id' => $id, + 'name' => $name, + 'artists' => [['name' => $artist]], + 'genres' => [], + 'release_date' => null, + 'total_tracks' => 0, + 'images' => $this->normalizeImages($rawAlbum['image'] ?? []), + 'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)], + 'source' => 'external', + 'tracks' => ['items' => [], 'total' => 0], + ]; + } + + /** + * @param array $rawAlbum + * @return array + */ + private function normalizeDetailedAlbum(array $rawAlbum, ?string $preferredId = null): array + { + $name = trim((string) ($rawAlbum['name'] ?? '')); + $artistField = $rawAlbum['artist'] ?? ''; + $artist = ''; + if (is_array($artistField)) { + $artist = trim((string) ($artistField['name'] ?? '')); + } else { + $artist = trim((string) $artistField); + } + + $mbid = trim((string) ($rawAlbum['mbid'] ?? '')); + $id = $preferredId ?? $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name); + + $tracks = $this->normalizeTracks($rawAlbum['tracks']['track'] ?? [], $id); + $genres = []; + foreach ($this->normalizeList($rawAlbum['tags']['tag'] ?? []) as $tag) { + $label = $this->normalizeGenreLabel((string) ($tag['name'] ?? '')); + if ($label !== '') { + $genres = [$label]; + break; + } + } + + $releaseDate = $this->normalizePublishedDate((string) ($rawAlbum['wiki']['published'] ?? '')); + + return [ + 'id' => $id, + 'name' => $name, + 'artists' => [['name' => $artist]], + 'genres' => $genres, + 'release_date' => $releaseDate, + 'total_tracks' => count($tracks), + 'images' => $this->normalizeImages($rawAlbum['image'] ?? []), + 'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)], + 'source' => 'external', + 'tracks' => [ + 'items' => $tracks, + 'total' => count($tracks), + ], + ]; + } + + /** + * @param mixed $rawTracks + * @return list> + */ + private function normalizeTracks(mixed $rawTracks, string $albumId): array + { + $tracks = []; + $rows = $this->normalizeList($rawTracks); + $index = 1; + + foreach ($rows as $row) { + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; + } + + $rank = (int) ($row['@attr']['rank'] ?? $row['rank'] ?? $index); + $seconds = (int) ($row['duration'] ?? 0); + $tracks[] = [ + 'id' => sha1($albumId . '|' . $rank . '|' . $name), + 'disc_number' => 1, + 'track_number' => max(1, $rank), + 'name' => $name, + 'duration_ms' => max(0, $seconds) * 1000, + 'preview_url' => null, + ]; + $index++; + } + + return $tracks; + } + + /** + * @param mixed $rawImages + * @return list + */ + private function normalizeImages(mixed $rawImages): array + { + $images = []; + foreach ($this->normalizeList($rawImages) as $image) { + $url = trim((string) ($image['#text'] ?? $image['url'] ?? '')); + if ($url !== '') { + $size = mb_strtolower(trim((string) ($image['size'] ?? ''))); + $images[] = [ + 'url' => $url, + 'rank' => self::IMAGE_SIZE_RANK[$size] ?? 0, + ]; + } + } + + if ($images === []) { + return []; + } + + usort($images, static fn(array $a, array $b) => $b['rank'] <=> $a['rank']); + + $unique = []; + $result = []; + foreach ($images as $image) { + $url = (string) ($image['url'] ?? ''); + if ($url === '' || isset($unique[$url])) { + continue; + } + $unique[$url] = true; + $result[] = ['url' => $url]; + } + + return $result; + } + + private function normalizePublishedDate(string $published): ?string + { + $published = trim($published); + if ($published === '') { + return null; + } + + try { + return (new \DateTimeImmutable($published))->format('Y-m-d'); + } catch (\Throwable) { + return null; + } + } + + private function buildAlbumIdentifier(?string $mbid, string $artist, string $album): string + { + if ($mbid !== null && trim($mbid) !== '') { + return 'mbid:' . trim($mbid); + } + + $slugSource = mb_strtolower(trim($artist) . '|' . trim($album)); + $encoded = rtrim(strtr(base64_encode($slugSource), '+/', '-_'), '='); + return 'slug:' . $encoded; + } + + /** + * @return array{mbid:string}|array{artist:string,album:string}|null + */ + private function parseAlbumIdentifier(string $identifier): ?array + { + if (str_starts_with($identifier, 'mbid:')) { + $mbid = trim(substr($identifier, 5)); + return $mbid === '' ? null : ['mbid' => $mbid]; + } + + if (str_starts_with($identifier, 'slug:')) { + $payload = substr($identifier, 5); + $decoded = base64_decode(strtr($payload, '-_', '+/'), true); + if ($decoded === false || !str_contains($decoded, '|')) { + return null; + } + [$artist, $album] = explode('|', $decoded, 2); + $artist = trim($artist); + $album = trim($album); + if ($artist === '' || $album === '') { + return null; + } + return ['artist' => $artist, 'album' => $album]; + } + + return null; + } + + private function stringOrNull(mixed $value): ?string + { + if ($value === null) { + return null; + } + $string = trim((string) $value); + return $string === '' ? null : $string; + } + + private function normalizeGenreLabel(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + return mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8'); + } +} diff --git a/src/Service/SpotifyMetadataRefresher.php b/src/Service/LastFmMetadataRefresher.php similarity index 65% rename from src/Service/SpotifyMetadataRefresher.php rename to src/Service/LastFmMetadataRefresher.php index 6713f4e..d52a34a 100644 --- a/src/Service/SpotifyMetadataRefresher.php +++ b/src/Service/LastFmMetadataRefresher.php @@ -8,62 +8,60 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** - * SpotifyMetadataRefresher bulk-updates stored Spotify albums using batched API calls. + * LastFmMetadataRefresher re-syncs locally stored external albums from Last.fm. */ -class SpotifyMetadataRefresher +class LastFmMetadataRefresher { private const BATCH_SIZE = 20; public function __construct( - private readonly SpotifyClient $spotifyClient, + private readonly LastFmClient $lastFmClient, private readonly AlbumRepository $albumRepository, private readonly AlbumTrackRepository $trackRepository, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, - private readonly SpotifyGenreResolver $genreResolver, + private readonly LastFmTagResolver $tagResolver, ) { } /** - * Refreshes all saved Spotify albums and returns the number of payloads that were re-synced. + * Refreshes all saved external albums and returns the number of payloads that were re-synced. */ - public function refreshAllSpotifyAlbums(): int + public function refreshAllExternalAlbums(): int { - $spotifyIds = $this->albumRepository->findAllSpotifyIds(); - if ($spotifyIds === []) { + $externalIds = $this->albumRepository->findAllExternalIds(); + if ($externalIds === []) { return 0; } $updatedAlbumCount = 0; - foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) { - $payload = $this->spotifyClient->getAlbums($albumIdChunk); + foreach (array_chunk($externalIds, self::BATCH_SIZE) as $albumIdChunk) { + $payload = $this->lastFmClient->getAlbums($albumIdChunk); $albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : []; if ($albums === []) { - $this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]); + $this->logger->warning('External getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]); continue; } - // Share the same genre resolution logic used during search so existing rows gain genre data too. - $genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums); + $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albums); foreach ($albums as $albumPayload) { try { $albumId = (string) ($albumPayload['id'] ?? ''); - $albumEntity = $this->albumRepository->upsertFromSpotifyAlbum( + $albumEntity = $this->albumRepository->upsertFromExternalAlbum( (array) $albumPayload, $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] ); if ($albumId !== '' && $albumEntity !== null) { $tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload); if ($tracks !== []) { - // Replace tracks wholesale to simplify diffs (instead of diffing rows). $this->trackRepository->replaceAlbumTracks($albumEntity, $tracks); $albumEntity->setTotalTracks(count($tracks)); } } $updatedAlbumCount++; } catch (\Throwable $e) { - $this->logger->error('Failed to upsert Spotify album', [ + $this->logger->error('Failed to upsert external album', [ 'error' => $e->getMessage(), 'album' => $albumPayload['id'] ?? null, ]); @@ -85,7 +83,7 @@ class SpotifyMetadataRefresher $total = (int) ($albumPayload['tracks']['total'] ?? 0); if ($total > count($tracks)) { - $fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId); + $fullTrackPayloads = $this->lastFmClient->getAlbumTracks($albumId); if ($fullTrackPayloads !== []) { return $fullTrackPayloads; } @@ -94,5 +92,3 @@ class SpotifyMetadataRefresher return $tracks; } } - - diff --git a/src/Service/LastFmTagResolver.php b/src/Service/LastFmTagResolver.php new file mode 100644 index 0000000..b03d0ca --- /dev/null +++ b/src/Service/LastFmTagResolver.php @@ -0,0 +1,72 @@ +> $albums + * @return array> Map of album ID => tag list + */ + public function resolveGenresForAlbums(array $albums): array + { + $albumTags = []; + + foreach ($albums as $album) { + $albumId = trim((string) ($album['id'] ?? '')); + if ($albumId === '') { + continue; + } + + $genres = $this->extractGenres((array) ($album['genres'] ?? [])); + if ($genres !== []) { + $albumTags[$albumId] = $genres; + continue; + } + + $hydrated = $this->lastFmClient->getAlbumWithTracks($albumId); + if ($hydrated === null) { + continue; + } + + $resolved = $this->extractGenres((array) ($hydrated['genres'] ?? [])); + if ($resolved !== []) { + $albumTags[$albumId] = $resolved; + } + } + + return $albumTags; + } + + /** + * @param array $values + * @return list + */ + private function extractGenres(array $values): array + { + foreach ($values as $value) { + $genre = $this->toTitleCase(trim((string) $value)); + if ($genre !== '') { + return [$genre]; + } + } + + return []; + } + + private function toTitleCase(string $value): string + { + if ($value === '') { + return ''; + } + + return mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8'); + } +} diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php deleted file mode 100644 index 915460d..0000000 --- a/src/Service/SpotifyClient.php +++ /dev/null @@ -1,292 +0,0 @@ -httpClient = $httpClient; - $this->cache = $cache; - $this->clientId = $clientId; - $this->clientSecret = $clientSecret; - $this->settings = $settings; - } - - /** - * Search Spotify albums by query string. - * - * @param string $query - * @param int $limit - * @return array - */ - public function searchAlbums(string $query, int $limit = 12): array - { - $accessToken = $this->getAccessToken(); - - if ($accessToken === null) { - return ['albums' => ['items' => []]]; - } - - $url = 'https://api.spotify.com/v1/search'; - $options = [ - 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], - 'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ], - ]; - return $this->sendRequest('GET', $url, $options, 600); - } - - /** - * Fetch a single album by Spotify ID. - * - * @return array|null - */ - public function getAlbum(string $albumId): ?array - { - $accessToken = $this->getAccessToken(); - if ($accessToken === null) { - return null; - } - - $url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId); - $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ]; - try { - return $this->sendRequest('GET', $url, $options, 3600); - } catch (\Throwable) { - return null; - } - } - - /** - * Fetches album metadata plus the full tracklist. - * - * @return array|null - */ - public function getAlbumWithTracks(string $albumId): ?array - { - $album = $this->getAlbum($albumId); - if ($album === null) { - return null; - } - $tracks = $this->getAlbumTracks($albumId); - if ($tracks !== []) { - $album['tracks'] = $album['tracks'] ?? []; - $album['tracks']['items'] = $tracks; - $album['tracks']['total'] = count($tracks); - $album['tracks']['limit'] = count($tracks); - $album['tracks']['offset'] = 0; - $album['tracks']['next'] = null; - $album['tracks']['previous'] = null; - } - return $album; - } - - /** - * Retrieves the complete tracklist for an album. - * - * @return list> - */ - public function getAlbumTracks(string $albumId): array - { - $accessToken = $this->getAccessToken(); - if ($accessToken === null) { - return []; - } - - $items = []; - $limit = 50; - $offset = 0; - do { - // Spotify returns tracks in pages of 50, so iterate until there are no further pages. - $page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset); - if ($page === null) { - break; - } - $batch = (array) ($page['items'] ?? []); - $items = array_merge($items, $batch); - $offset += $limit; - $total = isset($page['total']) ? (int) $page['total'] : null; - $hasNext = isset($page['next']) && $page['next'] !== null; - // Guard against Spotify omitting total by relying on the "next" cursor. - // Ensures album requests stop when Spotify has no more pages. - } while ($hasNext && ($total === null || $offset < $total)); - - return $items; - } - - /** - * Fetch multiple albums with one call. - * - * @param list $albumIds - * @return array|null - */ - public function getAlbums(array $albumIds): ?array - { - if ($albumIds === []) { return []; } - $accessToken = $this->getAccessToken(); - if ($accessToken === null) { return null; } - $url = 'https://api.spotify.com/v1/albums'; - $options = [ - 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], - 'query' => [ 'ids' => implode(',', $albumIds) ], - ]; - try { - return $this->sendRequest('GET', $url, $options, 3600); - } catch (\Throwable) { - return null; - } - } - - /** - * Fetch multiple artists to gather genre information. - * - * @param list $artistIds - * @return array|null - */ - public function getArtists(array $artistIds): ?array - { - if ($artistIds === []) { return []; } - $accessToken = $this->getAccessToken(); - if ($accessToken === null) { return null; } - - $url = 'https://api.spotify.com/v1/artists'; - $options = [ - 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], - 'query' => [ 'ids' => implode(',', $artistIds) ], - ]; - - try { - return $this->sendRequest('GET', $url, $options, 1800); - } catch (\Throwable) { - return null; - } - } - - /** - * Centralized request helper with lightweight caching. - * - * @param array $options - * @return array - */ - private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array - { - $request = function () use ($method, $url, $options): array { - $response = $this->httpClient->request($method, $url, $options); - return $response->toArray(false); - }; - - $shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET'; - if ($shouldCache) { - // Cache fingerprint mixes URL and query only; headers are static (Bearer token). - $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? [])); - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { - $item->expiresAfter($cacheTtlSeconds); - return $request(); - }); - } - - return $request(); - } - - /** - * Requests one paginated track list page for an album using the provided OAuth token. - * - * @return array|null - */ - private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array - { - $url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId)); - $options = [ - 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], - 'query' => [ 'limit' => $limit, 'offset' => $offset ], - ]; - try { - return $this->sendRequest('GET', $url, $options, 1200); - } catch (\Throwable) { - return null; - } - } - - /** - * Retrieves a cached access token or refreshes credentials when missing. - */ - private function getAccessToken(): ?string - { - $cacheKey = 'spotify_client_credentials_token'; - $token = $this->cache->get($cacheKey, function (ItemInterface $item) { - // Default to ~1 hour, adjusted after Spotify response - $item->expiresAfter(3500); - - $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '')); - $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '')); - if ($clientId === '' || $clientSecret === '') { - // surface the miss quickly so the cache can be recomputed on the next request - $item->expiresAfter(60); - return null; - } - - $response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [ - 'headers' => [ - 'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret), - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - 'body' => 'grant_type=client_credentials', - ]); - - $data = $response->toArray(false); - - if (!isset($data['access_token'])) { - $item->expiresAfter(60); - return null; - } - - if (isset($data['expires_in']) && is_int($data['expires_in'])) { - $ttl = max(60, $data['expires_in'] - 60); - $item->expiresAfter($ttl); - } - - return $data['access_token']; - }); - - if ($token === null) { - // Nuke cached nulls so the next request retries instead of reusing the failure sentinel. - $this->cache->delete($cacheKey); - } - - return $token; - } - - /** - * Returns true when credentials are available from DB or environment. - */ - public function isConfigured(): bool - { - $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '')); - $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '')); - return $clientId !== '' && $clientSecret !== ''; - } -} - - diff --git a/src/Service/SpotifyGenreResolver.php b/src/Service/SpotifyGenreResolver.php deleted file mode 100644 index 8f1d287..0000000 --- a/src/Service/SpotifyGenreResolver.php +++ /dev/null @@ -1,111 +0,0 @@ -> $albums - * @return array> Map of album ID => genres list - */ - public function resolveGenresForAlbums(array $albums): array - { - $albumGenres = []; - $artistIds = []; - - foreach ($albums as $album) { - $albumId = (string) ($album['id'] ?? ''); - if ($albumId === '') { - continue; - } - - $genres = $this->normalizeGenres((array) ($album['genres'] ?? [])); - if ($genres !== []) { - $albumGenres[$albumId] = $genres; - continue; - } - - // Collect artist IDs so we can fetch their genres in one pass rather than per album. - foreach ((array) ($album['artists'] ?? []) as $artist) { - $artistId = (string) ($artist['id'] ?? ''); - if ($artistId !== '') { - $artistIds[$artistId] = true; - } - } - } - - if ($artistIds === []) { - return $albumGenres; - } - - $artistGenres = $this->fetchArtistsGenres(array_keys($artistIds)); - if ($artistGenres === []) { - return $albumGenres; - } - - foreach ($albums as $album) { - $albumId = (string) ($album['id'] ?? ''); - if ($albumId === '' || isset($albumGenres[$albumId])) { - continue; - } - - $combined = []; - foreach ((array) ($album['artists'] ?? []) as $artist) { - $artistId = (string) ($artist['id'] ?? ''); - if ($artistId !== '' && isset($artistGenres[$artistId])) { - // Merge artist genres; duplicates removed later. - $combined = array_merge($combined, $artistGenres[$artistId]); - } - } - - if ($combined !== []) { - $albumGenres[$albumId] = array_values(array_unique($combined)); - } - } - - return $albumGenres; - } - - /** - * @param list $artistIds - * @return array> - */ - private function fetchArtistsGenres(array $artistIds): array - { - $genres = []; - foreach (array_chunk($artistIds, 50) as $artistIdChunk) { - // Spotify allows up to 50 artist IDs per request; batching keeps calls minimal. - $payload = $this->spotifyClient->getArtists($artistIdChunk); - $artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : []; - foreach ($artists as $artist) { - $id = (string) ($artist['id'] ?? ''); - if ($id === '') { - continue; - } - $genres[$id] = $this->normalizeGenres((array) ($artist['genres'] ?? [])); - } - } - return $genres; - } - - /** - * @param array $values - * @return list - */ - private function normalizeGenres(array $values): array - { - return array_values(array_filter(array_map( - static fn($genre) => trim((string) $genre), - $values - ), static fn($genre) => $genre !== '')); - } -} - - diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 30e09de..b56e6ab 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -9,12 +9,8 @@
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
- {{ form_label(form.SPOTIFY_CLIENT_ID) }} - {{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }} -
-
- {{ form_label(form.SPOTIFY_CLIENT_SECRET) }} - {{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }} + {{ form_label(form.LASTFM_API_KEY) }} + {{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }}
{% set registrationToggleAttrs = {class: 'form-check-input'} %} @@ -92,4 +88,3 @@
{% endblock %} - diff --git a/templates/admin/site_dashboard.html.twig b/templates/admin/site_dashboard.html.twig index eeaa7df..a1efca9 100644 --- a/templates/admin/site_dashboard.html.twig +++ b/templates/admin/site_dashboard.html.twig @@ -3,10 +3,10 @@ {% block body %}

Site dashboard

-
- + +
@@ -68,7 +68,7 @@

Latest albums (50)

{% for a in recentAlbums %} - {% set publicId = a.source == 'user' ? a.localId : a.spotifyId %} + {% set publicId = a.source == 'user' ? a.localId : a.externalId %}
@@ -77,7 +77,7 @@ {% else %} {{ a.name }} {% endif %} - {% if a.source == 'user' %}User{% else %}Spotify{% endif %} + {% if a.source == 'user' %}User{% else %}Last.fm{% endif %}
{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}
@@ -98,4 +98,3 @@
{% endblock %} - diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig index 15dda2b..eb6d1e8 100644 --- a/templates/album/search.html.twig +++ b/templates/album/search.html.twig @@ -28,11 +28,11 @@ {% endif %}

Search Albums

- {% if source_value != 'user' and spotifyConfigured is defined and not spotifyConfigured %} + {% if source_value != 'user' and externalConfigured is defined and not externalConfigured %}
- Spotify is not configured yet. Results will only include user-created albums. + Last.fm is not configured yet. Results will only include user-created albums. {% if is_granted('ROLE_ADMIN') %} - Enter Spotify credentials. + Enter Last.fm API key. {% endif %}
{% endif %} @@ -51,7 +51,7 @@
@@ -86,7 +86,7 @@
@@ -115,7 +115,7 @@ {% for album in albums %}
- {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% set image = (album.images[0] ?? album.images[1] ?? null) %} {% if image %} {{ album.name }} cover @@ -133,8 +133,8 @@ {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}

User score: {{ s.avg }}/10 ({{ s.count }})

- {% if album.external_urls.spotify is defined and album.external_urls.spotify %} - Open in Spotify + {% if album.external_urls.external is defined and album.external_urls.external %} + Open Source {% endif %} Reviews {% if app.user and (album.source is not defined or album.source != 'user') %} @@ -157,5 +157,3 @@

No albums found.

{% endif %} {% endblock %} - - diff --git a/templates/album/show.html.twig b/templates/album/show.html.twig index 1e790e0..712b73e 100644 --- a/templates/album/show.html.twig +++ b/templates/album/show.html.twig @@ -4,7 +4,7 @@
- {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% set image = (album.images[0] ?? album.images[1] ?? null) %} {% if image %} {{ album.name }} cover {% endif %} @@ -28,8 +28,8 @@

Genres: {{ album.genres|join(', ') }}

{% endif %}

User score: {{ avg }}/10 ({{ count }})

- {% if album.external_urls.spotify %} - Open in Spotify + {% if album.external_urls.external %} + Open Source {% endif %} {% if album.source is defined and album.source == 'user' %} User album @@ -153,5 +153,3 @@
{% endblock %} - - diff --git a/templates/review/show.html.twig b/templates/review/show.html.twig index 7b2631d..121ced8 100644 --- a/templates/review/show.html.twig +++ b/templates/review/show.html.twig @@ -20,7 +20,7 @@

{{ review.album.name }} — {{ review.album.artists|join(', ') }} - View album + View album

{{ review.content|nl2br }}