Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75756ac6ec | ||
|
|
72a99a5bcd | ||
|
|
c1b1c2f372 | ||
|
|
56dea097d6 |
No files matched your search
+23
-3
@@ -1,12 +1,32 @@
|
|||||||
# Uncomment to override stored setting.
|
# Uncomment to override stored setting.
|
||||||
#SPOTIFY_CLIENT_ID=
|
#LASTFM_API_KEY=
|
||||||
#SPOTIFY_CLIENT_SECRET=
|
#MUSICBRAINZ_CLIENT_ID=
|
||||||
|
#MUSICBRAINZ_CLIENT_SECRET=
|
||||||
|
# TIDAL uses OAuth client-credentials; set client ID + secret and token is fetched automatically.
|
||||||
|
#TIDAL_CLIENT_ID=
|
||||||
|
#TIDAL_CLIENT_SECRET=
|
||||||
|
# Optional ISO-2 country code for TIDAL catalog availability (defaults to US).
|
||||||
|
#TIDAL_COUNTRY_CODE=US
|
||||||
|
# Optional: not required for album search.
|
||||||
|
# If needed for authenticated MusicBrainz features, obtain via OAuth from:
|
||||||
|
# https://musicbrainz.org/account/applications
|
||||||
|
#MUSICBRAINZ_ACCESS_TOKEN=
|
||||||
|
# Optional provider override. Leave unset to use the admin setting.
|
||||||
|
# Valid values: lastfm, musicbrainz, tidal.
|
||||||
|
# Safety: when the effective provider changes to a different valid source,
|
||||||
|
# catalog data (albums/tracks/reviews) is reset automatically to prevent duplicates.
|
||||||
|
#EXTERNAL_ALBUM_PROVIDER=lastfm
|
||||||
|
|
||||||
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.
|
||||||
|
# API benchmark toggle:
|
||||||
|
# 1 = keep API image hydration/fallback calls enabled.
|
||||||
|
# 0 = disable API-side image hydration for raw upstream timing.
|
||||||
|
API_IMAGE_HYDRATION_ENABLED=1
|
||||||
|
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
|
||||||
+26
@@ -79,3 +79,29 @@ composer.phar
|
|||||||
.env
|
.env
|
||||||
*.pdf
|
*.pdf
|
||||||
*.lock
|
*.lock
|
||||||
|
docs/internal
|
||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
/.env.local
|
||||||
|
/.env.local.php
|
||||||
|
/.env.*.local
|
||||||
|
.env.dev
|
||||||
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/public/bundles/
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> lexik/jwt-authentication-bundle ###
|
||||||
|
/config/jwt/*.pem
|
||||||
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
|
###> symfony/asset-mapper ###
|
||||||
|
/public/assets/
|
||||||
|
/assets/vendor/
|
||||||
|
###< symfony/asset-mapper ###
|
||||||
@@ -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,15 @@ 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
|
||||||
|
- Automatic OpenAPI documentation at `/api/docs` and `/api/v1/docs` (JSON at `/api/docs.json` and `/api/v1/docs.json`)
|
||||||
- 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 +59,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 +69,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 +79,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');
|
|
||||||
|
|
||||||
+6
-1
@@ -13,6 +13,10 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.18",
|
"doctrine/doctrine-bundle": "^2.18",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.5",
|
"doctrine/doctrine-migrations-bundle": "^3.5",
|
||||||
"doctrine/orm": "^3.5",
|
"doctrine/orm": "^3.5",
|
||||||
|
"friendsofsymfony/rest-bundle": "^3.8",
|
||||||
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"lexik/jwt-authentication-bundle": "^3.1",
|
||||||
|
"nelmio/api-doc-bundle": "^5.0",
|
||||||
"symfony/asset": "7.3.*",
|
"symfony/asset": "7.3.*",
|
||||||
"symfony/asset-mapper": "7.3.*",
|
"symfony/asset-mapper": "7.3.*",
|
||||||
"symfony/console": "7.3.*",
|
"symfony/console": "7.3.*",
|
||||||
@@ -36,7 +40,8 @@
|
|||||||
"symfony/yaml": "7.3.*",
|
"symfony/yaml": "7.3.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/string-extra": "^3.22",
|
"twig/string-extra": "^3.22",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0",
|
||||||
|
"zircote/swagger-php": "^4.10"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
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],
|
||||||
|
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => 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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
nelmio_api_doc:
|
||||||
|
documentation:
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Tonehaus API
|
||||||
|
description: REST API for albums, reviews, ingestion, and JWT authentication.
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
description: Current application host
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
html_config:
|
||||||
|
assets_mode: cdn
|
||||||
|
swagger_ui_config:
|
||||||
|
docExpansion: list
|
||||||
|
defaultModelsExpandDepth: 1
|
||||||
|
displayRequestDuration: true
|
||||||
|
filter: true
|
||||||
|
areas:
|
||||||
|
default:
|
||||||
|
path_patterns:
|
||||||
|
- ^/api(?!/docs|/v1/docs)
|
||||||
@@ -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,5 @@
|
|||||||
|
lexik_jwt_authentication:
|
||||||
|
secret_key: '%kernel.project_dir%/tests/Fixtures/jwt/private-test.pem'
|
||||||
|
public_key: '%kernel.project_dir%/tests/Fixtures/jwt/public-test.pem'
|
||||||
|
pass_phrase: 'test'
|
||||||
|
token_ttl: 3600
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
api_docs_ui:
|
||||||
|
path: /api/docs
|
||||||
|
methods: [GET]
|
||||||
|
defaults:
|
||||||
|
_controller: nelmio_api_doc.controller.swagger_ui
|
||||||
|
area: default
|
||||||
|
|
||||||
|
api_v1_docs_ui:
|
||||||
|
path: /api/v1/docs
|
||||||
|
methods: [GET]
|
||||||
|
defaults:
|
||||||
|
_controller: nelmio_api_doc.controller.swagger_ui
|
||||||
|
area: default
|
||||||
|
|
||||||
|
api_docs_json:
|
||||||
|
path: /api/docs.json
|
||||||
|
methods: [GET]
|
||||||
|
defaults:
|
||||||
|
_controller: nelmio_api_doc.controller.swagger
|
||||||
|
area: default
|
||||||
|
|
||||||
|
api_v1_docs_json:
|
||||||
|
path: /api/v1/docs.json
|
||||||
|
methods: [GET]
|
||||||
|
defaults:
|
||||||
|
_controller: nelmio_api_doc.controller.swagger
|
||||||
|
area: default
|
||||||
+36
-3
@@ -5,6 +5,7 @@
|
|||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
|
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
|
||||||
|
api_image_hydration_enabled: '%env(bool:API_IMAGE_HYDRATION_ENABLED)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@@ -20,10 +21,33 @@ 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: 4.0, connect_timeout: 1.5, http_errors: false }
|
||||||
$clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
|
|
||||||
|
App\Service\LastFmClient:
|
||||||
|
arguments:
|
||||||
|
$httpClient: '@GuzzleHttp\Client'
|
||||||
|
$apiKey: '%env(default::LASTFM_API_KEY)%'
|
||||||
|
|
||||||
|
App\Service\MusicBrainzClient:
|
||||||
|
arguments:
|
||||||
|
$httpClient: '@GuzzleHttp\Client'
|
||||||
|
$clientId: '%env(default::MUSICBRAINZ_CLIENT_ID)%'
|
||||||
|
$clientSecret: '%env(default::MUSICBRAINZ_CLIENT_SECRET)%'
|
||||||
|
$accessToken: '%env(default::MUSICBRAINZ_ACCESS_TOKEN)%'
|
||||||
|
|
||||||
|
App\Service\TidalClient:
|
||||||
|
arguments:
|
||||||
|
$httpClient: '@GuzzleHttp\Client'
|
||||||
|
$clientId: '%env(default::TIDAL_CLIENT_ID)%'
|
||||||
|
$clientSecret: '%env(default::TIDAL_CLIENT_SECRET)%'
|
||||||
|
$countryCode: '%env(default::TIDAL_COUNTRY_CODE)%'
|
||||||
|
|
||||||
|
App\Service\ExternalAlbumClient:
|
||||||
|
arguments: []
|
||||||
|
|
||||||
|
App\Service\ExternalAlbumClientInterface: '@App\Service\ExternalAlbumClient'
|
||||||
|
|
||||||
App\Service\UploadStorage:
|
App\Service\UploadStorage:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -33,3 +57,12 @@ 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%'
|
||||||
|
$apiImageHydrationEnabled: '%api_image_hydration_enabled%'
|
||||||
|
|
||||||
|
App\EventSubscriber\ApiExceptionSubscriber:
|
||||||
|
arguments:
|
||||||
|
$debug: '%kernel.debug%'
|
||||||
+9
-3
@@ -1,11 +1,17 @@
|
|||||||
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: ${APP_ENV:-prod}
|
||||||
container_name: tonehaus
|
container_name: tonehaus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/var/www/html/public/uploads
|
- ./public/uploads:/var/www/html/public/uploads
|
||||||
- sqlite_data:/var/www/html/var/data
|
- ./var/data:/var/www/html/var/data
|
||||||
ports:
|
ports:
|
||||||
- "8085:80"
|
- "8085:80"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+10
-1
@@ -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 \
|
||||||
@@ -20,7 +21,7 @@ RUN apk add --no-cache \
|
|||||||
libzip-dev \
|
libzip-dev \
|
||||||
oniguruma-dev \
|
oniguruma-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
postgresql-dev \
|
libpq-dev \
|
||||||
sqlite-dev \
|
sqlite-dev \
|
||||||
zlib-dev \
|
zlib-dev \
|
||||||
su-exec
|
su-exec
|
||||||
@@ -102,7 +103,11 @@ COPY composer.json composer.lock* symfony.lock* ./
|
|||||||
# Install vendors (cached)
|
# Install vendors (cached)
|
||||||
RUN --mount=type=cache,target=/tmp/composer \
|
RUN --mount=type=cache,target=/tmp/composer \
|
||||||
if [ -f composer.json ]; then \
|
if [ -f composer.json ]; then \
|
||||||
|
if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \
|
||||||
|
composer install --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||||
|
else \
|
||||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||||
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy the rest of the app
|
# Copy the rest of the app
|
||||||
@@ -111,7 +116,11 @@ COPY . /var/www/html
|
|||||||
# Finalize install & warm cache
|
# Finalize install & warm cache
|
||||||
RUN if [ -f bin/console ]; then \
|
RUN if [ -f bin/console ]; then \
|
||||||
set -ex; \
|
set -ex; \
|
||||||
|
if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \
|
||||||
|
composer dump-autoload --optimize; \
|
||||||
|
else \
|
||||||
composer dump-autoload --no-dev --optimize; \
|
composer dump-autoload --no-dev --optimize; \
|
||||||
|
fi; \
|
||||||
mkdir -p var var/data public/uploads; \
|
mkdir -p var var/data public/uploads; \
|
||||||
chown -R www-data:www-data var public/uploads; \
|
chown -R www-data:www-data var public/uploads; \
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
prepare_runtime_dirs() {
|
||||||
|
cache_dir="var/cache/${APP_ENV:-prod}/asset_mapper"
|
||||||
|
mkdir -p "${cache_dir}" var/log var/data public/uploads
|
||||||
|
|
||||||
|
chown -R www-data:www-data var public/uploads
|
||||||
|
chmod -R u+rwX,g+rwX var public/uploads
|
||||||
|
|
||||||
|
# Keep group ownership sticky so subsequent writes inherit www-data group.
|
||||||
|
find var public/uploads -type d -exec chmod g+s {} \;
|
||||||
|
}
|
||||||
|
|
||||||
require_app_secret() {
|
require_app_secret() {
|
||||||
if [ -z "${APP_SECRET:-}" ]; then
|
if [ -z "${APP_SECRET:-}" ]; then
|
||||||
echo "APP_SECRET environment variable is required at runtime" >&2
|
echo "APP_SECRET environment variable is required at runtime" >&2
|
||||||
@@ -8,23 +19,60 @@ 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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Installing Composer dependencies..."
|
echo "Installing Composer dependencies..."
|
||||||
su-exec www-data composer install \
|
composer_flags="--prefer-dist --no-interaction --no-progress"
|
||||||
--no-dev \
|
if [ "${APP_ENV:-prod}" = "prod" ]; then
|
||||||
--prefer-dist \
|
composer_flags="${composer_flags} --no-dev"
|
||||||
--no-interaction \
|
fi
|
||||||
--no-progress
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
su-exec www-data composer install ${composer_flags}
|
||||||
}
|
}
|
||||||
|
|
||||||
install_runtime
|
install_runtime
|
||||||
|
|
||||||
if [ -f bin/console ]; then
|
if [ -f bin/console ]; then
|
||||||
require_app_secret
|
require_app_secret
|
||||||
|
ensure_jwt_keys
|
||||||
|
prepare_runtime_dirs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
|
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
|
||||||
@@ -41,11 +89,14 @@ if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
|
|||||||
su-exec www-data php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
su-exec www-data php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -f bin/console ]; then
|
||||||
|
su-exec www-data php bin/console app:sync-external-provider --no-interaction
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f bin/console ]; then
|
if [ -f bin/console ]; then
|
||||||
su-exec www-data php bin/console cache:clear --no-warmup
|
su-exec www-data php bin/console cache:clear --no-warmup
|
||||||
su-exec www-data php bin/console cache:warmup
|
su-exec www-data php bin/console cache:warmup
|
||||||
chown -R www-data:www-data var
|
prepare_runtime_dirs
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,969 @@
|
|||||||
|
# Tonehaus Project Briefing
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Tonehaus is a Symfony 7 music album review application extended for the Assignment 2 RESTful API brief. It combines a server-rendered website with a versioned JSON API, JWT authentication, external music metadata providers, and generated API documentation. The project is designed so the web UI and API share the same domain model and service layer rather than existing as separate systems.
|
||||||
|
|
||||||
|
## Core Stack
|
||||||
|
|
||||||
|
- Symfony 7
|
||||||
|
- PHP with Doctrine ORM
|
||||||
|
- Twig templates
|
||||||
|
- SQLite by default, with Postgres support
|
||||||
|
- Guzzle for external API consumption
|
||||||
|
- LexikJWTAuthenticationBundle for JWT auth
|
||||||
|
- FOSRestBundle for API response handling
|
||||||
|
- NelmioApiDocBundle for OpenAPI documentation
|
||||||
|
- PHPUnit for functional and unit tests
|
||||||
|
|
||||||
|
## Main Domain Model
|
||||||
|
|
||||||
|
- `User`: authenticated account with `ROLE_USER`, `ROLE_MODERATOR`, or `ROLE_ADMIN`
|
||||||
|
- `Album`: normalized album record, either external or user-created
|
||||||
|
- `AlbumTrack`: persisted track list for each album
|
||||||
|
- `Review`: user-authored review with title, content, rating, and timestamps
|
||||||
|
- `Setting`: key-value storage for site configuration and provider credentials
|
||||||
|
|
||||||
|
## What The Application Does
|
||||||
|
|
||||||
|
### Web Application
|
||||||
|
|
||||||
|
- lets users search for albums
|
||||||
|
- shows album pages with cover art, metadata, tracklists, review averages, and reviews
|
||||||
|
- allows logged-in users to create reviews from the album page
|
||||||
|
- allows users to create, edit, and delete their own user-created albums
|
||||||
|
- provides profile, password, and settings pages
|
||||||
|
- gives moderators/admins dashboards for moderation and site administration
|
||||||
|
- includes admin settings for external provider credentials and registration control
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
The API is versioned under `/api/v1` and returns JSON.
|
||||||
|
|
||||||
|
Implemented endpoints:
|
||||||
|
|
||||||
|
- `GET /api/v1`
|
||||||
|
- `POST /api/v1/auth/login`
|
||||||
|
- `GET /api/v1/albums`
|
||||||
|
- `POST /api/v1/albums/ingest`
|
||||||
|
- `GET /api/v1/albums/{id}`
|
||||||
|
- `GET /api/v1/albums/{id}/reviews`
|
||||||
|
- `GET /api/v1/reviews`
|
||||||
|
- `GET /api/v1/reviews/{id}`
|
||||||
|
- `POST /api/v1/reviews`
|
||||||
|
- `PUT /api/v1/reviews/{id}`
|
||||||
|
- `PATCH /api/v1/reviews/{id}`
|
||||||
|
- `DELETE /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
Automatic OpenAPI documentation is available at:
|
||||||
|
|
||||||
|
- `/api/docs`
|
||||||
|
- `/api/v1/docs`
|
||||||
|
- `/api/docs.json`
|
||||||
|
- `/api/v1/docs.json`
|
||||||
|
|
||||||
|
## API Endpoint Reference
|
||||||
|
|
||||||
|
This section documents every implemented API endpoint briefly, with purpose, authentication, key inputs, and the main response shape.
|
||||||
|
|
||||||
|
Example values below are representative of the current implementation and use realistic sample IDs such as `tidal:251380836` and numeric review IDs.
|
||||||
|
|
||||||
|
### `GET /api/v1`
|
||||||
|
|
||||||
|
- purpose: API index and discovery endpoint
|
||||||
|
- auth: none
|
||||||
|
- returns: API name, version, endpoint groups, top-level `links`, and top-level `actions`
|
||||||
|
- HATEOAS: exposes `self`, `auth_login`, `albums`, `reviews`, `docs`, `docs_v1`, `schema`, and `schema_v1`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1",
|
||||||
|
"auth_login": "/api/v1/auth/login",
|
||||||
|
"albums": "/api/v1/albums",
|
||||||
|
"reviews": "/api/v1/reviews",
|
||||||
|
"docs": "/api/docs"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"login": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/auth/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
- purpose: authenticate a user and issue a JWT for protected routes
|
||||||
|
- auth: none
|
||||||
|
- request body: `email`, `password`
|
||||||
|
- success response: `200` with `{ "token": "..." }`
|
||||||
|
- error response: `401` for invalid credentials
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example error response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "invalid_credentials",
|
||||||
|
"message": "Invalid credentials.",
|
||||||
|
"status": 401
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/albums`
|
||||||
|
|
||||||
|
- purpose: search albums from the local catalog and the currently active external provider
|
||||||
|
- auth: none
|
||||||
|
- query parameters: `query`, `album`, `artist`, `genre`, `year_from`, `year_to`, `source`, `limit`, `mode`
|
||||||
|
- returns:
|
||||||
|
- `meta` with source, limit, query, mode, and search profile
|
||||||
|
- `data` as album resources
|
||||||
|
- `stats` for review aggregates
|
||||||
|
- `saved_ids` for already-ingested external albums
|
||||||
|
- collection-level `links` and `actions`
|
||||||
|
- HATEOAS:
|
||||||
|
- collection links include `self`, `root`, and `ingest`
|
||||||
|
- action metadata includes `ingest_album`
|
||||||
|
- each album resource is decorated with its own `links` and `actions`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"source": "all",
|
||||||
|
"limit": 5,
|
||||||
|
"query": "4:44",
|
||||||
|
"mode": "fast"
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"images": [
|
||||||
|
{ "url": "https://cdn.example.test/320.jpg" }
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
{ "name": "JAY-Z" }
|
||||||
|
],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"external_urls": {
|
||||||
|
"external": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"source": "external",
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||||
|
"open_source": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create_review": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/reviews",
|
||||||
|
"requires_auth": true,
|
||||||
|
"payload": {
|
||||||
|
"album_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ingest": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/albums/ingest",
|
||||||
|
"requires_auth": true,
|
||||||
|
"payload": {
|
||||||
|
"external_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"tidal:251380836": {
|
||||||
|
"count": 2,
|
||||||
|
"avg": 8.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saved_ids": ["tidal:251380836"],
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/albums?source=all&limit=5&mode=fast&query=4%3A44&artist=JAY-Z",
|
||||||
|
"root": "/api/v1",
|
||||||
|
"ingest": "/api/v1/albums/ingest"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"ingest_album": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/albums/ingest",
|
||||||
|
"requires_auth": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/v1/albums/ingest`
|
||||||
|
|
||||||
|
- purpose: persist an external album into the local database
|
||||||
|
- auth: JWT required
|
||||||
|
- request body:
|
||||||
|
- either `external_id`
|
||||||
|
- or `query`
|
||||||
|
- or `artist` and `album`
|
||||||
|
- success response: `201` with `data` for the ingested album and `tracks`
|
||||||
|
- main error responses:
|
||||||
|
- `400` invalid JSON or missing lookup input
|
||||||
|
- `401` missing/invalid JWT
|
||||||
|
- `404` external album not found
|
||||||
|
- `422` album missing required metadata or tracks
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/albums/ingest
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"external_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"artists": [
|
||||||
|
{ "name": "JAY-Z" }
|
||||||
|
],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"source": "external",
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track-1",
|
||||||
|
"disc": 1,
|
||||||
|
"track": 1,
|
||||||
|
"name": "Kill Jay Z",
|
||||||
|
"duration_ms": 205000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/albums/{id}`
|
||||||
|
|
||||||
|
- purpose: fetch one album by public identifier
|
||||||
|
- auth: none
|
||||||
|
- path parameter: `id` string public album ID such as a provider ID or user album ID
|
||||||
|
- returns:
|
||||||
|
- `data` for one album
|
||||||
|
- `tracks` list
|
||||||
|
- `stats` with review count and average rating
|
||||||
|
- behaviour:
|
||||||
|
- if the album is not yet local but is available from the external provider, it can be hydrated and persisted automatically
|
||||||
|
- HATEOAS on `data`:
|
||||||
|
- links include `self`, `reviews`, and `open_source` when available
|
||||||
|
- actions include `create_review`
|
||||||
|
- external albums also expose `ingest`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/albums/tidal%3A251380836
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"images": [
|
||||||
|
{ "url": "https://cdn.example.test/640.jpg" }
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
{ "name": "JAY-Z" }
|
||||||
|
],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"external_urls": {
|
||||||
|
"external": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"source": "external",
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||||
|
"open_source": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create_review": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/reviews",
|
||||||
|
"requires_auth": true,
|
||||||
|
"payload": {
|
||||||
|
"album_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ingest": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/albums/ingest",
|
||||||
|
"requires_auth": true,
|
||||||
|
"payload": {
|
||||||
|
"external_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track-1",
|
||||||
|
"disc": 1,
|
||||||
|
"track": 1,
|
||||||
|
"name": "Kill Jay Z",
|
||||||
|
"duration_ms": 205000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"count": 2,
|
||||||
|
"avg": 8.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/albums/{id}/reviews`
|
||||||
|
|
||||||
|
- purpose: list reviews for a specific album
|
||||||
|
- auth: none
|
||||||
|
- path parameter: `id` string public album ID
|
||||||
|
- returns:
|
||||||
|
- `data` as review resources scoped to that album
|
||||||
|
- collection `links`
|
||||||
|
- collection `actions`
|
||||||
|
- HATEOAS:
|
||||||
|
- collection links include `self`, `album`, and `reviews`
|
||||||
|
- collection actions include `create_review`
|
||||||
|
- each review item includes links back to its own item route and related album routes
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/albums/tidal%3A251380836/reviews
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9,
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"reviews": "/api/v1/reviews"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create_review": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/reviews",
|
||||||
|
"requires_auth": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/reviews`
|
||||||
|
|
||||||
|
- purpose: list the latest reviews
|
||||||
|
- auth: none
|
||||||
|
- query parameters: `limit` from `1` to `100`, default `50`
|
||||||
|
- returns:
|
||||||
|
- `data` as review resources
|
||||||
|
- collection `links`
|
||||||
|
- collection `actions`
|
||||||
|
- HATEOAS:
|
||||||
|
- collection links include `self`, `root`, and `create`
|
||||||
|
- collection actions include `create_review`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/reviews?limit=10
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews?limit=10",
|
||||||
|
"root": "/api/v1",
|
||||||
|
"create": "/api/v1/reviews"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create_review": {
|
||||||
|
"method": "POST",
|
||||||
|
"href": "/api/v1/reviews",
|
||||||
|
"requires_auth": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
- purpose: fetch one review by numeric ID
|
||||||
|
- auth: none
|
||||||
|
- path parameter: `id` integer review ID
|
||||||
|
- returns: one review resource in `data`
|
||||||
|
- HATEOAS on the review resource:
|
||||||
|
- links include `self`, `album`, and `album_reviews`
|
||||||
|
- if the current user is allowed to change the review, action metadata can include `update` and `delete`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/reviews/12
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/v1/reviews`
|
||||||
|
|
||||||
|
- purpose: create a new review for an existing album
|
||||||
|
- auth: JWT required
|
||||||
|
- request body:
|
||||||
|
- `album_id`
|
||||||
|
- `title`
|
||||||
|
- `content`
|
||||||
|
- `rating`
|
||||||
|
- validation rules:
|
||||||
|
- `title` required
|
||||||
|
- `content` required and minimum 20 characters
|
||||||
|
- `rating` must be between 1 and 10
|
||||||
|
- `album_id` must resolve to a valid album with tracks
|
||||||
|
- success response: `201` with the created review resource
|
||||||
|
- main error responses:
|
||||||
|
- `400` invalid JSON
|
||||||
|
- `401` authentication required
|
||||||
|
- `404` album not found
|
||||||
|
- `422` validation failure
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/reviews
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"album_id": "tidal:251380836",
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
- purpose: update an existing review
|
||||||
|
- auth: JWT required
|
||||||
|
- path parameter: `id` integer review ID
|
||||||
|
- accepted body fields: `title`, `content`, `rating`
|
||||||
|
- authorization: review author, moderator, or admin
|
||||||
|
- success response: `200` with updated review resource
|
||||||
|
- main error responses:
|
||||||
|
- `400` invalid JSON
|
||||||
|
- `401` missing/invalid JWT
|
||||||
|
- `403` forbidden
|
||||||
|
- `422` validation failure
|
||||||
|
- implementation note: `PUT` currently shares the same update handler as `PATCH`
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/v1/reviews/12
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "Revisited after a week",
|
||||||
|
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||||
|
"rating": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 12,
|
||||||
|
"title": "Revisited after a week",
|
||||||
|
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||||
|
"rating": 8,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T12:05:44+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PATCH /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
- purpose: partially update an existing review
|
||||||
|
- auth: JWT required
|
||||||
|
- path parameter: `id` integer review ID
|
||||||
|
- accepted body fields: `title`, `content`, `rating`
|
||||||
|
- authorization: review author, moderator, or admin
|
||||||
|
- success response: `200` with updated review resource
|
||||||
|
- main error responses:
|
||||||
|
- `400` invalid JSON
|
||||||
|
- `401` missing/invalid JWT
|
||||||
|
- `403` forbidden
|
||||||
|
- `422` validation failure
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
PATCH /api/v1/reviews/12
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"rating": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 10,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T12:10:03+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"self": "/api/v1/reviews/12",
|
||||||
|
"album": "/api/v1/albums/tidal%3A251380836",
|
||||||
|
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
- purpose: delete a review
|
||||||
|
- auth: JWT required
|
||||||
|
- path parameter: `id` integer review ID
|
||||||
|
- authorization: review author, moderator, or admin
|
||||||
|
- success response: `204 No Content`
|
||||||
|
- main error responses:
|
||||||
|
- `401` missing/invalid JWT
|
||||||
|
- `403` forbidden
|
||||||
|
- `404` not found
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/reviews/12
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```http
|
||||||
|
204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
|
||||||
|
The project follows standard Symfony separation of concerns.
|
||||||
|
|
||||||
|
- controllers handle HTTP orchestration only
|
||||||
|
- services contain business logic and external API integration
|
||||||
|
- repositories handle domain-specific queries
|
||||||
|
- entities define persisted state
|
||||||
|
- forms handle server-side validation for web submissions
|
||||||
|
- security config and voters enforce route access and ownership rules
|
||||||
|
- event subscribers normalize API errors into JSON responses
|
||||||
|
|
||||||
|
This matters for the brief because the API is not implemented as one large controller. It is structured in a maintainable way using framework conventions and reusable services.
|
||||||
|
|
||||||
|
## API Implementation Summary
|
||||||
|
|
||||||
|
The API implementation is centered around resource-based routes rather than action-style routes. Review endpoints expose collection and item resources, while album endpoints expose search, detail, ingestion, and album-specific review resources.
|
||||||
|
|
||||||
|
Key implementation points:
|
||||||
|
|
||||||
|
- uses descriptive REST-style URIs such as `/api/v1/reviews` and `/api/v1/albums/{id}/reviews`
|
||||||
|
- uses correct HTTP verbs for retrieval, creation, update, and deletion
|
||||||
|
- returns JSON for both success and failure cases
|
||||||
|
- includes HATEOAS-style hypermedia in API responses through `links` and `actions`
|
||||||
|
- uses JWT authentication for protected write operations
|
||||||
|
- excludes sensitive data such as user email and password hashes from API responses
|
||||||
|
- uses structured status codes including `200`, `201`, `204`, `401`, `403`, `404`, and `422`
|
||||||
|
- provides generated OpenAPI documentation through Nelmio
|
||||||
|
|
||||||
|
The review API supports full CRUD behaviour for third-party clients. Public reads are available without authentication, while create, update, and delete require authenticated users. Authorization is more specific than simple login checks because review ownership is enforced with a `ReviewVoter`, allowing moderators and admins to manage any review while normal users can only manage their own.
|
||||||
|
|
||||||
|
The API also includes a useful sub-resource:
|
||||||
|
|
||||||
|
- `GET /api/v1/albums/{id}/reviews`
|
||||||
|
|
||||||
|
This improves the resource model because reviews can be queried globally or in the context of a specific album.
|
||||||
|
|
||||||
|
The API also now includes a lightweight HATEOAS layer. The base API index exposes navigational links such as `self`, `albums`, `reviews`, and `docs`, plus action metadata for login. Album and review resources expose their own `links` and, where relevant, `actions`, for example:
|
||||||
|
|
||||||
|
- album resources link to `self`, `reviews`, and `open_source`
|
||||||
|
- album resources expose actions such as `create_review` and `ingest`
|
||||||
|
- review resources link back to their own item route and related album routes
|
||||||
|
- review collections expose a `create_review` action for discoverability
|
||||||
|
|
||||||
|
This improves the API from a client perspective because consumers do not need to hard-code every related route manually. Instead, the response itself advertises relevant next operations.
|
||||||
|
|
||||||
|
## API Consumption Summary
|
||||||
|
|
||||||
|
The application consumes external music APIs through Guzzle and a provider abstraction rather than binding the UI directly to one upstream service.
|
||||||
|
|
||||||
|
Implemented external providers:
|
||||||
|
|
||||||
|
- Last.fm
|
||||||
|
- MusicBrainz
|
||||||
|
- TIDAL
|
||||||
|
|
||||||
|
Provider logic is normalized behind `ExternalAlbumClientInterface`, with concrete clients for each provider. This means the rest of the application receives one consistent album payload structure regardless of the source.
|
||||||
|
|
||||||
|
The consumed API data is used to:
|
||||||
|
|
||||||
|
- search external album catalogs
|
||||||
|
- populate album metadata
|
||||||
|
- fetch artists, genres, release dates, and track lists
|
||||||
|
- fetch cover art
|
||||||
|
- ingest external albums into the local database
|
||||||
|
- enrich album detail pages and search results in the UI
|
||||||
|
|
||||||
|
This is deep integration rather than a token demo call. External data directly affects the search experience, album display, save/ingest flow, and review context.
|
||||||
|
|
||||||
|
## External Integration Details
|
||||||
|
|
||||||
|
### Last.fm
|
||||||
|
|
||||||
|
Used for album search and metadata enrichment. Responses are cached and normalized into the project’s album format.
|
||||||
|
|
||||||
|
### MusicBrainz
|
||||||
|
|
||||||
|
Used as an alternative provider and for structured release metadata. The project supports optional authenticated requests and cover-art URL generation via normalized output.
|
||||||
|
|
||||||
|
### TIDAL
|
||||||
|
|
||||||
|
Used as another full external provider. The `TidalClient` implements OAuth 2.0 client credentials, caches access tokens, retries on expired auth, and converts JSON:API responses into the same normalized album contract used elsewhere.
|
||||||
|
|
||||||
|
This strengthens the project against the brief because it goes beyond a single trivial API call and includes a second non-trivial external integration path.
|
||||||
|
|
||||||
|
## Security Summary
|
||||||
|
|
||||||
|
Security is handled at multiple layers:
|
||||||
|
|
||||||
|
- JWT authentication for protected API routes
|
||||||
|
- Symfony access control rules for route-level protection
|
||||||
|
- role hierarchy: admin > moderator > user
|
||||||
|
- `ReviewVoter` for ownership-based edit/delete permissions
|
||||||
|
- normalized JSON authentication failures for API clients
|
||||||
|
- exclusion of sensitive user fields from serialized API responses
|
||||||
|
|
||||||
|
This is stronger than basic coursework authentication because it combines framework security with resource-level authorization.
|
||||||
|
|
||||||
|
## Error Handling and Robustness
|
||||||
|
|
||||||
|
API error handling is centralized so clients receive JSON errors rather than HTML exception pages.
|
||||||
|
|
||||||
|
The project includes:
|
||||||
|
|
||||||
|
- a shared API response helper
|
||||||
|
- exception subscribers that normalize failures
|
||||||
|
- JWT failure subscribers for missing, invalid, or expired tokens
|
||||||
|
- validation logic for malformed JSON and invalid field values
|
||||||
|
- server-side handling for missing albums, forbidden edits, and unsupported methods
|
||||||
|
|
||||||
|
This improves client usability and supports a more production-like API design.
|
||||||
|
|
||||||
|
## Testing Summary
|
||||||
|
|
||||||
|
The project includes both functional and unit tests.
|
||||||
|
|
||||||
|
Functional API tests cover:
|
||||||
|
|
||||||
|
- missing JWT
|
||||||
|
- invalid JWT
|
||||||
|
- malformed JSON
|
||||||
|
- validation failures
|
||||||
|
- forbidden review updates
|
||||||
|
- missing album resources
|
||||||
|
- protection of sensitive author data
|
||||||
|
- hypermedia links and action metadata on API index, album, and album-review responses
|
||||||
|
|
||||||
|
Unit tests cover provider-specific service behaviour, including TIDAL payload normalization and provider routing.
|
||||||
|
|
||||||
|
This supports the brief’s emphasis on working, demonstrable features rather than partially implemented code.
|
||||||
|
|
||||||
|
## How The Project Meets The Brief
|
||||||
|
|
||||||
|
### API Implementation
|
||||||
|
|
||||||
|
The brief requires a usable RESTful API capable of listing, reading, creating, editing, and deleting album reviews in JSON. This project meets that with:
|
||||||
|
|
||||||
|
- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` review endpoints
|
||||||
|
- JWT-protected write routes
|
||||||
|
- appropriate resource names
|
||||||
|
- JSON request and response formats
|
||||||
|
- HATEOAS-style `links` and `actions` to improve discoverability
|
||||||
|
- status-code-based error handling
|
||||||
|
- sub-resource support through album review listing
|
||||||
|
- automatically generated OpenAPI documentation
|
||||||
|
|
||||||
|
### API Consumption
|
||||||
|
|
||||||
|
The brief requires at least two significantly different examples of API interaction and integration into the review website. This project meets that with:
|
||||||
|
|
||||||
|
- external album search
|
||||||
|
- album metadata retrieval
|
||||||
|
- track list retrieval
|
||||||
|
- cover art integration
|
||||||
|
- album ingestion into local storage
|
||||||
|
- multiple provider implementations
|
||||||
|
|
||||||
|
The consumed data is visible in the website itself, especially on search results and album detail pages.
|
||||||
|
|
||||||
|
### Documentation and Report Potential
|
||||||
|
|
||||||
|
The project already has strong source material for the report because it includes:
|
||||||
|
|
||||||
|
- generated OpenAPI docs
|
||||||
|
- explicit controller routes and OpenAPI attributes
|
||||||
|
- centralized security configuration
|
||||||
|
- reusable external service classes
|
||||||
|
- tests that demonstrate expected API behaviour
|
||||||
|
|
||||||
|
## Honest Technical Caveats
|
||||||
|
|
||||||
|
- `PUT` and `PATCH` currently share the same update action, so `PUT` behaves more like a flexible update than a strict full replacement
|
||||||
|
- local verification in this shell was limited because `php` is not installed here, so PHPUnit was not run live during this session
|
||||||
|
- some observability headers in album search are still named with `Lastfm` even though the provider layer now supports multiple providers
|
||||||
|
|
||||||
|
These are not major blockers, but they are worth understanding if you want the final report to sound precise and credible.
|
||||||
|
|
||||||
|
## Strongest Points To Emphasize In A Final Report
|
||||||
|
|
||||||
|
- the API is versioned and resource-oriented
|
||||||
|
- the API now includes lightweight HATEOAS support through response `links` and `actions`
|
||||||
|
- write operations are protected with JWT rather than session auth
|
||||||
|
- review ownership is enforced through a voter, not just a login check
|
||||||
|
- external APIs are abstracted behind a provider interface
|
||||||
|
- external data is deeply integrated into the UI and persisted catalog
|
||||||
|
- OpenAPI documentation is generated automatically
|
||||||
|
- the project includes automated tests for important API behaviour
|
||||||
|
|
||||||
|
## Short Project Summary
|
||||||
|
|
||||||
|
Tonehaus is a Symfony-based music review platform extended into a RESTful web application. It implements a versioned JSON API for review CRUD and album access, secures write routes with JWT, consumes multiple external music providers through Guzzle and a normalized service layer, and integrates that data directly into album discovery, metadata display, tracklists, and review workflows. The architecture is modular, test-backed, and aligned with the main goals of the Assignment 2 brief.
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
# Assignment 2 Report: Tonehaus REST API
|
||||||
|
|
||||||
|
## Part 1: API Documentation
|
||||||
|
|
||||||
|
### 1. API Overview
|
||||||
|
|
||||||
|
Tonehaus extends the album review website with a versioned JSON API under `/api/v1`. The API supports two main concerns:
|
||||||
|
|
||||||
|
- discovery and ingestion of album data
|
||||||
|
- creation and management of user reviews
|
||||||
|
|
||||||
|
The implementation is resource-oriented. Public read endpoints are available without authentication, while write operations require JWT authentication. The API is documented manually in this report and automatically through Nelmio OpenAPI documentation at:
|
||||||
|
|
||||||
|
- `/api/docs`
|
||||||
|
- `/api/v1/docs`
|
||||||
|
- `/api/docs.json`
|
||||||
|
- `/api/v1/docs.json`
|
||||||
|
|
||||||
|
### 2. Authentication
|
||||||
|
|
||||||
|
Protected endpoints use bearer-token authentication with JSON Web Tokens (JWT). Clients obtain a token by sending credentials to:
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
The token must then be included on protected requests:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Protected routes:
|
||||||
|
|
||||||
|
- `POST /api/v1/albums/ingest`
|
||||||
|
- `POST /api/v1/reviews`
|
||||||
|
- `PUT /api/v1/reviews/{id}`
|
||||||
|
- `PATCH /api/v1/reviews/{id}`
|
||||||
|
- `DELETE /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
### 3. Common Response Format
|
||||||
|
|
||||||
|
#### Success
|
||||||
|
|
||||||
|
Success payloads are JSON objects. Most collection/detail routes return a top-level `data` key. Some album endpoints also return `meta`, `tracks`, `stats`, or `saved_ids`.
|
||||||
|
|
||||||
|
#### Error
|
||||||
|
|
||||||
|
API failures are normalized into one JSON error envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Review payload failed validation.",
|
||||||
|
"status": 422,
|
||||||
|
"details": {
|
||||||
|
"fields": {
|
||||||
|
"content": "Content must be at least 20 characters."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common error codes include:
|
||||||
|
|
||||||
|
- `authentication_required`
|
||||||
|
- `invalid_credentials`
|
||||||
|
- `invalid_token`
|
||||||
|
- `token_expired`
|
||||||
|
- `validation_error`
|
||||||
|
- `album_not_found`
|
||||||
|
- `external_album_not_found`
|
||||||
|
- `forbidden`
|
||||||
|
- `not_found`
|
||||||
|
- `method_not_allowed`
|
||||||
|
- `internal_server_error`
|
||||||
|
|
||||||
|
### 4. Resource Models
|
||||||
|
|
||||||
|
#### Review Resource
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen...",
|
||||||
|
"rating": 9,
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"source": "external"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Album Resource
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"images": [
|
||||||
|
{ "url": "https://cdn.example.test/640.jpg" }
|
||||||
|
],
|
||||||
|
"artists": [
|
||||||
|
{ "name": "JAY-Z" }
|
||||||
|
],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"external_urls": {
|
||||||
|
"external": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"source": "external"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Track Resource
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "track-1",
|
||||||
|
"disc_number": 1,
|
||||||
|
"track_number": 1,
|
||||||
|
"name": "Kill Jay Z",
|
||||||
|
"duration_ms": 205000,
|
||||||
|
"preview_url": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Album identifiers are public string IDs rather than numeric database IDs. Depending on provider and source, examples include:
|
||||||
|
|
||||||
|
- `tidal:251380836`
|
||||||
|
- `mbid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||||
|
- `u_ab12cd34ef56` for user-created albums
|
||||||
|
|
||||||
|
### 5. Endpoint Reference
|
||||||
|
|
||||||
|
### `GET /api/v1`
|
||||||
|
|
||||||
|
Purpose: API index and discovery endpoint.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
Purpose: authenticate a user and issue a JWT.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK` token issued
|
||||||
|
- `401 Unauthorized` invalid credentials
|
||||||
|
|
||||||
|
Success example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/albums`
|
||||||
|
|
||||||
|
Purpose: search albums from the local catalog and the currently configured external provider.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
- `query` free-text search
|
||||||
|
- `album` album title filter
|
||||||
|
- `artist` artist filter
|
||||||
|
- `genre` genre filter
|
||||||
|
- `year_from` inclusive lower year bound
|
||||||
|
- `year_to` inclusive upper year bound
|
||||||
|
- `source` `all`, `external`, or `user`
|
||||||
|
- `limit` result limit, capped server-side
|
||||||
|
- `mode` `full` or `fast`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
|
||||||
|
Success example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"source": "all",
|
||||||
|
"limit": 5,
|
||||||
|
"query": "4:44",
|
||||||
|
"mode": "fast",
|
||||||
|
"profile": {
|
||||||
|
"external_provider": "tidal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"artists": [{ "name": "JAY-Z" }],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"images": [{ "url": "https://cdn.example.test/320.jpg" }],
|
||||||
|
"external_urls": {
|
||||||
|
"external": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"source": "external"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"tidal:251380836": {
|
||||||
|
"count": 2,
|
||||||
|
"avg": 8.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saved_ids": ["tidal:251380836"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/v1/albums/ingest`
|
||||||
|
|
||||||
|
Purpose: persist an external album into local storage.
|
||||||
|
|
||||||
|
Auth: JWT required
|
||||||
|
|
||||||
|
Request body rules:
|
||||||
|
|
||||||
|
- either `external_id` must be supplied
|
||||||
|
- or `query`
|
||||||
|
- or a combination of `artist` and `album`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"external_id": "tidal:251380836"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `201 Created`
|
||||||
|
- `400 Bad Request` invalid JSON or missing identifier/query
|
||||||
|
- `401 Unauthorized`
|
||||||
|
- `404 Not Found` external album not found
|
||||||
|
- `422 Unprocessable Entity` album missing metadata or tracks
|
||||||
|
|
||||||
|
Success response includes:
|
||||||
|
|
||||||
|
- `data` album payload
|
||||||
|
- `tracks` normalized track list
|
||||||
|
|
||||||
|
### `GET /api/v1/albums/{id}`
|
||||||
|
|
||||||
|
Purpose: return one album by public ID.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Path parameter:
|
||||||
|
|
||||||
|
- `id` public album identifier string
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- `404 Not Found`
|
||||||
|
- `422 Unprocessable Entity` album payload exists upstream but is not ingestable
|
||||||
|
|
||||||
|
Success example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "tidal:251380836",
|
||||||
|
"name": "4:44",
|
||||||
|
"artists": [{ "name": "JAY-Z" }],
|
||||||
|
"genres": ["Hip Hop"],
|
||||||
|
"release_date": "2017-06-30",
|
||||||
|
"total_tracks": 10,
|
||||||
|
"images": [{ "url": "https://cdn.example.test/640.jpg" }],
|
||||||
|
"external_urls": {
|
||||||
|
"external": "https://tidal.com/browse/album/251380836"
|
||||||
|
},
|
||||||
|
"source": "external"
|
||||||
|
},
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track-1",
|
||||||
|
"disc_number": 1,
|
||||||
|
"track_number": 1,
|
||||||
|
"name": "Kill Jay Z",
|
||||||
|
"duration_ms": 205000,
|
||||||
|
"preview_url": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"count": 2,
|
||||||
|
"avg": 8.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/albums/{id}/reviews`
|
||||||
|
|
||||||
|
Purpose: list reviews for one album.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- `404 Not Found`
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen...",
|
||||||
|
"rating": 9,
|
||||||
|
"author": {
|
||||||
|
"id": 4,
|
||||||
|
"display_name": "Jake Andrews"
|
||||||
|
},
|
||||||
|
"created_at": "2026-04-02T11:40:21+00:00",
|
||||||
|
"updated_at": "2026-04-02T11:40:21+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/reviews`
|
||||||
|
|
||||||
|
Purpose: list recent reviews.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
- `limit` integer, clamped to `1-100`, default `50`
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/reviews?limit=10
|
||||||
|
Accept: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
Purpose: fetch one review by numeric ID.
|
||||||
|
|
||||||
|
Auth: None
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- `404 Not Found`
|
||||||
|
|
||||||
|
### `POST /api/v1/reviews`
|
||||||
|
|
||||||
|
Purpose: create a review for an existing album.
|
||||||
|
|
||||||
|
Auth: JWT required
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"album_id": "tidal:251380836",
|
||||||
|
"title": "A dense and rewarding record",
|
||||||
|
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||||
|
"rating": 9
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- `album_id` must resolve to an existing album with a track list
|
||||||
|
- `title` is required
|
||||||
|
- `content` is required and must be at least 20 characters
|
||||||
|
- `rating` must be between 1 and 10
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `201 Created`
|
||||||
|
- `400 Bad Request` invalid JSON
|
||||||
|
- `401 Unauthorized`
|
||||||
|
- `404 Not Found` album not found
|
||||||
|
- `422 Unprocessable Entity` validation failure
|
||||||
|
|
||||||
|
### `PUT /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
Purpose: update an existing review.
|
||||||
|
|
||||||
|
Auth: JWT required
|
||||||
|
|
||||||
|
Current implementation note: `PUT` and `PATCH` are handled by the same update action. In practice, the API currently accepts partial payloads on both methods rather than enforcing full replacement semantics for `PUT`.
|
||||||
|
|
||||||
|
Accepted fields:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `content`
|
||||||
|
- `rating`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- at least one updatable field is required
|
||||||
|
- `title` cannot be empty
|
||||||
|
- `content` cannot be empty and must be at least 20 characters
|
||||||
|
- `rating` must be between 1 and 10
|
||||||
|
- only the review author, moderator, or admin may modify the review
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- `400 Bad Request`
|
||||||
|
- `401 Unauthorized`
|
||||||
|
- `403 Forbidden`
|
||||||
|
- `404 Not Found`
|
||||||
|
- `422 Unprocessable Entity`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Revisited after a few weeks",
|
||||||
|
"content": "The sequencing is much better than I first thought, and the writing is sharper too.",
|
||||||
|
"rating": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PATCH /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
Purpose: partially update an existing review.
|
||||||
|
|
||||||
|
Auth: JWT required
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rating": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- `400 Bad Request`
|
||||||
|
- `401 Unauthorized`
|
||||||
|
- `403 Forbidden`
|
||||||
|
- `404 Not Found`
|
||||||
|
- `422 Unprocessable Entity`
|
||||||
|
|
||||||
|
### `DELETE /api/v1/reviews/{id}`
|
||||||
|
|
||||||
|
Purpose: delete a review.
|
||||||
|
|
||||||
|
Auth: JWT required
|
||||||
|
|
||||||
|
Authorization:
|
||||||
|
|
||||||
|
- author of the review
|
||||||
|
- moderator
|
||||||
|
- admin
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `204 No Content`
|
||||||
|
- `401 Unauthorized`
|
||||||
|
- `403 Forbidden`
|
||||||
|
- `404 Not Found`
|
||||||
|
|
||||||
|
### 6. Automatic Documentation and Testing Support
|
||||||
|
|
||||||
|
The API is additionally exposed through generated OpenAPI documentation with Nelmio. This gives the project a professional, interactive documentation layer on top of the written report. The application also includes automated tests for:
|
||||||
|
|
||||||
|
- JWT protection
|
||||||
|
- normalized JSON error envelopes
|
||||||
|
- validation failures
|
||||||
|
- forbidden review updates
|
||||||
|
- protection of sensitive user data in API responses
|
||||||
|
- provider-specific payload normalization, including TIDAL
|
||||||
|
|
||||||
|
## Part 2: Code Report
|
||||||
|
|
||||||
|
### Introduction
|
||||||
|
|
||||||
|
The Tonehaus application was originally a server-rendered album review website, but for Assignment 2 it has been extended into a REST-oriented application with both API implementation and API consumption. The final system does not simply expose review data as JSON. It also uses external music services to build and enrich the album catalog that reviews depend on. As a result, the API and the web UI now share the same underlying album model, review model, and security rules.
|
||||||
|
|
||||||
|
### API Implementation
|
||||||
|
|
||||||
|
The implemented API is versioned under `/api/v1`, which is preferable to exposing unversioned endpoints because it leaves room for future iterations without breaking existing clients. The main resources are `reviews` and `albums`. Review routes cover list, read, create, update, and delete operations. Album routes cover search, detail retrieval, album-specific review retrieval, and explicit ingestion of external albums into local storage.
|
||||||
|
|
||||||
|
The review API is centered on `ReviewApiController`. Read operations are public, while write operations require `ROLE_USER`. Responses are serialized into a stable JSON shape that includes the review itself plus minimal nested album and author information. Sensitive fields such as email addresses, passwords, and role data are intentionally excluded. This is important because the assignment criteria explicitly reward avoiding unnecessary or sensitive output.
|
||||||
|
|
||||||
|
Shared response behaviour is centralized in `ApiController`, which provides helper methods for successful responses and consistent error envelopes. This reduces duplication across controllers and makes the API easier to maintain. Errors are further normalized by `ApiExceptionSubscriber` and `ApiJwtFailureSubscriber`, so authentication failures, invalid tokens, missing credentials, validation errors, and unexpected server errors are all returned as JSON instead of HTML exception pages. That is a significant usability improvement for third-party clients.
|
||||||
|
|
||||||
|
### Authentication and Authorization
|
||||||
|
|
||||||
|
Authentication is implemented with LexikJWTAuthenticationBundle. The login endpoint is handled through Symfony security `json_login` at `POST /api/v1/auth/login`. This design is stateless because the client submits credentials once, receives a token, and then supplies that token on later requests. The server does not need to keep session state for API consumers.
|
||||||
|
|
||||||
|
Authorization is layered on top of authentication. Access control rules in the security configuration protect the write routes, but review ownership is enforced more precisely by `ReviewVoter`. A normal user may edit or delete only their own review, while moderators and administrators may manage any review. This is stronger than basic route-level protection because it applies business rules at the resource level.
|
||||||
|
|
||||||
|
### RESTful Design Criteria
|
||||||
|
|
||||||
|
The API satisfies several core REST criteria. First, resources are named with nouns rather than action verbs: `/reviews`, `/reviews/{id}`, `/albums`, and `/albums/{id}/reviews`. Second, HTTP methods are used according to intent: `GET` for retrieval, `POST` for creation and ingestion, `DELETE` for removal, and `PUT`/`PATCH` for updates. Third, the API uses JSON consistently for request and response bodies, which keeps the contract simple for clients.
|
||||||
|
|
||||||
|
The service is also stateless for protected routes because every authenticated request carries its own bearer token. Status codes are generally used correctly: `200` for successful reads and updates, `201` for creation, `204` for deletion, `401` for authentication failures, `403` for authorization failures, `404` for missing resources, and `422` for validation problems.
|
||||||
|
|
||||||
|
One limitation is that `PUT` and `PATCH` are currently routed to the same update handler. In strict REST terms, `PUT` would usually imply full replacement of editable state, whereas `PATCH` implies partial modification. The current implementation is still practical and easy for clients to consume, but it is better described as REST-oriented than perfectly REST-pure in that one area.
|
||||||
|
|
||||||
|
### External API Consumption
|
||||||
|
|
||||||
|
External API consumption is implemented with Guzzle and a provider abstraction. Rather than hard-coding one service into controllers, the application uses `ExternalAlbumClientInterface` and an `ExternalAlbumClient` router that resolves the currently active provider. This allows the application to work with Last.fm, MusicBrainz, or TIDAL while keeping the rest of the code independent from provider-specific payload formats.
|
||||||
|
|
||||||
|
This abstraction is one of the strongest architectural parts of the project. `LastFmClient`, `MusicBrainzClient`, and `TidalClient` all normalize external responses into the same internal album contract: album ID, title, artists, genres, release date, cover image URLs, external URL, and track list. Because of this, the UI and the API can render albums without knowing which provider originally supplied the data.
|
||||||
|
|
||||||
|
The integration is also deep rather than superficial. Album search merges local and external results, attaches review aggregates, supports filtering by artist, genre, year range, and source, and can run in fast or full modes. Opening an unsaved external album detail page can automatically hydrate and persist it locally. The album page then displays cover art, release metadata, genres, tracklists, and user reviews in one place.
|
||||||
|
|
||||||
|
The latest provider addition is TIDAL. Its client uses OAuth 2.0 client credentials, caches access tokens, retries once on a `401`, and normalizes JSON:API documents into the same internal shape used by the other providers. This is a non-trivial second external integration and improves the project’s depth against the assignment rubric.
|
||||||
|
|
||||||
|
### Code Organization and Maintainability
|
||||||
|
|
||||||
|
The codebase is organized around separation of concerns. Controllers are thin orchestration layers. Services handle provider logic, search composition, uploads, registration toggling, and provider switching. Entities define the core data model. Event subscribers centralize error handling. Security rules and JWT behaviour are configured declaratively rather than embedded into controller code.
|
||||||
|
|
||||||
|
Maintainability is strengthened further by tests. Functional API tests cover authentication, malformed JSON, validation failures, forbidden updates, missing resources, and protection of personally identifiable information. Unit tests cover provider routing and TIDAL normalization behaviour. Although I was not able to execute PHPUnit in this shell because `php` is not installed locally, the presence of these tests is still relevant evidence of intended behaviour and regression control.
|
||||||
|
|
||||||
|
### Limitations and Further Work
|
||||||
|
|
||||||
|
The current API is solid for the project scope, but there are clear next steps. Pagination metadata could be formalized on list endpoints. `PUT` and `PATCH` semantics could be separated more strictly. JWT refresh token support could be added for longer-lived API clients. Rate-limiting and request-throttling at the API boundary would also strengthen production readiness. Finally, album search response headers still use Last.fm-oriented metric names even though multiple providers are now supported, so those observability labels could be generalized.
|
||||||
|
|
||||||
|
### Conclusion
|
||||||
|
|
||||||
|
Overall, Tonehaus meets the assignment brief with a realistic REST API, JWT-protected write operations, structured error handling, and meaningful external API consumption. The project goes beyond a minimal CRUD exercise because the consumed APIs actively shape the web application experience: they power album discovery, metadata enrichment, cover art, tracklists, and ingestion into the local review catalog. The result is a more maintainable and scalable application that treats the API as part of the system architecture rather than an isolated add-on.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
1. Module brief: *Assignment 2 - RESTful APIs*, Advanced Web Development, CRN 31802.
|
||||||
|
2. Symfony Documentation.
|
||||||
|
3. LexikJWTAuthenticationBundle documentation.
|
||||||
|
4. FOSRestBundle documentation.
|
||||||
|
5. NelmioApiDocBundle documentation.
|
||||||
|
6. Guzzle documentation.
|
||||||
|
7. Last.fm API documentation.
|
||||||
|
8. MusicBrainz Web Service documentation and Cover Art Archive.
|
||||||
|
9. TIDAL Open API documentation.
|
||||||
+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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260227174500 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drop legacy Spotify and unused album columns (created_by_id, cover_url, local_id, cover_image_path)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite column drops/constraint rewrites are intentionally skipped in this migration.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasTable($schema, 'albums')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||||
|
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||||
|
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasTable($schema, 'albums')) {
|
||||||
|
if (!$this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD spotify_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!$this->hasColumn($schema, 'albums', 'local_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!$this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD created_by_id INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!$this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD cover_url VARCHAR(1024) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if (!$this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474FA905FC5C')) {
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
|
||||||
|
}
|
||||||
|
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474F5D5A2101')) {
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
|
||||||
|
}
|
||||||
|
if (!$this->indexExists($schema, 'albums', 'IDX_F4E2474FB03A8386')) {
|
||||||
|
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||||
|
$this->addSql('ALTER TABLE album_tracks ADD spotify_track_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260227181000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Apply SQLite-safe table rebuilds to remove legacy Spotify and unused album columns';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
$this->migrateSqlite();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasTable($schema, 'albums')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||||
|
}
|
||||||
|
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||||
|
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||||
|
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Forward-only migration: reverting would require data-loss-prone table rewrites.
|
||||||
|
}
|
||||||
|
|
||||||
|
private function migrateSqlite(): void
|
||||||
|
{
|
||||||
|
$this->addSql('PRAGMA foreign_keys = OFF');
|
||||||
|
|
||||||
|
$this->addSql(
|
||||||
|
"CREATE TABLE albums_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
external_id VARCHAR(64) DEFAULT NULL,
|
||||||
|
source VARCHAR(16) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
artists CLOB NOT NULL,
|
||||||
|
release_date VARCHAR(20) DEFAULT NULL,
|
||||||
|
total_tracks INTEGER NOT NULL,
|
||||||
|
external_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
genres CLOB NOT NULL DEFAULT '[]'
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
$this->addSql(
|
||||||
|
"INSERT INTO albums_new (
|
||||||
|
id, external_id, source, name, artists, release_date, total_tracks, external_url, created_at, updated_at, genres
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
CASE
|
||||||
|
WHEN external_id IS NOT NULL AND TRIM(external_id) <> '' THEN external_id
|
||||||
|
WHEN local_id IS NOT NULL AND TRIM(local_id) <> '' THEN local_id
|
||||||
|
ELSE 'legacy:' || id
|
||||||
|
END AS external_id,
|
||||||
|
CASE
|
||||||
|
WHEN source = 'spotify' OR source IS NULL OR TRIM(source) = '' THEN 'external'
|
||||||
|
ELSE source
|
||||||
|
END AS source,
|
||||||
|
name,
|
||||||
|
artists,
|
||||||
|
release_date,
|
||||||
|
total_tracks,
|
||||||
|
external_url,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
COALESCE(genres, '[]') AS genres
|
||||||
|
FROM albums"
|
||||||
|
);
|
||||||
|
$this->addSql('DROP TABLE albums');
|
||||||
|
$this->addSql('ALTER TABLE albums_new RENAME TO albums');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_external_id ON albums (external_id)');
|
||||||
|
|
||||||
|
$this->addSql(
|
||||||
|
"CREATE TABLE album_tracks_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
album_id INTEGER NOT NULL,
|
||||||
|
external_track_id VARCHAR(64) DEFAULT NULL,
|
||||||
|
disc_number INTEGER NOT NULL,
|
||||||
|
track_number INTEGER NOT NULL,
|
||||||
|
name VARCHAR(512) NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
preview_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
$this->addSql(
|
||||||
|
"INSERT INTO album_tracks_new (
|
||||||
|
id, album_id, external_track_id, disc_number, track_number, name, duration_ms, preview_url
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
album_id,
|
||||||
|
CASE
|
||||||
|
WHEN external_track_id IS NOT NULL AND TRIM(external_track_id) <> '' THEN external_track_id
|
||||||
|
WHEN spotify_track_id IS NOT NULL AND TRIM(spotify_track_id) <> '' THEN spotify_track_id
|
||||||
|
ELSE NULL
|
||||||
|
END AS external_track_id,
|
||||||
|
disc_number,
|
||||||
|
track_number,
|
||||||
|
name,
|
||||||
|
duration_ms,
|
||||||
|
preview_url
|
||||||
|
FROM album_tracks"
|
||||||
|
);
|
||||||
|
$this->addSql('DROP TABLE album_tracks');
|
||||||
|
$this->addSql('ALTER TABLE album_tracks_new RENAME TO album_tracks');
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
|
||||||
|
|
||||||
|
$this->addSql('PRAGMA foreign_keys = ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260401101500 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add nullable album owner relation (albums.owner_id -> users.id)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD COLUMN owner_id INTEGER DEFAULT NULL');
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE albums ADD owner_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||||
|
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_ALBUM_OWNER FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite down-migration omitted to avoid lossy table rebuild.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE albums DROP CONSTRAINT FK_ALBUM_OWNER');
|
||||||
|
$this->addSql('DROP INDEX IDX_ALBUM_OWNER');
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN owner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260402120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add nullable cover_image_path to albums for user-uploaded album artwork';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$this->hasTable($schema, 'albums') || $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD COLUMN cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite column removal requires a table rebuild; keep this migration forward-only on SQLite.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasTable($schema, 'albums') && $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Tonehaus Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<php>
|
||||||
|
<server name="APP_ENV" value="test" force="true"/>
|
||||||
|
<server name="APP_DEBUG" value="1" force="true"/>
|
||||||
|
<server name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||||
|
<server name="DATABASE_DRIVER" value="sqlite" force="true"/>
|
||||||
|
<server name="DATABASE_SQLITE_PATH" value="/tmp/tonehaus_test.sqlite" force="true"/>
|
||||||
|
<server name="JWT_PASSPHRASE" value="test" force="true"/>
|
||||||
|
|
||||||
|
<!-- ###+ symfony/framework-bundle ### -->
|
||||||
|
<env name="APP_ENV" value="dev"/>
|
||||||
|
<env name="APP_SECRET" value="test-secret"/>
|
||||||
|
<!-- ###- symfony/framework-bundle ### -->
|
||||||
|
|
||||||
|
<!-- ###+ symfony/routing ### -->
|
||||||
|
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
|
||||||
|
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
|
||||||
|
<env name="DEFAULT_URI" value="http://localhost"/>
|
||||||
|
<!-- ###- symfony/routing ### -->
|
||||||
|
|
||||||
|
<!-- ###+ doctrine/doctrine-bundle ### -->
|
||||||
|
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
|
||||||
|
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
|
||||||
|
<!-- -->
|
||||||
|
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
|
||||||
|
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
|
||||||
|
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
|
||||||
|
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"/>
|
||||||
|
<!-- ###- doctrine/doctrine-bundle ### -->
|
||||||
|
|
||||||
|
<!-- ###+ lexik/jwt-authentication-bundle ### -->
|
||||||
|
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
|
||||||
|
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
|
||||||
|
<env name="JWT_PASSPHRASE" value="f5b6626c282780b036de5dad4b5aa9a42ef5ec4632ecfbefc840eb02a60fae0c"/>
|
||||||
|
<!-- ###- lexik/jwt-authentication-bundle ### -->
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\ExternalProviderManager;
|
||||||
|
use App\Service\LastFmMetadataRefresher;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:refresh-external-albums',
|
||||||
|
description: 'Refresh all saved external albums from the configured metadata provider.'
|
||||||
|
)]
|
||||||
|
class RefreshExternalAlbumsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LastFmMetadataRefresher $refresher,
|
||||||
|
private readonly ExternalProviderManager $providerManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$providerLabel = $this->providerManager->getProviderLabel($this->providerManager->getEffectiveProvider());
|
||||||
|
$updated = $this->refresher->refreshAllExternalAlbums();
|
||||||
|
|
||||||
|
if ($updated === 0) {
|
||||||
|
$io->warning(sprintf('No external albums were refreshed from %s.', $providerLabel));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('Refreshed %d external albums from %s.', $updated, $providerLabel));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Entity\Album;
|
use App\Entity\Album;
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
@@ -23,9 +24,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
/**
|
/**
|
||||||
* Seeds the database with synthetic user-sourced albums.
|
* Seeds the database with synthetic user-sourced albums.
|
||||||
*
|
*
|
||||||
* - Always marked as "user" source with a unique localId.
|
* - Always marked as "user" source with a unique public ID.
|
||||||
* - Include randomized names, artists, genres, release dates, and cover URLs.
|
* - Include randomized names, artists, genres, and release dates.
|
||||||
* - Optionally link to existing users as creators when --attach-users is set.
|
|
||||||
*/
|
*/
|
||||||
class SeedDemoAlbumsCommand extends Command
|
class SeedDemoAlbumsCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,7 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
||||||
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
->addOption('attach-users', null, InputOption::VALUE_NONE, 'Assign each generated album to a random existing user');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -63,50 +63,57 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$count = max(1, (int) $input->getOption('count'));
|
$count = max(1, (int) $input->getOption('count'));
|
||||||
$attachUsers = (bool) $input->getOption('attach-users');
|
$attachUsers = (bool) $input->getOption('attach-users');
|
||||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
$owners = [];
|
||||||
|
if ($attachUsers) {
|
||||||
|
$owners = $this->userRepository->findAll();
|
||||||
|
if ($owners === []) {
|
||||||
|
$io->warning('No users available for --attach-users; albums will be created without owners.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
// Track generated localIds so we never attempt to persist obvious duplicates.
|
// Track generated album IDs so we never attempt to persist obvious duplicates.
|
||||||
$seenLocalIds = [];
|
$seenAlbumIds = [];
|
||||||
|
|
||||||
while ($created < $count) {
|
while ($created < $count) {
|
||||||
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
|
// Generate an ID that is unique in-memory and in the database to avoid constraint violations.
|
||||||
$localId = $this->generateLocalId();
|
$albumId = $this->generateAlbumId();
|
||||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
if (isset($seenAlbumIds[$albumId]) || $this->albumRepository->findOneByExternalId($albumId) !== null) {
|
||||||
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$album = new Album();
|
$album = new Album();
|
||||||
$album->setSource('user');
|
$album->setSource('user');
|
||||||
$album->setLocalId($localId);
|
$album->setExternalId($albumId);
|
||||||
$album->setName($this->generateAlbumName());
|
$album->setName($this->generateAlbumName());
|
||||||
$album->setArtists($this->generateArtists());
|
$album->setArtists($this->generateArtists());
|
||||||
$album->setGenres($this->generateGenres());
|
$album->setGenres($this->generateGenres());
|
||||||
$album->setReleaseDate($this->generateReleaseDate());
|
$album->setReleaseDate($this->generateReleaseDate());
|
||||||
$album->setTotalTracks(random_int(6, 16));
|
$trackCount = random_int(6, 16);
|
||||||
$album->setCoverUrl($this->generateCoverUrl($localId));
|
$album->setTotalTracks($trackCount);
|
||||||
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
|
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $albumId));
|
||||||
|
if ($owners !== []) {
|
||||||
if ($attachUsers && $users !== []) {
|
/** @var User $owner */
|
||||||
/** @var User $user */
|
$owner = $owners[array_rand($owners)];
|
||||||
$user = $users[array_rand($users)];
|
$album->setOwner($owner);
|
||||||
$album->setCreatedBy($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->addSyntheticTracks($album, $trackCount);
|
||||||
|
|
||||||
$this->entityManager->persist($album);
|
$this->entityManager->persist($album);
|
||||||
$seenLocalIds[$localId] = true;
|
$seenAlbumIds[$albumId] = true;
|
||||||
$created++;
|
$created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
|
$io->success(sprintf('Created %d demo albums.', $created));
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateLocalId(): string
|
private function generateAlbumId(): string
|
||||||
{
|
{
|
||||||
return 'demo_' . bin2hex(random_bytes(4));
|
return 'demo_' . bin2hex(random_bytes(4));
|
||||||
}
|
}
|
||||||
@@ -160,9 +167,24 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
return array_values(array_unique($genres));
|
return array_values(array_unique($genres));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateCoverUrl(string $seed): string
|
private function addSyntheticTracks(Album $album, int $trackCount): void
|
||||||
{
|
{
|
||||||
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
for ($trackNumber = 1; $trackNumber <= $trackCount; $trackNumber++) {
|
||||||
|
$track = new AlbumTrack();
|
||||||
|
$track->setDiscNumber(1);
|
||||||
|
$track->setTrackNumber($trackNumber);
|
||||||
|
$track->setName($this->generateTrackName($trackNumber));
|
||||||
|
$track->setDurationMs(random_int(120, 360) * 1000);
|
||||||
|
$album->addTrack($track);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private function generateTrackName(int $trackNumber): string
|
||||||
|
{
|
||||||
|
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
|
||||||
|
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
|
||||||
|
|
||||||
|
return sprintf('%02d. %s %s', $trackNumber, $adj, $noun);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -72,7 +72,10 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
// Pull all albums/users once up front so downstream helpers filter as needed.
|
// Pull all albums/users once up front so downstream helpers filter as needed.
|
||||||
$albums = $this->albumRepository->findAll();
|
$albums = array_values(array_filter(
|
||||||
|
$this->albumRepository->findAll(),
|
||||||
|
static fn(Album $album): bool => $album->getTotalTracks() > 0 && trim((string) $album->getExternalId()) !== ''
|
||||||
|
));
|
||||||
$users = $this->userRepository->findAll();
|
$users = $this->userRepository->findAll();
|
||||||
|
|
||||||
if ($albums === [] || $users === []) {
|
if ($albums === [] || $users === []) {
|
||||||
@@ -229,4 +232,3 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\ExternalProviderManager;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:sync-external-provider',
|
||||||
|
description: 'Applies effective external provider and resets catalog if provider changed.'
|
||||||
|
)]
|
||||||
|
class SyncExternalProviderCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ExternalProviderManager $providerManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$result = $this->providerManager->syncEffectiveProviderAtStartup();
|
||||||
|
|
||||||
|
if ($result['reset']) {
|
||||||
|
$io->warning(sprintf(
|
||||||
|
'External provider changed from %s to %s. Reset catalog: deleted %d albums and %d reviews.',
|
||||||
|
$result['previous_provider'],
|
||||||
|
$result['effective_provider'],
|
||||||
|
$result['albums'],
|
||||||
|
$result['reviews']
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'External provider unchanged (%s). Catalog reset not required.',
|
||||||
|
$result['effective_provider']
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ProfileFormType;
|
use App\Form\ProfileFormType;
|
||||||
use App\Repository\ReviewRepository;
|
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\ReviewRepository;
|
||||||
use App\Service\UploadStorage;
|
use App\Service\UploadStorage;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -36,10 +36,10 @@ class AccountController extends AbstractController
|
|||||||
->select('COUNT(r.id)')
|
->select('COUNT(r.id)')
|
||||||
->where('r.author = :u')->setParameter('u', $user)
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
$albumCount = (int) $albums->createQueryBuilder('a')
|
$albumCount = (int) $reviews->createQueryBuilder('r')
|
||||||
->select('COUNT(a.id)')
|
->select('COUNT(DISTINCT a.id)')
|
||||||
->where('a.source = :src')->setParameter('src', 'user')
|
->innerJoin('r.album', 'a')
|
||||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
if ($this->isGranted('ROLE_ADMIN')) {
|
if ($this->isGranted('ROLE_ADMIN')) {
|
||||||
$userType = 'Admin';
|
$userType = 'Admin';
|
||||||
@@ -54,8 +54,10 @@ class AccountController extends AbstractController
|
|||||||
->setMaxResults(10)
|
->setMaxResults(10)
|
||||||
->getQuery()->getResult();
|
->getQuery()->getResult();
|
||||||
$userAlbums = $albums->createQueryBuilder('a')
|
$userAlbums = $albums->createQueryBuilder('a')
|
||||||
->where('a.source = :src')->setParameter('src', 'user')
|
->where('a.owner = :u')
|
||||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
->andWhere('a.source = :src')
|
||||||
|
->setParameter('u', $user)
|
||||||
|
->setParameter('src', 'user')
|
||||||
->orderBy('a.createdAt', 'DESC')
|
->orderBy('a.createdAt', 'DESC')
|
||||||
->setMaxResults(10)
|
->setMaxResults(10)
|
||||||
->getQuery()->getResult();
|
->getQuery()->getResult();
|
||||||
@@ -123,5 +125,3 @@ class AccountController extends AbstractController
|
|||||||
return $this->render('account/settings.html.twig');
|
return $this->render('account/settings.html.twig');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,7 +5,8 @@ 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\ExternalProviderManager;
|
||||||
|
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;
|
||||||
@@ -22,7 +23,12 @@ class DashboardController extends AbstractController
|
|||||||
* Renders overall activity metrics for administrators.
|
* Renders overall activity metrics for administrators.
|
||||||
*/
|
*/
|
||||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
public function dashboard(
|
||||||
|
ReviewRepository $reviews,
|
||||||
|
AlbumRepository $albums,
|
||||||
|
UserRepository $users,
|
||||||
|
ExternalProviderManager $providerManager
|
||||||
|
): Response
|
||||||
{
|
{
|
||||||
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
|
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
|
||||||
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
||||||
@@ -50,25 +56,28 @@ class DashboardController extends AbstractController
|
|||||||
'totalUsers' => $totalUsers,
|
'totalUsers' => $totalUsers,
|
||||||
'recentReviews' => $recentReviews,
|
'recentReviews' => $recentReviews,
|
||||||
'recentAlbums' => $recentAlbums,
|
'recentAlbums' => $recentAlbums,
|
||||||
|
'externalProviderLabel' => $providerManager->getProviderLabel($providerManager->getEffectiveProvider()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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,
|
||||||
|
ExternalProviderManager $providerManager
|
||||||
): 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();
|
||||||
|
$providerLabel = $providerManager->getProviderLabel($providerManager->getEffectiveProvider());
|
||||||
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 %s.', $updated, $providerLabel));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('admin_dashboard');
|
return $this->redirectToRoute('admin_dashboard');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Form\SiteSettingsType;
|
|||||||
use App\Repository\SettingRepository;
|
use App\Repository\SettingRepository;
|
||||||
use App\Service\CatalogResetService;
|
use App\Service\CatalogResetService;
|
||||||
use App\Service\CommandRunner;
|
use App\Service\CommandRunner;
|
||||||
|
use App\Service\ExternalProviderManager;
|
||||||
use App\Service\RegistrationToggle;
|
use App\Service\RegistrationToggle;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -62,21 +63,70 @@ class SettingsController extends AbstractController
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays and persists Spotify credential settings.
|
* Displays and persists API credential/provider 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,
|
||||||
|
ExternalProviderManager $providerManager
|
||||||
|
): Response {
|
||||||
|
$storedProvider = $providerManager->getStoredProvider();
|
||||||
|
$effectiveProvider = $providerManager->getEffectiveProvider();
|
||||||
|
$envOverrideProvider = $providerManager->getEnvOverrideProvider();
|
||||||
|
$activeProvider = $providerManager->getActiveProvider();
|
||||||
|
|
||||||
|
$settingValues = $settings->getValues([
|
||||||
|
'LASTFM_API_KEY',
|
||||||
|
'MUSICBRAINZ_CLIENT_ID',
|
||||||
|
'MUSICBRAINZ_CLIENT_SECRET',
|
||||||
|
'MUSICBRAINZ_ACCESS_TOKEN',
|
||||||
|
'TIDAL_CLIENT_ID',
|
||||||
|
'TIDAL_CLIENT_SECRET',
|
||||||
|
'TIDAL_COUNTRY_CODE',
|
||||||
|
]);
|
||||||
|
|
||||||
$form = $this->createForm(SiteSettingsType::class);
|
$form = $this->createForm(SiteSettingsType::class);
|
||||||
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
$form->get('EXTERNAL_ALBUM_PROVIDER')->setData($storedProvider);
|
||||||
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
$form->get('LASTFM_API_KEY')->setData((string) ($settingValues['LASTFM_API_KEY'] ?? ''));
|
||||||
|
$form->get('MUSICBRAINZ_CLIENT_ID')->setData((string) ($settingValues['MUSICBRAINZ_CLIENT_ID'] ?? ''));
|
||||||
|
$form->get('MUSICBRAINZ_CLIENT_SECRET')->setData((string) ($settingValues['MUSICBRAINZ_CLIENT_SECRET'] ?? ''));
|
||||||
|
$form->get('MUSICBRAINZ_ACCESS_TOKEN')->setData((string) ($settingValues['MUSICBRAINZ_ACCESS_TOKEN'] ?? ''));
|
||||||
|
$form->get('TIDAL_CLIENT_ID')->setData((string) ($settingValues['TIDAL_CLIENT_ID'] ?? ''));
|
||||||
|
$form->get('TIDAL_CLIENT_SECRET')->setData((string) ($settingValues['TIDAL_CLIENT_SECRET'] ?? ''));
|
||||||
|
$form->get('TIDAL_COUNTRY_CODE')->setData((string) ($settingValues['TIDAL_COUNTRY_CODE'] ?? ''));
|
||||||
$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->setValues([
|
||||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
'LASTFM_API_KEY' => $this->normalizeSettingInput($form->get('LASTFM_API_KEY')->getData()),
|
||||||
|
'MUSICBRAINZ_CLIENT_ID' => $this->normalizeSettingInput($form->get('MUSICBRAINZ_CLIENT_ID')->getData()),
|
||||||
|
'MUSICBRAINZ_CLIENT_SECRET' => $this->normalizeSettingInput($form->get('MUSICBRAINZ_CLIENT_SECRET')->getData()),
|
||||||
|
'MUSICBRAINZ_ACCESS_TOKEN' => $this->normalizeSettingInput($form->get('MUSICBRAINZ_ACCESS_TOKEN')->getData()),
|
||||||
|
'TIDAL_CLIENT_ID' => $this->normalizeSettingInput($form->get('TIDAL_CLIENT_ID')->getData()),
|
||||||
|
'TIDAL_CLIENT_SECRET' => $this->normalizeSettingInput($form->get('TIDAL_CLIENT_SECRET')->getData()),
|
||||||
|
'TIDAL_COUNTRY_CODE' => $this->normalizeCountryCodeSetting($form->get('TIDAL_COUNTRY_CODE')->getData()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$providerChange = $providerManager->applyAdminSelectedProvider((string) $form->get('EXTERNAL_ALBUM_PROVIDER')->getData());
|
||||||
|
if ($providerChange['reset']) {
|
||||||
|
$this->addFlash('warning', sprintf(
|
||||||
|
'Provider changed from %s to %s. Catalog reset removed %d albums and %d reviews to avoid mixed-source duplicates.',
|
||||||
|
$providerManager->getProviderLabel($providerChange['previous_provider']),
|
||||||
|
$providerManager->getProviderLabel($providerChange['effective_provider']),
|
||||||
|
$providerChange['albums'],
|
||||||
|
$providerChange['reviews']
|
||||||
|
));
|
||||||
|
} elseif ($providerChange['env_override_active']) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'EXTERNAL_ALBUM_PROVIDER override is active (%s). Saved provider preference will apply only when the override is removed.',
|
||||||
|
$providerManager->getProviderLabel($providerChange['effective_provider'])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
@@ -91,6 +141,13 @@ class SettingsController extends AbstractController
|
|||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
'registrationImmutable' => $registrationOverride !== null,
|
'registrationImmutable' => $registrationOverride !== null,
|
||||||
'registrationOverrideValue' => $registrationOverride,
|
'registrationOverrideValue' => $registrationOverride,
|
||||||
|
'storedProvider' => $storedProvider,
|
||||||
|
'effectiveProvider' => $effectiveProvider,
|
||||||
|
'activeProvider' => $activeProvider,
|
||||||
|
'envOverrideProvider' => $envOverrideProvider,
|
||||||
|
'providerLabelStored' => $providerManager->getProviderLabel($storedProvider),
|
||||||
|
'providerLabelEffective' => $providerManager->getProviderLabel($effectiveProvider),
|
||||||
|
'providerLabelActive' => $providerManager->getProviderLabel($activeProvider),
|
||||||
'demoCommands' => self::DEMO_COMMANDS,
|
'demoCommands' => self::DEMO_COMMANDS,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -168,4 +225,20 @@ class SettingsController extends AbstractController
|
|||||||
}
|
}
|
||||||
return $options;
|
return $options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeSettingInput(mixed $input): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $input);
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCountryCodeSetting(mixed $input): ?string
|
||||||
|
{
|
||||||
|
$value = mb_strtoupper(trim((string) $input));
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Dto\AlbumSearchCriteria;
|
use App\Dto\AlbumSearchCriteria;
|
||||||
use App\Entity\Album;
|
use App\Entity\Album;
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
use App\Entity\Review;
|
use App\Entity\Review;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ReviewType;
|
use App\Form\ReviewType;
|
||||||
@@ -12,15 +13,17 @@ use App\Repository\AlbumRepository;
|
|||||||
use App\Repository\AlbumTrackRepository;
|
use App\Repository\AlbumTrackRepository;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Service\AlbumSearchService;
|
use App\Service\AlbumSearchService;
|
||||||
|
use App\Service\ExternalAlbumClientInterface;
|
||||||
|
use App\Service\LastFmTagResolver;
|
||||||
use App\Service\UploadStorage;
|
use App\Service\UploadStorage;
|
||||||
use App\Service\SpotifyClient;
|
|
||||||
use App\Service\SpotifyGenreResolver;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Form\FormError;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Form\FormInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -29,35 +32,180 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
*/
|
*/
|
||||||
class AlbumController extends AbstractController
|
class AlbumController extends AbstractController
|
||||||
{
|
{
|
||||||
|
private const ALBUM_ID_REQUIREMENT = '(?!new$)[^/]+';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UploadStorage $uploadStorage,
|
|
||||||
private readonly AlbumSearchService $albumSearch,
|
private readonly AlbumSearchService $albumSearch,
|
||||||
private readonly SpotifyGenreResolver $genreResolver,
|
private readonly LastFmTagResolver $genreResolver,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
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
|
||||||
{
|
{
|
||||||
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||||
$result = $this->albumSearch->search($criteria);
|
$result = $this->albumSearch->search($criteria, true);
|
||||||
|
$profile = $this->albumSearch->getLastSearchProfile();
|
||||||
|
$yearFromInput = trim((string) $request->query->get('year_from', ''));
|
||||||
|
$yearToInput = trim((string) $request->query->get('year_to', ''));
|
||||||
|
|
||||||
return $this->render('album/search.html.twig', [
|
$response = $this->render('album/search.html.twig', [
|
||||||
'query' => $criteria->query,
|
'query' => $criteria->query,
|
||||||
'album' => $criteria->albumName,
|
'album' => $criteria->albumName,
|
||||||
'artist' => $criteria->artist,
|
'artist' => $criteria->artist,
|
||||||
'genre' => $criteria->genre,
|
'genre' => $criteria->genre,
|
||||||
'year_from' => $criteria->yearFrom ?? '',
|
'year_from' => $yearFromInput !== '' ? $yearFromInput : ($criteria->yearFrom !== null ? sprintf('%04d-01-01', $criteria->yearFrom) : ''),
|
||||||
'year_to' => $criteria->yearTo ?? '',
|
'year_to' => $yearToInput !== '' ? $yearToInput : ($criteria->yearTo !== null ? sprintf('%04d-01-01', $criteria->yearTo) : ''),
|
||||||
'albums' => $result->albums,
|
'albums' => $result->albums,
|
||||||
'stats' => $result->stats,
|
'stats' => $result->stats,
|
||||||
'savedIds' => $result->savedIds,
|
'savedIds' => $result->savedIds,
|
||||||
'source' => $criteria->source,
|
'source' => $criteria->source,
|
||||||
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
|
'search_limit' => $this->searchLimit,
|
||||||
|
'externalConfigured' => $this->albumSearch->isExternalConfigured(),
|
||||||
|
]);
|
||||||
|
$this->attachSearchProfileHeaders($response, $profile);
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one incremental search batch used by the web "Show More" flow.
|
||||||
|
*/
|
||||||
|
#[Route('/albums/search/batch', name: 'album_search_batch', methods: ['GET'])]
|
||||||
|
public function searchBatch(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$offset = max(0, (int) $request->query->get('offset', 0));
|
||||||
|
$requestedLimit = (int) $request->query->get('limit', $this->searchLimit);
|
||||||
|
$batchLimit = max(1, min($this->searchLimit, $requestedLimit));
|
||||||
|
|
||||||
|
$baseCriteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||||
|
$expandedCriteria = new AlbumSearchCriteria(
|
||||||
|
query: $baseCriteria->query,
|
||||||
|
albumName: $baseCriteria->albumName,
|
||||||
|
artist: $baseCriteria->artist,
|
||||||
|
genre: $baseCriteria->genre,
|
||||||
|
yearFrom: $baseCriteria->yearFrom,
|
||||||
|
yearTo: $baseCriteria->yearTo,
|
||||||
|
source: $baseCriteria->source,
|
||||||
|
limit: $offset + $batchLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->albumSearch->search($expandedCriteria, true);
|
||||||
|
$albums = array_slice($result->albums, $offset, $batchLimit);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($albums as $album) {
|
||||||
|
if (!is_array($album)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumId = trim((string) ($album['id'] ?? ''));
|
||||||
|
$stats = $albumId !== '' && isset($result->stats[$albumId])
|
||||||
|
? [$albumId => $result->stats[$albumId]]
|
||||||
|
: [];
|
||||||
|
$savedIds = $albumId !== '' && in_array($albumId, $result->savedIds, true)
|
||||||
|
? [$albumId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$html .= $this->renderView('album/_search_card.html.twig', [
|
||||||
|
'album' => $album,
|
||||||
|
'stats' => $stats,
|
||||||
|
'savedIds' => $savedIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'html' => $html,
|
||||||
|
'count' => count($albums),
|
||||||
|
'next_offset' => $offset + count($albums),
|
||||||
|
'limit' => $batchLimit,
|
||||||
|
'exhausted' => count($albums) < $batchLimit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one album card to a fully openable payload for progressive search hydration.
|
||||||
|
*/
|
||||||
|
#[Route('/albums/{id}/card-data', name: 'album_card_data', methods: ['GET'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
|
||||||
|
public function cardData(
|
||||||
|
string $id,
|
||||||
|
ExternalAlbumClientInterface $externalAlbumClient,
|
||||||
|
AlbumRepository $albumRepo,
|
||||||
|
AlbumTrackRepository $trackRepo,
|
||||||
|
ReviewRepository $reviewRepo,
|
||||||
|
EntityManagerInterface $em
|
||||||
|
): JsonResponse
|
||||||
|
{
|
||||||
|
$albumEntity = $albumRepo->findOneByExternalId($id);
|
||||||
|
if ($albumEntity instanceof Album) {
|
||||||
|
$payload = $albumEntity->toTemplateArray();
|
||||||
|
$trackCount = max((int) $albumEntity->getTotalTracks(), $albumEntity->getTracks()->count());
|
||||||
|
$payload['total_tracks'] = $trackCount;
|
||||||
|
|
||||||
|
$canOpen = $trackCount > 0;
|
||||||
|
if ($albumEntity->getSource() === 'external') {
|
||||||
|
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
|
||||||
|
if (is_array($externalAlbum) && $this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||||
|
$em->flush();
|
||||||
|
$payload = $albumEntity->toTemplateArray();
|
||||||
|
$images = $externalAlbum['images'] ?? null;
|
||||||
|
if (is_array($images) && $images !== []) {
|
||||||
|
$payload['images'] = $images;
|
||||||
|
}
|
||||||
|
$payload['total_tracks'] = max((int) $payload['total_tracks'], $albumEntity->getTracks()->count());
|
||||||
|
$canOpen = true;
|
||||||
|
} elseif ($canOpen && is_array($externalAlbum)) {
|
||||||
|
$images = $externalAlbum['images'] ?? null;
|
||||||
|
if (is_array($images) && $images !== []) {
|
||||||
|
$payload['images'] = $images;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $reviewRepo->getAggregatesForAlbumIds([$id]);
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => $canOpen,
|
||||||
|
'can_open' => $canOpen,
|
||||||
|
'album' => $canOpen ? $payload : null,
|
||||||
|
'stats' => $stats[$id] ?? ['count' => 0, 'avg' => 0.0],
|
||||||
|
'saved' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
|
||||||
|
if (!is_array($externalAlbum) || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => false,
|
||||||
|
'can_open' => false,
|
||||||
|
'album' => null,
|
||||||
|
'stats' => ['count' => 0, 'avg' => 0.0],
|
||||||
|
'saved' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$payload = $albumEntity->toTemplateArray();
|
||||||
|
$images = $externalAlbum['images'] ?? null;
|
||||||
|
if (is_array($images) && $images !== []) {
|
||||||
|
$payload['images'] = $images;
|
||||||
|
}
|
||||||
|
$payload['total_tracks'] = max((int) $payload['total_tracks'], $albumEntity->getTracks()->count());
|
||||||
|
|
||||||
|
$stats = $reviewRepo->getAggregatesForAlbumIds([$id]);
|
||||||
|
return new JsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'can_open' => true,
|
||||||
|
'album' => $payload,
|
||||||
|
'stats' => $stats[$id] ?? ['count' => 0, 'avg' => 0.0],
|
||||||
|
'saved' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +214,13 @@ class AlbumController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
|
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
|
||||||
public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
public function create(
|
||||||
|
Request $request,
|
||||||
|
AlbumRepository $albumRepo,
|
||||||
|
AlbumTrackRepository $trackRepo,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
UploadStorage $uploadStorage
|
||||||
|
): Response
|
||||||
{
|
{
|
||||||
$album = new Album();
|
$album = new Album();
|
||||||
$album->setSource('user');
|
$album->setSource('user');
|
||||||
@@ -74,45 +228,73 @@ class AlbumController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->normalizeAlbumFormData($album);
|
$this->normalizeAlbumFormData($album);
|
||||||
$user = $this->getUser();
|
$album->setSource('user');
|
||||||
if ($user instanceof User) {
|
$album->setExternalId($this->generateUserAlbumId($albumRepo));
|
||||||
$album->setCreatedBy($user);
|
$owner = $this->getUser();
|
||||||
|
if ($owner instanceof User) {
|
||||||
|
$album->setOwner($owner);
|
||||||
}
|
}
|
||||||
$this->handleAlbumCoverUpload($album, $form);
|
|
||||||
$album->setLocalId($this->generateLocalId($albumRepo));
|
|
||||||
$em->persist($album);
|
$em->persist($album);
|
||||||
|
if (!$this->syncTracksFromEditorRequest($album, $request, $trackRepo)) {
|
||||||
|
$form->addError(new FormError('At least one track is required.'));
|
||||||
|
} else {
|
||||||
|
$uploadedCoverPath = null;
|
||||||
|
try {
|
||||||
|
$uploadedCoverPath = $this->storeCoverUpload($form->get('coverUpload')->getData(), $uploadStorage);
|
||||||
|
if ($uploadedCoverPath !== null) {
|
||||||
|
$album->setCoverImagePath($uploadedCoverPath);
|
||||||
|
}
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album created.');
|
$this->addFlash('success', 'Album created.');
|
||||||
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
|
return $this->redirectToRoute('album_show', ['id' => $album->getExternalId()]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
if ($uploadedCoverPath !== null) {
|
||||||
|
$uploadStorage->remove($uploadedCoverPath);
|
||||||
}
|
}
|
||||||
return $this->render('album/new.html.twig', [
|
$this->logger->error('Failed to create album', [
|
||||||
|
'album_name' => $album->getName(),
|
||||||
|
'owner_id' => $album->getOwner()?->getId(),
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError('Could not save album. Please check your input and try again.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('album/editor.html.twig', [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'trackRows' => $this->buildTrackEditorRows($album),
|
||||||
|
'isEdit' => false,
|
||||||
|
'albumId' => null,
|
||||||
|
'albumOwner' => $album->getOwner(),
|
||||||
|
'existingCoverUrl' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
|
||||||
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, ExternalAlbumClientInterface $externalAlbumClient, 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 the configured external provider.
|
||||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
|
||||||
if ($spotifyAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
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, $externalAlbumClient)) {
|
||||||
// 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, $externalAlbumClient);
|
||||||
$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());
|
||||||
|
|
||||||
@@ -149,30 +331,30 @@ class AlbumController extends AbstractController
|
|||||||
'avg' => $avg,
|
'avg' => $avg,
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
'albumOwner' => $albumEntity->getCreatedBy(),
|
'albumOwner' => $albumEntity->getOwner(),
|
||||||
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||||
'tracks' => $trackRows,
|
'tracks' => $trackRows,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
|
||||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
public function save(string $id, Request $request, ExternalAlbumClientInterface $externalAlbumClient, 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 = $externalAlbumClient->getAlbumWithTracks($id);
|
||||||
if ($spotifyAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
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 {
|
||||||
@@ -185,7 +367,7 @@ class AlbumController extends AbstractController
|
|||||||
* Deletes a user-created album when authorized.
|
* Deletes a user-created album when authorized.
|
||||||
*/
|
*/
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])]
|
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
|
||||||
public function delete(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
public function delete(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
$token = (string) $request->request->get('_token');
|
$token = (string) $request->request->get('_token');
|
||||||
@@ -195,9 +377,6 @@ class AlbumController extends AbstractController
|
|||||||
$album = $this->findAlbum($id, $albumRepo);
|
$album = $this->findAlbum($id, $albumRepo);
|
||||||
if ($album) {
|
if ($album) {
|
||||||
$this->ensureCanManageAlbum($album);
|
$this->ensureCanManageAlbum($album);
|
||||||
if ($album->getSource() === 'user') {
|
|
||||||
$this->uploadStorage->remove($album->getCoverImagePath());
|
|
||||||
}
|
|
||||||
$em->remove($album);
|
$em->remove($album);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album deleted.');
|
$this->addFlash('success', 'Album deleted.');
|
||||||
@@ -210,11 +389,11 @@ class AlbumController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Generates a unique user album identifier.
|
* Generates a unique user album identifier.
|
||||||
*/
|
*/
|
||||||
private function generateLocalId(AlbumRepository $albumRepo): string
|
private function generateUserAlbumId(AlbumRepository $albumRepo): string
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
$id = 'u_' . bin2hex(random_bytes(6));
|
$id = 'u_' . bin2hex(random_bytes(6));
|
||||||
} while ($albumRepo->findOneByLocalId($id) !== null);
|
} while ($albumRepo->findOneByExternalId($id) !== null);
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,41 +432,74 @@ class AlbumController extends AbstractController
|
|||||||
* Edits a saved album when the current user may manage it.
|
* Edits a saved album when the current user may manage it.
|
||||||
*/
|
*/
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])]
|
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
|
||||||
public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
public function edit(
|
||||||
|
string $id,
|
||||||
|
Request $request,
|
||||||
|
AlbumRepository $albumRepo,
|
||||||
|
AlbumTrackRepository $trackRepo,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
UploadStorage $uploadStorage
|
||||||
|
): Response
|
||||||
{
|
{
|
||||||
$album = $this->findAlbum($id, $albumRepo);
|
$album = $this->findAlbum($id, $albumRepo);
|
||||||
if (!$album) {
|
if (!$album) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
}
|
}
|
||||||
$this->ensureCanManageAlbum($album);
|
$this->ensureCanManageAlbum($album);
|
||||||
|
$this->normalizeAlbumFormData($album);
|
||||||
|
|
||||||
$form = $this->createForm(AlbumType::class, $album);
|
$form = $this->createForm(AlbumType::class, $album);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->normalizeAlbumFormData($album);
|
$this->normalizeAlbumFormData($album);
|
||||||
$this->handleAlbumCoverUpload($album, $form);
|
if (!$this->syncTracksFromEditorRequest($album, $request, $trackRepo)) {
|
||||||
|
$form->addError(new FormError('At least one track is required.'));
|
||||||
|
} else {
|
||||||
|
$previousCoverPath = $album->getCoverImagePath();
|
||||||
|
$uploadedCoverPath = null;
|
||||||
|
try {
|
||||||
|
$uploadedCoverPath = $this->storeCoverUpload($form->get('coverUpload')->getData(), $uploadStorage);
|
||||||
|
if ($uploadedCoverPath !== null) {
|
||||||
|
$album->setCoverImagePath($uploadedCoverPath);
|
||||||
|
}
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
if ($uploadedCoverPath !== null && $previousCoverPath !== null && $previousCoverPath !== $uploadedCoverPath) {
|
||||||
|
$uploadStorage->remove($previousCoverPath);
|
||||||
|
}
|
||||||
$this->addFlash('success', 'Album updated.');
|
$this->addFlash('success', 'Album updated.');
|
||||||
return $this->redirectToRoute('album_show', ['id' => $id]);
|
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
if ($uploadedCoverPath !== null) {
|
||||||
|
$uploadStorage->remove($uploadedCoverPath);
|
||||||
|
$album->setCoverImagePath($previousCoverPath);
|
||||||
}
|
}
|
||||||
return $this->render('album/edit.html.twig', [
|
$this->logger->error('Failed to update album', [
|
||||||
|
'album_id' => $id,
|
||||||
|
'owner_id' => $album->getOwner()?->getId(),
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
$form->addError(new FormError('Could not save album changes. Please check your input and try again.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('album/editor.html.twig', [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'trackRows' => $this->buildTrackEditorRows($album),
|
||||||
|
'isEdit' => true,
|
||||||
'albumId' => $id,
|
'albumId' => $id,
|
||||||
|
'albumOwner' => $album->getOwner(),
|
||||||
|
'existingCoverUrl' => $album->getCoverImagePath(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks up an album by either local or Spotify identifier.
|
* Looks up an album by public identifier.
|
||||||
*/
|
*/
|
||||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||||
{
|
{
|
||||||
$local = $albumRepo->findOneByLocalId($id);
|
return $albumRepo->findOneByExternalId($id);
|
||||||
if ($local instanceof Album) {
|
|
||||||
return $local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $albumRepo->findOneBySpotifyId($id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,10 +507,23 @@ class AlbumController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private function canManageAlbum(Album $album): bool
|
private function canManageAlbum(Album $album): bool
|
||||||
{
|
{
|
||||||
if ($this->isGranted('ROLE_MODERATOR')) {
|
if ($album->getSource() !== 'user') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_MODERATOR')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
|
|
||||||
|
$owner = $album->getOwner();
|
||||||
|
$user = $this->getUser();
|
||||||
|
if (!$owner instanceof User || !$user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownerId = $owner->getId();
|
||||||
|
$userId = $user->getId();
|
||||||
|
return $ownerId !== null && $userId !== null && $ownerId === $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,87 +536,287 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the current user created the album.
|
|
||||||
*/
|
|
||||||
private function isAlbumOwner(Album $album): bool
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeAlbumFormData(Album $album): void
|
private function normalizeAlbumFormData(Album $album): void
|
||||||
{
|
{
|
||||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
|
/**
|
||||||
|
* @return list<array{disc:int,track:int,title:string,duration:string}>
|
||||||
|
*/
|
||||||
|
private function buildTrackEditorRows(Album $album, int $minimumRows = 10): array
|
||||||
{
|
{
|
||||||
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
|
$rows = [];
|
||||||
return;
|
$position = 1;
|
||||||
|
foreach ($album->getTracks()->toArray() as $track) {
|
||||||
|
if (!$track instanceof AlbumTrack) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
$file = $form->get('coverUpload')->getData();
|
|
||||||
if ($file instanceof UploadedFile) {
|
$durationMs = (int) $track->getDurationMs();
|
||||||
$this->uploadStorage->remove($album->getCoverImagePath());
|
$seconds = max(0, (int) floor($durationMs / 1000));
|
||||||
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
|
$minutes = intdiv($seconds, 60);
|
||||||
|
$remainingSeconds = $seconds % 60;
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'disc' => max(1, (int) $track->getDiscNumber()),
|
||||||
|
'track' => max(1, (int) $track->getTrackNumber()),
|
||||||
|
'title' => trim((string) $track->getName()),
|
||||||
|
'duration' => sprintf('%d:%02d', $minutes, $remainingSeconds),
|
||||||
|
];
|
||||||
|
$position++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (count($rows) < $minimumRows) {
|
||||||
|
$rows[] = [
|
||||||
|
'disc' => 1,
|
||||||
|
'track' => count($rows) + 1,
|
||||||
|
'title' => '',
|
||||||
|
'duration' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncTracksFromEditorRequest(Album $album, Request $request, AlbumTrackRepository $trackRepo): bool
|
||||||
|
{
|
||||||
|
$trackPayloads = $this->extractTrackPayloadsFromRequest($request);
|
||||||
|
if ($trackPayloads === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trackRepo->replaceAlbumTracks($album, $trackPayloads);
|
||||||
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $spotifyAlbum
|
* @return list<array<string,mixed>>
|
||||||
*/
|
*/
|
||||||
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
private function extractTrackPayloadsFromRequest(Request $request): array
|
||||||
{
|
{
|
||||||
// Bring genres along when we persist Spotify albums so templates can display them immediately.
|
$titles = $request->request->all('track_title');
|
||||||
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
|
$durations = $request->request->all('track_duration');
|
||||||
$albumId = (string) ($spotifyAlbum['id'] ?? '');
|
$discNumbers = $request->request->all('track_disc');
|
||||||
$album = $albumRepo->upsertFromSpotifyAlbum(
|
$trackNumbers = $request->request->all('track_number');
|
||||||
$spotifyAlbum,
|
|
||||||
|
if (!is_array($titles)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloads = [];
|
||||||
|
$position = 1;
|
||||||
|
foreach ($titles as $idx => $rawTitle) {
|
||||||
|
$title = trim((string) $rawTitle);
|
||||||
|
if (mb_strlen($title) > 512) {
|
||||||
|
$title = mb_substr($title, 0, 512);
|
||||||
|
}
|
||||||
|
if ($title === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disc = isset($discNumbers[$idx]) ? (int) $discNumbers[$idx] : 1;
|
||||||
|
$track = isset($trackNumbers[$idx]) ? (int) $trackNumbers[$idx] : $position;
|
||||||
|
$durationRaw = isset($durations[$idx]) ? (string) $durations[$idx] : '';
|
||||||
|
|
||||||
|
$payloads[] = [
|
||||||
|
'disc_number' => max(1, $disc),
|
||||||
|
'track_number' => max(1, $track),
|
||||||
|
'name' => $title,
|
||||||
|
'duration_ms' => $this->parseTrackDurationToMs($durationRaw),
|
||||||
|
'preview_url' => null,
|
||||||
|
];
|
||||||
|
$position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseTrackDurationToMs(string $raw): int
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d+$/', $raw)) {
|
||||||
|
return max(0, (int) $raw) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!str_contains($raw, ':')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_values(array_filter(array_map('trim', explode(':', $raw)), static fn(string $value) => $value !== ''));
|
||||||
|
if ($parts === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = 0;
|
||||||
|
$multiplier = 1;
|
||||||
|
for ($i = count($parts) - 1; $i >= 0; $i--) {
|
||||||
|
if (!preg_match('/^\d+$/', $parts[$i])) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$seconds += ((int) $parts[$i]) * $multiplier;
|
||||||
|
$multiplier *= 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, $seconds) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeCoverUpload(mixed $uploadValue, UploadStorage $uploadStorage): ?string
|
||||||
|
{
|
||||||
|
if (!$uploadValue instanceof UploadedFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $uploadStorage->storeAlbumCover($uploadValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $externalAlbum
|
||||||
|
*/
|
||||||
|
private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||||
|
{
|
||||||
|
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
throw new \InvalidArgumentException('Album payload must include at least one track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bring genres along when we persist external albums so templates can display them immediately.
|
||||||
|
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||||
|
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||||
|
$album = $albumRepo->upsertFromExternalAlbum(
|
||||||
|
$externalAlbum,
|
||||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||||
);
|
);
|
||||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||||
if (is_array($tracks) && $tracks !== []) {
|
|
||||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
$album->setTotalTracks(count($tracks));
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
}
|
|
||||||
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, ExternalAlbumClientInterface $externalAlbumClient): 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;
|
||||||
|
}
|
||||||
|
if (!$needsSync && trim((string) $album->getReleaseDate()) === '') {
|
||||||
|
// Keep external metadata fresh when legacy rows are missing release dates.
|
||||||
$needsSync = true;
|
$needsSync = true;
|
||||||
}
|
}
|
||||||
if (!$needsSync) {
|
if (!$needsSync) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
|
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($externalId);
|
||||||
if ($spotifyAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
$album->setTotalTracks(count($tracks));
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $externalAlbum
|
||||||
|
*/
|
||||||
|
private function isIngestableExternalAlbum(array $externalAlbum): bool
|
||||||
|
{
|
||||||
|
$tracks = $externalAlbum['tracks']['items'] ?? null;
|
||||||
|
if (!is_array($tracks) || $tracks === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tracks as $track) {
|
||||||
|
$name = trim((string) ($track['name'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function hydrateExternalAlbumImages(array $payload, Album $album, ExternalAlbumClientInterface $externalAlbumClient): array
|
||||||
|
{
|
||||||
|
if ($album->getSource() !== 'external') {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalId = trim((string) $album->getExternalId());
|
||||||
|
if ($externalId === '') {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fresh = $externalAlbumClient->getAlbumWithTracks($externalId);
|
||||||
|
if (!is_array($fresh)) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $fresh['images'] ?? null;
|
||||||
|
if (is_array($images) && $images !== []) {
|
||||||
|
$payload['images'] = $images;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $profile
|
||||||
|
*/
|
||||||
|
private function attachSearchProfileHeaders(Response $response, array $profile): void
|
||||||
|
{
|
||||||
|
$timings = is_array($profile['timings_ms'] ?? null) ? $profile['timings_ms'] : [];
|
||||||
|
$counts = is_array($profile['counts'] ?? null) ? $profile['counts'] : [];
|
||||||
|
|
||||||
|
$total = (float) ($timings['total'] ?? 0.0);
|
||||||
|
$external = (float) ($timings['external'] ?? 0.0);
|
||||||
|
$user = (float) ($timings['user'] ?? 0.0);
|
||||||
|
$externalProvider = (string) ($profile['external_provider'] ?? 'lastfm');
|
||||||
|
$searchApi = (float) ($timings['lastfm_search_total'] ?? 0.0);
|
||||||
|
$getAlbumsApi = (float) ($timings['lastfm_getalbums_total'] ?? 0.0);
|
||||||
|
|
||||||
|
$response->headers->set('X-Tonehaus-Search-Ms', (string) $total);
|
||||||
|
$response->headers->set('X-Tonehaus-External-Ms', (string) $external);
|
||||||
|
$response->headers->set('X-Tonehaus-External-Provider', $externalProvider);
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Search-Calls', (string) ((int) ($counts['lastfm_search_calls'] ?? 0)));
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Calls', (string) ((int) ($counts['lastfm_getalbums_calls'] ?? 0)));
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Ids', (string) ((int) ($counts['lastfm_getalbums_requested_ids'] ?? 0)));
|
||||||
|
|
||||||
|
$response->headers->set(
|
||||||
|
'Server-Timing',
|
||||||
|
sprintf(
|
||||||
|
'search;dur=%.2f, external;dur=%.2f, user;dur=%.2f, lastfm_search;dur=%.2f, lastfm_getalbums;dur=%.2f',
|
||||||
|
$total,
|
||||||
|
$external,
|
||||||
|
$user,
|
||||||
|
$searchApi,
|
||||||
|
$getAlbumsApi
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use App\Dto\AlbumSearchCriteria;
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\Review;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
|
use App\Repository\ReviewRepository;
|
||||||
|
use App\Service\AlbumSearchService;
|
||||||
|
use App\Service\ExternalAlbumClientInterface;
|
||||||
|
use App\Service\LastFmTagResolver;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
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 ApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AlbumSearchService $albumSearchService,
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly AlbumTrackRepository $trackRepository,
|
||||||
|
private readonly ReviewRepository $reviewRepository,
|
||||||
|
private readonly ExternalAlbumClientInterface $externalAlbumClient,
|
||||||
|
private readonly LastFmTagResolver $tagResolver,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly int $searchLimit = 20,
|
||||||
|
private readonly bool $apiImageHydrationEnabled = true,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/albums',
|
||||||
|
operationId: 'api_v1_album_index',
|
||||||
|
summary: 'List albums from local catalog and configured external provider',
|
||||||
|
tags: ['Albums'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'query', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'album', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'artist', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'genre', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||||
|
new OA\Parameter(name: 'year_from', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||||
|
new OA\Parameter(name: 'year_to', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||||
|
new OA\Parameter(name: 'source', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['all', 'external', 'user'])),
|
||||||
|
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||||
|
new OA\Parameter(name: 'mode', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['full', 'fast'])),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Album list response',
|
||||||
|
content: new OA\JsonContent(type: 'object')
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||||
|
$searchMode = $this->resolveSearchMode($request);
|
||||||
|
$result = $this->albumSearchService->search(
|
||||||
|
$criteria,
|
||||||
|
$searchMode === 'fast',
|
||||||
|
$this->apiImageHydrationEnabled
|
||||||
|
);
|
||||||
|
$profile = $this->albumSearchService->getLastSearchProfile();
|
||||||
|
$queryParameters = $this->buildAlbumIndexQueryParameters($criteria, $searchMode);
|
||||||
|
$albums = array_map(
|
||||||
|
fn(array $album): array => $this->decorateAlbumResource($album),
|
||||||
|
$result->albums
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->apiResponse([
|
||||||
|
'meta' => [
|
||||||
|
'source' => $criteria->source,
|
||||||
|
'limit' => $criteria->limit,
|
||||||
|
'query' => $criteria->query,
|
||||||
|
'mode' => $searchMode,
|
||||||
|
'profile' => $profile,
|
||||||
|
],
|
||||||
|
'data' => $albums,
|
||||||
|
'stats' => $result->stats,
|
||||||
|
'saved_ids' => $result->savedIds,
|
||||||
|
'links' => [
|
||||||
|
'self' => $this->apiHref('api_v1_album_index', $queryParameters),
|
||||||
|
'root' => $this->apiHref('api_v1_index'),
|
||||||
|
'ingest' => $this->apiHref('api_v1_album_ingest'),
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'ingest_album' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_album_ingest'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
$this->attachSearchProfileHeaders($response, $profile);
|
||||||
|
$response->headers->set('X-Tonehaus-Api-Image-Hydration', $this->apiImageHydrationEnabled ? '1' : '0');
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSearchMode(Request $request): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $request->query->get('mode', 'full')));
|
||||||
|
return in_array($mode, ['full', 'fast'], true) ? $mode : 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[OA\Post(
|
||||||
|
path: '/api/v1/albums/ingest',
|
||||||
|
operationId: 'api_v1_album_ingest',
|
||||||
|
summary: 'Ingest an external album into local storage',
|
||||||
|
tags: ['Albums'],
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'external_id', type: 'string', nullable: true),
|
||||||
|
new OA\Property(property: 'query', type: 'string', nullable: true),
|
||||||
|
new OA\Property(property: 'artist', type: 'string', nullable: true),
|
||||||
|
new OA\Property(property: 'album', type: 'string', nullable: true),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 201, description: 'Album ingested', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 400, description: 'Invalid request', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 404, description: 'External album not found', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 422, description: 'Album missing required metadata/tracks', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function ingest(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $this->readJsonPayload($request);
|
||||||
|
if ($payload === null) {
|
||||||
|
return $this->apiError(
|
||||||
|
'invalid_json',
|
||||||
|
'Request body must be valid 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->externalAlbumClient->searchAlbums($query, 1);
|
||||||
|
$externalId = trim((string) ($search['albums']['items'][0]['id'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($externalId === '') {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Either external_id or query/artist/album is required.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($externalId);
|
||||||
|
if (!is_array($externalAlbum)) {
|
||||||
|
return $this->apiError(
|
||||||
|
'external_album_not_found',
|
||||||
|
'External album not found.',
|
||||||
|
Response::HTTP_NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
return $this->apiError(
|
||||||
|
'album_not_ingestable',
|
||||||
|
'Album is missing required metadata or tracks.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$payload = $album->toTemplateArray();
|
||||||
|
if ($this->apiImageHydrationEnabled) {
|
||||||
|
$payload = $this->hydrateExternalAlbumImages($payload, $album, $externalAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->apiResponse([
|
||||||
|
'data' => $this->decorateAlbumResource($payload, $album),
|
||||||
|
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/albums/{id}',
|
||||||
|
operationId: 'api_v1_album_show',
|
||||||
|
summary: 'Get album details by public ID',
|
||||||
|
tags: ['Albums'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Album details', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 422, description: 'Album missing required metadata/tracks', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function show(string $id): Response
|
||||||
|
{
|
||||||
|
$album = $this->findAlbum($id);
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($id);
|
||||||
|
if (!is_array($externalAlbum)) {
|
||||||
|
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
return $this->apiError(
|
||||||
|
'album_not_ingestable',
|
||||||
|
'Album is missing required metadata or tracks.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} elseif ($album->getSource() === 'external' && trim((string) $album->getReleaseDate()) === '') {
|
||||||
|
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks((string) $album->getExternalId());
|
||||||
|
if (is_array($externalAlbum) && $this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumId = (string) $album->getExternalId();
|
||||||
|
$stats = $albumId !== '' ? $this->reviewRepository->getAggregatesForAlbumIds([$albumId]) : [];
|
||||||
|
$payload = $album->toTemplateArray();
|
||||||
|
if ($this->apiImageHydrationEnabled) {
|
||||||
|
$payload = $this->hydrateExternalAlbumImages($payload, $album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->apiResponse([
|
||||||
|
'data' => $this->decorateAlbumResource($payload, $album),
|
||||||
|
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||||
|
'stats' => $stats[$albumId] ?? ['count' => 0, 'avg' => 0.0],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/albums/{id}/reviews',
|
||||||
|
operationId: 'api_v1_album_reviews',
|
||||||
|
summary: 'List reviews for an album',
|
||||||
|
tags: ['Albums'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function reviews(string $id): Response
|
||||||
|
{
|
||||||
|
$album = $this->findAlbum($id);
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviews = $this->reviewRepository->findBy(['album' => $album], ['createdAt' => 'DESC']);
|
||||||
|
return $this->apiResponse([
|
||||||
|
'data' => array_map(fn($review): array => $this->serializeAlbumReview($review, $album), $reviews),
|
||||||
|
'links' => [
|
||||||
|
'self' => $this->apiHref('api_v1_album_reviews', ['id' => (string) $album->getExternalId()]),
|
||||||
|
'album' => $this->apiHref('api_v1_album_show', ['id' => (string) $album->getExternalId()]),
|
||||||
|
'reviews' => $this->apiHref('api_v1_review_index'),
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'create_review' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_review_create'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function decorateAlbumResource(array $payload, ?Album $album = null): array
|
||||||
|
{
|
||||||
|
$albumId = trim((string) ($payload['id'] ?? $album?->getExternalId() ?? ''));
|
||||||
|
if ($albumId === '') {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$links = [
|
||||||
|
'self' => $this->apiHref('api_v1_album_show', ['id' => $albumId]),
|
||||||
|
'reviews' => $this->apiHref('api_v1_album_reviews', ['id' => $albumId]),
|
||||||
|
];
|
||||||
|
|
||||||
|
$sourceUrl = trim((string) ($payload['external_urls']['external'] ?? $album?->getExternalUrl() ?? ''));
|
||||||
|
if ($sourceUrl !== '') {
|
||||||
|
$links['open_source'] = $sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = [
|
||||||
|
'create_review' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_review_create'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
'payload' => ['album_id' => $albumId],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$source = strtolower(trim((string) ($payload['source'] ?? $album?->getSource() ?? '')));
|
||||||
|
if ($source === 'external') {
|
||||||
|
$actions['ingest'] = [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_album_ingest'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
'payload' => ['external_id' => $albumId],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->withHypermedia($payload, $links, $actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function serializeAlbumReview(Review $review, Album $album): array
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'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),
|
||||||
|
];
|
||||||
|
|
||||||
|
$reviewId = $review->getId();
|
||||||
|
if (!is_int($reviewId)) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumId = trim((string) $album->getExternalId());
|
||||||
|
$links = [
|
||||||
|
'self' => $this->apiHref('api_v1_review_show', ['id' => $reviewId]),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($albumId !== '') {
|
||||||
|
$links['album'] = $this->apiHref('api_v1_album_show', ['id' => $albumId]);
|
||||||
|
$links['album_reviews'] = $this->apiHref('api_v1_album_reviews', ['id' => $albumId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->withHypermedia($payload, $links);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildAlbumIndexQueryParameters(AlbumSearchCriteria $criteria, string $mode): array
|
||||||
|
{
|
||||||
|
$parameters = [
|
||||||
|
'source' => $criteria->source,
|
||||||
|
'limit' => $criteria->limit,
|
||||||
|
'mode' => $mode,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($criteria->query !== '') {
|
||||||
|
$parameters['query'] = $criteria->query;
|
||||||
|
}
|
||||||
|
if ($criteria->albumName !== '') {
|
||||||
|
$parameters['album'] = $criteria->albumName;
|
||||||
|
}
|
||||||
|
if ($criteria->artist !== '') {
|
||||||
|
$parameters['artist'] = $criteria->artist;
|
||||||
|
}
|
||||||
|
if ($criteria->genre !== '') {
|
||||||
|
$parameters['genre'] = $criteria->genre;
|
||||||
|
}
|
||||||
|
if ($criteria->yearFrom !== null) {
|
||||||
|
$parameters['year_from'] = $criteria->yearFrom;
|
||||||
|
}
|
||||||
|
if ($criteria->yearTo !== null) {
|
||||||
|
$parameters['year_to'] = $criteria->yearTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAlbum(string $id): ?Album
|
||||||
|
{
|
||||||
|
$normalizedId = trim($id);
|
||||||
|
if ($normalizedId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = $this->albumRepository->findOneByExternalId($normalizedId);
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $album->getTotalTracks() > 0 ? $album : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $externalAlbum
|
||||||
|
*/
|
||||||
|
private function persistExternalAlbumPayload(array $externalAlbum): Album
|
||||||
|
{
|
||||||
|
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
|
throw new \InvalidArgumentException('Album payload must include at least one track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||||
|
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||||
|
|
||||||
|
$album = $this->albumRepository->upsertFromExternalAlbum(
|
||||||
|
$externalAlbum,
|
||||||
|
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||||
|
$this->trackRepository->replaceAlbumTracks($album, $tracks);
|
||||||
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
|
|
||||||
|
return $album;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $externalAlbum
|
||||||
|
*/
|
||||||
|
private function isIngestableExternalAlbum(array $externalAlbum): bool
|
||||||
|
{
|
||||||
|
$albumId = trim((string) ($externalAlbum['id'] ?? ''));
|
||||||
|
$albumName = trim((string) ($externalAlbum['name'] ?? ''));
|
||||||
|
if ($albumId === '' || $albumName === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracks = $externalAlbum['tracks']['items'] ?? null;
|
||||||
|
if (!is_array($tracks) || $tracks === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tracks as $track) {
|
||||||
|
$name = trim((string) ($track['name'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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->externalAlbumClient->getAlbumWithTracks($externalId);
|
||||||
|
if (!is_array($resolved)) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
$fresh = $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = $fresh['images'] ?? null;
|
||||||
|
if (is_array($images) && $images !== []) {
|
||||||
|
$payload['images'] = $images;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $profile
|
||||||
|
*/
|
||||||
|
private function attachSearchProfileHeaders(Response $response, array $profile): void
|
||||||
|
{
|
||||||
|
$timings = is_array($profile['timings_ms'] ?? null) ? $profile['timings_ms'] : [];
|
||||||
|
$counts = is_array($profile['counts'] ?? null) ? $profile['counts'] : [];
|
||||||
|
|
||||||
|
$total = (float) ($timings['total'] ?? 0.0);
|
||||||
|
$external = (float) ($timings['external'] ?? 0.0);
|
||||||
|
$user = (float) ($timings['user'] ?? 0.0);
|
||||||
|
$externalProvider = (string) ($profile['external_provider'] ?? 'lastfm');
|
||||||
|
$searchApi = (float) ($timings['lastfm_search_total'] ?? 0.0);
|
||||||
|
$getAlbumsApi = (float) ($timings['lastfm_getalbums_total'] ?? 0.0);
|
||||||
|
|
||||||
|
$response->headers->set('X-Tonehaus-Search-Ms', (string) $total);
|
||||||
|
$response->headers->set('X-Tonehaus-External-Ms', (string) $external);
|
||||||
|
$response->headers->set('X-Tonehaus-External-Provider', $externalProvider);
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Search-Calls', (string) ((int) ($counts['lastfm_search_calls'] ?? 0)));
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Calls', (string) ((int) ($counts['lastfm_getalbums_calls'] ?? 0)));
|
||||||
|
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Ids', (string) ((int) ($counts['lastfm_getalbums_requested_ids'] ?? 0)));
|
||||||
|
|
||||||
|
$response->headers->set(
|
||||||
|
'Server-Timing',
|
||||||
|
sprintf(
|
||||||
|
'search;dur=%.2f, external;dur=%.2f, user;dur=%.2f, lastfm_search;dur=%.2f, lastfm_getalbums;dur=%.2f',
|
||||||
|
$total,
|
||||||
|
$external,
|
||||||
|
$user,
|
||||||
|
$searchApi,
|
||||||
|
$getAlbumsApi
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiController centralizes shared API response helpers.
|
||||||
|
*/
|
||||||
|
abstract class ApiController extends AbstractFOSRestController
|
||||||
|
{
|
||||||
|
protected function apiResponse(mixed $payload, int $status = Response::HTTP_OK): Response
|
||||||
|
{
|
||||||
|
return $this->handleView($this->view($payload, $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $details
|
||||||
|
*/
|
||||||
|
protected function apiError(string $code, string $message, int $status, array $details = []): Response
|
||||||
|
{
|
||||||
|
$error = [
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($details !== []) {
|
||||||
|
$error['details'] = $details;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->apiResponse(['error' => $error], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
protected 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function apiHref(string $route, array $parameters = []): string
|
||||||
|
{
|
||||||
|
return $this->generateUrl($route, $parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $resource
|
||||||
|
* @param array<string,string> $links
|
||||||
|
* @param array<string,array<string,mixed>> $actions
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function withHypermedia(array $resource, array $links = [], array $actions = []): array
|
||||||
|
{
|
||||||
|
if ($links !== []) {
|
||||||
|
$resource['links'] = $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actions !== []) {
|
||||||
|
$resource['actions'] = $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/v1')]
|
||||||
|
class ApiIndexController extends ApiController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'api_v1_index', methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1',
|
||||||
|
operationId: 'api_v1_index',
|
||||||
|
summary: 'API index',
|
||||||
|
tags: ['Auth'],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'API metadata and available endpoints',
|
||||||
|
content: new OA\JsonContent(type: 'object')
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse([
|
||||||
|
'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)',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'links' => [
|
||||||
|
'self' => $this->apiHref('api_v1_index'),
|
||||||
|
'auth_login' => $this->apiHref('api_v1_auth_login'),
|
||||||
|
'albums' => $this->apiHref('api_v1_album_index'),
|
||||||
|
'reviews' => $this->apiHref('api_v1_review_index'),
|
||||||
|
'docs' => $this->apiHref('api_docs_ui'),
|
||||||
|
'docs_v1' => $this->apiHref('api_v1_docs_ui'),
|
||||||
|
'schema' => $this->apiHref('api_docs_json'),
|
||||||
|
'schema_v1' => $this->apiHref('api_v1_docs_json'),
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'login' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_auth_login'),
|
||||||
|
],
|
||||||
|
'search_albums' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'href' => $this->apiHref('api_v1_album_index'),
|
||||||
|
],
|
||||||
|
'create_review' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_review_create'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/api/v1/auth')]
|
||||||
|
final class AuthApiController extends ApiController
|
||||||
|
{
|
||||||
|
#[Route('/login', name: 'api_v1_auth_login', methods: ['POST'])]
|
||||||
|
#[OA\Post(
|
||||||
|
path: '/api/v1/auth/login',
|
||||||
|
operationId: 'api_v1_auth_login',
|
||||||
|
summary: 'Obtain JWT access token',
|
||||||
|
tags: ['Auth'],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
required: ['email', 'password'],
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
|
||||||
|
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'JWT token response',
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'token', type: 'string', example: 'eyJ...')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
new OA\Response(response: 401, description: 'Invalid credentials', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function loginPlaceholder(): Response
|
||||||
|
{
|
||||||
|
return $this->apiError(
|
||||||
|
'unsupported_operation',
|
||||||
|
'This route is handled by Symfony security json_login.',
|
||||||
|
Response::HTTP_METHOD_NOT_ALLOWED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<?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 OpenApi\Attributes as OA;
|
||||||
|
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 ApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReviewRepository $reviewRepository,
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/reviews',
|
||||||
|
operationId: 'api_v1_review_index',
|
||||||
|
summary: 'List latest reviews',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 50)),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$limit = max(1, min(100, (int) $request->query->get('limit', 50)));
|
||||||
|
$reviews = $this->reviewRepository->findLatest($limit);
|
||||||
|
|
||||||
|
return $this->apiResponse([
|
||||||
|
'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews),
|
||||||
|
'links' => [
|
||||||
|
'self' => $this->apiHref('api_v1_review_index', ['limit' => $limit]),
|
||||||
|
'root' => $this->apiHref('api_v1_index'),
|
||||||
|
'create' => $this->apiHref('api_v1_review_create'),
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'create_review' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'href' => $this->apiHref('api_v1_review_create'),
|
||||||
|
'requires_auth' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||||
|
#[OA\Get(
|
||||||
|
path: '/api/v1/reviews/{id}',
|
||||||
|
operationId: 'api_v1_review_show',
|
||||||
|
summary: 'Get a review by ID',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Review details', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function show(Review $review): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'api_v1_review_create', methods: ['POST'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[OA\Post(
|
||||||
|
path: '/api/v1/reviews',
|
||||||
|
operationId: 'api_v1_review_create',
|
||||||
|
summary: 'Create a review',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
required: ['album_id', 'title', 'content', 'rating'],
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'album_id', type: 'string'),
|
||||||
|
new OA\Property(property: 'title', type: 'string'),
|
||||||
|
new OA\Property(property: 'content', type: 'string'),
|
||||||
|
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 201, description: 'Review created', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $this->readJsonPayload($request);
|
||||||
|
if ($payload === null) {
|
||||||
|
return $this->apiError(
|
||||||
|
'invalid_json',
|
||||||
|
'Request body must be valid JSON.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$albumId = trim((string) ($payload['album_id'] ?? ''));
|
||||||
|
$album = $this->resolveAlbum($albumId);
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = trim((string) ($payload['title'] ?? ''));
|
||||||
|
$content = trim((string) ($payload['content'] ?? ''));
|
||||||
|
$rating = (int) ($payload['rating'] ?? 0);
|
||||||
|
|
||||||
|
$fieldErrors = $this->validateCreatePayload($title, $content, $rating);
|
||||||
|
if ($fieldErrors !== []) {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Review payload failed validation.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => $fieldErrors]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$author = $this->getUser();
|
||||||
|
if (!$author instanceof User) {
|
||||||
|
return $this->apiError(
|
||||||
|
'authentication_required',
|
||||||
|
'Authentication is 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->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[OA\Put(
|
||||||
|
path: '/api/v1/reviews/{id}',
|
||||||
|
operationId: 'api_v1_review_update_put',
|
||||||
|
summary: 'Replace a review',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'title', type: 'string'),
|
||||||
|
new OA\Property(property: 'content', type: 'string'),
|
||||||
|
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[OA\Patch(
|
||||||
|
path: '/api/v1/reviews/{id}',
|
||||||
|
operationId: 'api_v1_review_update_patch',
|
||||||
|
summary: 'Partially update a review',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'title', type: 'string'),
|
||||||
|
new OA\Property(property: 'content', type: 'string'),
|
||||||
|
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function update(Review $review, Request $request): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
|
||||||
|
|
||||||
|
$payload = $this->readJsonPayload($request);
|
||||||
|
if ($payload === null) {
|
||||||
|
return $this->apiError(
|
||||||
|
'invalid_json',
|
||||||
|
'Request body must be valid JSON.',
|
||||||
|
Response::HTTP_BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->payloadContainsUpdateField($payload)) {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'At least one updatable field is required.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => ['title', 'content', 'rating']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('title', $payload)) {
|
||||||
|
$title = trim((string) $payload['title']);
|
||||||
|
if ($title === '') {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Review payload failed validation.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => ['title' => 'Title cannot be empty.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$review->setTitle($title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('content', $payload)) {
|
||||||
|
$content = trim((string) $payload['content']);
|
||||||
|
if ($content === '') {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Review payload failed validation.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => ['content' => 'Content cannot be empty.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mb_strlen($content) < 20) {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Review payload failed validation.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => ['content' => 'Content must be at least 20 characters.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$review->setContent($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('rating', $payload)) {
|
||||||
|
$rating = (int) $payload['rating'];
|
||||||
|
if ($rating < 1 || $rating > 10) {
|
||||||
|
return $this->apiError(
|
||||||
|
'validation_error',
|
||||||
|
'Review payload failed validation.',
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||||
|
['fields' => ['rating' => 'Rating must be between 1 and 10.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$review->setRating($rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[OA\Delete(
|
||||||
|
path: '/api/v1/reviews/{id}',
|
||||||
|
operationId: 'api_v1_review_delete',
|
||||||
|
summary: 'Delete a review',
|
||||||
|
tags: ['Reviews'],
|
||||||
|
security: [['bearerAuth' => []]],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(response: 204, description: 'Review deleted'),
|
||||||
|
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||||
|
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function delete(Review $review): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
||||||
|
|
||||||
|
$this->entityManager->remove($review);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->apiResponse(null, Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function serializeReview(Review $review): array
|
||||||
|
{
|
||||||
|
$album = $review->getAlbum();
|
||||||
|
$reviewId = $review->getId();
|
||||||
|
$albumId = trim((string) $album?->getExternalId());
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'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(),
|
||||||
|
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||||
|
],
|
||||||
|
'album' => [
|
||||||
|
'id' => $album?->getExternalId(),
|
||||||
|
'name' => $album?->getName(),
|
||||||
|
'source' => $album?->getSource(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
if (is_int($reviewId)) {
|
||||||
|
$links['self'] = $this->apiHref('api_v1_review_show', ['id' => $reviewId]);
|
||||||
|
}
|
||||||
|
if ($albumId !== '') {
|
||||||
|
$links['album'] = $this->apiHref('api_v1_album_show', ['id' => $albumId]);
|
||||||
|
$links['album_reviews'] = $this->apiHref('api_v1_album_reviews', ['id' => $albumId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
if (is_int($reviewId) && $this->isGranted('REVIEW_EDIT', $review)) {
|
||||||
|
$actions['update'] = [
|
||||||
|
'method' => 'PATCH',
|
||||||
|
'href' => $this->apiHref('api_v1_review_update', ['id' => $reviewId]),
|
||||||
|
'requires_auth' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (is_int($reviewId) && $this->isGranted('REVIEW_DELETE', $review)) {
|
||||||
|
$actions['delete'] = [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'href' => $this->apiHref('api_v1_review_delete', ['id' => $reviewId]),
|
||||||
|
'requires_auth' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->withHypermedia($payload, $links, $actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAlbum(string $albumId): ?Album
|
||||||
|
{
|
||||||
|
if ($albumId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = $this->albumRepository->findOneByExternalId($albumId);
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $album->getTotalTracks() > 0 ? $album : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function validateCreatePayload(string $title, string $content, int $rating): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
$errors['title'] = 'Title is required.';
|
||||||
|
}
|
||||||
|
if ($content === '') {
|
||||||
|
$errors['content'] = 'Content is required.';
|
||||||
|
} elseif (mb_strlen($content) < 20) {
|
||||||
|
$errors['content'] = 'Content must be at least 20 characters.';
|
||||||
|
}
|
||||||
|
if ($rating < 1 || $rating > 10) {
|
||||||
|
$errors['rating'] = 'Rating must be between 1 and 10.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*/
|
||||||
|
private function payloadContainsUpdateField(array $payload): bool
|
||||||
|
{
|
||||||
|
return array_key_exists('title', $payload)
|
||||||
|
|| array_key_exists('content', $payload)
|
||||||
|
|| array_key_exists('rating', $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` (`external` kept for legacy links). */
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,24 +58,26 @@ final class AlbumSearchCriteria
|
|||||||
*/
|
*/
|
||||||
public static function fromRequest(Request $request, int $defaultLimit = 20): self
|
public static function fromRequest(Request $request, int $defaultLimit = 20): self
|
||||||
{
|
{
|
||||||
|
$source = self::resolveSourceFromRequest($request);
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
query: trim((string) $request->query->get('q', '')),
|
query: self::resolveQueryFromRequest($request),
|
||||||
albumName: trim($request->query->getString('album', '')),
|
albumName: trim($request->query->getString('album', '')),
|
||||||
artist: trim($request->query->getString('artist', '')),
|
artist: trim($request->query->getString('artist', '')),
|
||||||
genre: trim($request->query->getString('genre', '')),
|
genre: trim($request->query->getString('genre', '')),
|
||||||
yearFrom: self::normalizeYear($request->query->get('year_from')),
|
yearFrom: self::normalizeYear($request->query->get('year_from')),
|
||||||
yearTo: self::normalizeYear($request->query->get('year_to')),
|
yearTo: self::normalizeYear($request->query->get('year_to')),
|
||||||
source: self::normalizeSource($request->query->getString('source', 'all')),
|
source: $source,
|
||||||
limit: $defaultLimit
|
limit: self::resolveLimitFromRequest($request, $defaultLimit)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,13 +97,58 @@ final class AlbumSearchCriteria
|
|||||||
if ($raw === '') {
|
if ($raw === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return preg_match('/^\d{4}$/', $raw) ? (int) $raw : null;
|
if (preg_match('/^\d{4}$/', $raw)) {
|
||||||
|
return (int) $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(\d{4})-\d{2}(?:-\d{2})?$/', $raw, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveSourceFromRequest(Request $request): string
|
||||||
|
{
|
||||||
|
// New faceted UX uses a single user-only toggle.
|
||||||
|
if ($request->query->has('user_only')) {
|
||||||
|
return $request->query->getBoolean('user_only') ? 'user' : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep compatibility for any existing links using `source=...`.
|
||||||
|
return self::normalizeSource($request->query->getString('source', 'all'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveQueryFromRequest(Request $request): string
|
||||||
|
{
|
||||||
|
// API uses `query`; web UI currently uses `q`. Accept both.
|
||||||
|
$query = trim($request->query->getString('query', ''));
|
||||||
|
if ($query !== '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $request->query->get('q', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveLimitFromRequest(Request $request, int $defaultLimit): int
|
||||||
|
{
|
||||||
|
$maxLimit = max(1, $defaultLimit);
|
||||||
|
$raw = trim((string) $request->query->get('limit', ''));
|
||||||
|
if ($raw === '' || !preg_match('/^\d+$/', $raw)) {
|
||||||
|
return $maxLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int) $raw;
|
||||||
|
if ($limit <= 0) {
|
||||||
|
return $maxLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($limit, $maxLimit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+77
-109
@@ -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,13 @@ 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
|
// Public identifier used by APIs and UI.
|
||||||
#[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
|
// 'external' or 'user'
|
||||||
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
|
||||||
private ?string $localId = null;
|
|
||||||
|
|
||||||
// 'spotify' 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,32 +51,29 @@ 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;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
private int $totalTracks = 0;
|
private int $totalTracks = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
|
||||||
private ?string $coverUrl = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
private ?string $coverImagePath = null;
|
private ?string $coverImagePath = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
private ?string $externalUrl = null;
|
private ?string $externalUrl = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
private ?User $createdBy = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $owner = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tracks = new ArrayCollection();
|
$this->tracks = new ArrayCollection();
|
||||||
@@ -115,39 +108,23 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the local unique identifier for user-created albums.
|
* Returns the album source flag ("external" or "user").
|
||||||
*/
|
|
||||||
public function getLocalId(): ?string
|
|
||||||
{
|
|
||||||
return $this->localId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns the local identifier for user-created albums.
|
|
||||||
*/
|
|
||||||
public function setLocalId(?string $localId): void
|
|
||||||
{
|
|
||||||
$this->localId = $localId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the album source flag ("spotify" or "user").
|
|
||||||
*/
|
*/
|
||||||
public function getSource(): string
|
public function getSource(): string
|
||||||
{
|
{
|
||||||
@@ -155,7 +132,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
|
||||||
{
|
{
|
||||||
@@ -248,29 +225,29 @@ class Album
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the preferred cover art URL.
|
* Returns an external link.
|
||||||
*/
|
|
||||||
public function getCoverUrl(): ?string
|
|
||||||
{
|
|
||||||
return $this->coverUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the preferred cover art URL.
|
|
||||||
*/
|
|
||||||
public function setCoverUrl(?string $coverUrl): void
|
|
||||||
{
|
|
||||||
$this->coverUrl = $coverUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an external link (defaults to Spotify).
|
|
||||||
*/
|
*/
|
||||||
public function getExternalUrl(): ?string
|
public function getExternalUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->externalUrl;
|
return $this->externalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the uploaded cover image path when present.
|
||||||
|
*/
|
||||||
|
public function getCoverImagePath(): ?string
|
||||||
|
{
|
||||||
|
return $this->coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the uploaded cover image path.
|
||||||
|
*/
|
||||||
|
public function setCoverImagePath(?string $coverImagePath): void
|
||||||
|
{
|
||||||
|
$this->coverImagePath = $coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the external reference link.
|
* Sets the external reference link.
|
||||||
*/
|
*/
|
||||||
@@ -279,38 +256,6 @@ class Album
|
|||||||
$this->externalUrl = $externalUrl;
|
$this->externalUrl = $externalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the user that created the album, when applicable.
|
|
||||||
*/
|
|
||||||
public function getCreatedBy(): ?User
|
|
||||||
{
|
|
||||||
return $this->createdBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the locally stored cover image path for user albums.
|
|
||||||
*/
|
|
||||||
public function getCoverImagePath(): ?string
|
|
||||||
{
|
|
||||||
return $this->coverImagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the locally stored cover image path for user albums.
|
|
||||||
*/
|
|
||||||
public function setCoverImagePath(?string $coverImagePath): void
|
|
||||||
{
|
|
||||||
$this->coverImagePath = $coverImagePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the owner responsible for the album.
|
|
||||||
*/
|
|
||||||
public function setCreatedBy(?User $user): void
|
|
||||||
{
|
|
||||||
$this->createdBy = $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the creation timestamp.
|
* Returns the creation timestamp.
|
||||||
*/
|
*/
|
||||||
@@ -327,6 +272,22 @@ class Album
|
|||||||
return $this->updatedAt;
|
return $this->updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user that owns this album entry, when available.
|
||||||
|
*/
|
||||||
|
public function getOwner(): ?User
|
||||||
|
{
|
||||||
|
return $this->owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns or clears the owner of this album entry.
|
||||||
|
*/
|
||||||
|
public function setOwner(?User $owner): void
|
||||||
|
{
|
||||||
|
$this->owner = $owner;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shapes the entity to the payload Twig templates expect.
|
* Shapes the entity to the payload Twig templates expect.
|
||||||
*
|
*
|
||||||
@@ -334,38 +295,47 @@ class Album
|
|||||||
*/
|
*/
|
||||||
public function toTemplateArray(): array
|
public function toTemplateArray(): array
|
||||||
{
|
{
|
||||||
$images = [];
|
|
||||||
$imageUrl = $this->coverUrl;
|
|
||||||
if ($this->source === 'user' && $this->coverImagePath) {
|
|
||||||
$imageUrl = $this->coverImagePath;
|
|
||||||
}
|
|
||||||
if ($imageUrl) {
|
|
||||||
$images = [
|
|
||||||
['url' => $imageUrl],
|
|
||||||
['url' => $imageUrl],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$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) {
|
$genres = $this->source === 'external'
|
||||||
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
|
? $this->normalizeTopGenreForOutput($this->genres)
|
||||||
|
: array_slice($this->genres, 0, 5);
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
$coverImagePath = trim((string) $this->coverImagePath);
|
||||||
|
if ($coverImagePath !== '') {
|
||||||
|
$images[] = ['url' => $coverImagePath];
|
||||||
}
|
}
|
||||||
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
|
|
||||||
$genres = array_slice($this->genres, 0, 5);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $publicId,
|
'id' => (string) $this->externalId,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
'artists' => $artists,
|
'artists' => $artists,
|
||||||
'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 +359,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
|
||||||
|
|||||||
+26
-1
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Validator\Exception\ValidationFailedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiExceptionSubscriber normalizes API failures into one JSON error envelope.
|
||||||
|
*/
|
||||||
|
class ApiExceptionSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly bool $debug = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
KernelEvents::EXCEPTION => 'onKernelException',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelException(ExceptionEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
if (!str_starts_with($request->getPathInfo(), '/api/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$throwable = $event->getThrowable();
|
||||||
|
$status = $this->resolveStatusCode($throwable);
|
||||||
|
[$code, $message] = $this->resolveCodeAndMessage($status, $throwable);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'error' => [
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'status' => $status,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->debug && $status >= 500) {
|
||||||
|
$payload['error']['details'] = [
|
||||||
|
'exception' => $throwable::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse(new JsonResponse($payload, $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveStatusCode(\Throwable $throwable): int
|
||||||
|
{
|
||||||
|
if ($throwable instanceof AuthenticationException) {
|
||||||
|
return Response::HTTP_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($throwable instanceof AccessDeniedException) {
|
||||||
|
return Response::HTTP_FORBIDDEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($throwable instanceof ValidationFailedException) {
|
||||||
|
return Response::HTTP_UNPROCESSABLE_ENTITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($throwable instanceof HttpExceptionInterface) {
|
||||||
|
return $throwable->getStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($throwable instanceof \InvalidArgumentException || $throwable instanceof \JsonException) {
|
||||||
|
return Response::HTTP_BAD_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::HTTP_INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private function resolveCodeAndMessage(int $status, \Throwable $throwable): array
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
Response::HTTP_BAD_REQUEST => ['invalid_request', 'Request validation failed.'],
|
||||||
|
Response::HTTP_UNAUTHORIZED => ['authentication_required', 'Authentication is required.'],
|
||||||
|
Response::HTTP_FORBIDDEN => ['forbidden', 'You do not have permission to perform this action.'],
|
||||||
|
Response::HTTP_NOT_FOUND => ['not_found', 'Requested resource was not found.'],
|
||||||
|
Response::HTTP_METHOD_NOT_ALLOWED => ['method_not_allowed', 'HTTP method is not allowed for this endpoint.'],
|
||||||
|
Response::HTTP_UNSUPPORTED_MEDIA_TYPE => ['unsupported_media_type', 'Content type is not supported.'],
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY => ['validation_error', 'Request validation failed.'],
|
||||||
|
default => $this->resolveFallbackCodeAndMessage($status, $throwable),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private function resolveFallbackCodeAndMessage(int $status, \Throwable $throwable): array
|
||||||
|
{
|
||||||
|
if ($status >= 500) {
|
||||||
|
return ['internal_server_error', 'An unexpected server error occurred.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackMessage = trim($throwable->getMessage());
|
||||||
|
if ($fallbackMessage === '') {
|
||||||
|
$fallbackMessage = 'Request could not be processed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['request_error', $fallbackMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationFailureEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Events;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiJwtFailureSubscriber normalizes Lexik JWT failures to the API error envelope.
|
||||||
|
*/
|
||||||
|
class ApiJwtFailureSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Events::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
|
||||||
|
Events::JWT_NOT_FOUND => 'onJwtNotFound',
|
||||||
|
Events::JWT_INVALID => 'onJwtInvalid',
|
||||||
|
Events::JWT_EXPIRED => 'onJwtExpired',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(AuthenticationFailureEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$this->isApiRequest($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $event->getResponse()?->getStatusCode() ?? Response::HTTP_UNAUTHORIZED;
|
||||||
|
$message = trim((string) $event->getException()->getMessageKey());
|
||||||
|
if ($message === '') {
|
||||||
|
$message = 'Authentication failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse($this->buildErrorResponse('invalid_credentials', $message, $status));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onJwtNotFound(JWTNotFoundEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$this->isApiRequest($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse($this->buildErrorResponse(
|
||||||
|
'authentication_required',
|
||||||
|
'Authentication is required.',
|
||||||
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onJwtInvalid(JWTInvalidEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$this->isApiRequest($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse($this->buildErrorResponse(
|
||||||
|
'invalid_token',
|
||||||
|
'Invalid authentication token.',
|
||||||
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onJwtExpired(JWTExpiredEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$this->isApiRequest($event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->setResponse($this->buildErrorResponse(
|
||||||
|
'token_expired',
|
||||||
|
'Authentication token has expired.',
|
||||||
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildErrorResponse(string $code, string $message, int $status): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => [
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
'status' => $status,
|
||||||
|
],
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isApiRequest(
|
||||||
|
AuthenticationFailureEvent|JWTNotFoundEvent|JWTInvalidEvent|JWTExpiredEvent $event
|
||||||
|
): bool
|
||||||
|
{
|
||||||
|
$path = $event->getRequest()?->getPathInfo();
|
||||||
|
|
||||||
|
return is_string($path) && str_starts_with($path, '/api/');
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -4,6 +4,7 @@ namespace App\Form;
|
|||||||
|
|
||||||
use App\Entity\Album;
|
use App\Entity\Album;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
@@ -38,11 +39,17 @@ class AlbumType extends AbstractType
|
|||||||
'label' => 'Genres (comma-separated)',
|
'label' => 'Genres (comma-separated)',
|
||||||
'help' => 'Optional: e.g. Dream pop, Shoegaze',
|
'help' => 'Optional: e.g. Dream pop, Shoegaze',
|
||||||
])
|
])
|
||||||
->add('releaseDate', TextType::class, [
|
->add('releaseDate', DateType::class, [
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
|
'help' => 'Release date',
|
||||||
|
'widget' => 'single_text',
|
||||||
|
'input' => 'string',
|
||||||
|
'html5' => true,
|
||||||
|
'format' => 'yyyy-MM-dd',
|
||||||
])
|
])
|
||||||
->add('totalTracks', IntegerType::class, [
|
->add('totalTracks', IntegerType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'empty_data' => '0',
|
||||||
'constraints' => [new Assert\Range(min: 0, max: 500)],
|
'constraints' => [new Assert\Range(min: 0, max: 500)],
|
||||||
])
|
])
|
||||||
->add('coverUpload', FileType::class, [
|
->add('coverUpload', FileType::class, [
|
||||||
@@ -55,6 +62,10 @@ class AlbumType extends AbstractType
|
|||||||
->add('externalUrl', TextType::class, [
|
->add('externalUrl', TextType::class, [
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => 'External link',
|
'label' => 'External link',
|
||||||
|
'constraints' => [
|
||||||
|
new Assert\Length(max: 1024),
|
||||||
|
new Assert\Url(requireTld: false),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seed the CSV helper fields with existing entity values before rendering.
|
// Seed the CSV helper fields with existing entity values before rendering.
|
||||||
@@ -129,5 +140,3 @@ class AlbumType extends AbstractType
|
|||||||
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
|
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,29 +4,65 @@ namespace App\Form;
|
|||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
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 external API/provider credentials and registration controls.
|
||||||
*/
|
*/
|
||||||
class SiteSettingsType extends AbstractType
|
class SiteSettingsType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Exposes Spotify credential inputs for administrators.
|
* Exposes provider + 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('EXTERNAL_ALBUM_PROVIDER', ChoiceType::class, [
|
||||||
|
'required' => true,
|
||||||
|
'label' => 'External album provider',
|
||||||
|
'mapped' => false,
|
||||||
|
'choices' => [
|
||||||
|
'Last.fm' => 'lastfm',
|
||||||
|
'MusicBrainz' => 'musicbrainz',
|
||||||
|
'TIDAL' => 'tidal',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('LASTFM_API_KEY', TextType::class, [
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => 'Spotify Client ID',
|
'label' => 'Last.fm API key',
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
])
|
])
|
||||||
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
|
->add('MUSICBRAINZ_CLIENT_ID', TextType::class, [
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => 'Spotify Client Secret',
|
'label' => 'MusicBrainz client ID (optional)',
|
||||||
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('MUSICBRAINZ_CLIENT_SECRET', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'MusicBrainz client secret (optional)',
|
||||||
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('MUSICBRAINZ_ACCESS_TOKEN', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'MusicBrainz access token (optional)',
|
||||||
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('TIDAL_CLIENT_ID', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'TIDAL client ID',
|
||||||
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('TIDAL_CLIENT_SECRET', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'TIDAL client secret',
|
||||||
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('TIDAL_COUNTRY_CODE', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'TIDAL country code (optional, ISO-2)',
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
])
|
])
|
||||||
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
||||||
@@ -44,5 +80,3 @@ class SiteSettingsType extends AbstractType
|
|||||||
$resolver->setDefaults([]);
|
$resolver->setDefaults([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -25,64 +25,68 @@ 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]);
|
$normalized = trim($externalId);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findOneBy(['externalId' => $normalized]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds one album by user-local identifier.
|
* Returns albums keyed by External identifiers for quick lookup.
|
||||||
*/
|
|
||||||
public function findOneByLocalId(string $localId): ?Album
|
|
||||||
{
|
|
||||||
return $this->findOneBy(['localId' => $localId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns albums keyed by Spotify 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 === []) {
|
$normalizedIds = array_values(array_unique(array_filter(
|
||||||
|
array_map(static fn(mixed $id): string => trim((string) $id), $externalIds),
|
||||||
|
static fn(string $id): bool => $id !== ''
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($normalizedIds === []) {
|
||||||
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', $normalizedIds)
|
||||||
->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.totalTracks > 0')
|
||||||
->setParameter('src', 'spotify')
|
->andWhere('a.externalId IS NOT NULL')
|
||||||
|
->andWhere("a.externalId <> ''")
|
||||||
|
->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;
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,9 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('a')
|
$qb = $this->createQueryBuilder('a')
|
||||||
->where('a.source = :src')
|
->where('a.source = :src')
|
||||||
|
->andWhere('a.totalTracks > 0')
|
||||||
|
->andWhere('a.externalId IS NOT NULL')
|
||||||
|
->andWhere("a.externalId <> ''")
|
||||||
->setParameter('src', 'user')
|
->setParameter('src', 'user')
|
||||||
->setMaxResults($limit * 2);
|
->setMaxResults($limit * 2);
|
||||||
if ($freeText !== null && $freeText !== '') {
|
if ($freeText !== null && $freeText !== '') {
|
||||||
@@ -124,15 +131,18 @@ 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')
|
->andWhere('a.totalTracks > 0')
|
||||||
|
->andWhere('a.externalId IS NOT NULL')
|
||||||
|
->andWhere("a.externalId <> ''")
|
||||||
|
->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 +166,46 @@ 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']) ? trim((string) $externalAlbum['release_date']) : null;
|
||||||
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
|
if ($releaseDate === '') {
|
||||||
|
$releaseDate = null;
|
||||||
$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'];
|
|
||||||
}
|
}
|
||||||
|
$totalTracks = (int) ($externalAlbum['total_tracks'] ?? 0);
|
||||||
$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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalId = trim($externalId);
|
||||||
|
$name = trim($name);
|
||||||
|
if ($externalId === '' || $name === '') {
|
||||||
|
throw new \InvalidArgumentException('External album payload must include non-empty id and name.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$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);
|
||||||
|
// Do not wipe a previously known release date when upstream omits it.
|
||||||
|
if ($releaseDate !== null || $album->getReleaseDate() === null) {
|
||||||
$album->setReleaseDate($releaseDate);
|
$album->setReleaseDate($releaseDate);
|
||||||
|
}
|
||||||
$album->setTotalTracks($totalTracks);
|
$album->setTotalTracks($totalTracks);
|
||||||
$album->setCoverUrl($coverUrl);
|
|
||||||
$album->setExternalUrl($external);
|
$album->setExternalUrl($external);
|
||||||
$em->persist($album);
|
$em->persist($album);
|
||||||
return $album;
|
return $album;
|
||||||
@@ -266,6 +279,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,18 +23,23 @@ 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
|
||||||
*/
|
*/
|
||||||
public function replaceAlbumTracks(Album $album, array $trackPayloads): void
|
public function replaceAlbumTracks(Album $album, array $trackPayloads): void
|
||||||
{
|
{
|
||||||
$em = $this->getEntityManager();
|
$em = $this->getEntityManager();
|
||||||
|
$albumId = $album->getId();
|
||||||
|
|
||||||
// Remove existing rows with a single query so unique constraints don't conflict during reinsert.
|
// Remove existing rows with a single query so unique constraints don't conflict during reinsert.
|
||||||
$em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE t.album = :album')
|
// Skip this for new albums that do not have a database identifier yet.
|
||||||
->setParameter('album', $album)
|
if ($albumId !== null) {
|
||||||
|
$em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE IDENTITY(t.album) = :albumId')
|
||||||
|
->setParameter('albumId', $albumId)
|
||||||
->execute();
|
->execute();
|
||||||
|
}
|
||||||
|
|
||||||
$album->getTracks()->clear();
|
$album->getTracks()->clear();
|
||||||
|
|
||||||
$position = 1;
|
$position = 1;
|
||||||
@@ -47,10 +52,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 +70,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
|
||||||
{
|
{
|
||||||
@@ -103,4 +108,3 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -43,6 +43,79 @@ class SettingRepository extends ServiceEntityRepository
|
|||||||
$em->persist($setting);
|
$em->persist($setting);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns multiple setting values keyed by setting name.
|
||||||
|
*
|
||||||
|
* @param list<string> $names
|
||||||
|
* @return array<string,?string>
|
||||||
|
*/
|
||||||
|
public function getValues(array $names): array
|
||||||
|
{
|
||||||
|
$normalized = array_values(array_unique(array_filter(array_map(
|
||||||
|
static fn(mixed $name): string => trim((string) $name),
|
||||||
|
$names
|
||||||
|
), static fn(string $name): bool => $name !== '')));
|
||||||
|
|
||||||
|
if ($normalized === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->createQueryBuilder('s')
|
||||||
|
->where('s.name IN (:names)')
|
||||||
|
->setParameter('names', $normalized)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach ($normalized as $name) {
|
||||||
|
$values[$name] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!$row instanceof Setting) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$values[$row->getName()] = $row->getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists multiple configuration values in one transaction.
|
||||||
|
*
|
||||||
|
* @param array<string,?string> $values
|
||||||
|
*/
|
||||||
|
public function setValues(array $values): void
|
||||||
|
{
|
||||||
|
if ($values === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$existing = $this->getValues(array_keys($values));
|
||||||
|
|
||||||
|
foreach ($values as $name => $value) {
|
||||||
|
$normalizedName = trim((string) $name);
|
||||||
|
if ($normalizedName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = null;
|
||||||
|
if (array_key_exists($normalizedName, $existing)) {
|
||||||
|
$setting = $this->findOneBy(['name' => $normalizedName]);
|
||||||
|
}
|
||||||
|
if (!$setting instanceof Setting) {
|
||||||
|
$setting = new Setting();
|
||||||
|
$setting->setName($normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting->setValue($value);
|
||||||
|
$em->persist($setting);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
@@ -44,8 +58,8 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
$rows = $this->createQueryBuilder('u')
|
$rows = $this->createQueryBuilder('u')
|
||||||
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
|
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
|
||||||
->leftJoin(Album::class, 'a', 'WITH', 'a.createdBy = u')
|
|
||||||
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
|
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
|
||||||
|
->leftJoin(Album::class, 'a', 'WITH', 'a = r.album')
|
||||||
->groupBy('u.id')
|
->groupBy('u.id')
|
||||||
->orderBy('u.email', 'ASC')
|
->orderBy('u.email', 'ASC')
|
||||||
->getQuery()
|
->getQuery()
|
||||||
@@ -65,5 +79,3 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
}, $rows);
|
}, $rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+869
-175
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExternalAlbumClient routes all external album requests to the configured provider.
|
||||||
|
*/
|
||||||
|
class ExternalAlbumClient implements ExternalAlbumClientInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LastFmClient $lastFmClient,
|
||||||
|
private readonly MusicBrainzClient $musicBrainzClient,
|
||||||
|
private readonly TidalClient $tidalClient,
|
||||||
|
private readonly ExternalProviderManager $providerManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||||
|
*/
|
||||||
|
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->searchAlbums($query, $limit, $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->getAlbumWithTracks($albumId, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $albumIds
|
||||||
|
* @return array{albums:list<array<string,mixed>>}|null
|
||||||
|
*/
|
||||||
|
public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->getAlbums($albumIds, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function getAlbumTracks(string $albumId): array
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->getAlbumTracks($albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderName(): string
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->getProviderName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveProvider(): ExternalAlbumClientInterface
|
||||||
|
{
|
||||||
|
$provider = $this->providerManager->getEffectiveProvider();
|
||||||
|
|
||||||
|
if ($provider === 'musicbrainz') {
|
||||||
|
return $this->musicBrainzClient;
|
||||||
|
}
|
||||||
|
if ($provider === 'tidal') {
|
||||||
|
return $this->tidalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->lastFmClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExternalAlbumClientInterface defines a normalized album API surface.
|
||||||
|
*/
|
||||||
|
interface ExternalAlbumClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||||
|
*/
|
||||||
|
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $albumIds
|
||||||
|
* @return array{albums:list<array<string,mixed>>}|null
|
||||||
|
*/
|
||||||
|
public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function getAlbumTracks(string $albumId): array;
|
||||||
|
|
||||||
|
public function isConfigured(): bool;
|
||||||
|
|
||||||
|
public function getProviderName(): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExternalProviderManager resolves active provider state and enforces catalog reset rules.
|
||||||
|
*/
|
||||||
|
class ExternalProviderManager
|
||||||
|
{
|
||||||
|
public const PROVIDER_LASTFM = 'lastfm';
|
||||||
|
public const PROVIDER_MUSICBRAINZ = 'musicbrainz';
|
||||||
|
public const PROVIDER_TIDAL = 'tidal';
|
||||||
|
public const SETTING_PROVIDER = 'EXTERNAL_ALBUM_PROVIDER';
|
||||||
|
public const SETTING_ACTIVE_PROVIDER = 'EXTERNAL_ALBUM_PROVIDER_ACTIVE';
|
||||||
|
private const VALID_PROVIDERS = [
|
||||||
|
self::PROVIDER_LASTFM,
|
||||||
|
self::PROVIDER_MUSICBRAINZ,
|
||||||
|
self::PROVIDER_TIDAL,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettingRepository $settings,
|
||||||
|
private readonly CatalogResetService $catalogResetService,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getValidProviders(): array
|
||||||
|
{
|
||||||
|
return self::VALID_PROVIDERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidProvider(?string $provider): bool
|
||||||
|
{
|
||||||
|
return $this->normalizeProvider($provider) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStoredProvider(): string
|
||||||
|
{
|
||||||
|
$stored = $this->normalizeProvider($this->settings->getValue(self::SETTING_PROVIDER));
|
||||||
|
return $stored ?? self::PROVIDER_LASTFM;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnvOverrideProvider(): ?string
|
||||||
|
{
|
||||||
|
$raw = $_ENV['EXTERNAL_ALBUM_PROVIDER'] ?? $_SERVER['EXTERNAL_ALBUM_PROVIDER'] ?? null;
|
||||||
|
return $this->normalizeProvider(is_string($raw) ? $raw : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnvOverrideActive(): bool
|
||||||
|
{
|
||||||
|
return $this->getEnvOverrideProvider() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEffectiveProvider(): string
|
||||||
|
{
|
||||||
|
$override = $this->getEnvOverrideProvider();
|
||||||
|
return $override ?? $this->getStoredProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveProvider(): string
|
||||||
|
{
|
||||||
|
$active = $this->normalizeProvider($this->settings->getValue(self::SETTING_ACTIVE_PROVIDER));
|
||||||
|
return $active ?? $this->getStoredProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderLabel(string $provider): string
|
||||||
|
{
|
||||||
|
return match ($provider) {
|
||||||
|
self::PROVIDER_MUSICBRAINZ => 'MusicBrainz',
|
||||||
|
self::PROVIDER_TIDAL => 'TIDAL',
|
||||||
|
default => 'Last.fm',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies provider state at startup and resets catalog if effective provider changed.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* effective_provider:string,
|
||||||
|
* previous_provider:string,
|
||||||
|
* changed:bool,
|
||||||
|
* reset:bool,
|
||||||
|
* albums:int,
|
||||||
|
* reviews:int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function syncEffectiveProviderAtStartup(): array
|
||||||
|
{
|
||||||
|
$effective = $this->getEffectiveProvider();
|
||||||
|
$previous = $this->getActiveProvider();
|
||||||
|
|
||||||
|
if ($effective === $previous) {
|
||||||
|
$this->settings->setValues([
|
||||||
|
self::SETTING_ACTIVE_PROVIDER => $effective,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'effective_provider' => $effective,
|
||||||
|
'previous_provider' => $previous,
|
||||||
|
'changed' => false,
|
||||||
|
'reset' => false,
|
||||||
|
'albums' => 0,
|
||||||
|
'reviews' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reset = $this->catalogResetService->resetCatalog();
|
||||||
|
$this->settings->setValues([
|
||||||
|
self::SETTING_ACTIVE_PROVIDER => $effective,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logger->warning('External provider changed between starts; catalog reset applied.', [
|
||||||
|
'previous_provider' => $previous,
|
||||||
|
'effective_provider' => $effective,
|
||||||
|
'deleted_albums' => $reset['albums'],
|
||||||
|
'deleted_reviews' => $reset['reviews'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'effective_provider' => $effective,
|
||||||
|
'previous_provider' => $previous,
|
||||||
|
'changed' => true,
|
||||||
|
'reset' => true,
|
||||||
|
'albums' => (int) $reset['albums'],
|
||||||
|
'reviews' => (int) $reset['reviews'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists admin-selected provider and resets catalog only when effective provider changes.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* effective_provider:string,
|
||||||
|
* previous_provider:string,
|
||||||
|
* changed:bool,
|
||||||
|
* reset:bool,
|
||||||
|
* albums:int,
|
||||||
|
* reviews:int,
|
||||||
|
* env_override_active:bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function applyAdminSelectedProvider(string $selectedProvider): array
|
||||||
|
{
|
||||||
|
$normalizedSelected = $this->normalizeProvider($selectedProvider);
|
||||||
|
if ($normalizedSelected === null) {
|
||||||
|
throw new \InvalidArgumentException('Unsupported provider selected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = $this->getActiveProvider();
|
||||||
|
$override = $this->getEnvOverrideProvider();
|
||||||
|
$effective = $override ?? $normalizedSelected;
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
self::SETTING_PROVIDER => $normalizedSelected,
|
||||||
|
];
|
||||||
|
|
||||||
|
$deletedAlbums = 0;
|
||||||
|
$deletedReviews = 0;
|
||||||
|
$didReset = false;
|
||||||
|
|
||||||
|
if ($effective !== $previous) {
|
||||||
|
$reset = $this->catalogResetService->resetCatalog();
|
||||||
|
$deletedAlbums = (int) $reset['albums'];
|
||||||
|
$deletedReviews = (int) $reset['reviews'];
|
||||||
|
$didReset = true;
|
||||||
|
$updates[self::SETTING_ACTIVE_PROVIDER] = $effective;
|
||||||
|
} else {
|
||||||
|
$updates[self::SETTING_ACTIVE_PROVIDER] = $previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settings->setValues($updates);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'effective_provider' => $effective,
|
||||||
|
'previous_provider' => $previous,
|
||||||
|
'changed' => $effective !== $previous,
|
||||||
|
'reset' => $didReset,
|
||||||
|
'albums' => $deletedAlbums,
|
||||||
|
'reviews' => $deletedReviews,
|
||||||
|
'env_override_active' => $override !== null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeProvider(?string $provider): ?string
|
||||||
|
{
|
||||||
|
if ($provider === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = mb_strtolower(trim($provider));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($normalized, self::VALID_PROVIDERS, true) ? $normalized : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use GuzzleHttp\Promise\Utils;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LastFmClient wraps Last.fm API calls with caching and payload normalization.
|
||||||
|
*/
|
||||||
|
class LastFmClient implements ExternalAlbumClientInterface
|
||||||
|
{
|
||||||
|
private const API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||||
|
private const IMAGE_PROFILE_LIST = 'list';
|
||||||
|
private const IMAGE_PROFILE_DETAIL = 'detail';
|
||||||
|
private const BULK_ALBUM_REQUEST_CONCURRENCY = 6;
|
||||||
|
private const SEARCH_CACHE_TTL_SECONDS = 600;
|
||||||
|
private const ALBUM_INFO_CACHE_TTL_SECONDS = 1800;
|
||||||
|
private const ALBUM_PAGE_RELEASE_CACHE_TTL_SECONDS = 86400;
|
||||||
|
private const IMAGE_SIZE_RANK = [
|
||||||
|
'mega' => 5,
|
||||||
|
'extralarge' => 4,
|
||||||
|
'large' => 3,
|
||||||
|
'medium' => 2,
|
||||||
|
'small' => 1,
|
||||||
|
];
|
||||||
|
private bool $apiKeyResolved = false;
|
||||||
|
private ?string $resolvedApiKey = null;
|
||||||
|
/** @var array<string,array<string,mixed>> */
|
||||||
|
private array $inMemoryResponseCache = [];
|
||||||
|
|
||||||
|
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, int $page = 1): 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),
|
||||||
|
'page' => max(1, $page),
|
||||||
|
], self::SEARCH_CACHE_TTL_SECONDS);
|
||||||
|
} 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, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||||
|
{
|
||||||
|
return $this->getAlbum($albumId, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single album by normalized external identifier.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function getAlbum(string $albumId, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||||
|
{
|
||||||
|
$apiKey = $this->getApiKey();
|
||||||
|
if ($apiKey === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->buildAlbumInfoQuery($albumId);
|
||||||
|
if ($query === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->sendRequest($query, self::ALBUM_INFO_CACHE_TTL_SECONDS);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = $payload['album'] ?? null;
|
||||||
|
if (!is_array($album)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeDetailedAlbum($album, $albumId, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch multiple albums using bounded concurrency to avoid serial network latency.
|
||||||
|
*
|
||||||
|
* @param list<string> $albumIds
|
||||||
|
* @return array{albums:list<array<string,mixed>>}|null
|
||||||
|
*/
|
||||||
|
public function getAlbums(array $albumIds, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||||
|
{
|
||||||
|
if ($albumIds === []) {
|
||||||
|
return ['albums' => []];
|
||||||
|
}
|
||||||
|
if ($this->getApiKey() === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->cache instanceof CacheItemPoolInterface) {
|
||||||
|
$albums = [];
|
||||||
|
foreach ($albumIds as $albumId) {
|
||||||
|
$album = $this->getAlbumWithTracks((string) $albumId, $imageProfile);
|
||||||
|
if ($album !== null) {
|
||||||
|
$albums[] = $album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['albums' => $albums];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderedIds = [];
|
||||||
|
$queriesById = [];
|
||||||
|
foreach ($albumIds as $albumId) {
|
||||||
|
$normalizedId = trim((string) $albumId);
|
||||||
|
if ($normalizedId === '' || isset($queriesById[$normalizedId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->buildAlbumInfoQuery($normalizedId);
|
||||||
|
if ($query === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderedIds[] = $normalizedId;
|
||||||
|
$queriesById[$normalizedId] = $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orderedIds === []) {
|
||||||
|
return ['albums' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsesById = [];
|
||||||
|
$pendingById = [];
|
||||||
|
|
||||||
|
foreach ($orderedIds as $albumId) {
|
||||||
|
$prepared = $this->prepareQuery($queriesById[$albumId]);
|
||||||
|
if ($prepared === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = $this->buildCacheKey($prepared);
|
||||||
|
if (isset($this->inMemoryResponseCache[$cacheKey])) {
|
||||||
|
$responsesById[$albumId] = $this->inMemoryResponseCache[$cacheKey];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheItem = $this->cache->getItem($cacheKey);
|
||||||
|
if ($cacheItem->isHit()) {
|
||||||
|
$cached = $cacheItem->get();
|
||||||
|
if (is_array($cached)) {
|
||||||
|
$responsesById[$albumId] = $cached;
|
||||||
|
$this->inMemoryResponseCache[$cacheKey] = $cached;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingById[$albumId] = [
|
||||||
|
'query' => $prepared,
|
||||||
|
'cache_key' => $cacheKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pendingById !== []) {
|
||||||
|
foreach (array_chunk($pendingById, self::BULK_ALBUM_REQUEST_CONCURRENCY, true) as $chunk) {
|
||||||
|
$promises = [];
|
||||||
|
foreach ($chunk as $albumId => $requestData) {
|
||||||
|
$promises[$albumId] = $this->httpClient
|
||||||
|
->requestAsync('GET', self::API_BASE_URL, ['query' => $requestData['query']])
|
||||||
|
->then(static function ($response): array {
|
||||||
|
$decoded = json_decode((string) $response->getBody(), true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$settled = Utils::settle($promises)->wait();
|
||||||
|
foreach ($settled as $albumId => $result) {
|
||||||
|
$payload = [];
|
||||||
|
$canCache = ($result['state'] ?? '') === 'fulfilled' && is_array($result['value'] ?? null);
|
||||||
|
if ($canCache) {
|
||||||
|
$payload = $result['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestData = $chunk[$albumId] ?? null;
|
||||||
|
if (!is_array($requestData)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = (string) ($requestData['cache_key'] ?? '');
|
||||||
|
if ($canCache && $cacheKey !== '') {
|
||||||
|
$cacheItem = $this->cache->getItem($cacheKey);
|
||||||
|
$cacheItem->set($payload);
|
||||||
|
$cacheItem->expiresAfter(self::ALBUM_INFO_CACHE_TTL_SECONDS);
|
||||||
|
$this->cache->save($cacheItem);
|
||||||
|
$this->inMemoryResponseCache[$cacheKey] = $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsesById[$albumId] = $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$albums = [];
|
||||||
|
foreach ($orderedIds as $albumId) {
|
||||||
|
$albumPayload = $responsesById[$albumId]['album'] ?? null;
|
||||||
|
if (!is_array($albumPayload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$albums[] = $this->normalizeDetailedAlbum($albumPayload, $albumId, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderName(): string
|
||||||
|
{
|
||||||
|
return 'lastfm';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $query
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function sendRequest(array $query, int $cacheTtlSeconds = 0): array
|
||||||
|
{
|
||||||
|
$preparedQuery = $this->prepareQuery($query);
|
||||||
|
if ($preparedQuery === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = $this->buildCacheKey($preparedQuery);
|
||||||
|
if (isset($this->inMemoryResponseCache[$cacheKey])) {
|
||||||
|
return $this->inMemoryResponseCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = function () use ($preparedQuery): array {
|
||||||
|
$response = $this->httpClient->request('GET', self::API_BASE_URL, ['query' => $preparedQuery]);
|
||||||
|
$decoded = json_decode((string) $response->getBody(), true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
$payload = [];
|
||||||
|
if ($cacheTtlSeconds > 0) {
|
||||||
|
$payload = $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
|
||||||
|
$item->expiresAfter($cacheTtlSeconds);
|
||||||
|
return $request();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$payload = $request();
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = is_array($payload) ? $payload : [];
|
||||||
|
$this->inMemoryResponseCache[$cacheKey] = $normalized;
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getApiKey(): ?string
|
||||||
|
{
|
||||||
|
if ($this->apiKeyResolved) {
|
||||||
|
return $this->resolvedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKeyResolved = true;
|
||||||
|
$envKey = trim((string) ($this->apiKey ?? ''));
|
||||||
|
if ($envKey !== '') {
|
||||||
|
$this->resolvedApiKey = $envKey;
|
||||||
|
return $this->resolvedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim((string) $this->settings->getValue('LASTFM_API_KEY', ''));
|
||||||
|
$this->resolvedApiKey = $key === '' ? null : $key;
|
||||||
|
return $this->resolvedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $query
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function prepareQuery(array $query): ?array
|
||||||
|
{
|
||||||
|
$apiKey = $this->getApiKey();
|
||||||
|
if ($apiKey === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query['api_key'] = $apiKey;
|
||||||
|
$query['format'] = 'json';
|
||||||
|
$query['autocorrect'] = 1;
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $query
|
||||||
|
*/
|
||||||
|
private function buildCacheKey(array $query): string
|
||||||
|
{
|
||||||
|
return 'lastfm_resp_' . sha1((string) json_encode($query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{method:string,mbid:string}|array{method:string,artist:string,album:string}|null
|
||||||
|
*/
|
||||||
|
private function buildAlbumInfoQuery(string $albumId): ?array
|
||||||
|
{
|
||||||
|
$lookup = $this->parseAlbumIdentifier($albumId);
|
||||||
|
if ($lookup === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = ['method' => 'album.getinfo'];
|
||||||
|
if (isset($lookup['mbid'])) {
|
||||||
|
$query['mbid'] = (string) $lookup['mbid'];
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artist = trim((string) ($lookup['artist'] ?? ''));
|
||||||
|
$name = trim((string) ($lookup['album'] ?? ''));
|
||||||
|
if ($artist === '' || $name === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query['artist'] = $artist;
|
||||||
|
$query['album'] = $name;
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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'] ?? [], self::IMAGE_PROFILE_LIST),
|
||||||
|
'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, string $imageProfile = self::IMAGE_PROFILE_DETAIL): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowPageFallback = $imageProfile === self::IMAGE_PROFILE_DETAIL;
|
||||||
|
$releaseDate = $this->resolveReleaseDate($rawAlbum, $allowPageFallback);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'artists' => [['name' => $artist]],
|
||||||
|
'genres' => $genres,
|
||||||
|
'release_date' => $releaseDate,
|
||||||
|
'total_tracks' => count($tracks),
|
||||||
|
'images' => $this->normalizeImages($rawAlbum['image'] ?? [], $imageProfile),
|
||||||
|
'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, string $imageProfile = self::IMAGE_PROFILE_DETAIL): array
|
||||||
|
{
|
||||||
|
$preferredSizes = $this->resolvePreferredSizes($imageProfile);
|
||||||
|
$bySize = [];
|
||||||
|
$ranked = [];
|
||||||
|
$seenUrls = [];
|
||||||
|
|
||||||
|
foreach ($this->normalizeList($rawImages) as $image) {
|
||||||
|
$url = trim((string) ($image['#text'] ?? $image['url'] ?? ''));
|
||||||
|
if ($url === '' || isset($seenUrls[$url])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenUrls[$url] = true;
|
||||||
|
$size = mb_strtolower(trim((string) ($image['size'] ?? '')));
|
||||||
|
if ($size !== '' && !isset($bySize[$size])) {
|
||||||
|
$bySize[$size] = $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ranked[] = [
|
||||||
|
'url' => $url,
|
||||||
|
'rank' => self::IMAGE_SIZE_RANK[$size] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ranked === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($preferredSizes as $size) {
|
||||||
|
if (isset($bySize[$size])) {
|
||||||
|
return [['url' => $this->transformImageUrlForProfile($bySize[$size], $imageProfile)]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($ranked, static fn(array $a, array $b) => $b['rank'] <=> $a['rank']);
|
||||||
|
$fallbackUrl = (string) ($ranked[0]['url'] ?? '');
|
||||||
|
if ($fallbackUrl === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [['url' => $this->transformImageUrlForProfile($fallbackUrl, $imageProfile)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function resolvePreferredSizes(string $imageProfile): array
|
||||||
|
{
|
||||||
|
if ($imageProfile === self::IMAGE_PROFILE_LIST) {
|
||||||
|
// Cards render around 300px wide; prefer a sharper source while keeping payloads moderate.
|
||||||
|
return ['extralarge', 'large', 'medium', 'small', 'mega'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['mega', 'extralarge', 'large', 'medium', 'small'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transformImageUrlForProfile(string $url, string $imageProfile): string
|
||||||
|
{
|
||||||
|
if ($imageProfile !== self::IMAGE_PROFILE_LIST) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last.fm image hashes are stable across size paths; upgrading to 300x300
|
||||||
|
// gives substantially better card quality without extra API round-trips.
|
||||||
|
return preg_replace('#/i/u/(34s|64s|174s)/#', '/i/u/300x300/', $url) ?? $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $rawAlbum
|
||||||
|
*/
|
||||||
|
private function resolveReleaseDate(array $rawAlbum, bool $allowPageFallback = false): ?string
|
||||||
|
{
|
||||||
|
$releaseDate = $this->normalizePublishedDate((string) ($rawAlbum['releasedate'] ?? ''));
|
||||||
|
$wikiReleaseDate = $this->extractReleaseDateFromWiki($rawAlbum['wiki'] ?? null);
|
||||||
|
|
||||||
|
if ($releaseDate !== null && $wikiReleaseDate !== null) {
|
||||||
|
$releaseYear = (int) substr($releaseDate, 0, 4);
|
||||||
|
$wikiYear = (int) substr($wikiReleaseDate, 0, 4);
|
||||||
|
|
||||||
|
// Last.fm often returns reissue/future MBID dates in `releasedate`.
|
||||||
|
// When wiki text clearly points to a much earlier canonical release,
|
||||||
|
// prefer that canonical date for album browsing and faceting.
|
||||||
|
if ($releaseYear > 0 && $wikiYear > 0 && ($releaseYear - $wikiYear) >= 5) {
|
||||||
|
return $wikiReleaseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $releaseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $releaseDate ?? $wikiReleaseDate;
|
||||||
|
if ($resolved !== null) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$allowPageFallback) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractReleaseDateFromAlbumPage($this->stringOrNull($rawAlbum['url'] ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractReleaseDateFromWiki(mixed $wiki): ?string
|
||||||
|
{
|
||||||
|
if (!is_array($wiki)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last.fm often omits explicit release fields; wiki publication date is a useful fallback.
|
||||||
|
$publishedFallback = $this->normalizePublishedDate((string) ($wiki['published'] ?? ''));
|
||||||
|
|
||||||
|
foreach (['summary', 'content'] as $field) {
|
||||||
|
$raw = (string) ($wiki[$field] ?? '');
|
||||||
|
if ($raw === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = html_entity_decode(strip_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
if ($text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [
|
||||||
|
'/\breleased(?:\s+on|\s+in)?\s+([A-Za-z]+\s+\d{1,2},\s+\d{4})/iu',
|
||||||
|
'/\breleased(?:\s+on|\s+in)?\s+(\d{1,2}\s+[A-Za-z]+\s+\d{4})/iu',
|
||||||
|
'/\breleased(?:\s+on|\s+in)?\s+([A-Za-z]+\s+\d{4})/iu',
|
||||||
|
'/\breleased(?:\s+on|\s+in)?\s+(\d{4})/iu',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
$matches = [];
|
||||||
|
if (preg_match($pattern, $text, $matches) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = trim((string) ($matches[1] ?? ''));
|
||||||
|
if ($candidate === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = $this->normalizePublishedDate($candidate);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $publishedFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractReleaseDateFromAlbumPage(?string $albumUrl): ?string
|
||||||
|
{
|
||||||
|
$url = trim((string) $albumUrl);
|
||||||
|
if ($url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'lastfm_album_page_release_' . sha1($url);
|
||||||
|
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($url): string {
|
||||||
|
$item->expiresAfter(self::ALBUM_PAGE_RELEASE_CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url);
|
||||||
|
$html = (string) $response->getBody();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($html === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [
|
||||||
|
'/<dt[^>]*catalogue-metadata-heading[^>]*>\s*Release Date\s*<\/dt>\s*<dd[^>]*catalogue-metadata-description[^>]*>\s*([^<]+)\s*<\/dd>/iu',
|
||||||
|
'/"releaseDate"\s*:\s*"([^"]+)"/i',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
$matches = [];
|
||||||
|
if (preg_match($pattern, $html, $matches) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = trim((string) ($matches[1] ?? ''));
|
||||||
|
if ($candidate === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = $this->normalizePublishedDate($candidate);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!is_string($cached)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($cached);
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LastFmMetadataRefresher re-syncs locally stored external albums from the active provider.
|
||||||
|
*/
|
||||||
|
class LastFmMetadataRefresher
|
||||||
|
{
|
||||||
|
private const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ExternalAlbumClientInterface $externalClient,
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly AlbumTrackRepository $trackRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly LastFmTagResolver $tagResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes all saved external albums and returns the number of payloads that were re-synced.
|
||||||
|
*/
|
||||||
|
public function refreshAllExternalAlbums(): int
|
||||||
|
{
|
||||||
|
$externalIds = $this->albumRepository->findAllExternalIds();
|
||||||
|
if ($externalIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updatedAlbumCount = 0;
|
||||||
|
foreach (array_chunk($externalIds, self::BATCH_SIZE) as $albumIdChunk) {
|
||||||
|
$payload = $this->externalClient->getAlbums($albumIdChunk);
|
||||||
|
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
|
||||||
|
if ($albums === []) {
|
||||||
|
$this->logger->warning('External getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albums);
|
||||||
|
|
||||||
|
foreach ($albums as $albumPayload) {
|
||||||
|
try {
|
||||||
|
$normalizedPayload = (array) $albumPayload;
|
||||||
|
$albumId = (string) ($normalizedPayload['id'] ?? '');
|
||||||
|
if ($albumId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracks = $this->filterTracksWithNames($this->resolveTrackPayloads($albumId, $normalizedPayload));
|
||||||
|
if ($tracks === []) {
|
||||||
|
$this->logger->info('Skipping external album refresh because no usable tracks were found', [
|
||||||
|
'album' => $albumId,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPayload['tracks'] = [
|
||||||
|
'items' => $tracks,
|
||||||
|
'total' => count($tracks),
|
||||||
|
];
|
||||||
|
$normalizedPayload['total_tracks'] = count($tracks);
|
||||||
|
|
||||||
|
$albumEntity = $this->albumRepository->upsertFromExternalAlbum(
|
||||||
|
$normalizedPayload,
|
||||||
|
$genresByAlbum[$albumId] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
|
||||||
|
$albumEntity->setTotalTracks(count($tracks));
|
||||||
|
$updatedAlbumCount++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('Failed to upsert external album', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'album' => $albumPayload['id'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updatedAlbumCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function resolveTrackPayloads(string $albumId, array $albumPayload): array
|
||||||
|
{
|
||||||
|
$tracks = (array) ($albumPayload['tracks']['items'] ?? []);
|
||||||
|
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
|
||||||
|
|
||||||
|
if ($total > count($tracks)) {
|
||||||
|
$fullTrackPayloads = $this->externalClient->getAlbumTracks($albumId);
|
||||||
|
if ($fullTrackPayloads !== []) {
|
||||||
|
return $fullTrackPayloads;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $tracks
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function filterTracksWithNames(array $tracks): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($tracks, static function (array $track): bool {
|
||||||
|
return trim((string) ($track['name'] ?? '')) !== '';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LastFmTagResolver enriches album payloads with normalized Last.fm tags.
|
||||||
|
*/
|
||||||
|
class LastFmTagResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MusicBrainzClient wraps MusicBrainz WS endpoints into Tonehaus payload shapes.
|
||||||
|
*/
|
||||||
|
class MusicBrainzClient implements ExternalAlbumClientInterface
|
||||||
|
{
|
||||||
|
private const API_BASE_URL = 'https://musicbrainz.org/ws/2';
|
||||||
|
private const SEARCH_CACHE_TTL_SECONDS = 600;
|
||||||
|
private const DETAIL_CACHE_TTL_SECONDS = 1800;
|
||||||
|
private const DEFAULT_USER_AGENT = 'Tonehaus/1.0 (https://tonehaus.local)';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClientInterface $httpClient,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly SettingRepository $settings,
|
||||||
|
private readonly ?string $clientId = null,
|
||||||
|
private readonly ?string $clientSecret = null,
|
||||||
|
private readonly ?string $accessToken = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||||
|
*/
|
||||||
|
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
if ($query === '') {
|
||||||
|
return ['albums' => ['items' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeLimit = max(1, $limit);
|
||||||
|
$safePage = max(1, $page);
|
||||||
|
$offset = ($safePage - 1) * $safeLimit;
|
||||||
|
|
||||||
|
$payload = $this->requestJson(
|
||||||
|
'/release',
|
||||||
|
[
|
||||||
|
'query' => $query,
|
||||||
|
'limit' => $safeLimit,
|
||||||
|
'offset' => $offset,
|
||||||
|
],
|
||||||
|
self::SEARCH_CACHE_TTL_SECONDS
|
||||||
|
);
|
||||||
|
$releases = $this->normalizeList($payload['releases'] ?? []);
|
||||||
|
if ($releases === []) {
|
||||||
|
return ['albums' => ['items' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($releases as $release) {
|
||||||
|
$normalized = $this->normalizeSearchRelease($release);
|
||||||
|
if ($normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = $normalized;
|
||||||
|
if (count($items) >= $safeLimit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['albums' => ['items' => $items]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array
|
||||||
|
{
|
||||||
|
return $this->getAlbum($albumId, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $albumIds
|
||||||
|
* @return array{albums:list<array<string,mixed>>}|null
|
||||||
|
*/
|
||||||
|
public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array
|
||||||
|
{
|
||||||
|
if ($albumIds === []) {
|
||||||
|
return ['albums' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$albums = [];
|
||||||
|
foreach ($albumIds as $albumId) {
|
||||||
|
$album = $this->getAlbum((string) $albumId, $imageProfile);
|
||||||
|
if (is_array($album)) {
|
||||||
|
$albums[] = $album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['albums' => $albums];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function getAlbumTracks(string $albumId): array
|
||||||
|
{
|
||||||
|
$album = $this->getAlbum($albumId, 'detail');
|
||||||
|
if (!is_array($album)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracks = $album['tracks']['items'] ?? [];
|
||||||
|
return is_array($tracks) ? array_values($tracks) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderName(): string
|
||||||
|
{
|
||||||
|
return 'musicbrainz';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function getAlbum(string $albumId, string $imageProfile): ?array
|
||||||
|
{
|
||||||
|
$mbid = $this->extractMbid($albumId);
|
||||||
|
if ($mbid === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->requestJson(
|
||||||
|
'/release/' . $mbid,
|
||||||
|
['inc' => 'recordings+artist-credits+url-rels+release-groups+tags'],
|
||||||
|
self::DETAIL_CACHE_TTL_SECONDS
|
||||||
|
);
|
||||||
|
if ($payload === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeDetailedRelease($payload, $imageProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $release
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private function normalizeSearchRelease(array $release): ?array
|
||||||
|
{
|
||||||
|
$releaseId = trim((string) ($release['id'] ?? ''));
|
||||||
|
$title = trim((string) ($release['title'] ?? ''));
|
||||||
|
if ($releaseId === '' || $title === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artists = $this->normalizeArtistCredits($release['artist-credit'] ?? []);
|
||||||
|
if ($artists === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseDate = $this->normalizeReleaseDate((string) ($release['date'] ?? ''));
|
||||||
|
$totalTracks = max(0, (int) ($release['track-count'] ?? 0));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'mbid:' . $releaseId,
|
||||||
|
'name' => $title,
|
||||||
|
'artists' => $artists,
|
||||||
|
'genres' => $this->normalizeGenres($release['tags'] ?? []),
|
||||||
|
'release_date' => $releaseDate,
|
||||||
|
'total_tracks' => $totalTracks,
|
||||||
|
'images' => $this->buildCoverArt($releaseId, 'list'),
|
||||||
|
'external_urls' => ['external' => 'https://musicbrainz.org/release/' . $releaseId],
|
||||||
|
'source' => 'external',
|
||||||
|
'tracks' => ['items' => [], 'total' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $release
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeDetailedRelease(array $release, string $imageProfile): array
|
||||||
|
{
|
||||||
|
$releaseId = trim((string) ($release['id'] ?? ''));
|
||||||
|
$title = trim((string) ($release['title'] ?? ''));
|
||||||
|
$artists = $this->normalizeArtistCredits($release['artist-credit'] ?? []);
|
||||||
|
$tracks = $this->normalizeTracks($release['media'] ?? [], $releaseId);
|
||||||
|
|
||||||
|
$genres = $this->normalizeGenres($release['tags'] ?? []);
|
||||||
|
if ($genres === []) {
|
||||||
|
$genres = $this->normalizeGenres($release['release-group']['tags'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'mbid:' . $releaseId,
|
||||||
|
'name' => $title,
|
||||||
|
'artists' => $artists,
|
||||||
|
'genres' => $genres,
|
||||||
|
'release_date' => $this->normalizeReleaseDate((string) ($release['date'] ?? '')),
|
||||||
|
'total_tracks' => count($tracks),
|
||||||
|
'images' => $this->buildCoverArt($releaseId, $imageProfile),
|
||||||
|
'external_urls' => ['external' => $this->resolveExternalUrl($releaseId)],
|
||||||
|
'source' => 'external',
|
||||||
|
'tracks' => [
|
||||||
|
'items' => $tracks,
|
||||||
|
'total' => count($tracks),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveExternalUrl(string $releaseId): string
|
||||||
|
{
|
||||||
|
return 'https://musicbrainz.org/release/' . $releaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $mediaRows
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function normalizeTracks(mixed $mediaRows, string $albumId): array
|
||||||
|
{
|
||||||
|
$tracks = [];
|
||||||
|
foreach ($this->normalizeList($mediaRows) as $media) {
|
||||||
|
$discNumber = max(1, (int) ($media['position'] ?? 1));
|
||||||
|
$rows = $this->normalizeList($media['tracks'] ?? []);
|
||||||
|
$index = 1;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = trim((string) ($row['title'] ?? $row['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trackId = trim((string) ($row['id'] ?? ''));
|
||||||
|
$numberRaw = trim((string) ($row['number'] ?? ''));
|
||||||
|
$trackNumber = ctype_digit($numberRaw) ? (int) $numberRaw : $index;
|
||||||
|
|
||||||
|
$tracks[] = [
|
||||||
|
'id' => $trackId !== '' ? $trackId : sha1($albumId . '|' . $discNumber . '|' . $trackNumber . '|' . $name),
|
||||||
|
'disc_number' => $discNumber,
|
||||||
|
'track_number' => max(1, $trackNumber),
|
||||||
|
'name' => $name,
|
||||||
|
'duration_ms' => max(0, (int) ($row['length'] ?? 0)),
|
||||||
|
'preview_url' => null,
|
||||||
|
];
|
||||||
|
$index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $credits
|
||||||
|
* @return list<array{name:string}>
|
||||||
|
*/
|
||||||
|
private function normalizeArtistCredits(mixed $credits): array
|
||||||
|
{
|
||||||
|
$artists = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($this->normalizeList($credits) as $credit) {
|
||||||
|
$name = trim((string) ($credit['name'] ?? $credit['artist']['name'] ?? ''));
|
||||||
|
if ($name === '' || isset($seen[$name])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$name] = true;
|
||||||
|
$artists[] = ['name' => $name];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $rawTags
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeGenres(mixed $rawTags): array
|
||||||
|
{
|
||||||
|
foreach ($this->normalizeList($rawTags) as $tag) {
|
||||||
|
$name = trim((string) ($tag['name'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
return [$this->toTitleCase($name)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{url:string}>
|
||||||
|
*/
|
||||||
|
private function buildCoverArt(string $releaseId, string $imageProfile): array
|
||||||
|
{
|
||||||
|
if ($releaseId === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = $imageProfile === 'list' ? '250' : '500';
|
||||||
|
return [[
|
||||||
|
'url' => sprintf('https://coverartarchive.org/release/%s/front-%s', $releaseId, $size),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeReleaseDate(string $rawDate): ?string
|
||||||
|
{
|
||||||
|
$rawDate = trim($rawDate);
|
||||||
|
if ($rawDate === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}$/', $rawDate) === 1) {
|
||||||
|
return $rawDate . '-01-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}$/', $rawDate) === 1) {
|
||||||
|
return $rawDate . '-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $rawDate) === 1) {
|
||||||
|
return $rawDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (new \DateTimeImmutable($rawDate))->format('Y-m-d');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMbid(string $albumId): ?string
|
||||||
|
{
|
||||||
|
$raw = trim($albumId);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($raw, 'mbid:')) {
|
||||||
|
$raw = trim(substr($raw, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^[0-9a-fA-F-]{36}$/', $raw) === 1 ? strtolower($raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $query
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function requestJson(string $path, array $query, int $cacheTtlSeconds): array
|
||||||
|
{
|
||||||
|
$query['fmt'] = 'json';
|
||||||
|
ksort($query);
|
||||||
|
|
||||||
|
$cacheKey = 'musicbrainz_resp_' . sha1($path . '|' . (string) json_encode($query));
|
||||||
|
|
||||||
|
$payload = $this->cache->get($cacheKey, function (ItemInterface $item) use ($path, $query, $cacheTtlSeconds) {
|
||||||
|
$item->expiresAfter($cacheTtlSeconds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = [
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'User-Agent' => self::DEFAULT_USER_AGENT,
|
||||||
|
];
|
||||||
|
$token = $this->resolveAccessToken();
|
||||||
|
if ($token !== null) {
|
||||||
|
$headers['Authorization'] = 'Bearer ' . $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->httpClient->request(
|
||||||
|
'GET',
|
||||||
|
self::API_BASE_URL . $path,
|
||||||
|
[
|
||||||
|
'query' => $query,
|
||||||
|
'headers' => $headers,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$decoded = json_decode((string) $response->getBody(), true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return is_array($payload) ? $payload : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAccessToken(): ?string
|
||||||
|
{
|
||||||
|
return $this->resolveCredential($this->accessToken, 'MUSICBRAINZ_ACCESS_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCredential(?string $envValue, string $settingKey): ?string
|
||||||
|
{
|
||||||
|
$envResolved = trim((string) ($envValue ?? ''));
|
||||||
|
if ($envResolved !== '') {
|
||||||
|
return $envResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = trim((string) ($this->settings->getValue($settingKey) ?? ''));
|
||||||
|
return $stored === '' ? null : $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toTitleCase(string $value): string
|
||||||
|
{
|
||||||
|
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 !== ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Repository\AlbumRepository;
|
|
||||||
use App\Repository\AlbumTrackRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SpotifyMetadataRefresher bulk-updates stored Spotify albums using batched API calls.
|
|
||||||
*/
|
|
||||||
class SpotifyMetadataRefresher
|
|
||||||
{
|
|
||||||
private const BATCH_SIZE = 20;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly SpotifyClient $spotifyClient,
|
|
||||||
private readonly AlbumRepository $albumRepository,
|
|
||||||
private readonly AlbumTrackRepository $trackRepository,
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly LoggerInterface $logger,
|
|
||||||
private readonly SpotifyGenreResolver $genreResolver,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes all saved Spotify albums and returns the number of payloads that were re-synced.
|
|
||||||
*/
|
|
||||||
public function refreshAllSpotifyAlbums(): int
|
|
||||||
{
|
|
||||||
$spotifyIds = $this->albumRepository->findAllSpotifyIds();
|
|
||||||
if ($spotifyIds === []) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updatedAlbumCount = 0;
|
|
||||||
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) {
|
|
||||||
$payload = $this->spotifyClient->getAlbums($albumIdChunk);
|
|
||||||
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
|
|
||||||
if ($albums === []) {
|
|
||||||
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share the same genre resolution logic used during search so existing rows gain genre data too.
|
|
||||||
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
|
|
||||||
|
|
||||||
foreach ($albums as $albumPayload) {
|
|
||||||
try {
|
|
||||||
$albumId = (string) ($albumPayload['id'] ?? '');
|
|
||||||
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
|
|
||||||
(array) $albumPayload,
|
|
||||||
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
|
||||||
);
|
|
||||||
if ($albumId !== '' && $albumEntity !== null) {
|
|
||||||
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
|
|
||||||
if ($tracks !== []) {
|
|
||||||
// Replace tracks wholesale to simplify diffs (instead of diffing rows).
|
|
||||||
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
|
|
||||||
$albumEntity->setTotalTracks(count($tracks));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$updatedAlbumCount++;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->logger->error('Failed to upsert Spotify album', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'album' => $albumPayload['id'] ?? null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updatedAlbumCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string,mixed>>
|
|
||||||
*/
|
|
||||||
private function resolveTrackPayloads(string $albumId, array $albumPayload): array
|
|
||||||
{
|
|
||||||
$tracks = (array) ($albumPayload['tracks']['items'] ?? []);
|
|
||||||
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
|
|
||||||
|
|
||||||
if ($total > count($tracks)) {
|
|
||||||
$fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId);
|
|
||||||
if ($fullTrackPayloads !== []) {
|
|
||||||
return $fullTrackPayloads;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tracks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -71,18 +71,27 @@
|
|||||||
<h2 class="h6 mb-3">Your albums</h2>
|
<h2 class="h6 mb-3">Your albums</h2>
|
||||||
<div class="vstack gap-2">
|
<div class="vstack gap-2">
|
||||||
{% for a in userAlbums %}
|
{% for a in userAlbums %}
|
||||||
|
{% set publicId = 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><a href="{{ path('album_show', {id: a.localId}) }}" class="text-decoration-none">{{ a.name }}</a></div>
|
<div>
|
||||||
|
{% if publicId %}
|
||||||
|
<a href="{{ path('album_show', {id: publicId}) }}" class="text-decoration-none">{{ a.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ a.name }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}</div>
|
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if publicId %}
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: a.localId}) }}">Edit</a>
|
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: publicId}) }}">Edit</a>
|
||||||
<form method="post" action="{{ path('album_delete', {id: a.localId}) }}" onsubmit="return confirm('Delete this album?');">
|
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ a.localId) }}" />
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
|
||||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-secondary">You haven't created any albums yet.</div>
|
<div class="text-secondary">You haven't created any albums yet.</div>
|
||||||
@@ -93,5 +102,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,17 +4,58 @@
|
|||||||
<h1 class="h4 mb-3">Site Settings</h1>
|
<h1 class="h4 mb-3">Site Settings</h1>
|
||||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||||
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
|
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
|
||||||
|
{% for msg in app.flashes('warning') %}<div class="alert alert-warning">{{ msg }}</div>{% endfor %}
|
||||||
|
{% for msg in app.flashes('danger') %}<div class="alert alert-danger">{{ msg }}</div>{% endfor %}
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
{{ form_start(form, {attr: {id: 'site-settings-form', class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||||
<div>
|
<div>
|
||||||
{{ form_label(form.SPOTIFY_CLIENT_ID) }}
|
{{ form_label(form.EXTERNAL_ALBUM_PROVIDER) }}
|
||||||
{{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }}
|
{{ form_widget(form.EXTERNAL_ALBUM_PROVIDER, {attr: {class: 'form-select', 'data-initial-provider': storedProvider}}) }}
|
||||||
|
<div class="form-text">Switching provider clears album/review catalog data to prevent mixed-source duplicates.</div>
|
||||||
|
<div class="form-text">Stored provider: <strong>{{ providerLabelStored }}</strong> • Active provider: <strong>{{ providerLabelActive }}</strong> • Effective now: <strong>{{ providerLabelEffective }}</strong></div>
|
||||||
|
{% if envOverrideProvider %}
|
||||||
|
<div class="form-text text-warning">Environment override <code>EXTERNAL_ALBUM_PROVIDER={{ envOverrideProvider }}</code> is active and takes priority over saved settings.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
|
{{ form_label(form.LASTFM_API_KEY) }}
|
||||||
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
{{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Environment override: <code>LASTFM_API_KEY</code>.</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-1">
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.MUSICBRAINZ_CLIENT_ID) }}
|
||||||
|
{{ form_widget(form.MUSICBRAINZ_CLIENT_ID, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Optional. Not required for album search. Create OAuth app credentials at <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>. Environment override: <code>MUSICBRAINZ_CLIENT_ID</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.MUSICBRAINZ_CLIENT_SECRET) }}
|
||||||
|
{{ form_widget(form.MUSICBRAINZ_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Optional. Not required for album search. Pairs with client ID from <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>. Environment override: <code>MUSICBRAINZ_CLIENT_SECRET</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.MUSICBRAINZ_ACCESS_TOKEN) }}
|
||||||
|
{{ form_widget(form.MUSICBRAINZ_ACCESS_TOKEN, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Optional. Leave empty for normal search.</div>
|
||||||
|
<div class="form-text">If you need authenticated MusicBrainz features, create an OAuth app under <a href="https://musicbrainz.org/account/applications" target="_blank" rel="noopener">MusicBrainz account applications</a>, authorize it, then paste the returned access token here. Environment override: <code>MUSICBRAINZ_ACCESS_TOKEN</code>.</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-1">
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.TIDAL_CLIENT_ID) }}
|
||||||
|
{{ form_widget(form.TIDAL_CLIENT_ID, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Required for TIDAL provider. Create credentials at <a href="https://developer.tidal.com/" target="_blank" rel="noopener">developer.tidal.com</a>. Environment override: <code>TIDAL_CLIENT_ID</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.TIDAL_CLIENT_SECRET) }}
|
||||||
|
{{ form_widget(form.TIDAL_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||||
|
<div class="form-text">Required for TIDAL provider. Environment override: <code>TIDAL_CLIENT_SECRET</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.TIDAL_COUNTRY_CODE) }}
|
||||||
|
{{ form_widget(form.TIDAL_COUNTRY_CODE, {attr: {class: 'form-control', placeholder: 'US'}}) }}
|
||||||
|
<div class="form-text">Optional ISO 3166-1 alpha-2 country code used for catalog availability (defaults to <code>US</code>). Environment override: <code>TIDAL_COUNTRY_CODE</code>.</div>
|
||||||
</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 +133,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('site-settings-form');
|
||||||
|
const providerField = document.getElementById('{{ form.EXTERNAL_ALBUM_PROVIDER.vars.id }}');
|
||||||
|
if (!form || !providerField) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialProvider = providerField.dataset.initialProvider || providerField.value;
|
||||||
|
const envOverrideActive = {{ envOverrideProvider ? 'true' : 'false' }};
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
if (providerField.value === initialProvider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = envOverrideActive
|
||||||
|
? 'EXTERNAL_ALBUM_PROVIDER override is active. This change saves your preferred provider, but reset only occurs when the effective provider actually changes. Continue?'
|
||||||
|
: 'Changing the external provider will reset all albums, tracks, and reviews to avoid mixed-source duplicates. Continue?';
|
||||||
|
const ok = window.confirm(message);
|
||||||
|
if (!ok) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% 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 {{ externalProviderLabel }} 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.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,11 +77,11 @@
|
|||||||
{% 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">{{ externalProviderLabel }}</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>
|
||||||
{% if publicId %}
|
{% if publicId and a.source == 'user' %}
|
||||||
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
|
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
|
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
|
||||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||||
@@ -97,5 +97,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% set album_id = album.id|default('') %}
|
||||||
|
{% set is_external = album.source is defined and album.source == 'external' %}
|
||||||
|
{% set has_track_count = (album.total_tracks|default(0)) > 0 %}
|
||||||
|
{% set has_release_date = album.release_date is defined and album.release_date %}
|
||||||
|
{% set needs_hydration = is_external and (not has_track_count) %}
|
||||||
|
{% set show_url = path('album_show', {id: album_id}) %}
|
||||||
|
{% set review_href = needs_hydration ? '#' : show_url %}
|
||||||
|
|
||||||
|
<div class="col" data-card-col="1">
|
||||||
|
<div class="card h-100 js-album-card" data-album-id="{{ album_id }}" data-hydrate="{{ needs_hydration ? '1' : '0' }}">
|
||||||
|
{% set image = (album.images[0] ?? album.images[1] ?? null) %}
|
||||||
|
{% if image %}
|
||||||
|
{% set image_url = image.url|default('') %}
|
||||||
|
{% set preview_image_url = image_url %}
|
||||||
|
{% set highres_image_url = '' %}
|
||||||
|
{% if '/i/u/300x300/' in image_url %}
|
||||||
|
{% set preview_image_url = image_url|replace({'/i/u/300x300/': '/i/u/174s/'}) %}
|
||||||
|
{% set highres_image_url = image_url %}
|
||||||
|
{% elseif '/front-500' in image_url %}
|
||||||
|
{% set preview_image_url = image_url|replace({'/front-500': '/front-250'}) %}
|
||||||
|
{% elseif '/front-1200' in image_url %}
|
||||||
|
{% set preview_image_url = image_url|replace({'/front-1200': '/front-250'}) %}
|
||||||
|
{% elseif image_url matches '/\\/front$/' %}
|
||||||
|
{% set preview_image_url = image_url ~ '-250' %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ review_href }}" class="js-image-link {{ needs_hydration ? 'disabled pe-none' : '' }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>
|
||||||
|
<img
|
||||||
|
class="card-img-top js-card-image"
|
||||||
|
src="{{ preview_image_url }}"
|
||||||
|
{% if highres_image_url %}data-highres="{{ highres_image_url }}"{% endif %}
|
||||||
|
alt="{{ album.name }} cover"
|
||||||
|
style="width: 100%; height: 220px; object-fit: cover; background: #f3edf0;"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{% elseif needs_hydration %}
|
||||||
|
<a href="#" class="js-image-link disabled pe-none" aria-disabled="true">
|
||||||
|
<div class="card-img-top js-image-shell d-flex align-items-center justify-content-center text-secondary" style="width: 100%; height: 220px; object-fit: cover; background: #f3edf0;">
|
||||||
|
Loading cover...
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<h5 class="card-title"><a href="{{ review_href }}" class="text-decoration-none js-title-link {{ needs_hydration ? 'disabled pe-none' : '' }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>{{ album.name }}</a></h5>
|
||||||
|
<p class="card-text text-secondary js-artist-line">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
||||||
|
<p class="card-text text-secondary js-release-line">
|
||||||
|
{% if needs_hydration %}
|
||||||
|
Loading album details...
|
||||||
|
{% else %}
|
||||||
|
{% set release_text = album.release_date is defined and album.release_date ? album.release_date : 'Unknown release date' %}
|
||||||
|
Released {{ release_text }} • {{ album.total_tracks }} tracks
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="card-text text-secondary js-genre-line {% if album.genres is not defined or album.genres is empty %}d-none{% endif %}">
|
||||||
|
{% if album.genres is defined and album.genres is not empty %}
|
||||||
|
<small>Genre: {{ album.genres|join(', ') }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||||
|
<p class="card-text js-score-line"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
|
||||||
|
<div class="mt-auto">
|
||||||
|
{% if album.external_urls.external is defined and album.external_urls.external and not needs_hydration %}
|
||||||
|
<a class="btn btn-outline-success btn-sm js-open-source" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-success btn-sm js-reviews-link {{ needs_hydration ? 'disabled' : '' }}" href="{{ review_href }}" {% if needs_hydration %}aria-disabled="true"{% endif %}>Reviews</a>
|
||||||
|
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||||
|
{% if savedIds is defined and (album.id in savedIds) %}
|
||||||
|
{# Saved indicator intentionally omitted to reduce noise #}
|
||||||
|
{% else %}
|
||||||
|
<form class="d-inline ms-2 js-save-form {{ needs_hydration ? 'd-none' : '' }}" method="post" action="{{ path('album_save', {id: album.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if needs_hydration %}
|
||||||
|
<p class="small text-secondary mt-2 mb-0 js-hydration-status">Loading details...</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,16 +3,16 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="h4 mb-3">Edit album</h1>
|
<h1 class="h4 mb-3">Edit album</h1>
|
||||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||||
|
{{ form_errors(form) }}
|
||||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}</div>
|
||||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}</div>
|
||||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}</div>
|
||||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}</div>
|
||||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}</div>
|
||||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}</div>
|
||||||
<button class="btn btn-success" type="submit">Save changes</button>
|
<button class="btn btn-success" type="submit">Save changes</button>
|
||||||
<a class="btn btn-link" href="{{ path('album_show', {id: albumId}) }}">Cancel</a>
|
<a class="btn btn-link" href="{{ path('album_show', {id: albumId}) }}">Cancel</a>
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
{% block title %}{{ isEdit ? 'Edit Album' : 'Create Album' }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<h1 class="h4 mb-3">{{ isEdit ? 'Edit album' : 'Create album' }}</h1>
|
||||||
|
<style>
|
||||||
|
.album-editor-cover {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
border: 2px dashed #d29ab2;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #faf6f8;
|
||||||
|
color: #8a4f69;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-editor-cover:hover {
|
||||||
|
border-color: #b55b80;
|
||||||
|
background: #f6eef2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-editor-cover__preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-col-disc,
|
||||||
|
.track-col-number {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-col-duration {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta-input {
|
||||||
|
min-height: 2.8rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title-input,
|
||||||
|
.track-duration-input {
|
||||||
|
min-height: 2.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{{ form_start(form, {attr: {class: 'vstack gap-4', novalidate: 'novalidate'}}) }}
|
||||||
|
{{ form_errors(form) }}
|
||||||
|
|
||||||
|
<div class="row g-4 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body vstack gap-3">
|
||||||
|
<div>
|
||||||
|
{{ form_widget(form.coverUpload, {attr: {class: 'd-none', accept: 'image/*'}}) }}
|
||||||
|
{% set has_existing_cover = existingCoverUrl is defined and existingCoverUrl %}
|
||||||
|
<label for="{{ form.coverUpload.vars.id }}" class="album-editor-cover" id="cover-upload-trigger">
|
||||||
|
<img
|
||||||
|
id="cover-upload-preview"
|
||||||
|
class="album-editor-cover__preview{{ has_existing_cover ? '' : ' d-none' }}"
|
||||||
|
{% if has_existing_cover %}src="{{ existingCoverUrl }}"{% endif %}
|
||||||
|
alt="Album cover preview"
|
||||||
|
>
|
||||||
|
<span id="cover-upload-placeholder" class="{{ has_existing_cover ? 'd-none' : '' }}">Upload Image</span>
|
||||||
|
</label>
|
||||||
|
{{ form_errors(form.coverUpload) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.name) }}
|
||||||
|
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.name) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.artistsCsv) }}
|
||||||
|
{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.artistsCsv) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.releaseDate) }}
|
||||||
|
{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.releaseDate) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.genresCsv) }}
|
||||||
|
{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.genresCsv) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ form_label(form.externalUrl) }}
|
||||||
|
{{ form_widget(form.externalUrl, {attr: {class: 'form-control', placeholder: 'https://...'}}) }}
|
||||||
|
{{ form_errors(form.externalUrl) }}
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
{{ form_widget(form.totalTracks) }}
|
||||||
|
{{ form_errors(form.totalTracks) }}
|
||||||
|
</div>
|
||||||
|
{% if isEdit and albumOwner %}
|
||||||
|
<small class="text-secondary">
|
||||||
|
Owner: {{ albumOwner.displayName ?? albumOwner.userIdentifier }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 mb-0">Tracklist</h2>
|
||||||
|
<span class="text-secondary"><span id="track-count">{{ trackRows|filter(t => t.title is not empty)|length }}</span> tracks</span>
|
||||||
|
</div>
|
||||||
|
<div class="mui-table-wrapper w-100">
|
||||||
|
<table class="mui-table mui-table--compact mui-table--striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Disc</th>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col" class="text-end">Duration</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="track-editor-body">
|
||||||
|
{% for track in trackRows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mui-table__number track-col-disc">
|
||||||
|
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_disc[]" value="{{ track.disc }}">
|
||||||
|
</td>
|
||||||
|
<td class="mui-table__number track-col-number">
|
||||||
|
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_number[]" value="{{ track.track }}">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="form-control track-title-input" type="text" name="track_title[]" value="{{ track.title }}" maxlength="512" placeholder="Track title">
|
||||||
|
</td>
|
||||||
|
<td class="text-end track-col-duration">
|
||||||
|
<input class="form-control track-duration-input text-end" type="text" inputmode="numeric" name="track_duration[]" value="{{ track.duration }}" placeholder="m:ss">
|
||||||
|
</td>
|
||||||
|
<td class="text-end" style="width:1%">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm track-remove-btn" title="Remove track">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 d-flex gap-2">
|
||||||
|
<button type="button" id="add-track-row" class="btn btn-outline-secondary btn-sm">Add track</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-success" type="submit">{{ isEdit ? 'Save changes' : 'Create album' }}</button>
|
||||||
|
<a class="btn btn-link" href="{{ isEdit and albumId ? path('album_show', {id: albumId}) : path('album_search') }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
|
||||||
|
<template id="track-row-template">
|
||||||
|
<tr>
|
||||||
|
<td class="mui-table__number track-col-disc">
|
||||||
|
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_disc[]" value="1">
|
||||||
|
</td>
|
||||||
|
<td class="mui-table__number track-col-number">
|
||||||
|
<input class="form-control track-meta-input" type="text" inputmode="numeric" name="track_number[]" value="1">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="form-control track-title-input" type="text" name="track_title[]" value="" maxlength="512" placeholder="Track title">
|
||||||
|
</td>
|
||||||
|
<td class="text-end track-col-duration">
|
||||||
|
<input class="form-control track-duration-input text-end" type="text" inputmode="numeric" name="track_duration[]" value="" placeholder="m:ss">
|
||||||
|
</td>
|
||||||
|
<td class="text-end" style="width:1%">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm track-remove-btn" title="Remove track">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const trackBody = document.getElementById('track-editor-body');
|
||||||
|
const addBtn = document.getElementById('add-track-row');
|
||||||
|
const rowTemplate = document.getElementById('track-row-template');
|
||||||
|
const countNode = document.getElementById('track-count');
|
||||||
|
const totalTracksInput = document.getElementById('{{ form.totalTracks.vars.id }}');
|
||||||
|
const coverInput = document.getElementById('{{ form.coverUpload.vars.id }}');
|
||||||
|
const coverPreview = document.getElementById('cover-upload-preview');
|
||||||
|
const coverPlaceholder = document.getElementById('cover-upload-placeholder');
|
||||||
|
if (!trackBody || !addBtn || !rowTemplate || !countNode || !totalTracksInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalcTrackCount = () => {
|
||||||
|
const titles = Array.from(trackBody.querySelectorAll('.track-title-input'));
|
||||||
|
const count = titles.filter((input) => input.value.trim() !== '').length;
|
||||||
|
countNode.textContent = String(count);
|
||||||
|
totalTracksInput.value = String(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindRow = (row) => {
|
||||||
|
const removeBtn = row.querySelector('.track-remove-btn');
|
||||||
|
const titleInput = row.querySelector('.track-title-input');
|
||||||
|
if (removeBtn) {
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
row.remove();
|
||||||
|
recalcTrackCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (titleInput) {
|
||||||
|
titleInput.addEventListener('input', recalcTrackCount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(trackBody.querySelectorAll('tr')).forEach((row) => bindRow(row));
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
const fragment = rowTemplate.content.cloneNode(true);
|
||||||
|
const row = fragment.querySelector('tr');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentRows = trackBody.querySelectorAll('tr').length;
|
||||||
|
const trackNumberInput = row.querySelector('input[name="track_number[]"]');
|
||||||
|
if (trackNumberInput) {
|
||||||
|
trackNumberInput.value = String(currentRows + 1);
|
||||||
|
}
|
||||||
|
bindRow(row);
|
||||||
|
trackBody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coverInput && coverPreview && coverPlaceholder) {
|
||||||
|
coverInput.addEventListener('change', () => {
|
||||||
|
const file = coverInput.files && coverInput.files.length > 0 ? coverInput.files[0] : null;
|
||||||
|
if (!file) {
|
||||||
|
coverPreview.src = '';
|
||||||
|
coverPreview.classList.add('d-none');
|
||||||
|
coverPlaceholder.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
coverPreview.src = objectUrl;
|
||||||
|
coverPreview.classList.remove('d-none');
|
||||||
|
coverPlaceholder.classList.add('d-none');
|
||||||
|
coverPreview.onload = () => URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recalcTrackCount();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="h4 mb-3">Create album</h1>
|
<h1 class="h4 mb-3">Create album</h1>
|
||||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||||
|
{{ form_errors(form) }}
|
||||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}</div>
|
||||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}</div>
|
||||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}</div>
|
||||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}</div>
|
||||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}</div>
|
||||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}</div>
|
||||||
<button class="btn btn-success" type="submit">Create</button>
|
<button class="btn btn-success" type="submit">Create</button>
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
{% set year_to_value = year_to|default('') %}
|
{% set year_to_value = year_to|default('') %}
|
||||||
{% set genre_value = genre|default('') %}
|
{% set genre_value = genre|default('') %}
|
||||||
{% set source_value = source|default('all') %}
|
{% set source_value = source|default('all') %}
|
||||||
|
{% set user_only_value = source_value == 'user' %}
|
||||||
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||||
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||||
{% set landing_view = not has_search %}
|
{% set landing_view = not has_search %}
|
||||||
@@ -28,11 +29,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 %}
|
||||||
@@ -48,12 +49,11 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-sm-3 order-sm-4">
|
<div class="col-sm-4 order-sm-4 d-flex align-items-center">
|
||||||
<select class="form-select" name="source">
|
<div class="form-check">
|
||||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
<input class="form-check-input" type="checkbox" id="userOnlyToggleLanding" name="user_only" value="1" {{ user_only_value ? 'checked' : '' }}>
|
||||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
<label class="form-check-label" for="userOnlyToggleLanding">Show only user-created content</label>
|
||||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-2">
|
<div class="col-sm-2">
|
||||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-2">
|
<div class="col-sm-2">
|
||||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,12 +83,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
<div class="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||||
<div class="row g-2 mt-1">
|
<div class="row g-2 mt-1">
|
||||||
<div class="col-sm-3 order-sm-4">
|
<div class="col-sm-4 order-sm-4 d-flex align-items-center">
|
||||||
<select class="form-select" name="source">
|
<div class="form-check">
|
||||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
<input class="form-check-input" type="checkbox" id="userOnlyToggleInline" name="user_only" value="1" {{ user_only_value ? 'checked' : '' }}>
|
||||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
<label class="form-check-label" for="userOnlyToggleInline">Show only user-created content</label>
|
||||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4">
|
<div class="col-sm-4">
|
||||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||||
@@ -100,10 +99,10 @@
|
|||||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-2">
|
<div class="col-sm-2">
|
||||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
<input class="form-control" type="date" name="year_from" value="{{ year_from_value }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-2">
|
<div class="col-sm-2">
|
||||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
<input class="form-control" type="date" name="year_to" value="{{ year_to_value }}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,51 +110,659 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if albums is defined and albums|length > 0 %}
|
{% if albums is defined and albums|length > 0 %}
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3" data-album-grid="1">
|
||||||
{% for album in albums %}
|
{% for album in albums %}
|
||||||
<div class="col">
|
{% include 'album/_search_card.html.twig' with { album: album, stats: stats, savedIds: savedIds } %}
|
||||||
<div class="card h-100">
|
|
||||||
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
|
|
||||||
{% if image %}
|
|
||||||
<a href="{{ path('album_show', {id: album.id}) }}">
|
|
||||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
|
|
||||||
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
|
||||||
<p class="card-text text-secondary">
|
|
||||||
Released {{ album.release_date }} • {{ album.total_tracks }} tracks
|
|
||||||
{% if album.genres is defined and album.genres is not empty %}
|
|
||||||
<br><small>Genre: {{ album.genres|join(', ') }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
{% 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>
|
|
||||||
<div class="mt-auto">
|
|
||||||
{% if album.external_urls.spotify is defined and album.external_urls.spotify %}
|
|
||||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
|
||||||
{% endif %}
|
|
||||||
<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 savedIds is defined and (album.id in savedIds) %}
|
|
||||||
{# Saved indicator intentionally omitted to reduce noise #}
|
|
||||||
{% else %}
|
|
||||||
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: album.id}) }}">
|
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
|
|
||||||
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if has_search %}
|
||||||
|
<div class="d-flex justify-content-center mt-4" data-show-more-wrap="1">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-show-more-btn="1">Show More</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% elseif query or album or artist or genre or year_from or year_to %}
|
{% elseif query or album or artist or genre or year_from or year_to %}
|
||||||
<p>No albums found.</p>
|
<p>No albums found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if albums is defined and albums|length > 0 %}
|
||||||
|
{% set card_data_url_template = path('album_card_data', {id: '__ALBUM_ID__'}) %}
|
||||||
|
{% set show_url_template = path('album_show', {id: '__ALBUM_ID__'}) %}
|
||||||
|
{% set batch_url = path('album_search_batch') %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const albumGrid = document.querySelector('[data-album-grid="1"]');
|
||||||
|
if (!albumGrid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointTemplate = {{ card_data_url_template|json_encode|raw }};
|
||||||
|
const showUrlTemplate = {{ show_url_template|json_encode|raw }};
|
||||||
|
const batchEndpoint = {{ batch_url|json_encode|raw }};
|
||||||
|
const hasSearch = {{ has_search ? 'true' : 'false' }};
|
||||||
|
const baseLimitRaw = Number({{ search_limit|default(20) }});
|
||||||
|
const baseLimit = Number.isFinite(baseLimitRaw) && baseLimitRaw > 0 ? Math.floor(baseLimitRaw) : 20;
|
||||||
|
const searchFilters = {
|
||||||
|
q: {{ query_value|json_encode|raw }},
|
||||||
|
album: {{ album_value|json_encode|raw }},
|
||||||
|
artist: {{ artist_value|json_encode|raw }},
|
||||||
|
genre: {{ genre_value|json_encode|raw }},
|
||||||
|
year_from: {{ year_from_value|json_encode|raw }},
|
||||||
|
year_to: {{ year_to_value|json_encode|raw }},
|
||||||
|
source: {{ source_value|json_encode|raw }},
|
||||||
|
user_only: {{ (user_only_value ? '1' : '')|json_encode|raw }},
|
||||||
|
};
|
||||||
|
const showMoreWrap = document.querySelector('[data-show-more-wrap="1"]');
|
||||||
|
const showMoreButton = document.querySelector('[data-show-more-btn="1"]');
|
||||||
|
|
||||||
|
const maxConcurrent = 3;
|
||||||
|
let active = 0;
|
||||||
|
let pendingHydrationCount = 0;
|
||||||
|
let batchLoading = false;
|
||||||
|
let exhausted = !hasSearch;
|
||||||
|
let targetVisibleCount = hasSearch ? baseLimit : albumGrid.querySelectorAll('.js-album-card').length;
|
||||||
|
let nextOffset = albumGrid.querySelectorAll('.js-album-card').length;
|
||||||
|
const pendingQueue = [];
|
||||||
|
const enqueuedCards = new WeakSet();
|
||||||
|
const knownAlbumIds = new Set();
|
||||||
|
let highResObserver = null;
|
||||||
|
let hydrationObserver = null;
|
||||||
|
|
||||||
|
const buildUrl = (template, albumId) => template.replace('__ALBUM_ID__', encodeURIComponent(albumId));
|
||||||
|
|
||||||
|
const countVisibleCards = () => albumGrid.querySelectorAll('.js-album-card').length;
|
||||||
|
|
||||||
|
const updateShowMoreButton = () => {
|
||||||
|
if (!showMoreWrap || !showMoreButton || !hasSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exhausted) {
|
||||||
|
showMoreWrap.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMoreWrap.classList.remove('d-none');
|
||||||
|
showMoreButton.disabled = batchLoading;
|
||||||
|
showMoreButton.textContent = batchLoading ? 'Loading...' : 'Show More';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveImageVariants = (url) => {
|
||||||
|
const normalized = typeof url === 'string' ? url.trim() : '';
|
||||||
|
if (normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('/i/u/300x300/')) {
|
||||||
|
return {
|
||||||
|
previewUrl: normalized.replace('/i/u/300x300/', '/i/u/174s/'),
|
||||||
|
highresUrl: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('/front-500')) {
|
||||||
|
return {
|
||||||
|
previewUrl: normalized.replace('/front-500', '/front-250'),
|
||||||
|
highresUrl: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('/front-1200')) {
|
||||||
|
return {
|
||||||
|
previewUrl: normalized.replace('/front-1200', '/front-250'),
|
||||||
|
highresUrl: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\/front$/.test(normalized)) {
|
||||||
|
return {
|
||||||
|
previewUrl: `${normalized}-250`,
|
||||||
|
highresUrl: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewUrl: normalized,
|
||||||
|
highresUrl: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCard = (card) => {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = card.closest('[data-card-col]') || card.closest('.col');
|
||||||
|
if (column) {
|
||||||
|
column.remove();
|
||||||
|
} else {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
ensureTargetVisible();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setHydrationStatus = (card, message) => {
|
||||||
|
const status = card.querySelector('.js-hydration-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCoverNotFound = (card) => {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageLink = card.querySelector('.js-image-link');
|
||||||
|
if (!imageLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageShell = card.querySelector('.js-image-shell');
|
||||||
|
if (!imageShell) {
|
||||||
|
imageShell = document.createElement('div');
|
||||||
|
imageShell.className = 'card-img-top js-image-shell d-flex align-items-center justify-content-center text-secondary';
|
||||||
|
imageShell.style.width = '100%';
|
||||||
|
imageShell.style.height = '220px';
|
||||||
|
imageShell.style.objectFit = 'cover';
|
||||||
|
imageShell.style.background = '#f3edf0';
|
||||||
|
|
||||||
|
const imageTag = card.querySelector('.js-card-image');
|
||||||
|
if (imageTag) {
|
||||||
|
imageTag.replaceWith(imageShell);
|
||||||
|
} else {
|
||||||
|
imageLink.appendChild(imageShell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageShell.textContent = 'Cover not found';
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachImageFallback = (imageTag) => {
|
||||||
|
if (!imageTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (imageTag.dataset.fallbackBound === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = imageTag.closest('.js-album-card');
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageTag.addEventListener('error', () => {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}, { once: true });
|
||||||
|
imageTag.dataset.fallbackBound = '1';
|
||||||
|
};
|
||||||
|
|
||||||
|
const upgradeToHighRes = (imageTag) => {
|
||||||
|
if (!imageTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highres = typeof imageTag.dataset.highres === 'string' ? imageTag.dataset.highres.trim() : '';
|
||||||
|
if (highres === '' || imageTag.src === highres) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageTag.src = highres;
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeHighResImage = (imageTag) => {
|
||||||
|
if (!imageTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highres = typeof imageTag.dataset.highres === 'string' ? imageTag.dataset.highres.trim() : '';
|
||||||
|
if (highres === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('IntersectionObserver' in window)) {
|
||||||
|
upgradeToHighRes(imageTag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!highResObserver) {
|
||||||
|
highResObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = entry.target;
|
||||||
|
if (image instanceof HTMLImageElement) {
|
||||||
|
upgradeToHighRes(image);
|
||||||
|
}
|
||||||
|
highResObserver.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
rootMargin: '250px 0px',
|
||||||
|
threshold: 0.01,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
highResObserver.observe(imageTag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCardImage = (imageTag, imageUrl, albumName) => {
|
||||||
|
const variants = resolveImageVariants(imageUrl);
|
||||||
|
if (!variants) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageTag.src = variants.previewUrl;
|
||||||
|
imageTag.alt = `${albumName || 'Album'} cover`;
|
||||||
|
if (variants.highresUrl !== '') {
|
||||||
|
imageTag.dataset.highres = variants.highresUrl;
|
||||||
|
observeHighResImage(imageTag);
|
||||||
|
} else {
|
||||||
|
delete imageTag.dataset.highres;
|
||||||
|
}
|
||||||
|
attachImageFallback(imageTag);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCardOpenable = (card, showUrl) => {
|
||||||
|
const reviewLink = card.querySelector('.js-reviews-link');
|
||||||
|
if (reviewLink) {
|
||||||
|
reviewLink.href = showUrl;
|
||||||
|
reviewLink.classList.remove('disabled');
|
||||||
|
reviewLink.removeAttribute('aria-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleLink = card.querySelector('.js-title-link');
|
||||||
|
if (titleLink) {
|
||||||
|
titleLink.href = showUrl;
|
||||||
|
titleLink.classList.remove('disabled', 'pe-none');
|
||||||
|
titleLink.removeAttribute('aria-disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageLink = card.querySelector('.js-image-link');
|
||||||
|
if (imageLink) {
|
||||||
|
imageLink.href = showUrl;
|
||||||
|
imageLink.classList.remove('disabled', 'pe-none');
|
||||||
|
imageLink.removeAttribute('aria-disabled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hydrateCard = async (card) => {
|
||||||
|
const albumId = String(card.dataset.albumId || '').trim();
|
||||||
|
if (albumId === '') {
|
||||||
|
removeCard(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildUrl(endpointTemplate, albumId), {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setHydrationStatus(card, 'Could not load details.');
|
||||||
|
showCoverNotFound(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload || payload.ok !== true || payload.can_open !== true || !payload.album) {
|
||||||
|
removeCard(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydratedAlbum = payload.album;
|
||||||
|
const hydratedId = String(hydratedAlbum.id || albumId).trim() || albumId;
|
||||||
|
const showUrl = buildUrl(showUrlTemplate, hydratedId);
|
||||||
|
setCardOpenable(card, showUrl);
|
||||||
|
|
||||||
|
const titleLink = card.querySelector('.js-title-link');
|
||||||
|
if (titleLink && typeof hydratedAlbum.name === 'string' && hydratedAlbum.name.trim() !== '') {
|
||||||
|
titleLink.textContent = hydratedAlbum.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistLine = card.querySelector('.js-artist-line');
|
||||||
|
if (artistLine && Array.isArray(hydratedAlbum.artists)) {
|
||||||
|
const artistNames = hydratedAlbum.artists
|
||||||
|
.map((artist) => (artist && typeof artist.name === 'string' ? artist.name.trim() : ''))
|
||||||
|
.filter((name) => name !== '');
|
||||||
|
artistLine.textContent = artistNames.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseLine = card.querySelector('.js-release-line');
|
||||||
|
if (releaseLine) {
|
||||||
|
const releaseDate = typeof hydratedAlbum.release_date === 'string' && hydratedAlbum.release_date.trim() !== ''
|
||||||
|
? hydratedAlbum.release_date.trim()
|
||||||
|
: 'Unknown release date';
|
||||||
|
const totalTracks = Number.isFinite(Number(hydratedAlbum.total_tracks))
|
||||||
|
? Math.max(0, Number(hydratedAlbum.total_tracks))
|
||||||
|
: 0;
|
||||||
|
releaseLine.textContent = `Released ${releaseDate} • ${totalTracks} tracks`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreLine = card.querySelector('.js-genre-line');
|
||||||
|
if (genreLine) {
|
||||||
|
const genres = Array.isArray(hydratedAlbum.genres)
|
||||||
|
? hydratedAlbum.genres.filter((genre) => typeof genre === 'string' && genre.trim() !== '')
|
||||||
|
: [];
|
||||||
|
if (genres.length > 0) {
|
||||||
|
const genreText = `Genre: ${genres.join(', ')}`;
|
||||||
|
const genreSmall = genreLine.querySelector('small');
|
||||||
|
if (genreSmall) {
|
||||||
|
genreSmall.textContent = genreText;
|
||||||
|
} else {
|
||||||
|
genreLine.textContent = genreText;
|
||||||
|
}
|
||||||
|
genreLine.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
genreLine.textContent = '';
|
||||||
|
genreLine.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageTag = card.querySelector('.js-card-image');
|
||||||
|
if (imageTag && Array.isArray(hydratedAlbum.images) && hydratedAlbum.images.length > 0) {
|
||||||
|
const firstImage = hydratedAlbum.images[0];
|
||||||
|
if (firstImage && typeof firstImage.url === 'string' && firstImage.url.trim() !== '') {
|
||||||
|
const applied = applyCardImage(imageTag, firstImage.url, hydratedAlbum.name);
|
||||||
|
if (!applied) {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(hydratedAlbum.images) && hydratedAlbum.images.length > 0) {
|
||||||
|
const firstImage = hydratedAlbum.images[0];
|
||||||
|
const imageLink = card.querySelector('.js-image-link');
|
||||||
|
const imageShell = card.querySelector('.js-image-shell');
|
||||||
|
if (imageLink && imageShell && firstImage && typeof firstImage.url === 'string' && firstImage.url.trim() !== '') {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'card-img-top js-card-image';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.style.width = '100%';
|
||||||
|
img.style.height = '220px';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
img.style.background = '#f3edf0';
|
||||||
|
const applied = applyCardImage(img, firstImage.url, hydratedAlbum.name);
|
||||||
|
if (!applied) {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imageShell.replaceWith(img);
|
||||||
|
} else {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = payload.stats && typeof payload.stats === 'object'
|
||||||
|
? payload.stats
|
||||||
|
: { avg: 0, count: 0 };
|
||||||
|
const avg = Number.isFinite(Number(stats.avg)) ? Number(stats.avg) : 0;
|
||||||
|
const count = Number.isFinite(Number(stats.count)) ? Number(stats.count) : 0;
|
||||||
|
const scoreLine = card.querySelector('.js-score-line');
|
||||||
|
if (scoreLine) {
|
||||||
|
const scoreText = `User score: ${avg}/10 (${count})`;
|
||||||
|
const scoreSmall = scoreLine.querySelector('small');
|
||||||
|
if (scoreSmall) {
|
||||||
|
scoreSmall.textContent = scoreText;
|
||||||
|
} else {
|
||||||
|
scoreLine.textContent = scoreText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSourceButton = card.querySelector('.js-open-source');
|
||||||
|
const sourceUrl = hydratedAlbum.external_urls && typeof hydratedAlbum.external_urls.external === 'string'
|
||||||
|
? hydratedAlbum.external_urls.external.trim()
|
||||||
|
: '';
|
||||||
|
if (openSourceButton) {
|
||||||
|
if (sourceUrl !== '') {
|
||||||
|
openSourceButton.href = sourceUrl;
|
||||||
|
openSourceButton.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
openSourceButton.remove();
|
||||||
|
}
|
||||||
|
} else if (sourceUrl !== '') {
|
||||||
|
const reviewsButton = card.querySelector('.js-reviews-link');
|
||||||
|
if (reviewsButton && reviewsButton.parentElement) {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.className = 'btn btn-outline-success btn-sm js-open-source me-2';
|
||||||
|
anchor.href = sourceUrl;
|
||||||
|
anchor.target = '_blank';
|
||||||
|
anchor.rel = 'noopener';
|
||||||
|
anchor.textContent = 'Open Source';
|
||||||
|
reviewsButton.parentElement.insertBefore(anchor, reviewsButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveForm = card.querySelector('.js-save-form');
|
||||||
|
if (saveForm) {
|
||||||
|
if (payload.saved === true) {
|
||||||
|
saveForm.classList.add('d-none');
|
||||||
|
} else {
|
||||||
|
saveForm.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHydrationStatus(card, '');
|
||||||
|
const status = card.querySelector('.js-hydration-status');
|
||||||
|
if (status) {
|
||||||
|
status.remove();
|
||||||
|
}
|
||||||
|
card.dataset.hydrate = '0';
|
||||||
|
} catch (error) {
|
||||||
|
setHydrationStatus(card, 'Could not load details.');
|
||||||
|
showCoverNotFound(card);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runQueue = () => {
|
||||||
|
while (active < maxConcurrent && pendingQueue.length > 0) {
|
||||||
|
const card = pendingQueue.shift();
|
||||||
|
if (!card) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active += 1;
|
||||||
|
hydrateCard(card).finally(() => {
|
||||||
|
active -= 1;
|
||||||
|
pendingHydrationCount = Math.max(0, pendingHydrationCount - 1);
|
||||||
|
runQueue();
|
||||||
|
ensureTargetVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueCard = (card) => {
|
||||||
|
if (!card || enqueuedCards.has(card) || card.dataset.hydrate !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueuedCards.add(card);
|
||||||
|
pendingHydrationCount += 1;
|
||||||
|
pendingQueue.push(card);
|
||||||
|
runQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeHydrationCard = (card) => {
|
||||||
|
if (!card || card.dataset.hydrate !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('IntersectionObserver' in window)) {
|
||||||
|
enqueueCard(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hydrationObserver) {
|
||||||
|
hydrationObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enqueueCard(entry.target);
|
||||||
|
hydrationObserver.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
rootMargin: '350px 0px',
|
||||||
|
threshold: 0.01,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrationObserver.observe(card);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerCard = (card) => {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumId = String(card.dataset.albumId || '').trim();
|
||||||
|
if (albumId !== '') {
|
||||||
|
knownAlbumIds.add(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardImages = Array.from(card.querySelectorAll('.js-card-image'));
|
||||||
|
cardImages.forEach((imageTag) => {
|
||||||
|
attachImageFallback(imageTag);
|
||||||
|
observeHighResImage(imageTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
observeHydrationCard(card);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBatchRequestUrl = (limit) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const appendIfPresent = (key, value) => {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (normalized !== '') {
|
||||||
|
params.set(key, normalized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
appendIfPresent('q', searchFilters.q);
|
||||||
|
appendIfPresent('album', searchFilters.album);
|
||||||
|
appendIfPresent('artist', searchFilters.artist);
|
||||||
|
appendIfPresent('genre', searchFilters.genre);
|
||||||
|
appendIfPresent('year_from', searchFilters.year_from);
|
||||||
|
appendIfPresent('year_to', searchFilters.year_to);
|
||||||
|
if (searchFilters.user_only === '1') {
|
||||||
|
params.set('user_only', '1');
|
||||||
|
} else if (searchFilters.source !== '' && searchFilters.source !== 'all') {
|
||||||
|
params.set('source', searchFilters.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set('offset', String(nextOffset));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
return `${batchEndpoint}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendBatchHtml = (html) => {
|
||||||
|
if (typeof html !== 'string' || html.trim() === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html.trim();
|
||||||
|
const incomingColumns = Array.from(template.content.querySelectorAll('[data-card-col]'));
|
||||||
|
let appendedCount = 0;
|
||||||
|
|
||||||
|
incomingColumns.forEach((column) => {
|
||||||
|
const card = column.querySelector('.js-album-card');
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumId = String(card.dataset.albumId || '').trim();
|
||||||
|
if (albumId !== '' && knownAlbumIds.has(albumId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
albumGrid.appendChild(column);
|
||||||
|
registerCard(card);
|
||||||
|
appendedCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return appendedCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBatch = async (requestLimit) => {
|
||||||
|
if (!hasSearch || batchLoading || exhausted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeLimit = Math.max(1, Math.min(baseLimit, requestLimit));
|
||||||
|
batchLoading = true;
|
||||||
|
updateShowMoreButton();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildBatchRequestUrl(safeLimit), {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const returnedCountRaw = Number(payload.count);
|
||||||
|
const returnedCount = Number.isFinite(returnedCountRaw) ? Math.max(0, Math.floor(returnedCountRaw)) : 0;
|
||||||
|
const nextOffsetRaw = Number(payload.next_offset);
|
||||||
|
if (Number.isFinite(nextOffsetRaw) && nextOffsetRaw >= 0) {
|
||||||
|
nextOffset = Math.max(nextOffset, Math.floor(nextOffsetRaw));
|
||||||
|
} else {
|
||||||
|
nextOffset += returnedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendBatchHtml(typeof payload.html === 'string' ? payload.html : '');
|
||||||
|
|
||||||
|
if (returnedCount === 0 || payload.exhausted === true || returnedCount < safeLimit) {
|
||||||
|
exhausted = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Keep current state and allow retries.
|
||||||
|
} finally {
|
||||||
|
batchLoading = false;
|
||||||
|
updateShowMoreButton();
|
||||||
|
ensureTargetVisible();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureTargetVisible = () => {
|
||||||
|
if (!hasSearch || exhausted || batchLoading) {
|
||||||
|
updateShowMoreButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingHydrationCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCards = countVisibleCards();
|
||||||
|
if (visibleCards >= targetVisibleCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = targetVisibleCount - visibleCards;
|
||||||
|
const nextLimit = Math.max(1, Math.min(baseLimit, missing));
|
||||||
|
fetchBatch(nextLimit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCards = Array.from(albumGrid.querySelectorAll('.js-album-card'));
|
||||||
|
initialCards.forEach((card) => registerCard(card));
|
||||||
|
|
||||||
|
if (showMoreButton) {
|
||||||
|
showMoreButton.addEventListener('click', () => {
|
||||||
|
targetVisibleCount += baseLimit;
|
||||||
|
ensureTargetVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShowMoreButton();
|
||||||
|
ensureTargetVisible();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -46,4 +46,3 @@
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
</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>
|
{% if review.album.externalId %}
|
||||||
|
<a class="ms-1" href="{{ path('album_show', {id: review.album.externalId}) }}">View album</a>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<article class="mb-3">
|
<article class="mb-3">
|
||||||
<p>{{ review.content|nl2br }}</p>
|
<p>{{ review.content|nl2br }}</p>
|
||||||
@@ -37,4 +39,3 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||||
|
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ1DfAnq5rpIGEmuuV
|
||||||
|
TxzKdgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEGq9s3Pc5KgVqWPc
|
||||||
|
DWBTt+kEggTQKShV0ASL1ZVSQa+fpUE8Alq65oJsTccgZu2MXFx0DaomeB5DSHQX
|
||||||
|
MxrwmrXz+f9/17AzPD7dBw+2YP1UIXkiP6B89RvCc2g6G1yHL52ziEgtXPrYRrcs
|
||||||
|
kwUHPbcd6X5XATJRSxmu8VOBimxmCNgAtuhPl6wb7a+YZYLv7EVYGaqAlPiySOKP
|
||||||
|
Nnuv2ENAz958FNX2O7VyI4N+8W+DEtVo2CCo8luxb8SrxmtO7yxEMC9Fs2wZitgs
|
||||||
|
PgCWIYy+laB0KBP0qIi0kOkuySDSQgC8psz0b2lKVS/k2AP3SkoyIXfjCSIgZYBI
|
||||||
|
cFLSWlbbau/h+0AAuMmcWTheVvhSowFRVMJ5KyGK0Mw0+cADs4sd28OVtC8px+jS
|
||||||
|
4Qr1D8jDpiEuSJhdJ1E9vRC6SYq03N2XjLWl/r18aydBtsdbMGGCi2aQ4KAAprNX
|
||||||
|
siDGR5gKHYkY0Jef7K2VGe8ojcsjlXJfRR9B10A1+uvbMUIVcz3qk436jlNyBIsG
|
||||||
|
Doqbp/Vortfvj97rPgY4VROYNbDaVa0I5etScRVuY95zurxbhHbT4sUny73+jDbN
|
||||||
|
IxXslYKKqJ+d9tWVM9CCIkBZj075HgEnss8Yl4/59r2FjMcruNwxV9Ife019yOdO
|
||||||
|
YSG/HEYveYOeM966avUhJw3AxpCSoeGRyKCFGcbj6Yu0T/MR69DA2vpLKrI29I5C
|
||||||
|
w1Hig2PpWktduTCA+XlYAnl5wvJ60Z2BZg78RRZ/B7iEKmc5uEIAgFtjVNZ2QKtx
|
||||||
|
xyrT/5b+e90ga1elPrNzzvdbPM3PPBuRGoLJjgv3wnDHDtqVvIEyU48RhD+EHG/1
|
||||||
|
d8AsBgBIz12s/gbKuZbrINfIvMhIhNM5VZjfstid4hk58gycKfNV4rZgptpoXUQ9
|
||||||
|
w4tmfDgmMuDIKrQYw+LuDB3kbY75RasmaQxNOLs4/40G6SBQ6stPyWjzMCUvWZk8
|
||||||
|
9HjxbP3MvryoCb4YCMW8MwzJCiAtetB9on0d+KAh5t2fyCVXkN1fqyAW55uQVvuf
|
||||||
|
vDRShBfX19dGiy5cFW6f9/aXpYWmtktLT+5Kreh1lGlb+2hX7qvom/ND1MFMqwXz
|
||||||
|
nNs4P2kvOHx9xnZMxcHCJZhyvIOn8Wwv4FpX1t/Hw0YBW1LE7wUnsoL0rI7p2CCY
|
||||||
|
1EJwQ61F4K02b12xTcbGM8LfxrsZ959eCe7XxmNUWWPQsqR1gLJxF92vTZdpO3VZ
|
||||||
|
bEZKDQZmnAvT3BbgelYABlCl9QqTs9/9wk0IEU59D/fWsLKx9/Q5b1C5czMXyzmd
|
||||||
|
OjGA3G4ADT2IE2GeGrL3W45wldcgTL5mxv6kCAnFsKJy2LmyOPYgFunixz8gTkOa
|
||||||
|
G0FwAP3q71D8JPQIuYZ8k9RPiKthSxfNHaDytLzjmJwzPpikP4oo1LCuDOYlst86
|
||||||
|
ZSTIcEk2w19J66sSOhXFs5ByHhpna1/o12cHBE+p/Xv7CM6rQq54ASk8Ss+lWCuf
|
||||||
|
JuHck+67YOVesC4moelTlOa0yK2mll24rqSVo4D/uXkEz7VxSY+cdKFUy9KRboxl
|
||||||
|
BU2u8npv3n6s0A0R8bOWFo3/3A64NEJ+zn+gXUGsHLD9QVTrQfUwNxqhlvVi+SOQ
|
||||||
|
TYQKbGX5aGNLDuieqY9LAkSYTDMZ8HpJyMZXXPcjk8f6+U91shJrKpw=
|
||||||
|
-----END ENCRYPTED PRIVATE KEY-----
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqcRbql9j/cn0Wb3dOb9R
|
||||||
|
jpgNkp1+g3ZBridR3hxCAUMB1jPrZgwL4vt4/Tr8lNrstDxMSk0KRodtD2SnergH
|
||||||
|
MA1eDJBGhKJhIyxATrj3KhtpCw8SO3otq4JlT3gJWUTpenfEnQ9sCuJiK3muRLy5
|
||||||
|
EDTidwPG6RHca4t4hng62P8mjE3wZUMbFJ8Nf73y4kKi5BLmpk3VZ3/FZGb9o+6C
|
||||||
|
COXjRdeV1Ubhyd+Kxz3LQFcgVSzlqry3K8EaG0b82jyG6r9wJ/RGJJbJ67+q4jdD
|
||||||
|
+TFBcH0FaoO8KPM+CrPpJYvYJtYTz6HoCFbDHdGMOZnwKeF9oeIoW8sz+a6Pg0k5
|
||||||
|
hQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Api;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
|
use App\Entity\Review;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
final class ApiHardeningFunctionalTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::ensureKernelShutdown();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->resetDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewCreateWithoutJwtReturns401Envelope(): void
|
||||||
|
{
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/reviews',
|
||||||
|
server: ['CONTENT_TYPE' => 'application/json'],
|
||||||
|
content: json_encode([
|
||||||
|
'album_id' => 'missing-album',
|
||||||
|
'title' => 'Sample',
|
||||||
|
'content' => str_repeat('A', 30),
|
||||||
|
'rating' => 8,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'authentication_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewCreateWithInvalidJwtReturns401Envelope(): void
|
||||||
|
{
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/reviews',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_AUTHORIZATION' => 'Bearer definitely-not-a-valid-jwt',
|
||||||
|
],
|
||||||
|
content: json_encode([
|
||||||
|
'album_id' => 'missing-album',
|
||||||
|
'title' => 'Sample',
|
||||||
|
'content' => str_repeat('A', 30),
|
||||||
|
'rating' => 8,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'invalid_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewCreateWithInvalidJsonReturns400Envelope(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('api-invalid-json@example.test');
|
||||||
|
$album = $this->createAlbum('album_invalid_json');
|
||||||
|
$token = $this->issueToken($user);
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/reviews',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
|
||||||
|
],
|
||||||
|
content: sprintf('{"album_id":"%s","title":"oops"', $album->getExternalId())
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_BAD_REQUEST, ['invalid_json', 'invalid_request']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewCreateValidationReturns422EnvelopeWithFieldDetails(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('api-validation@example.test');
|
||||||
|
$album = $this->createAlbum('album_validation');
|
||||||
|
$token = $this->issueToken($user);
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/reviews',
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
|
||||||
|
],
|
||||||
|
content: json_encode([
|
||||||
|
'album_id' => $album->getExternalId(),
|
||||||
|
'title' => '',
|
||||||
|
'content' => 'too short',
|
||||||
|
'rating' => 42,
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $this->assertErrorEnvelope(Response::HTTP_UNPROCESSABLE_ENTITY, 'validation_error');
|
||||||
|
self::assertIsArray($payload['error']['details'] ?? null);
|
||||||
|
self::assertIsArray($payload['error']['details']['fields'] ?? null);
|
||||||
|
self::assertArrayHasKey('title', $payload['error']['details']['fields']);
|
||||||
|
self::assertArrayHasKey('content', $payload['error']['details']['fields']);
|
||||||
|
self::assertArrayHasKey('rating', $payload['error']['details']['fields']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewUpdateByDifferentUserReturns403Envelope(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('review-owner@example.test');
|
||||||
|
$intruder = $this->createUser('review-intruder@example.test');
|
||||||
|
$album = $this->createAlbum('album_forbidden');
|
||||||
|
$review = $this->createReview($album, $owner);
|
||||||
|
$token = $this->issueToken($intruder);
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'PATCH',
|
||||||
|
'/api/v1/reviews/' . (string) $review->getId(),
|
||||||
|
server: [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
|
||||||
|
],
|
||||||
|
content: json_encode([
|
||||||
|
'title' => 'Unauthorized update attempt',
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_FORBIDDEN, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlbumReviewsUnknownAlbumReturns404Envelope(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/api/v1/albums/not-a-real-album-id/reviews');
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_NOT_FOUND, 'album_not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnsupportedMethodOnApiIndexReturns405Envelope(): void
|
||||||
|
{
|
||||||
|
$this->client->request('POST', '/api/v1');
|
||||||
|
|
||||||
|
$this->assertErrorEnvelope(Response::HTTP_METHOD_NOT_ALLOWED, 'method_not_allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReviewIndexDoesNotExposeAuthorEmail(): void
|
||||||
|
{
|
||||||
|
$author = $this->createUser('pii-check@example.test');
|
||||||
|
$album = $this->createAlbum('album_pii');
|
||||||
|
$this->createReview($album, $author);
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/v1/reviews?limit=1');
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($payload);
|
||||||
|
self::assertIsArray($payload['data'] ?? null);
|
||||||
|
self::assertNotEmpty($payload['data']);
|
||||||
|
|
||||||
|
$first = $payload['data'][0] ?? null;
|
||||||
|
self::assertIsArray($first);
|
||||||
|
$authorPayload = $first['author'] ?? null;
|
||||||
|
self::assertIsArray($authorPayload);
|
||||||
|
self::assertArrayHasKey('id', $authorPayload);
|
||||||
|
self::assertArrayHasKey('display_name', $authorPayload);
|
||||||
|
self::assertArrayNotHasKey('email', $authorPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApiIndexExposesHypermediaLinksAndActions(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/api/v1');
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($payload);
|
||||||
|
self::assertIsArray($payload['links'] ?? null);
|
||||||
|
self::assertSame('/api/v1', $payload['links']['self'] ?? null);
|
||||||
|
self::assertSame('/api/v1/albums', $payload['links']['albums'] ?? null);
|
||||||
|
self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null);
|
||||||
|
self::assertSame('/api/docs', $payload['links']['docs'] ?? null);
|
||||||
|
self::assertIsArray($payload['actions'] ?? null);
|
||||||
|
self::assertSame('/api/v1/auth/login', $payload['actions']['login']['href'] ?? null);
|
||||||
|
self::assertSame('POST', $payload['actions']['login']['method'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlbumShowIncludesHypermediaLinksAndActions(): void
|
||||||
|
{
|
||||||
|
$album = $this->createAlbum('album_hypermedia');
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/v1/albums/' . $album->getExternalId());
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($payload);
|
||||||
|
self::assertIsArray($payload['data'] ?? null);
|
||||||
|
self::assertSame('/api/v1/albums/album_hypermedia', $payload['data']['links']['self'] ?? null);
|
||||||
|
self::assertSame('/api/v1/albums/album_hypermedia/reviews', $payload['data']['links']['reviews'] ?? null);
|
||||||
|
self::assertSame('https://example.test/albums/album_hypermedia', $payload['data']['links']['open_source'] ?? null);
|
||||||
|
self::assertSame('/api/v1/reviews', $payload['data']['actions']['create_review']['href'] ?? null);
|
||||||
|
self::assertSame('/api/v1/albums/ingest', $payload['data']['actions']['ingest']['href'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlbumReviewsEndpointIncludesHypermedia(): void
|
||||||
|
{
|
||||||
|
$author = $this->createUser('album-reviews-hypermedia@example.test');
|
||||||
|
$album = $this->createAlbum('album_reviews_hypermedia');
|
||||||
|
$review = $this->createReview($album, $author);
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/v1/albums/' . $album->getExternalId() . '/reviews');
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($payload);
|
||||||
|
self::assertSame('/api/v1/albums/album_reviews_hypermedia/reviews', $payload['links']['self'] ?? null);
|
||||||
|
self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null);
|
||||||
|
self::assertSame('/api/v1/reviews', $payload['actions']['create_review']['href'] ?? null);
|
||||||
|
self::assertSame('POST', $payload['actions']['create_review']['method'] ?? null);
|
||||||
|
self::assertIsArray($payload['data'] ?? null);
|
||||||
|
self::assertNotEmpty($payload['data']);
|
||||||
|
|
||||||
|
$first = $payload['data'][0] ?? null;
|
||||||
|
self::assertIsArray($first);
|
||||||
|
self::assertSame($review->getId(), $first['id'] ?? null);
|
||||||
|
self::assertSame('/api/v1/reviews/' . $review->getId(), $first['links']['self'] ?? null);
|
||||||
|
self::assertSame('/api/v1/albums/album_reviews_hypermedia', $first['links']['album'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetDatabase(): void
|
||||||
|
{
|
||||||
|
$allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||||
|
if ($allMetadata === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schemaTool = new SchemaTool($this->entityManager);
|
||||||
|
$schemaTool->dropDatabase();
|
||||||
|
$schemaTool->createSchema($allMetadata);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUser(string $email, string $plainPassword = 'password123'): User
|
||||||
|
{
|
||||||
|
/** @var UserPasswordHasherInterface $passwordHasher */
|
||||||
|
$passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($email);
|
||||||
|
$user->setDisplayName('Test User');
|
||||||
|
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
|
||||||
|
|
||||||
|
$this->entityManager->persist($user);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAlbum(string $externalId): Album
|
||||||
|
{
|
||||||
|
$album = new Album();
|
||||||
|
$album->setSource('external');
|
||||||
|
$album->setExternalId($externalId);
|
||||||
|
$album->setName('Functional Test Album');
|
||||||
|
$album->setArtists(['Functional Test Artist']);
|
||||||
|
$album->setGenres(['Rock']);
|
||||||
|
$album->setReleaseDate('2020-01-01');
|
||||||
|
$album->setTotalTracks(1);
|
||||||
|
$album->setExternalUrl('https://example.test/albums/' . $externalId);
|
||||||
|
|
||||||
|
$track = new AlbumTrack();
|
||||||
|
$track->setDiscNumber(1);
|
||||||
|
$track->setTrackNumber(1);
|
||||||
|
$track->setName('Functional Test Track');
|
||||||
|
$track->setDurationMs(210000);
|
||||||
|
$album->addTrack($track);
|
||||||
|
|
||||||
|
$this->entityManager->persist($album);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $album;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createReview(Album $album, User $author): Review
|
||||||
|
{
|
||||||
|
$review = new Review();
|
||||||
|
$review->setAlbum($album);
|
||||||
|
$review->setAuthor($author);
|
||||||
|
$review->setTitle('Excellent reference review');
|
||||||
|
$review->setContent('This review content is comfortably longer than twenty characters.');
|
||||||
|
$review->setRating(8);
|
||||||
|
|
||||||
|
$this->entityManager->persist($review);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $review;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueToken(User $user): string
|
||||||
|
{
|
||||||
|
/** @var JWTTokenManagerInterface $jwtTokenManager */
|
||||||
|
$jwtTokenManager = static::getContainer()->get(JWTTokenManagerInterface::class);
|
||||||
|
|
||||||
|
return $jwtTokenManager->create($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function assertErrorEnvelope(int $expectedStatus, string|array $expectedCode): array
|
||||||
|
{
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
self::assertSame($expectedStatus, $response->getStatusCode(), $response->getContent());
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertIsArray($payload);
|
||||||
|
self::assertIsArray($payload['error'] ?? null);
|
||||||
|
if (is_array($expectedCode)) {
|
||||||
|
self::assertContains($payload['error']['code'] ?? null, $expectedCode);
|
||||||
|
} else {
|
||||||
|
self::assertSame($expectedCode, $payload['error']['code'] ?? null);
|
||||||
|
}
|
||||||
|
self::assertSame($expectedStatus, $payload['error']['status'] ?? null);
|
||||||
|
self::assertIsString($payload['error']['message'] ?? null);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Web;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
final class AlbumWorkflowRegressionTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
private string $projectDir;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::ensureKernelShutdown();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->projectDir = (string) static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$this->resetDatabase();
|
||||||
|
$this->ensureUploadDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlbumEditAndDeleteRoutesWorkForUserAlbum(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('album-owner@example.test');
|
||||||
|
$album = $this->createUserAlbum($user, 'u_album_route_fix');
|
||||||
|
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/albums/' . $album->getExternalId());
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
|
$this->client->request('GET', '/albums/' . $album->getExternalId() . '/edit');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
|
$token = $crawler->filter('form[action$="/albums/' . $album->getExternalId() . '/delete"] input[name="_token"]')->attr('value');
|
||||||
|
self::assertIsString($token);
|
||||||
|
|
||||||
|
$this->client->request('POST', '/albums/' . $album->getExternalId() . '/delete', ['_token' => $token]);
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
|
||||||
|
$deleted = $this->entityManager->getRepository(Album::class)->findOneBy(['externalId' => $album->getExternalId()]);
|
||||||
|
self::assertNull($deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAlbumWithCoverUploadDoesNotErrorAndPersistsCoverPath(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('cover-upload@example.test');
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/albums/new');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
|
||||||
|
$formToken = $crawler->filter('input[name="album[_token]"]')->attr('value');
|
||||||
|
self::assertIsString($formToken);
|
||||||
|
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'cover_');
|
||||||
|
self::assertIsString($tmpFile);
|
||||||
|
file_put_contents($tmpFile, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgQwM0R8AAAAASUVORK5CYII=', true));
|
||||||
|
$cover = new UploadedFile($tmpFile, 'cover.png', 'image/png', null, true);
|
||||||
|
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/albums/new',
|
||||||
|
[
|
||||||
|
'album' => [
|
||||||
|
'_token' => $formToken,
|
||||||
|
'name' => 'Cover Upload Regression',
|
||||||
|
'artistsCsv' => 'Cover Artist',
|
||||||
|
'genresCsv' => 'Rock',
|
||||||
|
'releaseDate' => '2024-01-01',
|
||||||
|
'totalTracks' => '1',
|
||||||
|
'externalUrl' => 'https://example.test/cover-source',
|
||||||
|
],
|
||||||
|
'track_title' => ['Track One'],
|
||||||
|
'track_duration' => ['3:21'],
|
||||||
|
'track_disc' => ['1'],
|
||||||
|
'track_number' => ['1'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'album' => [
|
||||||
|
'coverUpload' => $cover,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(302);
|
||||||
|
|
||||||
|
$album = $this->entityManager->getRepository(Album::class)->findOneBy(['name' => 'Cover Upload Regression']);
|
||||||
|
self::assertInstanceOf(Album::class, $album);
|
||||||
|
self::assertNotNull($album->getCoverImagePath());
|
||||||
|
self::assertStringStartsWith('/uploads/album_covers/', (string) $album->getCoverImagePath());
|
||||||
|
self::assertFileExists($this->projectDir . '/public' . $album->getCoverImagePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDashboardPagesLinkToUserAlbumByExternalId(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('dashboard-owner@example.test');
|
||||||
|
$moderator = $this->createUser('dashboard-moderator@example.test', ['ROLE_MODERATOR']);
|
||||||
|
$album = $this->createUserAlbum($owner, 'u_dashboard_link');
|
||||||
|
|
||||||
|
$this->client->loginUser($owner);
|
||||||
|
$this->client->request('GET', '/dashboard');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringContainsString('/albums/' . $album->getExternalId(), (string) $this->client->getResponse()->getContent());
|
||||||
|
|
||||||
|
$this->client->loginUser($moderator);
|
||||||
|
$this->client->request('GET', '/admin/dashboard');
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
self::assertStringContainsString('/albums/' . $album->getExternalId(), (string) $this->client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureUploadDirectories(): void
|
||||||
|
{
|
||||||
|
$base = $this->projectDir . '/public/uploads';
|
||||||
|
@mkdir($base . '/album_covers', 0777, true);
|
||||||
|
@mkdir($base . '/avatars', 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetDatabase(): void
|
||||||
|
{
|
||||||
|
$allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||||
|
if ($allMetadata === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schemaTool = new SchemaTool($this->entityManager);
|
||||||
|
$schemaTool->dropDatabase();
|
||||||
|
$schemaTool->createSchema($allMetadata);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
*/
|
||||||
|
private function createUser(string $email, array $roles = []): User
|
||||||
|
{
|
||||||
|
/** @var UserPasswordHasherInterface $passwordHasher */
|
||||||
|
$passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($email);
|
||||||
|
$user->setDisplayName('Regression User');
|
||||||
|
$user->setRoles($roles);
|
||||||
|
$user->setPassword($passwordHasher->hashPassword($user, 'password123'));
|
||||||
|
|
||||||
|
$this->entityManager->persist($user);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUserAlbum(User $owner, string $externalId): Album
|
||||||
|
{
|
||||||
|
$album = new Album();
|
||||||
|
$album->setSource('user');
|
||||||
|
$album->setExternalId($externalId);
|
||||||
|
$album->setName('User Album ' . $externalId);
|
||||||
|
$album->setArtists(['Regression Artist']);
|
||||||
|
$album->setGenres(['Rock']);
|
||||||
|
$album->setReleaseDate('2024-01-01');
|
||||||
|
$album->setTotalTracks(1);
|
||||||
|
$album->setOwner($owner);
|
||||||
|
|
||||||
|
$track = new AlbumTrack();
|
||||||
|
$track->setDiscNumber(1);
|
||||||
|
$track->setTrackNumber(1);
|
||||||
|
$track->setName('Regression Track');
|
||||||
|
$track->setDurationMs(180000);
|
||||||
|
$album->addTrack($track);
|
||||||
|
|
||||||
|
$this->entityManager->persist($album);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $album;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\ExternalAlbumClient;
|
||||||
|
use App\Service\ExternalProviderManager;
|
||||||
|
use App\Service\LastFmClient;
|
||||||
|
use App\Service\MusicBrainzClient;
|
||||||
|
use App\Service\TidalClient;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ExternalAlbumClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testUnknownProviderFallsBackToLastFm(): void
|
||||||
|
{
|
||||||
|
$lastFm = $this->createMock(LastFmClient::class);
|
||||||
|
$musicBrainz = $this->createMock(MusicBrainzClient::class);
|
||||||
|
$tidal = $this->createMock(TidalClient::class);
|
||||||
|
$providerManager = $this->createMock(ExternalProviderManager::class);
|
||||||
|
|
||||||
|
$providerManager->expects(self::atLeastOnce())
|
||||||
|
->method('getEffectiveProvider')
|
||||||
|
->willReturn('unknown-provider');
|
||||||
|
|
||||||
|
$lastFm->expects(self::once())
|
||||||
|
->method('searchAlbums')
|
||||||
|
->with('radiohead', 5, 1)
|
||||||
|
->willReturn(['albums' => ['items' => []]]);
|
||||||
|
$lastFm->expects(self::once())
|
||||||
|
->method('getProviderName')
|
||||||
|
->willReturn('lastfm');
|
||||||
|
|
||||||
|
$musicBrainz->expects(self::never())->method('searchAlbums');
|
||||||
|
$tidal->expects(self::never())->method('searchAlbums');
|
||||||
|
|
||||||
|
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
|
||||||
|
$result = $client->searchAlbums('radiohead', 5, 1);
|
||||||
|
|
||||||
|
self::assertSame(['albums' => ['items' => []]], $result);
|
||||||
|
self::assertSame('lastfm', $client->getProviderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMusicBrainzProviderRoutesCallsToMusicBrainzClient(): void
|
||||||
|
{
|
||||||
|
$lastFm = $this->createMock(LastFmClient::class);
|
||||||
|
$musicBrainz = $this->createMock(MusicBrainzClient::class);
|
||||||
|
$tidal = $this->createMock(TidalClient::class);
|
||||||
|
$providerManager = $this->createMock(ExternalProviderManager::class);
|
||||||
|
|
||||||
|
$providerManager->expects(self::atLeastOnce())
|
||||||
|
->method('getEffectiveProvider')
|
||||||
|
->willReturn('musicbrainz');
|
||||||
|
|
||||||
|
$musicBrainz->expects(self::once())
|
||||||
|
->method('searchAlbums')
|
||||||
|
->with('tool', 3, 2)
|
||||||
|
->willReturn([
|
||||||
|
'albums' => [
|
||||||
|
'items' => [
|
||||||
|
['id' => 'mbid:abc', 'name' => 'Lateralus'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$musicBrainz->expects(self::once())
|
||||||
|
->method('getProviderName')
|
||||||
|
->willReturn('musicbrainz');
|
||||||
|
|
||||||
|
$lastFm->expects(self::never())->method('searchAlbums');
|
||||||
|
$tidal->expects(self::never())->method('searchAlbums');
|
||||||
|
|
||||||
|
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
|
||||||
|
$result = $client->searchAlbums('tool', 3, 2);
|
||||||
|
|
||||||
|
self::assertSame('mbid:abc', $result['albums']['items'][0]['id'] ?? null);
|
||||||
|
self::assertSame('musicbrainz', $client->getProviderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTidalProviderRoutesCallsToTidalClient(): void
|
||||||
|
{
|
||||||
|
$lastFm = $this->createMock(LastFmClient::class);
|
||||||
|
$musicBrainz = $this->createMock(MusicBrainzClient::class);
|
||||||
|
$tidal = $this->createMock(TidalClient::class);
|
||||||
|
$providerManager = $this->createMock(ExternalProviderManager::class);
|
||||||
|
|
||||||
|
$providerManager->expects(self::atLeastOnce())
|
||||||
|
->method('getEffectiveProvider')
|
||||||
|
->willReturn('tidal');
|
||||||
|
|
||||||
|
$tidal->expects(self::once())
|
||||||
|
->method('searchAlbums')
|
||||||
|
->with('kanye', 4, 1)
|
||||||
|
->willReturn([
|
||||||
|
'albums' => [
|
||||||
|
'items' => [
|
||||||
|
['id' => 'tidal:123', 'name' => 'The College Dropout'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$tidal->expects(self::once())
|
||||||
|
->method('getProviderName')
|
||||||
|
->willReturn('tidal');
|
||||||
|
|
||||||
|
$lastFm->expects(self::never())->method('searchAlbums');
|
||||||
|
$musicBrainz->expects(self::never())->method('searchAlbums');
|
||||||
|
|
||||||
|
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
|
||||||
|
$result = $client->searchAlbums('kanye', 4, 1);
|
||||||
|
|
||||||
|
self::assertSame('tidal:123', $result['albums']['items'][0]['id'] ?? null);
|
||||||
|
self::assertSame('tidal', $client->getProviderName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use App\Service\MusicBrainzClient;
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use GuzzleHttp\Psr7\Response;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
|
||||||
|
final class MusicBrainzClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDetailedAlbumUsesCanonicalMusicBrainzUrlForOpenSourceLink(): void
|
||||||
|
{
|
||||||
|
$releaseId = '78eb2077-b86d-4382-9ce7-38f9260316f0';
|
||||||
|
|
||||||
|
$httpClient = $this->createMock(ClientInterface::class);
|
||||||
|
$httpClient->expects(self::once())
|
||||||
|
->method('request')
|
||||||
|
->with(
|
||||||
|
'GET',
|
||||||
|
'https://musicbrainz.org/ws/2/release/' . $releaseId,
|
||||||
|
self::callback(static function (array $options): bool {
|
||||||
|
$query = $options['query'] ?? [];
|
||||||
|
return ($query['fmt'] ?? null) === 'json';
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'id' => $releaseId,
|
||||||
|
'title' => 'Reference Album',
|
||||||
|
'artist-credit' => [
|
||||||
|
['name' => 'Reference Artist'],
|
||||||
|
],
|
||||||
|
'media' => [[
|
||||||
|
'position' => 1,
|
||||||
|
'tracks' => [[
|
||||||
|
'id' => 'track-1',
|
||||||
|
'number' => '1',
|
||||||
|
'title' => 'Reference Track',
|
||||||
|
'length' => 180000,
|
||||||
|
]],
|
||||||
|
]],
|
||||||
|
// External storefront URL should not be used as the "Open Source" link.
|
||||||
|
'relations' => [[
|
||||||
|
'url' => ['resource' => 'https://store.example.test/reference-album'],
|
||||||
|
]],
|
||||||
|
], JSON_THROW_ON_ERROR)));
|
||||||
|
|
||||||
|
$settings = $this->createMock(SettingRepository::class);
|
||||||
|
$cache = new ArrayAdapter();
|
||||||
|
|
||||||
|
$client = new MusicBrainzClient($httpClient, $cache, $settings);
|
||||||
|
$album = $client->getAlbumWithTracks('mbid:' . $releaseId);
|
||||||
|
|
||||||
|
self::assertIsArray($album);
|
||||||
|
self::assertSame(
|
||||||
|
'https://musicbrainz.org/release/' . $releaseId,
|
||||||
|
$album['external_urls']['external'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use App\Service\TidalClient;
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use GuzzleHttp\Psr7\Response;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||||
|
|
||||||
|
final class TidalClientTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIsConfiguredRequiresClientIdAndSecret(): void
|
||||||
|
{
|
||||||
|
$http = $this->createMock(ClientInterface::class);
|
||||||
|
$settings = $this->createMock(SettingRepository::class);
|
||||||
|
$cache = new ArrayAdapter();
|
||||||
|
|
||||||
|
$client = new TidalClient($http, $cache, $settings, 'client-id-only', null, 'US');
|
||||||
|
|
||||||
|
self::assertFalse($client->isConfigured());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchAlbumsUsesClientCredentialsAndNormalizesPayload(): void
|
||||||
|
{
|
||||||
|
$http = $this->createMock(ClientInterface::class);
|
||||||
|
$settings = $this->createMock(SettingRepository::class);
|
||||||
|
$cache = new ArrayAdapter();
|
||||||
|
|
||||||
|
$call = 0;
|
||||||
|
$http->expects(self::exactly(2))
|
||||||
|
->method('request')
|
||||||
|
->willReturnCallback(function (string $method, string $uri, array $options) use (&$call) {
|
||||||
|
$call++;
|
||||||
|
if ($call === 1) {
|
||||||
|
self::assertSame('POST', $method);
|
||||||
|
self::assertSame('https://auth.tidal.com/v1/oauth2/token', $uri);
|
||||||
|
self::assertSame('client_credentials', $options['form_params']['grant_type'] ?? null);
|
||||||
|
self::assertSame('tidal-client-id', $options['form_params']['client_id'] ?? null);
|
||||||
|
self::assertSame('tidal-client-secret', $options['form_params']['client_secret'] ?? null);
|
||||||
|
|
||||||
|
return new Response(200, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'access_token' => 'token-abc',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame('GET', $method);
|
||||||
|
self::assertStringContainsString('/searchResults/kanye%20west/relationships/albums', $uri);
|
||||||
|
self::assertSame('US', $options['query']['countryCode'] ?? null);
|
||||||
|
self::assertSame('albums', $options['query']['include'] ?? null);
|
||||||
|
self::assertSame(5, $options['query']['page[limit]'] ?? null);
|
||||||
|
self::assertSame('Bearer token-abc', $options['headers']['Authorization'] ?? null);
|
||||||
|
|
||||||
|
return new Response(200, ['Content-Type' => 'application/vnd.api+json'], json_encode([
|
||||||
|
'data' => [
|
||||||
|
['type' => 'albums', 'id' => '251380836'],
|
||||||
|
],
|
||||||
|
'included' => [
|
||||||
|
[
|
||||||
|
'type' => 'albums',
|
||||||
|
'id' => '251380836',
|
||||||
|
'attributes' => [
|
||||||
|
'title' => '4:44',
|
||||||
|
'releaseDate' => '2017-06-30',
|
||||||
|
'numberOfItems' => 10,
|
||||||
|
'externalLinks' => [
|
||||||
|
[
|
||||||
|
'href' => 'https://tidal.com/browse/album/251380836',
|
||||||
|
'meta' => ['type' => 'TIDAL_SHARING'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'relationships' => [
|
||||||
|
'artists' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'artists', 'id' => 'artist-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'genres' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'genres', 'id' => 'genre-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'coverArt' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'artworks', 'id' => 'art-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'artists',
|
||||||
|
'id' => 'artist-1',
|
||||||
|
'attributes' => ['name' => 'JAY-Z'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'genres',
|
||||||
|
'id' => 'genre-1',
|
||||||
|
'attributes' => ['genreName' => 'hip hop'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'artworks',
|
||||||
|
'id' => 'art-1',
|
||||||
|
'attributes' => [
|
||||||
|
'files' => [
|
||||||
|
['href' => 'https://cdn.example.test/160.jpg', 'meta' => ['width' => 160, 'height' => 160]],
|
||||||
|
['href' => 'https://cdn.example.test/320.jpg', 'meta' => ['width' => 320, 'height' => 320]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'links' => ['self' => '/searchResults/kanye west/relationships/albums'],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new TidalClient($http, $cache, $settings, 'tidal-client-id', 'tidal-client-secret', 'US');
|
||||||
|
$result = $client->searchAlbums('kanye west', 5, 1);
|
||||||
|
|
||||||
|
$items = $result['albums']['items'] ?? [];
|
||||||
|
self::assertCount(1, $items);
|
||||||
|
self::assertSame('tidal:251380836', $items[0]['id'] ?? null);
|
||||||
|
self::assertSame('4:44', $items[0]['name'] ?? null);
|
||||||
|
self::assertSame('JAY-Z', $items[0]['artists'][0]['name'] ?? null);
|
||||||
|
self::assertSame('Hip Hop', $items[0]['genres'][0] ?? null);
|
||||||
|
self::assertSame('2017-06-30', $items[0]['release_date'] ?? null);
|
||||||
|
self::assertSame(10, $items[0]['total_tracks'] ?? null);
|
||||||
|
self::assertSame('https://cdn.example.test/320.jpg', $items[0]['images'][0]['url'] ?? null);
|
||||||
|
self::assertSame('https://tidal.com/browse/album/251380836', $items[0]['external_urls']['external'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAlbumWithTracksNormalizesTracksAndCoverArt(): void
|
||||||
|
{
|
||||||
|
$http = $this->createMock(ClientInterface::class);
|
||||||
|
$settings = $this->createMock(SettingRepository::class);
|
||||||
|
$cache = new ArrayAdapter();
|
||||||
|
|
||||||
|
$call = 0;
|
||||||
|
$http->expects(self::exactly(2))
|
||||||
|
->method('request')
|
||||||
|
->willReturnCallback(function (string $method, string $uri, array $options) use (&$call) {
|
||||||
|
$call++;
|
||||||
|
if ($call === 1) {
|
||||||
|
return new Response(200, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'access_token' => 'token-xyz',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame('GET', $method);
|
||||||
|
self::assertStringContainsString('/albums/251380836', $uri);
|
||||||
|
self::assertSame('artists,coverArt,genres,items', $options['query']['include'] ?? null);
|
||||||
|
self::assertSame('US', $options['query']['countryCode'] ?? null);
|
||||||
|
|
||||||
|
return new Response(200, ['Content-Type' => 'application/vnd.api+json'], json_encode([
|
||||||
|
'data' => [
|
||||||
|
'type' => 'albums',
|
||||||
|
'id' => '251380836',
|
||||||
|
'attributes' => [
|
||||||
|
'title' => '4:44',
|
||||||
|
'releaseDate' => '2017-06-30',
|
||||||
|
'numberOfItems' => 2,
|
||||||
|
'externalLinks' => [
|
||||||
|
[
|
||||||
|
'href' => 'https://tidal.com/browse/album/251380836',
|
||||||
|
'meta' => ['type' => 'TIDAL_SHARING'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'relationships' => [
|
||||||
|
'artists' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'artists', 'id' => 'artist-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'genres' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'genres', 'id' => 'genre-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'coverArt' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'artworks', 'id' => 'art-1'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'items' => [
|
||||||
|
'data' => [
|
||||||
|
['type' => 'tracks', 'id' => 'track-1', 'meta' => ['volumeNumber' => 1, 'trackNumber' => 1]],
|
||||||
|
['type' => 'tracks', 'id' => 'track-2', 'meta' => ['volumeNumber' => 1, 'trackNumber' => 2]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'included' => [
|
||||||
|
['type' => 'artists', 'id' => 'artist-1', 'attributes' => ['name' => 'JAY-Z']],
|
||||||
|
['type' => 'genres', 'id' => 'genre-1', 'attributes' => ['genreName' => 'hip hop']],
|
||||||
|
[
|
||||||
|
'type' => 'artworks',
|
||||||
|
'id' => 'art-1',
|
||||||
|
'attributes' => [
|
||||||
|
'files' => [
|
||||||
|
['href' => 'https://cdn.example.test/640.jpg', 'meta' => ['width' => 640, 'height' => 640]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
['type' => 'tracks', 'id' => 'track-1', 'attributes' => ['title' => 'Kill Jay Z', 'duration' => 'PT3M25S']],
|
||||||
|
['type' => 'tracks', 'id' => 'track-2', 'attributes' => ['title' => 'The Story of O.J.', 'duration' => 'PT3M52S']],
|
||||||
|
],
|
||||||
|
'links' => ['self' => '/albums/251380836'],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
});
|
||||||
|
|
||||||
|
$client = new TidalClient($http, $cache, $settings, 'tidal-client-id', 'tidal-client-secret', 'US');
|
||||||
|
$album = $client->getAlbumWithTracks('tidal:251380836');
|
||||||
|
|
||||||
|
self::assertIsArray($album);
|
||||||
|
self::assertSame('tidal:251380836', $album['id'] ?? null);
|
||||||
|
self::assertSame('4:44', $album['name'] ?? null);
|
||||||
|
self::assertSame('JAY-Z', $album['artists'][0]['name'] ?? null);
|
||||||
|
self::assertSame('Hip Hop', $album['genres'][0] ?? null);
|
||||||
|
self::assertSame('2017-06-30', $album['release_date'] ?? null);
|
||||||
|
self::assertSame('https://cdn.example.test/640.jpg', $album['images'][0]['url'] ?? null);
|
||||||
|
self::assertSame(2, $album['total_tracks'] ?? null);
|
||||||
|
self::assertSame(2, $album['tracks']['total'] ?? null);
|
||||||
|
self::assertSame('Kill Jay Z', $album['tracks']['items'][0]['name'] ?? null);
|
||||||
|
self::assertSame(205000, $album['tracks']['items'][0]['duration_ms'] ?? null);
|
||||||
|
self::assertSame(1, $album['tracks']['items'][0]['track_number'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (class_exists(Dotenv::class)) {
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'test';
|
||||||
|
$_ENV['APP_DEBUG'] = $_SERVER['APP_DEBUG'] = '1';
|
||||||
|
$_ENV['APP_SECRET'] = $_SERVER['APP_SECRET'] = 'test-secret';
|
||||||
|
$_ENV['KERNEL_CLASS'] = $_SERVER['KERNEL_CLASS'] = 'App\\Kernel';
|
||||||
|
$_ENV['DATABASE_DRIVER'] = $_SERVER['DATABASE_DRIVER'] = 'sqlite';
|
||||||
|
$_ENV['DATABASE_SQLITE_PATH'] = $_SERVER['DATABASE_SQLITE_PATH'] = '/tmp/tonehaus_test.sqlite';
|
||||||
|
$_ENV['JWT_PASSPHRASE'] = $_SERVER['JWT_PASSPHRASE'] = 'test';
|
||||||
|
|
||||||
|
putenv('APP_ENV=test');
|
||||||
|
putenv('APP_DEBUG=1');
|
||||||
|
putenv('APP_SECRET=test-secret');
|
||||||
|
putenv('KERNEL_CLASS=App\\Kernel');
|
||||||
|
putenv('DATABASE_DRIVER=sqlite');
|
||||||
|
putenv('DATABASE_SQLITE_PATH=/tmp/tonehaus_test.sqlite');
|
||||||
|
putenv('JWT_PASSPHRASE=test');
|
||||||
Reference in new issue
Block a user