Migration to last.fm, removed old spotify api client. FOSRestBundle API serving data driven front end (JWT).
This commit is contained in:
1 parent
391ecf1732
commit
56dea097d6
51 files changed
+1734
-829
No files matched your search
+3
-3
@@ -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
|
||||||
@@ -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
@@ -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');
|
|
||||||
|
|
||||||
@@ -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,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],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+177
-135
@@ -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));
|
||||||
* Quick gate to tell if it's worth running the user catalog query.
|
}
|
||||||
*/
|
|
||||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
|
private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool
|
||||||
{
|
{
|
||||||
if ($criteria->source === 'user') {
|
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->entityManager->flush();
|
||||||
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
|
||||||
|
|
||||||
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
|
$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,11 +364,11 @@ 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)) {
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-19
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 !== '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 !== ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user