Migration to last.fm, removed old spotify api client. FOSRestBundle API serving data driven front end (JWT).

This commit is contained in:
boris committed 2026-02-27 16:52:57 +00:00
1 parent 391ecf1732
commit 56dea097d6
51 files changed
+1742 -837

No files matched your search

+3 -3
View File
@@ -1,12 +1,12 @@
# Uncomment to override stored setting. # Uncomment to override stored setting.
#SPOTIFY_CLIENT_ID= #LASTFM_API_KEY=
#SPOTIFY_CLIENT_SECRET=
APP_ENV=prod APP_ENV=prod
APP_SECRET=changeme APP_SECRET=changeme
# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting # APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
DEFAULT_URI=http://localhost:8085 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_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 # DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
+12 -9
View File
@@ -1,6 +1,6 @@
# Tonehaus — Music Ratings (Symfony 7) # 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) ## 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 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 - Go to `http://localhost:8085/admin/settings` and enter your Last.fm API key, or
- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` - Set env var in `.env`: `LASTFM_API_KEY`
5) (Optional) Seed demo data 5) (Optional) Seed demo data
@@ -42,11 +42,14 @@ Notes:
## Features ## 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) - Album page with details, tracklist, reviews list, and inline new review (logged-in)
- Auth modal (Login/Sign up) with remember‑me; no separate pages - Auth modal (Login/Sign up) with remember‑me; no separate pages
- Role-based access: authors manage their own reviews; moderators/admins can moderate content - 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 - User Dashboard: profile updates and password change
- Light/Dark theme toggle (cookie-backed) - Light/Dark theme toggle (cookie-backed)
@@ -55,7 +58,7 @@ Notes:
- Setup and configuration: `docs/setup.md` - Setup and configuration: `docs/setup.md`
- Feature overview: `docs/features.md` - Feature overview: `docs/features.md`
- Authentication and users: `docs/auth-and-users.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` - Reviews and albums: `docs/reviews-and-albums.md`
- Admin & site settings: `docs/admin-and-settings.md` - Admin & site settings: `docs/admin-and-settings.md`
- Troubleshooting: `docs/troubleshooting.md` - Troubleshooting: `docs/troubleshooting.md`
@@ -65,7 +68,8 @@ Notes:
## Environment overview ## Environment overview
- `APP_ENV` (dev|prod), `APP_SECRET` (required) - `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 - `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres) - `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`) - `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
@@ -74,4 +78,3 @@ Notes:
## License ## License
MIT MIT
+1 -5
View File
@@ -1,5 +1 @@
// Placeholder front-end entrypoint to satisfy importmap & asset mapper. // Intentionally empty importmap entrypoint.
// You can add real JS behavior here; for now we only register a noop.
console.debug('Tonehaus assets initialized');
+3
View File
@@ -9,6 +9,9 @@
"php": ">=8.2", "php": ">=8.2",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"friendsofsymfony/rest-bundle": "^3.8",
"guzzlehttp/guzzle": "^7.9",
"lexik/jwt-authentication-bundle": "^3.1",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.5", "doctrine/doctrine-migrations-bundle": "^3.5",
+2
View File
@@ -2,6 +2,8 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], 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\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
+10
View File
@@ -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
@@ -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
+20
View File
@@ -15,6 +15,21 @@ security:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false 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: main:
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
@@ -48,6 +63,11 @@ security:
# Easy way to control access for large sections of your site # Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: 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/settings, roles: ROLE_ADMIN }
- { path: ^/admin/users, roles: ROLE_MODERATOR } - { path: ^/admin/users, roles: ROLE_MODERATOR }
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR } - { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
@@ -0,0 +1,2 @@
api_login_check:
path: /api/v1/auth/login
+11 -3
View File
@@ -20,10 +20,14 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Service\SpotifyClient: GuzzleHttp\Client:
arguments: arguments:
$clientId: '%env(default::SPOTIFY_CLIENT_ID)%' $config: { timeout: 10.0, connect_timeout: 5.0, http_errors: false }
$clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
App\Service\LastFmClient:
arguments:
$httpClient: '@GuzzleHttp\Client'
$apiKey: '%env(default::LASTFM_API_KEY)%'
App\Service\UploadStorage: App\Service\UploadStorage:
arguments: arguments:
@@ -33,3 +37,7 @@ services:
App\Controller\AlbumController: App\Controller\AlbumController:
arguments: arguments:
$searchLimit: '%album_search_limit%' $searchLimit: '%album_search_limit%'
App\Controller\Api\V1\AlbumApiController:
arguments:
$searchLimit: '%album_search_limit%'
+7 -1
View File
@@ -1,6 +1,12 @@
services: services:
tonehaus: 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 container_name: tonehaus
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1
View File
@@ -12,6 +12,7 @@ WORKDIR /var/www/html
RUN apk add --no-cache \ RUN apk add --no-cache \
bash \ bash \
git \ git \
openssl \
unzip \ unzip \
icu-dev \ icu-dev \
libpng-dev \ libpng-dev \
+34 -1
View File
@@ -8,6 +8,39 @@ require_app_secret() {
fi 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() { install_runtime() {
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
return return
@@ -25,6 +58,7 @@ install_runtime
if [ -f bin/console ]; then if [ -f bin/console ]; then
require_app_secret require_app_secret
ensure_jwt_keys
fi fi
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
@@ -48,4 +82,3 @@ if [ -f bin/console ]; then
fi fi
exec "$@" exec "$@"
+8
View File
@@ -23,6 +23,14 @@ server {
return 200 "OK"; 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; error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
} }
+2 -2
View File
@@ -23,7 +23,7 @@
## Site settings (ROLE_ADMIN) ## Site settings (ROLE_ADMIN)
- URL: `/admin/settings` - 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. - 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. - 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. - 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 ## Useful tips
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable. - 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. - Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
+12 -14
View File
@@ -6,19 +6,19 @@ This project follows a conventional Symfony architecture with clear separation o
- **Classes** - **Classes**
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`). - **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. - **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`). - **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
- **Methods** - **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()`). - 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()`, `isSpotifyConfigured()`). - 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. - Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
- **Variables & parameters** - **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). - 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`, `$shouldQuerySpotify`, `$needsSync`). - Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`).
- Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`). - Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`).
- **Files & namespaces** - **Files & namespaces**
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`). - 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. 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 ## 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 2. Logged‑in users can write, edit, and delete reviews
3. Moderators and admins can moderate content and manage users 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 ## 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 - `User` — authentication principal and roles
- `Album`, `AlbumTrack` — normalized album metadata and track list - `Album`, `AlbumTrack` — normalized album metadata and track list
- `Review` — user‑authored review with rating and timestamps - `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/*`) ### Repositories (`src/Repository/*`)
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users) - 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 - Leverage Symfony validation constraints for robust server‑side validation
### Services (`src/Service/*`) ### Services (`src/Service/*`)
- `SpotifyClient` — Client Credentials token management (cached) and API calls - `LastFmClient` — Last.fm API integration with caching
- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data - `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data
- `CatalogResetService` — admin action to reset/sync catalog state safely - `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 - `RegistrationToggle` — DB‑backed registration flag with env override
### Security (`config/packages/security.yaml`, `src/Security/*`) ### 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 ## Testing & tooling
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage - PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
- Web Profiler enabled in dev - Web Profiler enabled in dev
+2 -4
View File
@@ -18,8 +18,7 @@ docker run -d \
-p 8080:8080 \ -p 8080:8080 \
-e APP_ENV=prod \ -e APP_ENV=prod \
-e APP_SECRET=change_me \ -e APP_SECRET=change_me \
-e SPOTIFY_CLIENT_ID=your_client_id \ -e LASTFM_API_KEY=your_api_key \
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
tonehaus-app:latest tonehaus-app:latest
``` ```
@@ -51,7 +50,7 @@ docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interactio
## Environment variables ## Environment variables
- `APP_ENV` (`prod` recommended in production) - `APP_ENV` (`prod` recommended in production)
- `APP_SECRET` (required; random string) - `APP_SECRET` (required; random string)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` - `LASTFM_API_KEY`
- `APP_ALLOW_REGISTRATION` (env override for public registration) - `APP_ALLOW_REGISTRATION` (env override for public registration)
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`) - `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
- `DATABASE_URL` (when using 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 - 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 - Keep SQLite on a named volume, or use Postgres for shared state across replicas
+2 -2
View File
@@ -1,7 +1,7 @@
# Features # Features
## Albums & Reviews ## 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) - Album page: cover art, metadata, full tracklist (when available)
- Reviews list (newest first) and inline new review form (logged-in) - Reviews list (newest first) and inline new review form (logged-in)
- Rating slider (1–10) with live badge - Rating slider (1–10) with live badge
@@ -16,7 +16,7 @@
## Administration ## Administration
- Dashboard: latest reviews/albums and key counts (moderator+) - Dashboard: latest reviews/albums and key counts (moderator+)
- Users: create/delete users, promote/demote moderators (admin constraints) - 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 ## Design & UX
- Responsive Bootstrap UI - Responsive Bootstrap UI
+26
View File
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@
## Album page ## Album page
- Artwork, metadata, average rating, and review count - Artwork, metadata, average rating, and review count
- Full Spotify tracklist when available - Full Last.fm tracklist when available
- Reviews list (newest first) - Reviews list (newest first)
- Inline new review form for logged‑in users - Inline new review form for logged‑in users
+12 -7
View File
@@ -2,7 +2,7 @@
## Prerequisites ## Prerequisites
- Docker + Docker Compose - Docker + Docker Compose
- Spotify Developer account (Client ID/Secret) - Last.fm API key
- A unique `APP_SECRET` value in your environment (for prod builds) - A unique `APP_SECRET` value in your environment (for prod builds)
## 1) Start the stack ## 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 docker compose exec tonehaus php bin/console app:promote-admin you@example.com
``` ```
## 3) Configure Spotify ## 3) Configure Last.fm
- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB) - Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB)
- Env fallback (in `.env` or your shell): - Env fallback (in `.env` or your shell):
```bash ```bash
SPOTIFY_CLIENT_ID=your_client_id LASTFM_API_KEY=your_api_key
SPOTIFY_CLIENT_SECRET=your_client_secret
``` ```
## 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 ```bash
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50 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 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 ## Environment variables
- `APP_ENV=dev|prod` - `APP_ENV=dev|prod`
- `APP_SECRET=<random_string>` - `APP_SECRET=<random_string>`
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET` - `LASTFM_API_KEY`
- `JWT_PASSPHRASE=<random_string>`
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting) - `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
- `DATABASE_DRIVER=sqlite|postgres` - `DATABASE_DRIVER=sqlite|postgres`
- `DATABASE_SQLITE_PATH` (optional) - `DATABASE_SQLITE_PATH` (optional)
-30
View File
@@ -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
+2 -3
View File
@@ -29,9 +29,8 @@
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8 postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
``` ```
## Spotify errors ## Last.fm errors
- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`. - Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`.
- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container.
## ARM64 Build ## ARM64 Build
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
}
public function up(Schema $schema): void
{
if ($this->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';
}
}
+8 -8
View File
@@ -5,7 +5,7 @@ namespace App\Controller\Admin;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\SpotifyMetadataRefresher; use App\Service\LastFmMetadataRefresher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request; 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'])] #[Route('/admin/dashboard/refresh-external', name: 'admin_dashboard_refresh_external', methods: ['POST'])]
public function refreshSpotify( public function refreshExternal(
Request $request, Request $request,
SpotifyMetadataRefresher $refresher LastFmMetadataRefresher $refresher
): Response { ): Response {
$token = (string) $request->request->get('_token'); $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.'); throw $this->createAccessDeniedException('Invalid CSRF token.');
} }
// Refresh runs synchronously; keep user feedback short so the POST remains snappy. // Refresh runs synchronously; keep user feedback short so the POST remains snappy.
$updated = $refresher->refreshAllSpotifyAlbums(); $updated = $refresher->refreshAllExternalAlbums();
if ($updated === 0) { 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 { } 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'); return $this->redirectToRoute('admin_dashboard');
+3 -5
View File
@@ -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'])] #[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): Response public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): Response
{ {
$form = $this->createForm(SiteSettingsType::class); $form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID')); $form->get('LASTFM_API_KEY')->setData($settings->getValue('LASTFM_API_KEY'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
$registrationOverride = $registrationToggle->getEnvOverride(); $registrationOverride = $registrationToggle->getEnvOverride();
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled()); $form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData()); $settings->setValue('LASTFM_API_KEY', (string) $form->get('LASTFM_API_KEY')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
if ($registrationOverride === null) { if ($registrationOverride === null) {
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION. // Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData()); $registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
+68 -41
View File
@@ -13,8 +13,8 @@ use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService; use App\Service\AlbumSearchService;
use App\Service\UploadStorage; use App\Service\UploadStorage;
use App\Service\SpotifyClient; use App\Service\LastFmClient;
use App\Service\SpotifyGenreResolver; use App\Service\LastFmTagResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -32,13 +32,13 @@ class AlbumController extends AbstractController
public function __construct( public function __construct(
private readonly UploadStorage $uploadStorage, private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch, private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver, private readonly LastFmTagResolver $genreResolver,
private readonly int $searchLimit = 20 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'])] #[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request): Response public function search(Request $request): Response
@@ -57,7 +57,7 @@ class AlbumController extends AbstractController
'stats' => $result->stats, 'stats' => $result->stats,
'savedIds' => $result->savedIds, 'savedIds' => $result->savedIds,
'source' => $criteria->source, '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. * Renders a detailed album view plus inline review form.
*/ */
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] #[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); $albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null; $isSaved = $albumEntity !== null;
if (!$albumEntity) { if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via Spotify before rendering. // Album has never been saved locally, so hydrate it via Last.fm before rendering.
$spotifyAlbum = $spotify->getAlbumWithTracks($id); $externalAlbum = $lastFmClient->getAlbumWithTracks($id);
if ($spotifyAlbum === null) { if ($externalAlbum === null) {
throw $this->createNotFoundException('Album not found'); throw $this->createNotFoundException('Album not found');
} }
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
$em->flush(); $em->flush();
} else { } 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. // Track sync mutated the entity: persist before we build template arrays.
$em->flush(); $em->flush();
} }
} }
$albumCard = $albumEntity->toTemplateArray(); $albumCard = $albumEntity->toTemplateArray();
$albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $lastFmClient);
$canManage = $this->canManageAlbum($albumEntity); $canManage = $this->canManageAlbum($albumEntity);
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray()); $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')] #[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] #[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'); $token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.'); throw $this->createAccessDeniedException('Invalid CSRF token.');
} }
$existing = $albumRepo->findOneBySpotifyId($id); $existing = $albumRepo->findOneByExternalId($id);
if (!$existing) { if (!$existing) {
$spotifyAlbum = $spotify->getAlbumWithTracks($id); $externalAlbum = $lastFmClient->getAlbumWithTracks($id);
if ($spotifyAlbum === null) { if ($externalAlbum === null) {
throw $this->createNotFoundException('Album not found'); throw $this->createNotFoundException('Album not found');
} }
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
$em->flush(); $em->flush();
$this->addFlash('success', 'Album saved.'); $this->addFlash('success', 'Album saved.');
} else { } 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 private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
{ {
@@ -287,7 +288,7 @@ class AlbumController extends AbstractController
return $local; return $local;
} }
return $albumRepo->findOneBySpotifyId($id); return $albumRepo->findOneByExternalId($id);
} }
/** /**
@@ -338,18 +339,18 @@ class AlbumController extends AbstractController
} }
/** /**
* @param array<string,mixed> $spotifyAlbum * @param array<string,mixed> $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. // Bring genres along when we persist external albums so templates can display them immediately.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); $genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
$albumId = (string) ($spotifyAlbum['id'] ?? ''); $albumId = (string) ($externalAlbum['id'] ?? '');
$album = $albumRepo->upsertFromSpotifyAlbum( $album = $albumRepo->upsertFromExternalAlbum(
$spotifyAlbum, $externalAlbum,
$albumId !== '' ? ($genresMap[$albumId] ?? []) : [] $albumId !== '' ? ($genresMap[$albumId] ?? []) : []
); );
$tracks = $spotifyAlbum['tracks']['items'] ?? []; $tracks = $externalAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) { if (is_array($tracks) && $tracks !== []) {
$trackRepo->replaceAlbumTracks($album, $tracks); $trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks)); $album->setTotalTracks(count($tracks));
@@ -357,33 +358,33 @@ class AlbumController extends AbstractController
return $album; 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; return false;
} }
$spotifyId = $album->getSpotifyId(); $externalId = $album->getExternalId();
if ($spotifyId === null) { if ($externalId === null) {
return false; return false;
} }
$storedCount = $album->getTracks()->count(); $storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0; $needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) { 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; $needsSync = true;
} }
if (!$needsSync) { if (!$needsSync) {
return false; return false;
} }
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId); $externalAlbum = $lastFmClient->getAlbumWithTracks($externalId);
if ($spotifyAlbum === null) { if ($externalAlbum === null) {
return false; return false;
} }
// Rehydrate genres during syncs as well, in case Spotify has updated the metadata. // Rehydrate genres during syncs as well, in case source metadata has updated.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); $genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
$albumGenres = $genresMap[$spotifyId] ?? []; $albumGenres = $genresMap[$externalId] ?? [];
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres); $albumRepo->upsertFromExternalAlbum($externalAlbum, $albumGenres);
$tracks = $spotifyAlbum['tracks']['items'] ?? []; $tracks = $externalAlbum['tracks']['items'] ?? [];
if (!is_array($tracks) || $tracks === []) { if (!is_array($tracks) || $tracks === []) {
return false; return false;
} }
@@ -392,6 +393,32 @@ class AlbumController extends AbstractController
return true; return true;
} }
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
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;
}
} }
@@ -0,0 +1,231 @@
<?php
namespace App\Controller\Api\V1;
use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\LastFmClient;
use App\Service\LastFmTagResolver;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/v1/albums')]
class AlbumApiController extends AbstractFOSRestController
{
public function __construct(
private readonly AlbumSearchService $albumSearchService,
private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository,
private readonly ReviewRepository $reviewRepository,
private readonly LastFmClient $lastFmClient,
private readonly LastFmTagResolver $tagResolver,
private readonly EntityManagerInterface $entityManager,
private readonly int $searchLimit = 20,
) {
}
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
public function index(Request $request): Response
{
$criteria = AlbumSearchCriteria::fromRequest($request, $this->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<string,mixed> $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<string,mixed>|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<string,mixed> $payload
* @param array<string,mixed>|null $externalAlbum
* @return array<string,mixed>
*/
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;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Controller\Api\V1;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/v1')]
class ApiIndexController extends AbstractFOSRestController
{
#[Route('', name: 'api_v1_index', methods: ['GET'])]
public function index(): Response
{
return $this->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));
}
}
@@ -0,0 +1,203 @@
<?php
namespace App\Controller\Api\V1;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/v1/reviews')]
class ReviewApiController extends AbstractFOSRestController
{
public function __construct(
private readonly ReviewRepository $reviewRepository,
private readonly AlbumRepository $albumRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
public function index(Request $request): Response
{
$limit = max(1, min(100, (int) $request->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<string,mixed>
*/
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<string,mixed>|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;
}
}
+7 -8
View File
@@ -18,7 +18,7 @@ final class AlbumSearchCriteria
/** Explicit artist filter supplied via the advanced panel. */ /** Explicit artist filter supplied via the advanced panel. */
public readonly string $artist; 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; public readonly string $genre;
/** Lower bound (inclusive) of the release year filter, if any. */ /** 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. */ /** Upper bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearTo; public readonly ?int $yearTo;
/** Requested source scope: `all`, `spotify`, or `user`. */ /** Requested source scope: `all`, `external`, or `user`. */
public readonly string $source; public readonly string $source;
/** Maximum number of results the search should return. */ /** Maximum number of results the search should return. */
@@ -49,7 +49,7 @@ final class AlbumSearchCriteria
$this->genre = $genre; $this->genre = $genre;
$this->yearFrom = $yearFrom; $this->yearFrom = $yearFrom;
$this->yearTo = $yearTo; $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); $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 private static function normalizeSource(string $source): string
{ {
$source = strtolower(trim($source)); $source = strtolower(trim($source));
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all'; return in_array($source, ['all', 'external', 'user'], true) ? $source : 'all';
} }
} }
+2 -3
View File
@@ -5,7 +5,7 @@ namespace App\Dto;
/** /**
* AlbumSearchResult is the value object returned by AlbumSearchService. * AlbumSearchResult is the value object returned by AlbumSearchService.
* It keeps the original criteria alongside the merged album payloads, * 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 final class AlbumSearchResult
{ {
@@ -21,9 +21,8 @@ final class AlbumSearchResult
public readonly array $albums, public readonly array $albums,
/** Per-album review aggregates keyed by album ID. */ /** Per-album review aggregates keyed by album ID. */
public readonly array $stats, 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 public readonly array $savedIds
) { ) {
} }
} }
+40 -25
View File
@@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; 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\Entity(repositoryClass: AlbumRepository::class)]
#[ORM\Table(name: 'albums')] #[ORM\Table(name: 'albums')]
@@ -27,17 +27,17 @@ class Album
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private ?int $id = null; 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)] #[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)] #[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $localId = null; private ?string $localId = null;
// 'spotify' or 'user' // 'external' or 'user'
#[ORM\Column(type: 'string', length: 16)] #[ORM\Column(type: 'string', length: 16)]
private string $source = 'spotify'; private string $source = 'external';
#[ORM\Column(type: 'string', length: 255)] #[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank] #[Assert\NotBlank]
@@ -55,7 +55,7 @@ class Album
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $genres = []; 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)] #[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null; 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 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 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 public function getExternalUrl(): ?string
{ {
@@ -335,9 +335,11 @@ class Album
public function toTemplateArray(): array public function toTemplateArray(): array
{ {
$images = []; $images = [];
$imageUrl = $this->coverUrl; $imageUrl = null;
if ($this->source === 'user' && $this->coverImagePath) { if ($this->source === 'user' && $this->coverImagePath) {
$imageUrl = $this->coverImagePath; $imageUrl = $this->coverImagePath;
} elseif ($this->source !== 'external') {
$imageUrl = $this->coverUrl;
} }
if ($imageUrl) { if ($imageUrl) {
$images = [ $images = [
@@ -347,11 +349,10 @@ class Album
} }
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists); $artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
$external = $this->externalUrl; $external = $this->externalUrl;
if ($external === null && $this->source === 'spotify' && $this->spotifyId) { $publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->externalId;
$external = 'https://open.spotify.com/album/' . $this->spotifyId; $genres = $this->source === 'external'
} ? $this->normalizeTopGenreForOutput($this->genres)
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId; : array_slice($this->genres, 0, 5);
$genres = array_slice($this->genres, 0, 5);
return [ return [
'id' => $publicId, 'id' => $publicId,
@@ -361,11 +362,27 @@ class Album
'genres' => $genres, 'genres' => $genres,
'release_date' => $this->releaseDate, 'release_date' => $this->releaseDate,
'total_tracks' => $this->totalTracks, 'total_tracks' => $this->totalTracks,
'external_urls' => [ 'spotify' => $external ], 'external_urls' => ['external' => $external],
'source' => $this->source, 'source' => $this->source,
]; ];
} }
/**
* @param list<string> $genres
* @return list<string>
*/
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<int, AlbumTrack> * @return Collection<int, AlbumTrack>
*/ */
@@ -389,5 +406,3 @@ class Album
} }
} }
} }
+6 -6
View File
@@ -6,7 +6,7 @@ use App\Repository\AlbumTrackRepository;
use Doctrine\ORM\Mapping as ORM; 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\Entity(repositoryClass: AlbumTrackRepository::class)]
#[ORM\Table(name: 'album_tracks')] #[ORM\Table(name: 'album_tracks')]
@@ -23,7 +23,7 @@ class AlbumTrack
private ?Album $album = null; private ?Album $album = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)] #[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $spotifyTrackId = null; private ?string $externalTrackId = null;
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private int $discNumber = 1; private int $discNumber = 1;
@@ -55,14 +55,14 @@ class AlbumTrack
$this->album = $album; $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 public function getDiscNumber(): int
+26 -1
View File
@@ -46,6 +46,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'string', length: 255, nullable: true)] #[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $profileImagePath = null; 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. * Returns the database identifier.
*/ */
@@ -164,6 +170,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{ {
$this->profileImagePath = $profileImagePath; $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);
}
} }
+4 -10
View File
@@ -9,24 +9,19 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; 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 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 public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('SPOTIFY_CLIENT_ID', TextType::class, [ ->add('LASTFM_API_KEY', TextType::class, [
'required' => false, 'required' => false,
'label' => 'Spotify Client ID', 'label' => 'Last.fm API key',
'mapped' => false,
])
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
'required' => false,
'label' => 'Spotify Client Secret',
'mapped' => false, 'mapped' => false,
]) ])
->add('REGISTRATION_ENABLED', CheckboxType::class, [ ->add('REGISTRATION_ENABLED', CheckboxType::class, [
@@ -45,4 +40,3 @@ class SiteSettingsType extends AbstractType
} }
} }
+56 -47
View File
@@ -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<string> $spotifyIds * @param list<string> $externalIds
* @return array<string,Album> * @return array<string,Album>
*/ */
public function findBySpotifyIdsKeyed(array $spotifyIds): array public function findByExternalIdsKeyed(array $externalIds): array
{ {
if ($spotifyIds === []) { if ($externalIds === []) {
return []; return [];
} }
$rows = $this->createQueryBuilder('a') $rows = $this->createQueryBuilder('a')
->where('a.spotifyId IN (:ids)') ->where('a.externalId IN (:ids)')
->setParameter('ids', $spotifyIds) ->setParameter('ids', $externalIds)
->getQuery() ->getQuery()
->getResult(); ->getResult();
$out = []; $out = [];
foreach ($rows as $row) { foreach ($rows as $row) {
if ($row instanceof Album && $row->getSpotifyId() !== null) { if ($row instanceof Album && $row->getExternalId() !== null) {
$out[$row->getSpotifyId()] = $row; $out[$row->getExternalId()] = $row;
} }
} }
return $out; 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<string> * @return list<string>
*/ */
public function findAllSpotifyIds(): array public function findAllExternalIds(): array
{ {
$rows = $this->createQueryBuilder('a') $rows = $this->createQueryBuilder('a')
->select('a.spotifyId') ->select('a.externalId')
->where('a.source = :src') ->where('a.source = :src')
->andWhere('a.spotifyId IS NOT NULL') ->andWhere('a.externalId IS NOT NULL')
->setParameter('src', 'spotify') ->setParameter('src', 'external')
->getQuery() ->getQuery()
->getScalarResult(); ->getScalarResult();
$ids = []; $ids = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$sid = (string) ($row['spotifyId'] ?? ''); $sid = (string) ($row['externalId'] ?? '');
if ($sid !== '') { if ($sid !== '') {
$ids[] = $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<Album> * @return list<Album>
*/ */
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') $qb = $this->createQueryBuilder('a')
->where('a.source = :src') ->where('a.source = :src')
->setParameter('src', 'spotify') ->setParameter('src', 'external')
->setMaxResults($limit * 2); ->setMaxResults($limit * 2);
if ($freeText !== null && $freeText !== '') { if ($freeText !== null && $freeText !== '') {
$qb->andWhere('LOWER(a.name) LIKE :qName') $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<string,mixed> $spotifyAlbum Raw Spotify album payload. * @param array<string,mixed> $externalAlbum Raw external album payload.
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups). * @param list<string> $resolvedGenres Optional, precomputed genres.
*/ */
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album public function upsertFromExternalAlbum(array $externalAlbum, array $resolvedGenres = []): Album
{ {
$spotifyId = (string) ($spotifyAlbum['id'] ?? ''); $externalId = (string) ($externalAlbum['id'] ?? '');
$name = (string) ($spotifyAlbum['name'] ?? ''); $name = (string) ($externalAlbum['name'] ?? '');
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? []))); $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($externalAlbum['artists'] ?? [])));
$rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($spotifyAlbum['genres'] ?? []); $rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($externalAlbum['genres'] ?? []);
$genres = array_values(array_filter(array_map(static fn($g) => trim((string) $g), $rawGenres), static fn($g) => $g !== '')); $genres = $this->normalizeTopGenre($rawGenres);
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null; $releaseDate = isset($externalAlbum['release_date']) ? (string) $externalAlbum['release_date'] : null;
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0); $totalTracks = (int) ($externalAlbum['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'];
}
$external = null; $external = null;
if (isset($spotifyAlbum['external_urls']['spotify'])) { if (isset($externalAlbum['external_urls']['external'])) {
$external = (string) $spotifyAlbum['external_urls']['spotify']; $external = (string) $externalAlbum['external_urls']['external'];
} }
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$album = $this->findOneBy(['spotifyId' => $spotifyId]) ?? new Album(); $album = $this->findOneBy(['externalId' => $externalId]) ?? new Album();
$album->setSource('spotify'); $album->setSource('external');
$album->setSpotifyId($spotifyId); $album->setExternalId($externalId);
$album->setName($name); $album->setName($name);
$album->setArtists($artists); $album->setArtists($artists);
$album->setGenres($genres); $album->setGenres($genres);
$album->setReleaseDate($releaseDate); $album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks); $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); $album->setExternalUrl($external);
$em->persist($album); $em->persist($album);
return $album; return $album;
@@ -266,6 +259,22 @@ class AlbumRepository extends ServiceEntityRepository
} }
return mb_strtolower($trimmed); return mb_strtolower($trimmed);
} }
/**
* @param array<int,mixed> $rawGenres
* @return list<string>
*/
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 [];
}
} }
+4 -4
View File
@@ -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<array<string,mixed>> $trackPayloads * @param list<array<string,mixed>> $trackPayloads
*/ */
@@ -47,10 +47,10 @@ class AlbumTrackRepository extends ServiceEntityRepository
$track = new AlbumTrack(); $track = new AlbumTrack();
$track->setAlbum($album); $track->setAlbum($album);
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null)); $track->setExternalTrackId($this->stringOrNull($payload['id'] ?? null));
$disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1); $disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1);
$trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position); $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); $trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber);
$track->setDiscNumber($disc); $track->setDiscNumber($disc);
$track->setTrackNumber($trackNumber); $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 private function stringOrNull(mixed $value): ?string
{ {
+4 -4
View File
@@ -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<string> $albumIds * @param list<string> $albumIds
* @return array<string,array{count:int,avg:float}> * @return array<string,array{count:int,avg:float}>
@@ -49,10 +49,10 @@ class ReviewRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('r') $qb = $this->createQueryBuilder('r')
->innerJoin('r.album', 'a') ->innerJoin('r.album', 'a')
->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating') ->select('a.externalId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('a.spotifyId IN (:ids)') ->where('a.externalId IN (:ids)')
->setParameter('ids', $albumIds) ->setParameter('ids', $albumIds)
->groupBy('a.spotifyId'); ->groupBy('a.externalId');
$rows = $qb->getQuery()->getArrayResult(); $rows = $qb->getQuery()->getArrayResult();
$out = []; $out = [];
+14 -1
View File
@@ -35,6 +35,20 @@ class UserRepository extends ServiceEntityRepository
->getOneOrNullResult(); ->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. * Returns every user with aggregated album/review counts.
* *
@@ -66,4 +80,3 @@ class UserRepository extends ServiceEntityRepository
} }
} }
+180 -138
View File
@@ -7,110 +7,90 @@ use App\Dto\AlbumSearchResult;
use App\Entity\Album; use App\Entity\Album;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; 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. * adds review stats, and hands the UI one uniform structure to render.
*/ */
class AlbumSearchService class AlbumSearchService
{ {
public function __construct( public function __construct(
private readonly SpotifyClient $spotifyClient, private readonly LastFmClient $lastFmClient,
private readonly AlbumRepository $albumRepository, private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository, private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, 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, * Runs the end-to-end search flow and returns review stats plus saved IDs.
* and returns review stats plus saved IDs so templates get everything in one package.
*/ */
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{ {
$spotifyQuery = $this->buildSpotifySearchQuery($criteria); $externalQuery = $this->buildExternalSearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery); $hasUserFilters = $this->hasUserFilters($criteria, $externalQuery);
// 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). $shouldQueryExternal = $criteria->shouldUseExternal()
$shouldQuerySpotify = $criteria->shouldUseSpotify() && ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external');
&& ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
$stats = []; $stats = [];
$savedIds = []; $savedIds = [];
$spotifyPayloads = []; $externalPayloads = [];
$userPayloads = []; $userPayloads = [];
if ($shouldQuerySpotify) { if ($shouldQueryExternal) {
// Try to reuse cached Spotify albums and only hit the API if we still need more. $externalData = $this->resolveExternalAlbums($criteria, $externalQuery);
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery); $externalPayloads = $externalData['payloads'];
$spotifyPayloads = $spotifyData['payloads']; $stats = $this->mergeStats($stats, $externalData['stats']);
$stats = $this->mergeStats($stats, $spotifyData['stats']); $savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
} }
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) { if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
// Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria); $userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads']; $userPayloads = $userData['payloads'];
$stats = $this->mergeStats($stats, $userData['stats']); $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); return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
} }
/** /**
* Indicates whether Spotify credentials are configured. * Indicates whether Last.fm credentials are configured.
*/ */
public function isSpotifyConfigured(): bool public function isExternalConfigured(): bool
{ {
return $this->spotifyClient->isConfigured(); return $this->lastFmClient->isConfigured();
} }
/** private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string
* Turns structured filters into Spotify's free-form query syntax.
*/
private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
{ {
$parts = []; $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 !== '') { if ($criteria->query !== '') {
$parts[] = $criteria->query; $parts[] = $criteria->query;
} }
return implode(' ', $parts); if ($criteria->albumName !== '') {
$parts[] = $criteria->albumName;
}
if ($criteria->artist !== '') {
$parts[] = $criteria->artist;
}
return trim(implode(' ', $parts));
} }
/** private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool
* Quick gate to tell if it's worth running the user catalog query.
*/
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
{ {
if ($criteria->source === 'user') { if ($criteria->source === 'user') {
return true; return true;
} }
return $spotifyQuery !== '' return $externalQuery !== ''
|| $criteria->albumName !== '' || $criteria->albumName !== ''
|| $criteria->artist !== '' || $criteria->artist !== ''
|| $criteria->genre !== '' || $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<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/ */
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->query,
$criteria->albumName, $criteria->albumName,
$criteria->artist, $criteria->artist,
@@ -134,100 +112,92 @@ class AlbumSearchService
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $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); $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) : []; $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit; $shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit;
if (!$shouldFetchFromExternal) {
if (!$shouldFetchFromSpotify) {
return [ return [
'payloads' => array_slice($storedPayloads, 0, $criteria->limit), 'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
'stats' => $stats, 'stats' => $stats,
'savedIds' => $savedIds, 'savedIds' => $storedIds,
]; ];
} }
// Mix cached payloads with just enough fresh API data to satisfy the limit. $apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery);
// This has the consequence of preferring cached data, but reduces API calls.
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre); $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); $filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
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<int,array<mixed>> $storedPayloads
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/ */
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); $result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit);
$searchItems = $result['albums']['items'] ?? []; $searchItems = (array) ($result['albums']['items'] ?? []);
$this->logger->info('Album search results received', [ $this->logger->info('Album search results received', [
'query' => $spotifyQuery, 'query' => $externalQuery,
'items' => is_countable($searchItems) ? count($searchItems) : 0, 'items' => count($searchItems),
]); ]);
if (!$searchItems) { if ($searchItems === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []]; return ['payloads' => [], 'stats' => [], 'savedIds' => []];
} }
$ids = $this->extractSpotifyIds($searchItems); $ids = $this->extractExternalIds($searchItems);
if ($ids === []) { if ($ids === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []]; return ['payloads' => [], 'stats' => [], 'savedIds' => []];
} }
$full = $this->spotifyClient->getAlbums($ids); $full = $this->lastFmClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : []; $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
if ($albumsPayload === [] && $searchItems !== []) { if ($albumsPayload === []) {
$albumsPayload = $searchItems; $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->tagResolver->resolveGenresForAlbums($albumsPayload);
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload);
$upserted = 0;
foreach ($albumsPayload as $payload) { foreach ($albumsPayload as $payload) {
// Persist each album so the next search can reuse it without another API call.
$albumId = (string) ($payload['id'] ?? ''); $albumId = (string) ($payload['id'] ?? '');
$this->albumRepository->upsertFromSpotifyAlbum( $this->albumRepository->upsertFromExternalAlbum(
(array) $payload, (array) $payload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] $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 = []; $payloads = [];
foreach ($ids as $sid) { foreach ($ids as $externalId) {
if (isset($existing[$sid])) { if (isset($existing[$externalId])) {
$payloads[] = $existing[$sid]->toTemplateArray(); $payloads[] = $existing[$externalId]->toTemplateArray();
} }
} }
$payloads = $this->hydrateExternalImages($payloads);
$stats = $this->reviewRepository->getAggregatesForAlbumIds($ids);
return [ return [
'payloads' => $payloads, 'payloads' => $payloads,
'stats' => $stats, 'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids),
'savedIds' => array_keys($existing), 'savedIds' => array_keys($existing),
]; ];
} }
/** /**
* Searches user-created albums and attaches review aggregates keyed to each payload.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/ */
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
@@ -241,23 +211,21 @@ class AlbumSearchService
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $criteria->limit
); );
if ($userAlbums === []) { if ($userAlbums === []) {
return ['payloads' => [], 'stats' => []]; return ['payloads' => [], 'stats' => []];
} }
$entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums)); $entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums));
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds); $userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
$payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums);
return [ return [
'payloads' => $payloads, 'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats), 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
]; ];
} }
/** /**
* Re-keys entity-based stats to the local IDs that templates actually use.
*
* @param list<Album> $userAlbums * @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats * @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}> * @return array<string,array{count:int,avg:float}>
@@ -269,56 +237,52 @@ class AlbumSearchService
$entityId = (int) $album->getId(); $entityId = (int) $album->getId();
$localId = (string) $album->getLocalId(); $localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) { 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]; $mapped[$localId] = $userStats[$entityId];
} }
} }
return $mapped; return $mapped;
} }
/** /**
* Chooses which payload list to return based on the requested source preference.
*
* @param array<int,array<mixed>> $userPayloads * @param array<int,array<mixed>> $userPayloads
* @param array<int,array<mixed>> $spotifyPayloads * @param array<int,array<mixed>> $externalPayloads
* @return array<int,array<mixed>> * @return array<int,array<mixed>>
*/ */
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') { if ($source === 'user') {
return array_slice($userPayloads, 0, $limit); return array_slice($userPayloads, 0, $limit);
} }
if ($source === 'spotify') { if ($source === 'external') {
return array_slice($spotifyPayloads, 0, $limit); 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<Album> $albums * @param list<Album> $albums
* @return list<string> * @return list<string>
*/ */
private function collectSpotifyIds(array $albums): array private function collectExternalIds(array $albums): array
{ {
$ids = []; $ids = [];
foreach ($albums as $album) { foreach ($albums as $album) {
$sid = (string) $album->getSpotifyId(); $externalId = (string) $album->getExternalId();
if ($sid !== '') { if ($externalId !== '') {
$ids[] = $sid; $ids[] = $externalId;
} }
} }
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
/** /**
* Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls.
*
* @param array<int,mixed> $searchItems * @param array<int,mixed> $searchItems
* @return list<string> * @return list<string>
*/ */
private function extractSpotifyIds(array $searchItems): array private function extractExternalIds(array $searchItems): array
{ {
$ids = []; $ids = [];
foreach ($searchItems as $item) { foreach ($searchItems as $item) {
@@ -327,12 +291,11 @@ class AlbumSearchService
$ids[] = $id; $ids[] = $id;
} }
} }
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
/** /**
* Overwrites stats with the newest aggregates from the update set.
*
* @param array<string,array{count:int,avg:float}> $current * @param array<string,array{count:int,avg:float}> $current
* @param array<string,array{count:int,avg:float}> $updates * @param array<string,array{count:int,avg:float}> $updates
* @return array<string,array{count:int,avg:float}> * @return array<string,array{count:int,avg:float}>
@@ -342,12 +305,11 @@ class AlbumSearchService
foreach ($updates as $key => $value) { foreach ($updates as $key => $value) {
$current[$key] = $value; $current[$key] = $value;
} }
return $current; return $current;
} }
/** /**
* Adds newly saved Spotify IDs while tossing blanks and duplicates.
*
* @param list<string> $current * @param list<string> $current
* @param list<string> $updates * @param list<string> $updates
* @return list<string> * @return list<string>
@@ -359,8 +321,6 @@ class AlbumSearchService
} }
/** /**
* Combines payload lists without duplicates, preferring primary items and honoring the limit.
*
* @param array<int,array<mixed>> $primary * @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary * @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>> * @return array<int,array<mixed>>
@@ -369,29 +329,32 @@ class AlbumSearchService
{ {
$seen = []; $seen = [];
$merged = []; $merged = [];
foreach ($primary as $payload) { foreach ($primary as $payload) {
$merged[] = $payload; $id = (string) ($payload['id'] ?? '');
if (isset($payload['id'])) { if ($id !== '') {
$seen[$payload['id']] = true; $seen[$id] = true;
} }
$merged[] = $payload;
if (count($merged) >= $limit) { if (count($merged) >= $limit) {
return array_slice($merged, 0, $limit); return array_slice($merged, 0, $limit);
} }
} }
foreach ($secondary as $payload) { foreach ($secondary as $payload) {
$id = $payload['id'] ?? null; $id = (string) ($payload['id'] ?? '');
if ($id !== null && isset($seen[$id])) { if ($id !== '' && isset($seen[$id])) {
continue; continue;
} }
// Secondary payloads often duplicate the primary list; skip anything we've already emitted.
$merged[] = $payload; $merged[] = $payload;
if ($id !== null) { if ($id !== '') {
$seen[$id] = true; $seen[$id] = true;
} }
if (count($merged) >= $limit) { if (count($merged) >= $limit) {
break; break;
} }
} }
return array_slice($merged, 0, $limit); return array_slice($merged, 0, $limit);
} }
@@ -401,12 +364,12 @@ class AlbumSearchService
*/ */
private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array
{ {
$genreNeedle = trim($genreNeedle); $needle = trim(mb_strtolower($genreNeedle));
if ($genreNeedle === '') { if ($needle === '') {
return $payloads; 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'] ?? []; $genres = $payload['genres'] ?? [];
if (!is_array($genres)) { if (!is_array($genres)) {
return false; return false;
@@ -419,5 +382,84 @@ class AlbumSearchService
return false; return false;
})); }));
} }
}
/**
* @param array<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
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<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
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;
}
}
+443
View File
@@ -0,0 +1,443 @@
<?php
namespace App\Service;
use GuzzleHttp\ClientInterface;
use App\Repository\SettingRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* LastFmClient wraps Last.fm API calls with caching and payload normalization.
*/
class LastFmClient
{
private const API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
private const IMAGE_SIZE_RANK = [
'mega' => 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<array<string,mixed>>}}
*/
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<string,mixed>|null
*/
public function getAlbumWithTracks(string $albumId): ?array
{
return $this->getAlbum($albumId);
}
/**
* Fetch a single album by normalized external identifier.
*
* @return array<string,mixed>|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<string> $albumIds
* @return array{albums:list<array<string,mixed>>}|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<array<string,mixed>>
*/
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<string,mixed> $query
* @return array<string,mixed>
*/
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<array<string,mixed>>
*/
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<string,mixed> $rawAlbum
* @return array<string,mixed>|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<string,mixed> $rawAlbum
* @return array<string,mixed>
*/
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<array<string,mixed>>
*/
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<array{url:string}>
*/
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');
}
}
@@ -8,62 +8,60 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; 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; private const BATCH_SIZE = 20;
public function __construct( public function __construct(
private readonly SpotifyClient $spotifyClient, private readonly LastFmClient $lastFmClient,
private readonly AlbumRepository $albumRepository, private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository, private readonly AlbumTrackRepository $trackRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, 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(); $externalIds = $this->albumRepository->findAllExternalIds();
if ($spotifyIds === []) { if ($externalIds === []) {
return 0; return 0;
} }
$updatedAlbumCount = 0; $updatedAlbumCount = 0;
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) { foreach (array_chunk($externalIds, self::BATCH_SIZE) as $albumIdChunk) {
$payload = $this->spotifyClient->getAlbums($albumIdChunk); $payload = $this->lastFmClient->getAlbums($albumIdChunk);
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : []; $albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
if ($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; continue;
} }
// Share the same genre resolution logic used during search so existing rows gain genre data too. $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albums);
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
foreach ($albums as $albumPayload) { foreach ($albums as $albumPayload) {
try { try {
$albumId = (string) ($albumPayload['id'] ?? ''); $albumId = (string) ($albumPayload['id'] ?? '');
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum( $albumEntity = $this->albumRepository->upsertFromExternalAlbum(
(array) $albumPayload, (array) $albumPayload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
); );
if ($albumId !== '' && $albumEntity !== null) { if ($albumId !== '' && $albumEntity !== null) {
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload); $tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
if ($tracks !== []) { if ($tracks !== []) {
// Replace tracks wholesale to simplify diffs (instead of diffing rows).
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks); $this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
$albumEntity->setTotalTracks(count($tracks)); $albumEntity->setTotalTracks(count($tracks));
} }
} }
$updatedAlbumCount++; $updatedAlbumCount++;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('Failed to upsert Spotify album', [ $this->logger->error('Failed to upsert external album', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'album' => $albumPayload['id'] ?? null, 'album' => $albumPayload['id'] ?? null,
]); ]);
@@ -85,7 +83,7 @@ class SpotifyMetadataRefresher
$total = (int) ($albumPayload['tracks']['total'] ?? 0); $total = (int) ($albumPayload['tracks']['total'] ?? 0);
if ($total > count($tracks)) { if ($total > count($tracks)) {
$fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId); $fullTrackPayloads = $this->lastFmClient->getAlbumTracks($albumId);
if ($fullTrackPayloads !== []) { if ($fullTrackPayloads !== []) {
return $fullTrackPayloads; return $fullTrackPayloads;
} }
@@ -94,5 +92,3 @@ class SpotifyMetadataRefresher
return $tracks; return $tracks;
} }
} }
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Service;
/**
* LastFmTagResolver enriches album payloads with normalized Last.fm tags.
*/
class LastFmTagResolver
{
public function __construct(private readonly LastFmClient $lastFmClient)
{
}
/**
* @param list<array<string,mixed>> $albums
* @return array<string,list<string>> 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<int,mixed> $values
* @return list<string>
*/
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');
}
}
-292
View File
@@ -1,292 +0,0 @@
<?php
namespace App\Service;
use App\Repository\SettingRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* SpotifyClient wraps API calls with caching and error handling.
*/
class SpotifyClient
{
private HttpClientInterface $httpClient;
private CacheInterface $cache;
private ?string $clientId;
private ?string $clientSecret;
private SettingRepository $settings;
/**
* Builds the client with HTTP, cache, and configuration dependencies.
*/
public function __construct(
HttpClientInterface $httpClient,
CacheInterface $cache,
?string $clientId,
?string $clientSecret,
SettingRepository $settings
) {
$this->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<mixed>
*/
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<mixed>|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<mixed>|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<array<string,mixed>>
*/
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<string> $albumIds
* @return array<mixed>|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<string> $artistIds
* @return array<mixed>|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<string,mixed> $options
* @return array<mixed>
*/
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<mixed>|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 !== '';
}
}
-111
View File
@@ -1,111 +0,0 @@
<?php
namespace App\Service;
/**
* SpotifyGenreResolver fills in album genres using the Spotify artists endpoint as a fallback.
*/
class SpotifyGenreResolver
{
public function __construct(private readonly SpotifyClient $spotifyClient)
{
}
/**
* @param list<array<string,mixed>> $albums
* @return array<string,list<string>> 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<string> $artistIds
* @return array<string,list<string>>
*/
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<int,mixed> $values
* @return list<string>
*/
private function normalizeGenres(array $values): array
{
return array_values(array_filter(array_map(
static fn($genre) => trim((string) $genre),
$values
), static fn($genre) => $genre !== ''));
}
}
+2 -7
View File
@@ -9,12 +9,8 @@
<div class="card-body"> <div class="card-body">
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div> <div>
{{ form_label(form.SPOTIFY_CLIENT_ID) }} {{ form_label(form.LASTFM_API_KEY) }}
{{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }} {{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }}
</div>
<div>
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
{% set registrationToggleAttrs = {class: 'form-check-input'} %} {% set registrationToggleAttrs = {class: 'form-check-input'} %}
@@ -92,4 +88,3 @@
</div> </div>
{% endblock %} {% endblock %}
+5 -6
View File
@@ -3,10 +3,10 @@
{% block body %} {% block body %}
<div class="d-flex flex-wrap align-items-center gap-3 mb-3"> <div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<h1 class="h4 mb-0">Site dashboard</h1> <h1 class="h4 mb-0">Site dashboard</h1>
<form method="post" action="{{ path('admin_dashboard_refresh_spotify') }}" class="ms-auto"> <form method="post" action="{{ path('admin_dashboard_refresh_external') }}" class="ms-auto">
<input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_spotify') }}"> <input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_external') }}">
<button class="btn btn-outline-success" type="submit"> <button class="btn btn-outline-success" type="submit">
Refresh Spotify metadata Refresh Last.fm metadata
</button> </button>
</form> </form>
</div> </div>
@@ -68,7 +68,7 @@
<h2 class="h6 mb-3">Latest albums (50)</h2> <h2 class="h6 mb-3">Latest albums (50)</h2>
<div class="vstack gap-2"> <div class="vstack gap-2">
{% for a in recentAlbums %} {% for a in recentAlbums %}
{% set publicId = a.source == 'user' ? a.localId : a.spotifyId %} {% set publicId = a.source == 'user' ? a.localId : a.externalId %}
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-2"> <div class="me-2">
<div> <div>
@@ -77,7 +77,7 @@
{% else %} {% else %}
<span>{{ a.name }}</span> <span>{{ a.name }}</span>
{% endif %} {% endif %}
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Spotify</span>{% endif %} {% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Last.fm</span>{% endif %}
</div> </div>
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div> <div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div>
</div> </div>
@@ -98,4 +98,3 @@
</div> </div>
{% endblock %} {% endblock %}
+8 -10
View File
@@ -28,11 +28,11 @@
{% endif %} {% endif %}
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1> <h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
{% if source_value != 'user' and spotifyConfigured is defined and not spotifyConfigured %} {% if source_value != 'user' and externalConfigured is defined and not externalConfigured %}
<div class="alert alert-info"> <div class="alert alert-info">
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') %} {% if is_granted('ROLE_ADMIN') %}
<a class="alert-link" href="{{ path('admin_settings') }}">Enter Spotify credentials</a>. <a class="alert-link" href="{{ path('admin_settings') }}">Enter Last.fm API key</a>.
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -51,7 +51,7 @@
<div class="col-sm-3 order-sm-4"> <div class="col-sm-3 order-sm-4">
<select class="form-select" name="source"> <select class="form-select" name="source">
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option> <option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option> <option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option> <option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
</select> </select>
</div> </div>
@@ -86,7 +86,7 @@
<div class="col-sm-3 order-sm-4"> <div class="col-sm-3 order-sm-4">
<select class="form-select" name="source"> <select class="form-select" name="source">
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option> <option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option> <option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option> <option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
</select> </select>
</div> </div>
@@ -115,7 +115,7 @@
{% for album in albums %} {% for album in albums %}
<div class="col"> <div class="col">
<div class="card h-100"> <div class="card h-100">
{% set image = (album.images[1] ?? album.images[0] ?? null) %} {% set image = (album.images[0] ?? album.images[1] ?? null) %}
{% if image %} {% if image %}
<a href="{{ path('album_show', {id: album.id}) }}"> <a href="{{ path('album_show', {id: album.id}) }}">
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" /> <img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
@@ -133,8 +133,8 @@
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p> <p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
<div class="mt-auto"> <div class="mt-auto">
{% if album.external_urls.spotify is defined and album.external_urls.spotify %} {% if album.external_urls.external is defined and album.external_urls.external %}
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a> <a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
{% endif %} {% endif %}
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a> <a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
{% if app.user and (album.source is not defined or album.source != 'user') %} {% if app.user and (album.source is not defined or album.source != 'user') %}
@@ -157,5 +157,3 @@
<p>No albums found.</p> <p>No albums found.</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+3 -5
View File
@@ -4,7 +4,7 @@
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100"> <div class="card h-100">
{% set image = (album.images[1] ?? album.images[0] ?? null) %} {% set image = (album.images[0] ?? album.images[1] ?? null) %}
{% if image %} {% if image %}
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" /> <img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
{% endif %} {% endif %}
@@ -28,8 +28,8 @@
<p class="text-secondary mb-2">Genres: {{ album.genres|join(', ') }}</p> <p class="text-secondary mb-2">Genres: {{ album.genres|join(', ') }}</p>
{% endif %} {% endif %}
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p> <p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
{% if album.external_urls.spotify %} {% if album.external_urls.external %}
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a> <a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
{% endif %} {% endif %}
{% if album.source is defined and album.source == 'user' %} {% if album.source is defined and album.source == 'user' %}
<span class="badge text-bg-primary ms-2">User album</span> <span class="badge text-bg-primary ms-2">User album</span>
@@ -153,5 +153,3 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -20,7 +20,7 @@
</div> </div>
<p class="text-secondary"> <p class="text-secondary">
{{ review.album.name }} — {{ review.album.artists|join(', ') }} {{ review.album.name }} — {{ review.album.artists|join(', ') }}
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId ?? review.album.localId}) }}">View album</a> <a class="ms-1" href="{{ path('album_show', {id: review.album.externalId ?? review.album.localId}) }}">View album</a>
</p> </p>
<article class="mb-3"> <article class="mb-3">
<p>{{ review.content|nl2br }}</p> <p>{{ review.content|nl2br }}</p>