diff --git a/.DS_Store b/.DS_Store index 0de310f..e389fc4 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index 191a20e..8a7e281 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,21 @@ # Uncomment to override stored setting. #LASTFM_API_KEY= +#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_SECRET=changeme diff --git a/.gitignore b/.gitignore index fd9fb0e..7d0206e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,30 @@ composer.phar # Docker .env *.pdf -*.lock \ No newline at end of file +*.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 ### diff --git a/README.md b/README.md index a6e8e33..d3f1377 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Notes: - 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 -- OpenAPI documentation via swagger-php at `/docs` (`/docs/openapi.json`) +- 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) - Auth modal (Login/Sign up) with remember‑me; no separate pages - Role-based access: authors manage their own reviews; moderators/admins can moderate content diff --git a/composer.json b/composer.json index e2d5f4f..f42374d 100644 --- a/composer.json +++ b/composer.json @@ -9,14 +9,14 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", - "friendsofsymfony/rest-bundle": "^3.8", - "guzzlehttp/guzzle": "^7.9", - "lexik/jwt-authentication-bundle": "^3.1", - "zircote/swagger-php": "^4.10", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-migrations-bundle": "^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-mapper": "7.3.*", "symfony/console": "7.3.*", @@ -40,7 +40,8 @@ "symfony/yaml": "7.3.*", "twig/extra-bundle": "^2.12|^3.0", "twig/string-extra": "^3.22", - "twig/twig": "^2.12|^3.0" + "twig/twig": "^2.12|^3.0", + "zircote/swagger-php": "^4.10" }, "config": { "allow-plugins": { diff --git a/config/bundles.php b/config/bundles.php index fe4d42d..8eb337c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -6,6 +6,7 @@ return [ Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::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\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml new file mode 100644 index 0000000..e48be02 --- /dev/null +++ b/config/packages/nelmio_api_doc.yaml @@ -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) diff --git a/config/packages/test/lexik_jwt_authentication.yaml b/config/packages/test/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..2d52356 --- /dev/null +++ b/config/packages/test/lexik_jwt_authentication.yaml @@ -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 diff --git a/config/routes/lexik_jwt_authentication.yaml b/config/routes/lexik_jwt_authentication.yaml deleted file mode 100644 index e96fcad..0000000 --- a/config/routes/lexik_jwt_authentication.yaml +++ /dev/null @@ -1,2 +0,0 @@ -api_login_check: - path: /api/v1/auth/login diff --git a/config/routes/nelmio_api_doc.yaml b/config/routes/nelmio_api_doc.yaml new file mode 100644 index 0000000..28a2927 --- /dev/null +++ b/config/routes/nelmio_api_doc.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 89a2d3c..e35a2e5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,13 +22,32 @@ services: GuzzleHttp\Client: arguments: - $config: { timeout: 10.0, connect_timeout: 5.0, http_errors: false } + $config: { timeout: 4.0, connect_timeout: 1.5, http_errors: false } 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: arguments: $storageRoot: '%kernel.project_dir%/public/uploads' @@ -41,3 +60,7 @@ services: App\Controller\Api\V1\AlbumApiController: arguments: $searchLimit: '%album_search_limit%' + + App\EventSubscriber\ApiExceptionSubscriber: + arguments: + $debug: '%kernel.debug%' diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 616917a..364a335 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -21,7 +21,7 @@ RUN apk add --no-cache \ libzip-dev \ oniguruma-dev \ libxml2-dev \ - postgresql-dev \ + libpq-dev \ sqlite-dev \ zlib-dev \ su-exec diff --git a/docker/prod/entrypoint.sh b/docker/prod/entrypoint.sh index cde11a8..419dd63 100755 --- a/docker/prod/entrypoint.sh +++ b/docker/prod/entrypoint.sh @@ -1,6 +1,16 @@ #!/bin/sh set -eu +prepare_runtime_dirs() { + mkdir -p var/cache/prod/asset_mapper 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() { if [ -z "${APP_SECRET:-}" ]; then echo "APP_SECRET environment variable is required at runtime" >&2 @@ -59,6 +69,7 @@ install_runtime if [ -f bin/console ]; then require_app_secret ensure_jwt_keys + prepare_runtime_dirs fi if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then @@ -75,10 +86,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 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 su-exec www-data php bin/console cache:clear --no-warmup su-exec www-data php bin/console cache:warmup - chown -R www-data:www-data var + prepare_runtime_dirs fi exec "$@" diff --git a/docs/assignment2-report.md b/docs/assignment2-report.md new file mode 100644 index 0000000..a35b1bd --- /dev/null +++ b/docs/assignment2-report.md @@ -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 +``` + +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. diff --git a/migrations/Version20260401101500.php b/migrations/Version20260401101500.php new file mode 100644 index 0000000..68818f3 --- /dev/null +++ b/migrations/Version20260401101500.php @@ -0,0 +1,47 @@ + 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'; + } +} + diff --git a/migrations/Version20260402120000.php b/migrations/Version20260402120000.php new file mode 100644 index 0000000..5690c4d --- /dev/null +++ b/migrations/Version20260402120000.php @@ -0,0 +1,58 @@ +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'; + } +} + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6dac4ea --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,50 @@ + + + + + tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Command/RefreshExternalAlbumsCommand.php b/src/Command/RefreshExternalAlbumsCommand.php new file mode 100644 index 0000000..4703cb2 --- /dev/null +++ b/src/Command/RefreshExternalAlbumsCommand.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/src/Command/SeedDemoAlbumsCommand.php b/src/Command/SeedDemoAlbumsCommand.php index 2e8bbc0..9bfc09d 100644 --- a/src/Command/SeedDemoAlbumsCommand.php +++ b/src/Command/SeedDemoAlbumsCommand.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace App\Command; use App\Entity\Album; +use App\Entity\AlbumTrack; +use App\Entity\User; use App\Repository\AlbumRepository; +use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -43,6 +46,7 @@ class SeedDemoAlbumsCommand extends Command public function __construct( private readonly EntityManagerInterface $entityManager, private readonly AlbumRepository $albumRepository, + private readonly UserRepository $userRepository, ) { parent::__construct(); } @@ -50,13 +54,22 @@ class SeedDemoAlbumsCommand extends Command protected function configure(): void { $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, 'Assign each generated album to a random existing user'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $count = max(1, (int) $input->getOption('count')); + $attachUsers = (bool) $input->getOption('attach-users'); + $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; // Track generated album IDs so we never attempt to persist obvious duplicates. @@ -77,8 +90,16 @@ class SeedDemoAlbumsCommand extends Command $album->setArtists($this->generateArtists()); $album->setGenres($this->generateGenres()); $album->setReleaseDate($this->generateReleaseDate()); - $album->setTotalTracks(random_int(6, 16)); + $trackCount = random_int(6, 16); + $album->setTotalTracks($trackCount); $album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $albumId)); + if ($owners !== []) { + /** @var User $owner */ + $owner = $owners[array_rand($owners)]; + $album->setOwner($owner); + } + + $this->addSyntheticTracks($album, $trackCount); $this->entityManager->persist($album); $seenAlbumIds[$albumId] = true; @@ -146,4 +167,24 @@ class SeedDemoAlbumsCommand extends Command return array_values(array_unique($genres)); } + private function addSyntheticTracks(Album $album, int $trackCount): void + { + 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); + } + } diff --git a/src/Command/SeedDemoReviewsCommand.php b/src/Command/SeedDemoReviewsCommand.php index cf4cbb2..08b4da3 100644 --- a/src/Command/SeedDemoReviewsCommand.php +++ b/src/Command/SeedDemoReviewsCommand.php @@ -72,7 +72,10 @@ class SeedDemoReviewsCommand extends Command { $io = new SymfonyStyle($input, $output); // 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(); if ($albums === [] || $users === []) { @@ -229,4 +232,3 @@ class SeedDemoReviewsCommand extends Command ); } } - diff --git a/src/Command/SyncExternalProviderCommand.php b/src/Command/SyncExternalProviderCommand.php new file mode 100644 index 0000000..0218bb9 --- /dev/null +++ b/src/Command/SyncExternalProviderCommand.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index e7333f2..8479b74 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Entity\User; use App\Form\ProfileFormType; +use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; use App\Service\UploadStorage; use Doctrine\ORM\EntityManagerInterface; @@ -26,7 +27,7 @@ class AccountController extends AbstractController * Summarizes the signed-in user's recent activity. */ #[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])] - public function dashboard(ReviewRepository $reviews): Response + public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response { /** @var User $user */ $user = $this->getUser(); @@ -52,11 +53,12 @@ class AccountController extends AbstractController ->orderBy('r.createdAt', 'DESC') ->setMaxResults(10) ->getQuery()->getResult(); - $userAlbums = $reviews->createQueryBuilder('r') - ->select('DISTINCT a') - ->innerJoin('r.album', 'a') - ->where('r.author = :u')->setParameter('u', $user) - ->orderBy('r.createdAt', 'DESC') + $userAlbums = $albums->createQueryBuilder('a') + ->where('a.owner = :u') + ->andWhere('a.source = :src') + ->setParameter('u', $user) + ->setParameter('src', 'user') + ->orderBy('a.createdAt', 'DESC') ->setMaxResults(10) ->getQuery()->getResult(); @@ -123,4 +125,3 @@ class AccountController extends AbstractController return $this->render('account/settings.html.twig'); } } - diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 9c9fcbb..b2e0182 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -5,6 +5,7 @@ namespace App\Controller\Admin; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; use App\Repository\UserRepository; +use App\Service\ExternalProviderManager; use App\Service\LastFmMetadataRefresher; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -22,7 +23,12 @@ class DashboardController extends AbstractController * Renders overall activity metrics for administrators. */ #[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. $totalReviews = (int) $reviews->createQueryBuilder('r') @@ -50,13 +56,15 @@ class DashboardController extends AbstractController 'totalUsers' => $totalUsers, 'recentReviews' => $recentReviews, 'recentAlbums' => $recentAlbums, + 'externalProviderLabel' => $providerManager->getProviderLabel($providerManager->getEffectiveProvider()), ]); } #[Route('/admin/dashboard/refresh-external', name: 'admin_dashboard_refresh_external', methods: ['POST'])] public function refreshExternal( Request $request, - LastFmMetadataRefresher $refresher + LastFmMetadataRefresher $refresher, + ExternalProviderManager $providerManager ): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('dashboard_refresh_external', $token)) { @@ -65,10 +73,11 @@ class DashboardController extends AbstractController // Refresh runs synchronously; keep user feedback short so the POST remains snappy. $updated = $refresher->refreshAllExternalAlbums(); + $providerLabel = $providerManager->getProviderLabel($providerManager->getEffectiveProvider()); if ($updated === 0) { $this->addFlash('info', 'No external albums needed refresh or none are saved.'); } else { - $this->addFlash('success', sprintf('Refreshed %d external albums from Last.fm.', $updated)); + $this->addFlash('success', sprintf('Refreshed %d external albums from %s.', $updated, $providerLabel)); } return $this->redirectToRoute('admin_dashboard'); diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php index b796588..77a24d4 100644 --- a/src/Controller/Admin/SettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -6,6 +6,7 @@ use App\Form\SiteSettingsType; use App\Repository\SettingRepository; use App\Service\CatalogResetService; use App\Service\CommandRunner; +use App\Service\ExternalProviderManager; use App\Service\RegistrationToggle; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -62,19 +63,70 @@ class SettingsController extends AbstractController ]; /** - * Displays and persists Last.fm credential settings. + * Displays and persists API credential/provider settings. */ #[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->get('LASTFM_API_KEY')->setData($settings->getValue('LASTFM_API_KEY')); + $form->get('EXTERNAL_ALBUM_PROVIDER')->setData($storedProvider); + $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(); $form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $settings->setValue('LASTFM_API_KEY', (string) $form->get('LASTFM_API_KEY')->getData()); + $settings->setValues([ + '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) { // Persist only when the flag is not locked by APP_ALLOW_REGISTRATION. $registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData()); @@ -89,6 +141,13 @@ class SettingsController extends AbstractController 'form' => $form->createView(), 'registrationImmutable' => $registrationOverride !== null, '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, ]); } @@ -166,4 +225,20 @@ class SettingsController extends AbstractController } 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; + } } diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index e34a006..6003719 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -4,17 +4,24 @@ namespace App\Controller; use App\Dto\AlbumSearchCriteria; use App\Entity\Album; +use App\Entity\AlbumTrack; use App\Entity\Review; +use App\Entity\User; use App\Form\ReviewType; use App\Form\AlbumType; use App\Repository\AlbumRepository; use App\Repository\AlbumTrackRepository; use App\Repository\ReviewRepository; use App\Service\AlbumSearchService; -use App\Service\LastFmClient; +use App\Service\ExternalAlbumClientInterface; use App\Service\LastFmTagResolver; +use App\Service\UploadStorage; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; 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\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -25,9 +32,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; */ class AlbumController extends AbstractController { + private const ALBUM_ID_REQUIREMENT = '(?!new$)[^/]+'; + public function __construct( private readonly AlbumSearchService $albumSearch, private readonly LastFmTagResolver $genreResolver, + private readonly LoggerInterface $logger, private readonly int $searchLimit = 20 ) { } @@ -39,21 +49,164 @@ class AlbumController extends AbstractController public function search(Request $request): Response { $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, 'album' => $criteria->albumName, 'artist' => $criteria->artist, 'genre' => $criteria->genre, - 'year_from' => $criteria->yearFrom ?? '', - 'year_to' => $criteria->yearTo ?? '', + 'year_from' => $yearFromInput !== '' ? $yearFromInput : ($criteria->yearFrom !== null ? sprintf('%04d-01-01', $criteria->yearFrom) : ''), + 'year_to' => $yearToInput !== '' ? $yearToInput : ($criteria->yearTo !== null ? sprintf('%04d-01-01', $criteria->yearTo) : ''), 'albums' => $result->albums, 'stats' => $result->stats, 'savedIds' => $result->savedIds, 'source' => $criteria->source, + '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, + ]); } /** @@ -61,7 +214,13 @@ class AlbumController extends AbstractController */ #[IsGranted('ROLE_USER')] #[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->setSource('user'); @@ -69,41 +228,73 @@ class AlbumController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->normalizeAlbumFormData($album); + $album->setSource('user'); $album->setExternalId($this->generateUserAlbumId($albumRepo)); + $owner = $this->getUser(); + if ($owner instanceof User) { + $album->setOwner($owner); + } $em->persist($album); - $em->flush(); - $this->addFlash('success', 'Album created.'); - return $this->redirectToRoute('album_show', ['id' => $album->getExternalId()]); + 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(); + $this->addFlash('success', 'Album created.'); + return $this->redirectToRoute('album_show', ['id' => $album->getExternalId()]); + } catch (\Throwable $exception) { + if ($uploadedCoverPath !== null) { + $uploadStorage->remove($uploadedCoverPath); + } + $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/new.html.twig', [ + + return $this->render('album/editor.html.twig', [ '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. */ - #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] - public function show(string $id, Request $request, LastFmClient $lastFmClient, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response + #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])] + public function show(string $id, Request $request, ExternalAlbumClientInterface $externalAlbumClient, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $albumEntity = $this->findAlbum($id, $albumRepo); $isSaved = $albumEntity !== null; if (!$albumEntity) { - // Album has never been saved locally, so hydrate it via Last.fm before rendering. - $externalAlbum = $lastFmClient->getAlbumWithTracks($id); + // Album has never been saved locally, so hydrate it via the configured external provider. + $externalAlbum = $externalAlbumClient->getAlbumWithTracks($id); if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) { throw $this->createNotFoundException('Album not found'); } $albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo); $em->flush(); } else { - if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $lastFmClient)) { + if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $externalAlbumClient)) { // Track sync mutated the entity: persist before we build template arrays. $em->flush(); } } $albumCard = $albumEntity->toTemplateArray(); - $albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $lastFmClient); + $albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $externalAlbumClient); $canManage = $this->canManageAlbum($albumEntity); $trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray()); @@ -140,7 +331,7 @@ class AlbumController extends AbstractController 'avg' => $avg, 'count' => $count, 'form' => $form->createView(), - 'albumOwner' => null, + 'albumOwner' => $albumEntity->getOwner(), 'albumCreatedAt' => $albumEntity->getCreatedAt(), 'tracks' => $trackRows, ]); @@ -150,8 +341,8 @@ class AlbumController extends AbstractController * Saves an external album locally for quicker access. */ #[IsGranted('ROLE_USER')] - #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] - public function save(string $id, Request $request, LastFmClient $lastFmClient, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response + #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])] + public function save(string $id, Request $request, ExternalAlbumClientInterface $externalAlbumClient, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { @@ -159,7 +350,7 @@ class AlbumController extends AbstractController } $existing = $albumRepo->findOneByExternalId($id); if (!$existing) { - $externalAlbum = $lastFmClient->getAlbumWithTracks($id); + $externalAlbum = $externalAlbumClient->getAlbumWithTracks($id); if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) { throw $this->createNotFoundException('Album not found'); } @@ -176,7 +367,7 @@ class AlbumController extends AbstractController * Deletes a user-created album when authorized. */ #[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 { $token = (string) $request->request->get('_token'); @@ -241,26 +432,65 @@ class AlbumController extends AbstractController * Edits a saved album when the current user may manage it. */ #[IsGranted('ROLE_USER')] - #[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])] - public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response + #[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, + AlbumTrackRepository $trackRepo, + EntityManagerInterface $em, + UploadStorage $uploadStorage + ): Response { $album = $this->findAlbum($id, $albumRepo); if (!$album) { throw $this->createNotFoundException('Album not found'); } $this->ensureCanManageAlbum($album); + $this->normalizeAlbumFormData($album); $form = $this->createForm(AlbumType::class, $album); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->normalizeAlbumFormData($album); - $em->flush(); - $this->addFlash('success', 'Album updated.'); - return $this->redirectToRoute('album_show', ['id' => $id]); + 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(); + if ($uploadedCoverPath !== null && $previousCoverPath !== null && $previousCoverPath !== $uploadedCoverPath) { + $uploadStorage->remove($previousCoverPath); + } + $this->addFlash('success', 'Album updated.'); + return $this->redirectToRoute('album_show', ['id' => $id]); + } catch (\Throwable $exception) { + if ($uploadedCoverPath !== null) { + $uploadStorage->remove($uploadedCoverPath); + $album->setCoverImagePath($previousCoverPath); + } + $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/edit.html.twig', [ + + return $this->render('album/editor.html.twig', [ 'form' => $form->createView(), + 'trackRows' => $this->buildTrackEditorRows($album), + 'isEdit' => true, 'albumId' => $id, + 'albumOwner' => $album->getOwner(), + 'existingCoverUrl' => $album->getCoverImagePath(), ]); } @@ -277,7 +507,23 @@ class AlbumController extends AbstractController */ private function canManageAlbum(Album $album): bool { - return $this->isGranted('ROLE_MODERATOR'); + if ($album->getSource() !== 'user') { + return false; + } + + if ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_MODERATOR')) { + return true; + } + + $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; } /** @@ -295,13 +541,147 @@ class AlbumController extends AbstractController $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); } + /** + * @return list + */ + private function buildTrackEditorRows(Album $album, int $minimumRows = 10): array + { + $rows = []; + $position = 1; + foreach ($album->getTracks()->toArray() as $track) { + if (!$track instanceof AlbumTrack) { + continue; + } + + $durationMs = (int) $track->getDurationMs(); + $seconds = max(0, (int) floor($durationMs / 1000)); + $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; + } + + /** + * @return list> + */ + private function extractTrackPayloadsFromRequest(Request $request): array + { + $titles = $request->request->all('track_title'); + $durations = $request->request->all('track_duration'); + $discNumbers = $request->request->all('track_disc'); + $trackNumbers = $request->request->all('track_number'); + + 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 $externalAlbum */ private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album { if (!$this->isIngestableExternalAlbum($externalAlbum)) { - throw new \InvalidArgumentException('Album payload must include a release date and at least one track.'); + 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. @@ -317,7 +697,7 @@ class AlbumController extends AbstractController return $album; } - private function syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, LastFmClient $lastFmClient): bool + private function syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, ExternalAlbumClientInterface $externalAlbumClient): bool { if ($album->getSource() !== 'external') { return false; @@ -332,10 +712,14 @@ class AlbumController extends AbstractController // 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; + } if (!$needsSync) { return false; } - $externalAlbum = $lastFmClient->getAlbumWithTracks($externalId); + $externalAlbum = $externalAlbumClient->getAlbumWithTracks($externalId); if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) { return false; } @@ -357,11 +741,6 @@ class AlbumController extends AbstractController */ private function isIngestableExternalAlbum(array $externalAlbum): bool { - $releaseDate = trim((string) ($externalAlbum['release_date'] ?? '')); - if ($releaseDate === '') { - return false; - } - $tracks = $externalAlbum['tracks']['items'] ?? null; if (!is_array($tracks) || $tracks === []) { return false; @@ -381,7 +760,7 @@ class AlbumController extends AbstractController * @param array $payload * @return array */ - private function hydrateExternalAlbumImages(array $payload, Album $album, LastFmClient $lastFmClient): array + private function hydrateExternalAlbumImages(array $payload, Album $album, ExternalAlbumClientInterface $externalAlbumClient): array { if ($album->getSource() !== 'external') { return $payload; @@ -392,7 +771,7 @@ class AlbumController extends AbstractController return $payload; } - $fresh = $lastFmClient->getAlbumWithTracks($externalId); + $fresh = $externalAlbumClient->getAlbumWithTracks($externalId); if (!is_array($fresh)) { return $payload; } @@ -405,4 +784,39 @@ class AlbumController extends AbstractController return $payload; } + /** + * @param array $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 + ) + ); + } + } diff --git a/src/Controller/Api/ApiDocsController.php b/src/Controller/Api/ApiDocsController.php deleted file mode 100644 index b3abbcd..0000000 --- a/src/Controller/Api/ApiDocsController.php +++ /dev/null @@ -1,35 +0,0 @@ -getParameter('kernel.project_dir'); - $openApi = Generator::scan([ - $projectDir . '/src/OpenApi', - $projectDir . '/src/Controller/Api/V1', - ]); - - return new JsonResponse( - json_decode($openApi->toJson(), true, 512, JSON_THROW_ON_ERROR), - Response::HTTP_OK - ); - } - - #[Route('/docs', name: 'api_docs_ui', methods: ['GET'])] - public function ui(): Response - { - return $this->render('api/docs.html.twig', [ - 'specUrl' => $this->generateUrl('api_docs_json'), - ]); - } -} diff --git a/src/Controller/Api/V1/AlbumApiController.php b/src/Controller/Api/V1/AlbumApiController.php index 0e23974..d0bed03 100644 --- a/src/Controller/Api/V1/AlbumApiController.php +++ b/src/Controller/Api/V1/AlbumApiController.php @@ -4,14 +4,14 @@ 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\LastFmClient; +use App\Service\ExternalAlbumClientInterface; use App\Service\LastFmTagResolver; use Doctrine\ORM\EntityManagerInterface; -use FOS\RestBundle\Controller\AbstractFOSRestController; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -19,14 +19,14 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/v1/albums')] -class AlbumApiController extends AbstractFOSRestController +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 LastFmClient $lastFmClient, + private readonly ExternalAlbumClientInterface $externalAlbumClient, private readonly LastFmTagResolver $tagResolver, private readonly EntityManagerInterface $entityManager, private readonly int $searchLimit = 20, @@ -37,7 +37,7 @@ class AlbumApiController extends AbstractFOSRestController #[OA\Get( path: '/api/v1/albums', operationId: 'api_v1_album_index', - summary: 'List albums from local catalog and Last.fm', + 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')), @@ -48,6 +48,7 @@ class AlbumApiController extends AbstractFOSRestController 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( @@ -60,18 +61,47 @@ class AlbumApiController extends AbstractFOSRestController public function index(Request $request): Response { $criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit); - $result = $this->albumSearchService->search($criteria); + $searchMode = $this->resolveSearchMode($request); + $result = $this->albumSearchService->search($criteria, $searchMode === 'fast'); + $profile = $this->albumSearchService->getLastSearchProfile(); + $queryParameters = $this->buildAlbumIndexQueryParameters($criteria, $searchMode); + $albums = array_map( + fn(array $album): array => $this->decorateAlbumResource($album), + $result->albums + ); - return $this->handleView($this->view([ + $response = $this->apiResponse([ 'meta' => [ 'source' => $criteria->source, 'limit' => $criteria->limit, 'query' => $criteria->query, + 'mode' => $searchMode, + 'profile' => $profile, ], - 'data' => $result->albums, + 'data' => $albums, 'stats' => $result->stats, 'saved_ids' => $result->savedIds, - ], Response::HTTP_OK)); + '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); + 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'])] @@ -98,13 +128,18 @@ class AlbumApiController extends AbstractFOSRestController 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->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + return $this->apiError( + 'invalid_json', + 'Request body must be valid JSON.', + Response::HTTP_BAD_REQUEST + ); } $externalId = trim((string) ($payload['external_id'] ?? '')); @@ -116,31 +151,43 @@ class AlbumApiController extends AbstractFOSRestController $query = trim($artist . ' ' . $album); } if ($query !== '') { - $search = $this->lastFmClient->searchAlbums($query, 1); + $search = $this->externalAlbumClient->searchAlbums($query, 1); $externalId = trim((string) ($search['albums']['items'][0]['id'] ?? '')); } } if ($externalId === '') { - return $this->handleView($this->view(['error' => 'external_id_or_query_required'], Response::HTTP_BAD_REQUEST)); + return $this->apiError( + 'validation_error', + 'Either external_id or query/artist/album is required.', + Response::HTTP_BAD_REQUEST + ); } - $externalAlbum = $this->lastFmClient->getAlbumWithTracks($externalId); + $externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($externalId); if (!is_array($externalAlbum)) { - return $this->handleView($this->view(['error' => 'external_album_not_found'], Response::HTTP_NOT_FOUND)); + return $this->apiError( + 'external_album_not_found', + 'External album not found.', + Response::HTTP_NOT_FOUND + ); } if (!$this->isIngestableExternalAlbum($externalAlbum)) { - return $this->handleView($this->view(['error' => 'album_missing_release_date_or_tracks'], Response::HTTP_UNPROCESSABLE_ENTITY)); + 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 = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album, $externalAlbum); - return $this->handleView($this->view([ - 'data' => $payload, + return $this->apiResponse([ + 'data' => $this->decorateAlbumResource($payload, $album), 'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()), - ], Response::HTTP_CREATED)); + ], Response::HTTP_CREATED); } #[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])] @@ -155,32 +202,43 @@ class AlbumApiController extends AbstractFOSRestController 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->lastFmClient->getAlbumWithTracks($id); + $externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($id); if (!is_array($externalAlbum)) { - return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND); } if (!$this->isIngestableExternalAlbum($externalAlbum)) { - return $this->handleView($this->view(['error' => 'album_missing_release_date_or_tracks'], Response::HTTP_UNPROCESSABLE_ENTITY)); + 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 = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album); - return $this->handleView($this->view([ - 'data' => $payload, + 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)); + ], Response::HTTP_OK); } #[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])] @@ -201,29 +259,151 @@ class AlbumApiController extends AbstractFOSRestController { $album = $this->findAlbum($id); if (!$album instanceof Album) { - return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND); } $reviews = $this->reviewRepository->findBy(['album' => $album], ['createdAt' => 'DESC']); - return $this->handleView($this->view([ - 'data' => array_map(static fn($review) => [ - 'id' => $review->getId(), - 'title' => $review->getTitle(), - 'content' => $review->getContent(), - 'rating' => $review->getRating(), - 'author' => [ - 'id' => $review->getAuthor()?->getId(), - 'display_name' => $review->getAuthor()?->getDisplayName(), + 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, ], - 'created_at' => $review->getCreatedAt()?->format(DATE_ATOM), - 'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM), - ], $reviews), - ], Response::HTTP_OK)); + ], + ], Response::HTTP_OK); + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 { - return $this->albumRepository->findOneByExternalId($id); + $normalizedId = trim($id); + if ($normalizedId === '') { + return null; + } + + $album = $this->albumRepository->findOneByExternalId($normalizedId); + if (!$album instanceof Album) { + return null; + } + + return $album->getTotalTracks() > 0 ? $album : null; } /** @@ -232,7 +412,7 @@ class AlbumApiController extends AbstractFOSRestController private function persistExternalAlbumPayload(array $externalAlbum): Album { if (!$this->isIngestableExternalAlbum($externalAlbum)) { - throw new \InvalidArgumentException('Album payload must include a release date and at least one track.'); + throw new \InvalidArgumentException('Album payload must include at least one track.'); } $genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]); @@ -255,8 +435,9 @@ class AlbumApiController extends AbstractFOSRestController */ private function isIngestableExternalAlbum(array $externalAlbum): bool { - $releaseDate = trim((string) ($externalAlbum['release_date'] ?? '')); - if ($releaseDate === '') { + $albumId = trim((string) ($externalAlbum['id'] ?? '')); + $albumName = trim((string) ($externalAlbum['name'] ?? '')); + if ($albumId === '' || $albumName === '') { return false; } @@ -275,25 +456,6 @@ class AlbumApiController extends AbstractFOSRestController return false; } - /** - * @return array|null - */ - private function readJsonPayload(Request $request): ?array - { - $content = trim($request->getContent()); - if ($content === '') { - return []; - } - - try { - $decoded = $request->toArray(); - } catch (\JsonException) { - return null; - } - - return is_array($decoded) ? $decoded : null; - } - /** * @param array $payload * @param array|null $externalAlbum @@ -312,7 +474,7 @@ class AlbumApiController extends AbstractFOSRestController return $payload; } - $resolved = $this->lastFmClient->getAlbumWithTracks($externalId); + $resolved = $this->externalAlbumClient->getAlbumWithTracks($externalId); if (!is_array($resolved)) { return $payload; } @@ -326,4 +488,39 @@ class AlbumApiController extends AbstractFOSRestController return $payload; } + + /** + * @param array $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 + ) + ); + } } diff --git a/src/Controller/Api/V1/ApiController.php b/src/Controller/Api/V1/ApiController.php new file mode 100644 index 0000000..43930c7 --- /dev/null +++ b/src/Controller/Api/V1/ApiController.php @@ -0,0 +1,79 @@ +handleView($this->view($payload, $status)); + } + + /** + * @param array $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|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 $resource + * @param array $links + * @param array> $actions + * @return array + */ + protected function withHypermedia(array $resource, array $links = [], array $actions = []): array + { + if ($links !== []) { + $resource['links'] = $links; + } + + if ($actions !== []) { + $resource['actions'] = $actions; + } + + return $resource; + } +} diff --git a/src/Controller/Api/V1/ApiIndexController.php b/src/Controller/Api/V1/ApiIndexController.php index a1f9707..479ae8d 100644 --- a/src/Controller/Api/V1/ApiIndexController.php +++ b/src/Controller/Api/V1/ApiIndexController.php @@ -2,13 +2,12 @@ namespace App\Controller\Api\V1; -use FOS\RestBundle\Controller\AbstractFOSRestController; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/api/v1')] -class ApiIndexController extends AbstractFOSRestController +class ApiIndexController extends ApiController { #[Route('', name: 'api_v1_index', methods: ['GET'])] #[OA\Get( @@ -26,7 +25,7 @@ class ApiIndexController extends AbstractFOSRestController )] public function index(): Response { - return $this->handleView($this->view([ + return $this->apiResponse([ 'name' => 'Tonehaus API', 'version' => 'v1', 'endpoints' => [ @@ -48,6 +47,31 @@ class ApiIndexController extends AbstractFOSRestController 'DELETE /api/v1/reviews/{id} (JWT)', ], ], - ], Response::HTTP_OK)); + '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); } } diff --git a/src/Controller/Api/V1/AuthApiController.php b/src/Controller/Api/V1/AuthApiController.php new file mode 100644 index 0000000..83740b5 --- /dev/null +++ b/src/Controller/Api/V1/AuthApiController.php @@ -0,0 +1,49 @@ +apiError( + 'unsupported_operation', + 'This route is handled by Symfony security json_login.', + Response::HTTP_METHOD_NOT_ALLOWED + ); + } +} diff --git a/src/Controller/Api/V1/ReviewApiController.php b/src/Controller/Api/V1/ReviewApiController.php index 4b3ac9b..21d1987 100644 --- a/src/Controller/Api/V1/ReviewApiController.php +++ b/src/Controller/Api/V1/ReviewApiController.php @@ -8,7 +8,6 @@ use App\Entity\User; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; use Doctrine\ORM\EntityManagerInterface; -use FOS\RestBundle\Controller\AbstractFOSRestController; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,7 +15,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/api/v1/reviews')] -class ReviewApiController extends AbstractFOSRestController +class ReviewApiController extends ApiController { public function __construct( private readonly ReviewRepository $reviewRepository, @@ -43,9 +42,21 @@ class ReviewApiController extends AbstractFOSRestController $limit = max(1, min(100, (int) $request->query->get('limit', 50))); $reviews = $this->reviewRepository->findLatest($limit); - return $this->handleView($this->view([ + return $this->apiResponse([ 'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews), - ], Response::HTTP_OK)); + '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'])] @@ -63,7 +74,7 @@ class ReviewApiController extends AbstractFOSRestController )] public function show(Review $review): Response { - return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); + return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK); } #[Route('', name: 'api_v1_review_create', methods: ['POST'])] @@ -88,7 +99,8 @@ class ReviewApiController extends AbstractFOSRestController ), responses: [ new OA\Response(response: 201, description: 'Review created', content: new OA\JsonContent(type: 'object')), - new OA\Response(response: 400, description: 'Validation failed', 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')), ] @@ -97,30 +109,40 @@ class ReviewApiController extends AbstractFOSRestController { $payload = $this->readJsonPayload($request); if ($payload === null) { - return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + 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->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); + 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); - if ($title === '' || $content === '' || $rating < 1 || $rating > 10) { - return $this->handleView($this->view(['error' => 'title_content_rating_required'], Response::HTTP_BAD_REQUEST)); - } - - if (mb_strlen($content) < 20) { - return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); + $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->handleView($this->view(['error' => 'authentication_required'], Response::HTTP_UNAUTHORIZED)); + return $this->apiError( + 'authentication_required', + 'Authentication is required.', + Response::HTTP_UNAUTHORIZED + ); } $review = new Review(); @@ -133,7 +155,7 @@ class ReviewApiController extends AbstractFOSRestController $this->entityManager->persist($review); $this->entityManager->flush(); - return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_CREATED)); + return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_CREATED); } #[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])] @@ -159,7 +181,8 @@ class ReviewApiController extends AbstractFOSRestController ), responses: [ new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')), - new OA\Response(response: 400, description: 'Validation failed', 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')), ] @@ -185,7 +208,8 @@ class ReviewApiController extends AbstractFOSRestController ), responses: [ new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')), - new OA\Response(response: 400, description: 'Validation failed', 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')), ] @@ -196,13 +220,31 @@ class ReviewApiController extends AbstractFOSRestController $payload = $this->readJsonPayload($request); if ($payload === null) { - return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); + 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->handleView($this->view(['error' => 'title_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); + return $this->apiError( + 'validation_error', + 'Review payload failed validation.', + Response::HTTP_UNPROCESSABLE_ENTITY, + ['fields' => ['title' => 'Title cannot be empty.']] + ); } $review->setTitle($title); } @@ -210,10 +252,20 @@ class ReviewApiController extends AbstractFOSRestController if (array_key_exists('content', $payload)) { $content = trim((string) $payload['content']); if ($content === '') { - return $this->handleView($this->view(['error' => 'content_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); + 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->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); + 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); } @@ -221,14 +273,19 @@ class ReviewApiController extends AbstractFOSRestController if (array_key_exists('rating', $payload)) { $rating = (int) $payload['rating']; if ($rating < 1 || $rating > 10) { - return $this->handleView($this->view(['error' => 'rating_out_of_range'], Response::HTTP_BAD_REQUEST)); + 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->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); + return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK); } #[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] @@ -255,7 +312,7 @@ class ReviewApiController extends AbstractFOSRestController $this->entityManager->remove($review); $this->entityManager->flush(); - return $this->handleView($this->view(null, Response::HTTP_NO_CONTENT)); + return $this->apiResponse(null, Response::HTTP_NO_CONTENT); } /** @@ -264,8 +321,10 @@ class ReviewApiController extends AbstractFOSRestController private function serializeReview(Review $review): array { $album = $review->getAlbum(); + $reviewId = $review->getId(); + $albumId = trim((string) $album?->getExternalId()); - return [ + $payload = [ 'id' => $review->getId(), 'title' => $review->getTitle(), 'content' => $review->getContent(), @@ -274,7 +333,6 @@ class ReviewApiController extends AbstractFOSRestController 'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM), 'author' => [ 'id' => $review->getAuthor()?->getId(), - 'email' => $review->getAuthor()?->getEmail(), 'display_name' => $review->getAuthor()?->getDisplayName(), ], 'album' => [ @@ -283,6 +341,33 @@ class ReviewApiController extends AbstractFOSRestController '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 @@ -291,25 +376,43 @@ class ReviewApiController extends AbstractFOSRestController return null; } - return $this->albumRepository->findOneByExternalId($albumId); - } - - /** - * @return array|null - */ - private function readJsonPayload(Request $request): ?array - { - $content = trim($request->getContent()); - if ($content === '') { - return []; - } - - try { - $decoded = $request->toArray(); - } catch (\JsonException) { + $album = $this->albumRepository->findOneByExternalId($albumId); + if (!$album instanceof Album) { return null; } - return is_array($decoded) ? $decoded : null; + return $album->getTotalTracks() > 0 ? $album : null; + } + + /** + * @return array + */ + 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 $payload + */ + private function payloadContainsUpdateField(array $payload): bool + { + return array_key_exists('title', $payload) + || array_key_exists('content', $payload) + || array_key_exists('rating', $payload); } } diff --git a/src/Dto/AlbumSearchCriteria.php b/src/Dto/AlbumSearchCriteria.php index 8963a86..fb7330b 100644 --- a/src/Dto/AlbumSearchCriteria.php +++ b/src/Dto/AlbumSearchCriteria.php @@ -27,7 +27,7 @@ final class AlbumSearchCriteria /** Upper bound (inclusive) of the release year filter, if any. */ public readonly ?int $yearTo; - /** Requested source scope: `all`, `external`, or `user`. */ + /** Requested source scope: `all`, `external`, or `user` (`external` kept for legacy links). */ public readonly string $source; /** Maximum number of results the search should return. */ @@ -58,15 +58,17 @@ final class AlbumSearchCriteria */ public static function fromRequest(Request $request, int $defaultLimit = 20): self { + $source = self::resolveSourceFromRequest($request); + return new self( - query: trim((string) $request->query->get('q', '')), + query: self::resolveQueryFromRequest($request), albumName: trim($request->query->getString('album', '')), artist: trim($request->query->getString('artist', '')), genre: trim($request->query->getString('genre', '')), yearFrom: self::normalizeYear($request->query->get('year_from')), yearTo: self::normalizeYear($request->query->get('year_to')), - source: self::normalizeSource($request->query->getString('source', 'all')), - limit: $defaultLimit + source: $source, + limit: self::resolveLimitFromRequest($request, $defaultLimit) ); } @@ -95,7 +97,15 @@ final class AlbumSearchCriteria if ($raw === '') { 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 @@ -103,4 +113,42 @@ final class AlbumSearchCriteria $source = strtolower(trim($source)); 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); + } } diff --git a/src/Entity/Album.php b/src/Entity/Album.php index 46e35df..0db6b55 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -3,6 +3,7 @@ namespace App\Entity; use App\Entity\AlbumTrack; +use App\Entity\User; use App\Repository\AlbumRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -57,6 +58,9 @@ class Album #[ORM\Column(type: 'integer')] private int $totalTracks = 0; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $coverImagePath = null; + #[ORM\Column(type: 'string', length: 1024, nullable: true)] private ?string $externalUrl = null; @@ -66,6 +70,10 @@ class Album #[ORM\Column(type: 'datetime_immutable')] private ?\DateTimeImmutable $updatedAt = null; + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?User $owner = null; + public function __construct() { $this->tracks = new ArrayCollection(); @@ -224,6 +232,22 @@ class Album 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. */ @@ -248,6 +272,22 @@ class Album 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. * @@ -261,10 +301,16 @@ class Album ? $this->normalizeTopGenreForOutput($this->genres) : array_slice($this->genres, 0, 5); + $images = []; + $coverImagePath = trim((string) $this->coverImagePath); + if ($coverImagePath !== '') { + $images[] = ['url' => $coverImagePath]; + } + return [ 'id' => (string) $this->externalId, 'name' => $this->name, - 'images' => [], + 'images' => $images, 'artists' => $artists, 'genres' => $genres, 'release_date' => $this->releaseDate, diff --git a/src/EventSubscriber/ApiExceptionSubscriber.php b/src/EventSubscriber/ApiExceptionSubscriber.php new file mode 100644 index 0000000..a53c33c --- /dev/null +++ b/src/EventSubscriber/ApiExceptionSubscriber.php @@ -0,0 +1,122 @@ + '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]; + } +} diff --git a/src/EventSubscriber/ApiJwtFailureSubscriber.php b/src/EventSubscriber/ApiJwtFailureSubscriber.php new file mode 100644 index 0000000..4c62834 --- /dev/null +++ b/src/EventSubscriber/ApiJwtFailureSubscriber.php @@ -0,0 +1,102 @@ + '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/'); + } +} diff --git a/src/Form/AlbumType.php b/src/Form/AlbumType.php index e6497a7..8e94f12 100644 --- a/src/Form/AlbumType.php +++ b/src/Form/AlbumType.php @@ -4,6 +4,7 @@ namespace App\Form; use App\Entity\Album; 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\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -38,11 +39,17 @@ class AlbumType extends AbstractType 'label' => 'Genres (comma-separated)', 'help' => 'Optional: e.g. Dream pop, Shoegaze', ]) - ->add('releaseDate', TextType::class, [ + ->add('releaseDate', DateType::class, [ '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, [ + 'required' => false, + 'empty_data' => '0', 'constraints' => [new Assert\Range(min: 0, max: 500)], ]) ->add('coverUpload', FileType::class, [ @@ -55,6 +62,10 @@ class AlbumType extends AbstractType ->add('externalUrl', TextType::class, [ 'required' => false, '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. @@ -129,5 +140,3 @@ class AlbumType extends AbstractType return implode(', ', array_map(static fn(string $item) => trim($item), $items)); } } - - diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php index 383acec..dbf3458 100644 --- a/src/Form/SiteSettingsType.php +++ b/src/Form/SiteSettingsType.php @@ -4,26 +4,67 @@ namespace App\Form; use Symfony\Component\Form\AbstractType; 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\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** - * SiteSettingsType exposes toggles for operations staff (Last.fm key, registration). + * SiteSettingsType exposes external API/provider credentials and registration controls. */ class SiteSettingsType extends AbstractType { /** - * Exposes Last.fm credential inputs for administrators. + * Exposes provider + credential inputs for administrators. */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder + ->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, 'label' => 'Last.fm API key', 'mapped' => false, ]) + ->add('MUSICBRAINZ_CLIENT_ID', TextType::class, [ + 'required' => false, + '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, + ]) ->add('REGISTRATION_ENABLED', CheckboxType::class, [ 'required' => false, 'label' => 'Allow self-service registration', @@ -39,4 +80,3 @@ class SiteSettingsType extends AbstractType $resolver->setDefaults([]); } } - diff --git a/src/OpenApi/OpenApiSpec.php b/src/OpenApi/OpenApiSpec.php deleted file mode 100644 index f8e5c6b..0000000 --- a/src/OpenApi/OpenApiSpec.php +++ /dev/null @@ -1,64 +0,0 @@ -findOneBy(['externalId' => $externalId]); + $normalized = trim($externalId); + if ($normalized === '') { + return null; + } + + return $this->findOneBy(['externalId' => $normalized]); } /** @@ -40,12 +45,17 @@ class AlbumRepository extends ServiceEntityRepository */ public function findByExternalIdsKeyed(array $externalIds): array { - if ($externalIds === []) { + $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 []; } $rows = $this->createQueryBuilder('a') ->where('a.externalId IN (:ids)') - ->setParameter('ids', $externalIds) + ->setParameter('ids', $normalizedIds) ->getQuery() ->getResult(); $out = []; @@ -67,7 +77,9 @@ class AlbumRepository extends ServiceEntityRepository $rows = $this->createQueryBuilder('a') ->select('a.externalId') ->where('a.source = :src') + ->andWhere('a.totalTracks > 0') ->andWhere('a.externalId IS NOT NULL') + ->andWhere("a.externalId <> ''") ->setParameter('src', 'external') ->getQuery() ->getScalarResult(); @@ -92,6 +104,9 @@ class AlbumRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') + ->andWhere('a.totalTracks > 0') + ->andWhere('a.externalId IS NOT NULL') + ->andWhere("a.externalId <> ''") ->setParameter('src', 'user') ->setMaxResults($limit * 2); if ($freeText !== null && $freeText !== '') { @@ -124,6 +139,9 @@ class AlbumRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') + ->andWhere('a.totalTracks > 0') + ->andWhere('a.externalId IS NOT NULL') + ->andWhere("a.externalId <> ''") ->setParameter('src', 'external') ->setMaxResults($limit * 2); if ($freeText !== null && $freeText !== '') { @@ -160,13 +178,22 @@ class AlbumRepository extends ServiceEntityRepository $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($externalAlbum['artists'] ?? []))); $rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($externalAlbum['genres'] ?? []); $genres = $this->normalizeTopGenre($rawGenres); - $releaseDate = isset($externalAlbum['release_date']) ? (string) $externalAlbum['release_date'] : null; + $releaseDate = isset($externalAlbum['release_date']) ? trim((string) $externalAlbum['release_date']) : null; + if ($releaseDate === '') { + $releaseDate = null; + } $totalTracks = (int) ($externalAlbum['total_tracks'] ?? 0); $external = null; if (isset($externalAlbum['external_urls']['external'])) { $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(); $album = $this->findOneBy(['externalId' => $externalId]) ?? new Album(); $album->setSource('external'); @@ -174,7 +201,10 @@ class AlbumRepository extends ServiceEntityRepository $album->setName($name); $album->setArtists($artists); $album->setGenres($genres); - $album->setReleaseDate($releaseDate); + // Do not wipe a previously known release date when upstream omits it. + if ($releaseDate !== null || $album->getReleaseDate() === null) { + $album->setReleaseDate($releaseDate); + } $album->setTotalTracks($totalTracks); $album->setExternalUrl($external); $em->persist($album); diff --git a/src/Repository/AlbumTrackRepository.php b/src/Repository/AlbumTrackRepository.php index a83fe32..3262a78 100644 --- a/src/Repository/AlbumTrackRepository.php +++ b/src/Repository/AlbumTrackRepository.php @@ -30,11 +30,16 @@ class AlbumTrackRepository extends ServiceEntityRepository public function replaceAlbumTracks(Album $album, array $trackPayloads): void { $em = $this->getEntityManager(); + $albumId = $album->getId(); // 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') - ->setParameter('album', $album) - ->execute(); + // Skip this for new albums that do not have a database identifier yet. + if ($albumId !== null) { + $em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE IDENTITY(t.album) = :albumId') + ->setParameter('albumId', $albumId) + ->execute(); + } + $album->getTracks()->clear(); $position = 1; @@ -103,4 +108,3 @@ class AlbumTrackRepository extends ServiceEntityRepository } } - diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php index bd4a3d6..ef46008 100644 --- a/src/Repository/SettingRepository.php +++ b/src/Repository/SettingRepository.php @@ -43,6 +43,79 @@ class SettingRepository extends ServiceEntityRepository $em->persist($setting); $em->flush(); } + + /** + * Returns multiple setting values keyed by setting name. + * + * @param list $names + * @return array + */ + 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 $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(); + } } - diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php index b646c12..ce17d89 100644 --- a/src/Service/AlbumSearchService.php +++ b/src/Service/AlbumSearchService.php @@ -11,13 +11,21 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** - * AlbumSearchService pulls albums from Last.fm and the local catalog, + * AlbumSearchService pulls albums from the configured external provider and local catalog, * adds review stats, and hands the UI one uniform structure to render. */ class AlbumSearchService { + private const MAX_EXTERNAL_SEARCH_PAGES = 3; + private const MAX_FAST_SEARCH_PAGES = 2; + private const MAX_IMAGE_HYDRATION_ALBUMS = 20; + private const DETAIL_VALIDATION_BATCH_SIZE = 24; + private const VALIDATION_ID_HEADROOM = 8; + /** @var array */ + private array $lastSearchProfile = []; + public function __construct( - private readonly LastFmClient $lastFmClient, + private readonly ExternalAlbumClientInterface $externalClient, private readonly AlbumRepository $albumRepository, private readonly ReviewRepository $reviewRepository, private readonly EntityManagerInterface $entityManager, @@ -29,13 +37,21 @@ class AlbumSearchService /** * Runs the end-to-end search flow and returns review stats plus saved IDs. */ - public function search(AlbumSearchCriteria $criteria): AlbumSearchResult + public function search(AlbumSearchCriteria $criteria, bool $preferFastExternal = false): AlbumSearchResult { + $searchStartedAt = microtime(true); $externalQuery = $this->buildExternalSearchQuery($criteria); $hasUserFilters = $this->hasUserFilters($criteria, $externalQuery); + $this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal); $shouldQueryExternal = $criteria->shouldUseExternal() - && ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external'); + && ( + $externalQuery !== '' + || $criteria->genre !== '' + || $criteria->yearFrom !== null + || $criteria->yearTo !== null + || $criteria->source === 'external' + ); $stats = []; $savedIds = []; @@ -43,33 +59,56 @@ class AlbumSearchService $userPayloads = []; if ($shouldQueryExternal) { - $externalData = $this->resolveExternalAlbums($criteria, $externalQuery); + $externalStartedAt = microtime(true); + $externalData = $this->resolveExternalAlbums($criteria, $externalQuery, $preferFastExternal); $externalPayloads = $externalData['payloads']; $stats = $this->mergeStats($stats, $externalData['stats']); $savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']); + $this->recordTimingMs('external', $externalStartedAt); } if ($criteria->shouldUseUserCatalog() && $hasUserFilters) { + $userStartedAt = microtime(true); $userData = $this->resolveUserAlbums($criteria); $userPayloads = $userData['payloads']; $stats = $this->mergeStats($stats, $userData['stats']); + $this->recordTimingMs('user', $userStartedAt); } + $composeStartedAt = microtime(true); $albums = $this->composeAlbumList($criteria->source, $userPayloads, $externalPayloads, $criteria->limit); + $this->recordTimingMs('compose', $composeStartedAt); + $this->setCount('result_albums', count($albums)); + $this->recordTimingMs('total', $searchStartedAt); + $this->logger->info('Album search profile', $this->lastSearchProfile); return new AlbumSearchResult($criteria, $albums, $stats, $savedIds); } + /** + * Returns metrics collected from the latest search() call in this request. + * + * @return array + */ + public function getLastSearchProfile(): array + { + return $this->lastSearchProfile; + } + /** * Indicates whether Last.fm credentials are configured. */ public function isExternalConfigured(): bool { - return $this->lastFmClient->isConfigured(); + return $this->externalClient->isConfigured(); } private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string { + if ($this->externalClient->getProviderName() === 'musicbrainz') { + return $this->buildMusicBrainzSearchQuery($criteria); + } + $parts = []; if ($criteria->query !== '') { $parts[] = $criteria->query; @@ -81,9 +120,67 @@ class AlbumSearchService $parts[] = $criteria->artist; } + // Faceted-only searches (genre/date without text) still need a query token upstream. + if ($parts === [] && $criteria->genre !== '') { + $parts[] = $criteria->genre; + } + if ($parts === [] && $criteria->yearFrom !== null) { + $parts[] = (string) $criteria->yearFrom; + } + if ($parts === [] && $criteria->yearTo !== null) { + $parts[] = (string) $criteria->yearTo; + } + return trim(implode(' ', $parts)); } + private function buildMusicBrainzSearchQuery(AlbumSearchCriteria $criteria): string + { + $clauses = []; + + $query = trim($criteria->query); + if ($query !== '') { + $queryPhrase = $this->musicBrainzPhrase($query); + $clauses[] = sprintf('(release:%s OR artist:%s)', $queryPhrase, $queryPhrase); + } + + if ($criteria->albumName !== '') { + $clauses[] = 'release:' . $this->musicBrainzPhrase($criteria->albumName); + } + + if ($criteria->artist !== '') { + $clauses[] = 'artist:' . $this->musicBrainzPhrase($criteria->artist); + } + + if ($criteria->genre !== '') { + // MusicBrainz indexes style/tags via the `tag` field. + $clauses[] = 'tag:' . $this->musicBrainzPhrase($criteria->genre); + } + + if ($criteria->yearFrom !== null || $criteria->yearTo !== null) { + $from = $criteria->yearFrom !== null ? (string) $criteria->yearFrom : '*'; + $to = $criteria->yearTo !== null ? (string) $criteria->yearTo : '*'; + $clauses[] = sprintf('date:[%s TO %s]', $from, $to); + } + + if ($clauses === []) { + return ''; + } + + return implode(' AND ', $clauses); + } + + private function musicBrainzPhrase(string $raw): string + { + $value = trim($raw); + if ($value === '') { + return '""'; + } + + $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + return '"' . $escaped . '"'; + } + private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool { if ($criteria->source === 'user') { @@ -101,61 +198,137 @@ class AlbumSearchService /** * @return array{payloads:array>,stats:array,savedIds:array} */ - private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery): array + private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): array { + $providerName = $this->externalClient->getProviderName(); + $skipGenrePostFilter = $providerName === 'musicbrainz' && $criteria->genre !== ''; + $storedGenreFilter = $skipGenrePostFilter ? '' : $criteria->genre; + $storedExternalAlbums = $this->albumRepository->searchExternalAlbums( $criteria->query, $criteria->albumName, $criteria->artist, - $criteria->genre, + $storedGenreFilter, $criteria->yearFrom ?? 0, $criteria->yearTo ?? 0, $criteria->limit ); + $this->setCount('stored_external', count($storedExternalAlbums)); $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums); - $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre); + $storedPayloads = $this->filterPayloadsByTrackCount($storedPayloads); + $storedPayloads = $this->filterPayloadsByArtist($storedPayloads, $criteria->artist); + if (!$skipGenrePostFilter) { + $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre); + } $storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo); - $storedPayloads = $this->hydrateExternalImages($storedPayloads); $storedIds = $this->collectExternalIds($storedExternalAlbums); $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : []; + // Cache-first policy: only hit Last.fm when local storage cannot satisfy this search. $shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit; if (!$shouldFetchFromExternal) { + $limitedStoredPayloads = array_slice($storedPayloads, 0, $criteria->limit); return [ - 'payloads' => array_slice($storedPayloads, 0, $criteria->limit), + 'payloads' => $this->hydrateExternalImages($limitedStoredPayloads), 'stats' => $stats, 'savedIds' => $storedIds, ]; } - $apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery); - $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre); + $useSearchOnlyFastPath = $preferFastExternal && !$this->requiresDetailedExternalFetch($criteria); + $apiPayloads = $useSearchOnlyFastPath + ? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery) + : $this->fetchExternalPayloads($criteria, $externalQuery); + $filteredApiPayloads = $this->filterPayloadsByArtist($apiPayloads['payloads'], $criteria->artist); + if (!$skipGenrePostFilter) { + $filteredApiPayloads = $this->filterPayloadsByGenre($filteredApiPayloads, $criteria->genre); + } $filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo); + $mergedPayloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); return [ - 'payloads' => $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit), + 'payloads' => $this->hydrateExternalImages($mergedPayloads), 'stats' => $this->mergeStats($stats, $apiPayloads['stats']), 'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']), ]; } + /** + * Fastest path: search payload only (name/id/image), no detail fan-out. + * + * @return array{payloads:array>,stats:array,savedIds:array} + */ + private function fetchExternalPayloadsSearchOnly(AlbumSearchCriteria $criteria, string $externalQuery): array + { + $perPage = max($criteria->limit, 10); + $seenIds = []; + $payloads = []; + + for ($page = 1; $page <= self::MAX_FAST_SEARCH_PAGES && count($payloads) < $criteria->limit; $page++) { + $this->incrementCount('external_pages_scanned'); + $searchStartedAt = microtime(true); + $result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page); + $this->recordTimingMs('lastfm_search_total', $searchStartedAt, true); + $this->incrementCount('lastfm_search_calls'); + $searchItems = (array) ($result['albums']['items'] ?? []); + if ($searchItems === []) { + break; + } + + foreach ($searchItems as $searchItem) { + if (!is_array($searchItem)) { + continue; + } + + $id = trim((string) ($searchItem['id'] ?? '')); + if ($id === '' || isset($seenIds[$id])) { + continue; + } + + $seenIds[$id] = true; + $payloads[] = $searchItem; + if (count($payloads) >= $criteria->limit) { + break; + } + } + } + + $this->setCount('external_candidates', count($payloads)); + $this->setCount('external_valid', count($payloads)); + + return [ + 'payloads' => array_slice($payloads, 0, $criteria->limit), + 'stats' => [], + 'savedIds' => [], + ]; + } + /** * @return array{payloads:array>,stats:array,savedIds:array} */ private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array { + if (!$this->requiresDetailedExternalFetch($criteria)) { + return $this->fetchExternalPayloadsLight($criteria, $externalQuery); + } + $perPage = max($criteria->limit, 10); - $maxPages = 5; + $maxPages = self::MAX_EXTERNAL_SEARCH_PAGES; $seenSearchIds = []; $persistableIds = []; $persistableLookup = []; + $imagesByPersistableId = []; $pagesScanned = 0; for ($page = 1; $page <= $maxPages && count($persistableIds) < $criteria->limit; $page++) { $pagesScanned++; - $result = $this->lastFmClient->searchAlbums($externalQuery, $perPage, $page); + $this->incrementCount('external_pages_scanned'); + $searchStartedAt = microtime(true); + $result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page); + $this->recordTimingMs('lastfm_search_total', $searchStartedAt, true); + $this->incrementCount('lastfm_search_calls'); $searchItems = (array) ($result['albums']['items'] ?? []); if ($searchItems === []) { break; @@ -172,8 +345,13 @@ class AlbumSearchService if ($ids === []) { continue; } + $this->incrementCount('external_candidates', count($ids)); - $full = $this->lastFmClient->getAlbums($ids); + $getAlbumsStartedAt = microtime(true); + $full = $this->externalClient->getAlbums($ids, 'list'); + $this->recordTimingMs('lastfm_getalbums_total', $getAlbumsStartedAt, true); + $this->incrementCount('lastfm_getalbums_calls'); + $this->incrementCount('lastfm_getalbums_requested_ids', count($ids)); $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; if ($albumsPayload === []) { continue; @@ -194,6 +372,12 @@ class AlbumSearchService if (!isset($persistableLookup[$albumId])) { $persistableLookup[$albumId] = true; $persistableIds[] = $albumId; + $this->incrementCount('external_valid'); + } + + $images = $payload['images'] ?? null; + if (is_array($images) && $images !== []) { + $imagesByPersistableId[$albumId] = $images; } } } @@ -224,9 +408,12 @@ class AlbumSearchService $payloads = []; foreach ($orderedIds as $externalId) { - $payloads[] = $existing[$externalId]->toTemplateArray(); + $payload = $existing[$externalId]->toTemplateArray(); + if (isset($imagesByPersistableId[$externalId]) && is_array($imagesByPersistableId[$externalId])) { + $payload['images'] = $imagesByPersistableId[$externalId]; + } + $payloads[] = $payload; } - $payloads = $this->hydrateExternalImages($payloads); return [ 'payloads' => $payloads, @@ -235,29 +422,156 @@ class AlbumSearchService ]; } + /** + * Fast path: use search payloads directly and avoid per-result detail lookups. + * + * @return array{payloads:array>,stats:array,savedIds:array} + */ + private function fetchExternalPayloadsLight(AlbumSearchCriteria $criteria, string $externalQuery): array + { + $perPage = max($criteria->limit, 10); + $maxCandidateIdsPerPage = max($criteria->limit + self::VALIDATION_ID_HEADROOM, $criteria->limit); + $seenIds = []; + $payloads = []; + $persistedExternalIdLookup = []; + $imagesByPersistedId = []; + + for ($page = 1; $page <= self::MAX_EXTERNAL_SEARCH_PAGES && count($payloads) < $criteria->limit; $page++) { + $this->incrementCount('external_pages_scanned'); + $searchStartedAt = microtime(true); + $result = $this->externalClient->searchAlbums($externalQuery, $perPage, $page); + $this->recordTimingMs('lastfm_search_total', $searchStartedAt, true); + $this->incrementCount('lastfm_search_calls'); + $searchItems = (array) ($result['albums']['items'] ?? []); + if ($searchItems === []) { + break; + } + + $candidateIds = []; + foreach ($searchItems as $item) { + if (!is_array($item)) { + continue; + } + + $id = trim((string) ($item['id'] ?? '')); + if ($id === '' || isset($seenIds[$id])) { + continue; + } + + $seenIds[$id] = true; + $candidateIds[] = $id; + if (count($candidateIds) >= $maxCandidateIdsPerPage) { + break; + } + } + + if ($candidateIds === []) { + continue; + } + $this->incrementCount('external_candidates', count($candidateIds)); + + foreach (array_chunk($candidateIds, self::DETAIL_VALIDATION_BATCH_SIZE) as $candidateChunk) { + if (count($payloads) >= $criteria->limit) { + break; + } + + $getAlbumsStartedAt = microtime(true); + $full = $this->externalClient->getAlbums($candidateChunk, 'list'); + $this->recordTimingMs('lastfm_getalbums_total', $getAlbumsStartedAt, true); + $this->incrementCount('lastfm_getalbums_calls'); + $this->incrementCount('lastfm_getalbums_requested_ids', count($candidateChunk)); + $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; + if ($albumsPayload === []) { + continue; + } + + $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload); + $ingestablePayloads = []; + foreach ($albumsPayload as $albumPayload) { + if (!is_array($albumPayload) || !$this->hasAtLeastOneTrack($albumPayload)) { + continue; + } + + $externalId = trim((string) ($albumPayload['id'] ?? '')); + if ($externalId === '') { + continue; + } + + $this->albumRepository->upsertFromExternalAlbum( + $albumPayload, + $genresByAlbum[$externalId] ?? [] + ); + $persistedExternalIdLookup[$externalId] = true; + $this->incrementCount('external_valid'); + $images = $albumPayload['images'] ?? null; + if (is_array($images) && $images !== []) { + $imagesByPersistedId[$externalId] = $images; + } + $ingestablePayloads[] = $albumPayload; + } + + foreach ($ingestablePayloads as $ingestablePayload) { + $payloads[] = $ingestablePayload; + if (count($payloads) >= $criteria->limit) { + break; + } + } + } + } + + if ($persistedExternalIdLookup !== []) { + $this->entityManager->flush(); + } + + if ($payloads === []) { + return ['payloads' => [], 'stats' => [], 'savedIds' => []]; + } + + $orderedPayloadIds = $this->extractExternalIds($payloads); + $ids = array_values(array_unique(array_merge($orderedPayloadIds, array_keys($persistedExternalIdLookup)))); + $saved = $this->albumRepository->findByExternalIdsKeyed($ids); + $savedIds = array_values(array_keys($saved)); + + // Return persisted entities (not raw Last.fm payloads) so UI/API always render + // the same canonical data source after ingestion. + $persistedPayloads = []; + foreach ($orderedPayloadIds as $externalId) { + if (!isset($saved[$externalId])) { + continue; + } + + $persistedPayload = $saved[$externalId]->toTemplateArray(); + if (isset($imagesByPersistedId[$externalId]) && is_array($imagesByPersistedId[$externalId])) { + $persistedPayload['images'] = $imagesByPersistedId[$externalId]; + } + $persistedPayloads[] = $persistedPayload; + if (count($persistedPayloads) >= $criteria->limit) { + break; + } + } + + if ($persistedPayloads === []) { + return ['payloads' => [], 'stats' => [], 'savedIds' => $savedIds]; + } + + return [ + 'payloads' => $persistedPayloads, + 'stats' => $savedIds !== [] ? $this->reviewRepository->getAggregatesForAlbumIds($savedIds) : [], + 'savedIds' => $savedIds, + ]; + } + /** * @param array $payload */ private function isIngestableExternalAlbum(array $payload): bool { - $releaseDate = trim((string) ($payload['release_date'] ?? '')); - if ($releaseDate === '') { - return false; - } - $tracks = $payload['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; + return $this->hasAtLeastOneTrack($payload); } /** @@ -274,6 +588,7 @@ class AlbumSearchService $criteria->yearTo ?? 0, $criteria->limit ); + $this->setCount('stored_user', count($userAlbums)); if ($userAlbums === []) { return ['payloads' => [], 'stats' => []]; @@ -283,11 +598,36 @@ class AlbumSearchService $userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds); return [ - 'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums), + 'payloads' => $this->filterPayloadsByTrackCount(array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums)), 'stats' => $this->mapUserStatsToAlbumIds($userAlbums, $userStats), ]; } + /** + * @param array $payload + */ + private function hasAtLeastOneTrack(array $payload): bool + { + $totalTracks = (int) ($payload['total_tracks'] ?? 0); + if ($totalTracks > 0) { + return true; + } + + $tracks = $payload['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 list $userAlbums * @param array $userStats @@ -446,6 +786,40 @@ class AlbumSearchService })); } + /** + * @param array> $payloads + * @return array> + */ + private function filterPayloadsByArtist(array $payloads, string $artistNeedle): array + { + $needle = trim(mb_strtolower($artistNeedle)); + if ($needle === '') { + return $payloads; + } + + return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool { + $artists = $payload['artists'] ?? []; + if (!is_array($artists)) { + return false; + } + + foreach ($artists as $artist) { + $name = ''; + if (is_array($artist)) { + $name = trim((string) ($artist['name'] ?? '')); + } else { + $name = trim((string) $artist); + } + + if ($name !== '' && str_contains(mb_strtolower($name), $needle)) { + return true; + } + } + + return false; + })); + } + /** * @param array> $payloads * @return array> @@ -472,6 +846,15 @@ class AlbumSearchService })); } + /** + * @param array> $payloads + * @return array> + */ + private function filterPayloadsByTrackCount(array $payloads): array + { + return array_values(array_filter($payloads, fn(array $payload): bool => $this->hasAtLeastOneTrack($payload))); + } + /** * @param array> $payloads * @return array> @@ -483,6 +866,12 @@ class AlbumSearchService if ((string) ($payload['source'] ?? '') !== 'external') { continue; } + + $existingImages = $payload['images'] ?? []; + if (is_array($existingImages) && $existingImages !== []) { + continue; + } + $id = trim((string) ($payload['id'] ?? '')); if ($id !== '') { $externalIds[] = $id; @@ -493,7 +882,15 @@ class AlbumSearchService return $payloads; } - $full = $this->lastFmClient->getAlbums(array_values(array_unique($externalIds))); + if (count($externalIds) > self::MAX_IMAGE_HYDRATION_ALBUMS) { + $externalIds = array_slice($externalIds, 0, self::MAX_IMAGE_HYDRATION_ALBUMS); + } + + $hydrateStartedAt = microtime(true); + $full = $this->externalClient->getAlbums(array_values(array_unique($externalIds)), 'list'); + $this->recordTimingMs('hydrate_images', $hydrateStartedAt); + $this->incrementCount('lastfm_getalbums_calls'); + $this->incrementCount('lastfm_getalbums_requested_ids', count(array_unique($externalIds))); $albums = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; if ($albums === []) { return $payloads; @@ -525,4 +922,83 @@ class AlbumSearchService return $payloads; } + + private function requiresDetailedExternalFetch(AlbumSearchCriteria $criteria): bool + { + if ($this->externalClient->getProviderName() === 'tidal') { + // TIDAL search listing payloads are identifier-first; detailed fetch ensures + // artist/genre/track metadata is present before filtering and persistence. + return true; + } + + return $criteria->genre !== '' + || $criteria->yearFrom !== null + || $criteria->yearTo !== null; + } + + private function resetSearchProfile(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): void + { + $this->lastSearchProfile = [ + 'query' => $externalQuery, + 'source' => $criteria->source, + 'limit' => $criteria->limit, + 'external_provider' => $this->externalClient->getProviderName(), + 'prefer_fast_external' => $preferFastExternal, + 'timings_ms' => [ + 'total' => 0.0, + 'external' => 0.0, + 'user' => 0.0, + 'compose' => 0.0, + 'lastfm_search_total' => 0.0, + 'lastfm_getalbums_total' => 0.0, + 'hydrate_images' => 0.0, + ], + 'counts' => [ + 'stored_external' => 0, + 'stored_user' => 0, + 'external_pages_scanned' => 0, + 'external_candidates' => 0, + 'external_valid' => 0, + 'lastfm_search_calls' => 0, + 'lastfm_getalbums_calls' => 0, + 'lastfm_getalbums_requested_ids' => 0, + 'result_albums' => 0, + ], + ]; + } + + private function recordTimingMs(string $key, float $startedAt, bool $accumulate = false): void + { + if (!is_array($this->lastSearchProfile['timings_ms'] ?? null)) { + return; + } + + $elapsed = round((microtime(true) - $startedAt) * 1000, 2); + if ($accumulate) { + $current = (float) ($this->lastSearchProfile['timings_ms'][$key] ?? 0.0); + $this->lastSearchProfile['timings_ms'][$key] = round($current + $elapsed, 2); + return; + } + + $this->lastSearchProfile['timings_ms'][$key] = $elapsed; + } + + private function incrementCount(string $key, int $incrementBy = 1): void + { + if (!is_array($this->lastSearchProfile['counts'] ?? null)) { + return; + } + + $current = (int) ($this->lastSearchProfile['counts'][$key] ?? 0); + $this->lastSearchProfile['counts'][$key] = $current + $incrementBy; + } + + private function setCount(string $key, int $value): void + { + if (!is_array($this->lastSearchProfile['counts'] ?? null)) { + return; + } + + $this->lastSearchProfile['counts'][$key] = $value; + } } diff --git a/src/Service/ExternalAlbumClient.php b/src/Service/ExternalAlbumClient.php new file mode 100644 index 0000000..d1e7a8c --- /dev/null +++ b/src/Service/ExternalAlbumClient.php @@ -0,0 +1,74 @@ +>}} + */ + public function searchAlbums(string $query, int $limit = 12, int $page = 1): array + { + return $this->resolveProvider()->searchAlbums($query, $limit, $page); + } + + /** + * @return array|null + */ + public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array + { + return $this->resolveProvider()->getAlbumWithTracks($albumId, $imageProfile); + } + + /** + * @param list $albumIds + * @return array{albums:list>}|null + */ + public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array + { + return $this->resolveProvider()->getAlbums($albumIds, $imageProfile); + } + + /** + * @return list> + */ + 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; + } +} diff --git a/src/Service/ExternalAlbumClientInterface.php b/src/Service/ExternalAlbumClientInterface.php new file mode 100644 index 0000000..eeaa390 --- /dev/null +++ b/src/Service/ExternalAlbumClientInterface.php @@ -0,0 +1,34 @@ +>}} + */ + public function searchAlbums(string $query, int $limit = 12, int $page = 1): array; + + /** + * @return array|null + */ + public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array; + + /** + * @param list $albumIds + * @return array{albums:list>}|null + */ + public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array; + + /** + * @return list> + */ + public function getAlbumTracks(string $albumId): array; + + public function isConfigured(): bool; + + public function getProviderName(): string; +} diff --git a/src/Service/ExternalProviderManager.php b/src/Service/ExternalProviderManager.php new file mode 100644 index 0000000..a1091a4 --- /dev/null +++ b/src/Service/ExternalProviderManager.php @@ -0,0 +1,204 @@ + + */ + 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; + } +} diff --git a/src/Service/LastFmClient.php b/src/Service/LastFmClient.php index cc9e484..0b04540 100644 --- a/src/Service/LastFmClient.php +++ b/src/Service/LastFmClient.php @@ -2,17 +2,25 @@ namespace App\Service; -use GuzzleHttp\ClientInterface; 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 +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, @@ -20,6 +28,10 @@ class LastFmClient 'medium' => 2, 'small' => 1, ]; + private bool $apiKeyResolved = false; + private ?string $resolvedApiKey = null; + /** @var array> */ + private array $inMemoryResponseCache = []; public function __construct( private readonly ClientInterface $httpClient, @@ -47,7 +59,7 @@ class LastFmClient 'album' => $query, 'limit' => max(1, $limit), 'page' => max(1, $page), - ], 600); + ], self::SEARCH_CACHE_TTL_SECONDS); } catch (\Throwable) { return ['albums' => ['items' => []]]; } @@ -72,9 +84,9 @@ class LastFmClient * * @return array|null */ - public function getAlbumWithTracks(string $albumId): ?array + public function getAlbumWithTracks(string $albumId, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array { - return $this->getAlbum($albumId); + return $this->getAlbum($albumId, $imageProfile); } /** @@ -82,33 +94,20 @@ class LastFmClient * * @return array|null */ - public function getAlbum(string $albumId): ?array + public function getAlbum(string $albumId, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array { $apiKey = $this->getApiKey(); if ($apiKey === null) { return null; } - $lookup = $this->parseAlbumIdentifier($albumId); - if ($lookup === null) { + $query = $this->buildAlbumInfoQuery($albumId); + if ($query === null) { return null; } - $query = ['method' => 'album.getinfo']; - if (isset($lookup['mbid'])) { - $query['mbid'] = (string) $lookup['mbid']; - } else { - $artist = trim((string) ($lookup['artist'] ?? '')); - $name = trim((string) ($lookup['album'] ?? '')); - if ($artist === '' || $name === '') { - return null; - } - $query['artist'] = $artist; - $query['album'] = $name; - } - try { - $payload = $this->sendRequest($query, 1800); + $payload = $this->sendRequest($query, self::ALBUM_INFO_CACHE_TTL_SECONDS); } catch (\Throwable) { return null; } @@ -118,16 +117,16 @@ class LastFmClient return null; } - return $this->normalizeDetailedAlbum($album, $albumId); + return $this->normalizeDetailedAlbum($album, $albumId, $imageProfile); } /** - * Fetch multiple albums in sequence. + * Fetch multiple albums using bounded concurrency to avoid serial network latency. * * @param list $albumIds * @return array{albums:list>}|null */ - public function getAlbums(array $albumIds): ?array + public function getAlbums(array $albumIds, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array { if ($albumIds === []) { return ['albums' => []]; @@ -136,12 +135,116 @@ class LastFmClient return null; } - $albums = []; - foreach ($albumIds as $albumId) { - $album = $this->getAlbumWithTracks((string) $albumId); - if ($album !== null) { - $albums[] = $album; + 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]; @@ -169,42 +272,116 @@ class LastFmClient return $this->getApiKey() !== null; } + public function getProviderName(): string + { + return 'lastfm'; + } + /** * @param array $query * @return array */ 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 $query + * @return array|null + */ + private function prepareQuery(array $query): ?array { $apiKey = $this->getApiKey(); if ($apiKey === null) { - return []; + return null; } $query['api_key'] = $apiKey; $query['format'] = 'json'; $query['autocorrect'] = 1; - - $request = function () use ($query): array { - $response = $this->httpClient->request('GET', self::API_BASE_URL, ['query' => $query]); - $decoded = json_decode((string) $response->getBody(), true); - return is_array($decoded) ? $decoded : []; - }; - - if ($cacheTtlSeconds > 0) { - $cacheKey = 'lastfm_resp_' . sha1(json_encode($query)); - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { - $item->expiresAfter($cacheTtlSeconds); - return $request(); - }); - } - - return $request(); + return $query; } - private function getApiKey(): ?string + /** + * @param array $query + */ + private function buildCacheKey(array $query): string { - $key = trim((string) $this->settings->getValue('LASTFM_API_KEY', $this->apiKey ?? '')); - return $key === '' ? null : $key; + 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; } /** @@ -246,7 +423,7 @@ class LastFmClient 'genres' => [], 'release_date' => null, 'total_tracks' => 0, - 'images' => $this->normalizeImages($rawAlbum['image'] ?? []), + 'images' => $this->normalizeImages($rawAlbum['image'] ?? [], self::IMAGE_PROFILE_LIST), 'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)], 'source' => 'external', 'tracks' => ['items' => [], 'total' => 0], @@ -257,7 +434,7 @@ class LastFmClient * @param array $rawAlbum * @return array */ - private function normalizeDetailedAlbum(array $rawAlbum, ?string $preferredId = null): array + private function normalizeDetailedAlbum(array $rawAlbum, ?string $preferredId = null, string $imageProfile = self::IMAGE_PROFILE_DETAIL): array { $name = trim((string) ($rawAlbum['name'] ?? '')); $artistField = $rawAlbum['artist'] ?? ''; @@ -281,7 +458,8 @@ class LastFmClient } } - $releaseDate = $this->normalizePublishedDate((string) ($rawAlbum['wiki']['published'] ?? '')); + $allowPageFallback = $imageProfile === self::IMAGE_PROFILE_DETAIL; + $releaseDate = $this->resolveReleaseDate($rawAlbum, $allowPageFallback); return [ 'id' => $id, @@ -290,7 +468,7 @@ class LastFmClient 'genres' => $genres, 'release_date' => $releaseDate, 'total_tracks' => count($tracks), - 'images' => $this->normalizeImages($rawAlbum['image'] ?? []), + 'images' => $this->normalizeImages($rawAlbum['image'] ?? [], $imageProfile), 'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)], 'source' => 'external', 'tracks' => [ @@ -336,38 +514,72 @@ class LastFmClient * @param mixed $rawImages * @return list */ - private function normalizeImages(mixed $rawImages): array + private function normalizeImages(mixed $rawImages, string $imageProfile = self::IMAGE_PROFILE_DETAIL): array { - $images = []; + $preferredSizes = $this->resolvePreferredSizes($imageProfile); + $bySize = []; + $ranked = []; + $seenUrls = []; + foreach ($this->normalizeList($rawImages) as $image) { $url = trim((string) ($image['#text'] ?? $image['url'] ?? '')); - if ($url !== '') { - $size = mb_strtolower(trim((string) ($image['size'] ?? ''))); - $images[] = [ - 'url' => $url, - 'rank' => self::IMAGE_SIZE_RANK[$size] ?? 0, - ]; + if ($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 ($images === []) { + if ($ranked === []) { return []; } - usort($images, static fn(array $a, array $b) => $b['rank'] <=> $a['rank']); - - $unique = []; - $result = []; - foreach ($images as $image) { - $url = (string) ($image['url'] ?? ''); - if ($url === '' || isset($unique[$url])) { - continue; + foreach ($preferredSizes as $size) { + if (isset($bySize[$size])) { + return [['url' => $this->transformImageUrlForProfile($bySize[$size], $imageProfile)]]; } - $unique[$url] = true; - $result[] = ['url' => $url]; } - return $result; + 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 + */ + 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 @@ -384,6 +596,143 @@ class LastFmClient } } + /** + * @param array $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 = [ + '/]*catalogue-metadata-heading[^>]*>\s*Release Date\s*<\/dt>\s*]*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) !== '') { diff --git a/src/Service/LastFmMetadataRefresher.php b/src/Service/LastFmMetadataRefresher.php index d52a34a..171281f 100644 --- a/src/Service/LastFmMetadataRefresher.php +++ b/src/Service/LastFmMetadataRefresher.php @@ -8,14 +8,14 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** - * LastFmMetadataRefresher re-syncs locally stored external albums from Last.fm. + * LastFmMetadataRefresher re-syncs locally stored external albums from the active provider. */ class LastFmMetadataRefresher { private const BATCH_SIZE = 20; public function __construct( - private readonly LastFmClient $lastFmClient, + private readonly ExternalAlbumClientInterface $externalClient, private readonly AlbumRepository $albumRepository, private readonly AlbumTrackRepository $trackRepository, private readonly EntityManagerInterface $entityManager, @@ -36,7 +36,7 @@ class LastFmMetadataRefresher $updatedAlbumCount = 0; foreach (array_chunk($externalIds, self::BATCH_SIZE) as $albumIdChunk) { - $payload = $this->lastFmClient->getAlbums($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)]); @@ -47,18 +47,33 @@ class LastFmMetadataRefresher foreach ($albums as $albumPayload) { try { - $albumId = (string) ($albumPayload['id'] ?? ''); - $albumEntity = $this->albumRepository->upsertFromExternalAlbum( - (array) $albumPayload, - $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] - ); - if ($albumId !== '' && $albumEntity !== null) { - $tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload); - if ($tracks !== []) { - $this->trackRepository->replaceAlbumTracks($albumEntity, $tracks); - $albumEntity->setTotalTracks(count($tracks)); - } + $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', [ @@ -83,7 +98,7 @@ class LastFmMetadataRefresher $total = (int) ($albumPayload['tracks']['total'] ?? 0); if ($total > count($tracks)) { - $fullTrackPayloads = $this->lastFmClient->getAlbumTracks($albumId); + $fullTrackPayloads = $this->externalClient->getAlbumTracks($albumId); if ($fullTrackPayloads !== []) { return $fullTrackPayloads; } @@ -91,4 +106,15 @@ class LastFmMetadataRefresher return $tracks; } + + /** + * @param list> $tracks + * @return list> + */ + private function filterTracksWithNames(array $tracks): array + { + return array_values(array_filter($tracks, static function (array $track): bool { + return trim((string) ($track['name'] ?? '')) !== ''; + })); + } } diff --git a/src/Service/LastFmTagResolver.php b/src/Service/LastFmTagResolver.php index b03d0ca..bfdfba3 100644 --- a/src/Service/LastFmTagResolver.php +++ b/src/Service/LastFmTagResolver.php @@ -7,10 +7,6 @@ namespace App\Service; */ class LastFmTagResolver { - public function __construct(private readonly LastFmClient $lastFmClient) - { - } - /** * @param list> $albums * @return array> Map of album ID => tag list @@ -28,17 +24,6 @@ class LastFmTagResolver $genres = $this->extractGenres((array) ($album['genres'] ?? [])); if ($genres !== []) { $albumTags[$albumId] = $genres; - continue; - } - - $hydrated = $this->lastFmClient->getAlbumWithTracks($albumId); - if ($hydrated === null) { - continue; - } - - $resolved = $this->extractGenres((array) ($hydrated['genres'] ?? [])); - if ($resolved !== []) { - $albumTags[$albumId] = $resolved; } } diff --git a/src/Service/MusicBrainzClient.php b/src/Service/MusicBrainzClient.php new file mode 100644 index 0000000..5bc9997 --- /dev/null +++ b/src/Service/MusicBrainzClient.php @@ -0,0 +1,429 @@ +>}} + */ + 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|null + */ + public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array + { + return $this->getAlbum($albumId, $imageProfile); + } + + /** + * @param list $albumIds + * @return array{albums:list>}|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> + */ + 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|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 $release + * @return array|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 $release + * @return array + */ + 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> + */ + 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 + */ + 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 + */ + 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 + */ + 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 $query + * @return array + */ + 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> + */ + 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'); + } +} diff --git a/src/Service/TidalClient.php b/src/Service/TidalClient.php new file mode 100644 index 0000000..2137775 --- /dev/null +++ b/src/Service/TidalClient.php @@ -0,0 +1,1014 @@ +>> */ + private array $searchDocumentCache = []; + /** @var array> */ + private array $searchNextCursorCache = []; + + 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 $countryCode = null, + ) { + } + + /** + * @return array{albums:array{items:list>}} + */ + public function searchAlbums(string $query, int $limit = 12, int $page = 1): array + { + $query = trim($query); + if ($query === '' || !$this->isConfigured()) { + return ['albums' => ['items' => []]]; + } + + $document = $this->fetchSearchAlbumsDocument($query, max(1, $page)); + if ($document === []) { + return ['albums' => ['items' => []]]; + } + + $included = $this->indexIncluded($document['included'] ?? []); + $items = []; + $safeLimit = max(1, $limit); + + foreach ($this->normalizeList($document['data'] ?? []) as $row) { + $tidalAlbumId = trim((string) ($row['id'] ?? '')); + if ($tidalAlbumId === '') { + continue; + } + + $albumResource = $included['albums'][$tidalAlbumId] ?? null; + if (!is_array($albumResource)) { + continue; + } + + $normalized = $this->normalizeSearchAlbum($tidalAlbumId, $albumResource, $included); + if ($normalized === null) { + continue; + } + + $items[] = $normalized; + if (count($items) >= $safeLimit) { + break; + } + } + + return ['albums' => ['items' => $items]]; + } + + /** + * @return array|null + */ + public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array + { + return $this->getAlbum($albumId, $imageProfile); + } + + /** + * @param list $albumIds + * @return array{albums:list>}|null + */ + public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array + { + if ($albumIds === []) { + return ['albums' => []]; + } + if (!$this->isConfigured()) { + return null; + } + + $seen = []; + $albums = []; + foreach ($albumIds as $albumId) { + $id = trim((string) $albumId); + if ($id === '' || isset($seen[$id])) { + continue; + } + + $seen[$id] = true; + $album = $this->getAlbum($id, $imageProfile); + if (is_array($album)) { + $albums[] = $album; + } + } + + return ['albums' => $albums]; + } + + /** + * @return list> + */ + public function getAlbumTracks(string $albumId): array + { + $album = $this->getAlbumWithTracks($albumId, 'detail'); + if (!is_array($album)) { + return []; + } + + $items = $album['tracks']['items'] ?? []; + return is_array($items) ? array_values($items) : []; + } + + public function isConfigured(): bool + { + return $this->getClientId() !== null && $this->getClientSecret() !== null; + } + + public function getProviderName(): string + { + return 'tidal'; + } + + /** + * @return array|null + */ + private function getAlbum(string $albumId, string $imageProfile): ?array + { + if (!$this->isConfigured()) { + return null; + } + + $tidalId = $this->extractTidalAlbumId($albumId); + if ($tidalId === null) { + return null; + } + + $document = $this->requestJsonApi( + '/albums/' . rawurlencode($tidalId), + [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'artists,coverArt,genres,items', + ], + self::DETAIL_CACHE_TTL_SECONDS + ); + if ($document === []) { + return null; + } + + $albumResource = $document['data'] ?? null; + if (!is_array($albumResource)) { + return null; + } + + $included = $this->indexIncluded($document['included'] ?? []); + $attributes = is_array($albumResource['attributes'] ?? null) ? $albumResource['attributes'] : []; + $title = trim((string) ($attributes['title'] ?? '')); + if ($title === '') { + return null; + } + + $artists = $this->extractArtists($albumResource, $included); + if ($artists === []) { + $artistDoc = $this->requestJsonApi( + '/albums/' . rawurlencode($tidalId) . '/relationships/artists', + [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'artists', + ], + self::DETAIL_CACHE_TTL_SECONDS + ); + $artists = $this->extractArtistsFromRelationshipDocument($artistDoc); + } + + $genres = $this->extractGenres($albumResource, $included); + if ($genres === []) { + $genresDoc = $this->requestJsonApi( + '/albums/' . rawurlencode($tidalId) . '/relationships/genres', + [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'genres', + ], + self::DETAIL_CACHE_TTL_SECONDS + ); + $genres = $this->extractGenresFromRelationshipDocument($genresDoc); + } + + $tracks = $this->extractTracks($albumResource, $included, $tidalId); + if ($tracks === []) { + $tracksDoc = $this->requestJsonApi( + '/albums/' . rawurlencode($tidalId) . '/relationships/items', + [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'items', + ], + self::DETAIL_CACHE_TTL_SECONDS + ); + $trackIncluded = $this->indexIncluded($tracksDoc['included'] ?? []); + $trackRefs = $this->normalizeList($tracksDoc['data'] ?? []); + $tracks = $this->extractTracksFromRelationship($trackRefs, $trackIncluded, $tidalId); + } + + $images = $this->extractCoverArtImages($albumResource, $included, $imageProfile); + if ($images === []) { + $coverDoc = $this->requestJsonApi( + '/albums/' . rawurlencode($tidalId) . '/relationships/coverArt', + [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'coverArt', + ], + self::DETAIL_CACHE_TTL_SECONDS + ); + $coverIncluded = $this->indexIncluded($coverDoc['included'] ?? []); + $coverProxy = [ + 'relationships' => [ + 'coverArt' => [ + 'data' => $coverDoc['data'] ?? [], + ], + ], + ]; + $images = $this->extractCoverArtImages($coverProxy, $coverIncluded, $imageProfile); + } + + $releaseDate = $this->normalizeReleaseDate((string) ($attributes['releaseDate'] ?? '')); + $externalUrl = $this->extractExternalUrl($attributes['externalLinks'] ?? [], $tidalId); + $totalTracks = max((int) ($attributes['numberOfItems'] ?? 0), count($tracks)); + + return [ + 'id' => $this->buildAlbumIdentifier($tidalId), + 'name' => $title, + 'artists' => $artists, + 'genres' => $genres, + 'release_date' => $releaseDate, + 'total_tracks' => $totalTracks, + 'images' => $images, + 'external_urls' => ['external' => $externalUrl], + 'source' => 'external', + 'tracks' => [ + 'items' => $tracks, + 'total' => count($tracks), + ], + ]; + } + + /** + * @param array $albumResource + * @param array>> $included + * @return array|null + */ + private function normalizeSearchAlbum(string $tidalAlbumId, array $albumResource, array $included): ?array + { + $attributes = is_array($albumResource['attributes'] ?? null) ? $albumResource['attributes'] : []; + $title = trim((string) ($attributes['title'] ?? '')); + if ($title === '') { + return null; + } + + return [ + 'id' => $this->buildAlbumIdentifier($tidalAlbumId), + 'name' => $title, + 'artists' => $this->extractArtists($albumResource, $included), + 'genres' => $this->extractGenres($albumResource, $included), + 'release_date' => $this->normalizeReleaseDate((string) ($attributes['releaseDate'] ?? '')), + 'total_tracks' => max(0, (int) ($attributes['numberOfItems'] ?? 0)), + 'images' => $this->extractCoverArtImages($albumResource, $included, 'list'), + 'external_urls' => ['external' => $this->extractExternalUrl($attributes['externalLinks'] ?? [], $tidalAlbumId)], + 'source' => 'external', + 'tracks' => ['items' => [], 'total' => 0], + ]; + } + + private function fetchSearchAlbumsDocument(string $query, int $page): array + { + $safePage = max(1, $page); + $cacheKey = mb_strtolower($query) . '|' . $this->getCountryCode(); + $cursor = null; + + for ($currentPage = 1; $currentPage <= $safePage; $currentPage++) { + if (isset($this->searchDocumentCache[$cacheKey][$currentPage])) { + $cursor = $this->searchNextCursorCache[$cacheKey][$currentPage] ?? null; + continue; + } + + $queryParams = [ + 'countryCode' => $this->getCountryCode(), + 'include' => 'albums', + ]; + if ($cursor !== null && $cursor !== '') { + $queryParams['page[cursor]'] = $cursor; + } + + $document = $this->requestJsonApi( + '/searchResults/' . rawurlencode($query) . '/relationships/albums', + $queryParams, + self::SEARCH_CACHE_TTL_SECONDS + ); + if ($document === []) { + return []; + } + + $this->searchDocumentCache[$cacheKey][$currentPage] = $document; + $nextCursor = $this->extractNextCursor($document); + $this->searchNextCursorCache[$cacheKey][$currentPage] = $nextCursor; + + if ($currentPage < $safePage && $nextCursor === null) { + return []; + } + $cursor = $nextCursor; + } + + return $this->searchDocumentCache[$cacheKey][$safePage] ?? []; + } + + /** + * @param array $document + */ + private function extractNextCursor(array $document): ?string + { + $nextLink = trim((string) ($document['links']['next'] ?? '')); + if ($nextLink === '') { + return null; + } + + $queryString = parse_url($nextLink, PHP_URL_QUERY); + if (!is_string($queryString) || $queryString === '') { + if (str_contains($nextLink, '?')) { + $queryString = (string) substr($nextLink, (int) strpos($nextLink, '?') + 1); + } else { + return null; + } + } + + parse_str($queryString, $params); + $candidate = null; + if (isset($params['page']) && is_array($params['page'])) { + $candidate = $params['page']['cursor'] ?? null; + } + if (!is_string($candidate) || trim($candidate) === '') { + $flat = $params['page[cursor]'] ?? null; + if (is_string($flat) && trim($flat) !== '') { + $candidate = $flat; + } + } + + $normalized = trim((string) ($candidate ?? '')); + return $normalized === '' ? null : $normalized; + } + + /** + * @param array $albumResource + * @param array>> $included + * @return list + */ + private function extractArtists(array $albumResource, array $included): array + { + $artistRefs = $this->extractRelationshipData($albumResource, 'artists'); + $artists = []; + $seen = []; + + foreach ($artistRefs as $ref) { + $artistId = trim((string) ($ref['id'] ?? '')); + if ($artistId === '') { + continue; + } + + $artistResource = $included['artists'][$artistId] ?? null; + if (!is_array($artistResource)) { + continue; + } + + $name = trim((string) ($artistResource['attributes']['name'] ?? '')); + if ($name === '' || isset($seen[$name])) { + continue; + } + + $seen[$name] = true; + $artists[] = ['name' => $name]; + } + + return $artists; + } + + /** + * @param array $document + * @return list + */ + private function extractArtistsFromRelationshipDocument(array $document): array + { + $included = $this->indexIncluded($document['included'] ?? []); + $artists = []; + $seen = []; + + foreach ($this->normalizeList($document['data'] ?? []) as $ref) { + $artistId = trim((string) ($ref['id'] ?? '')); + if ($artistId === '') { + continue; + } + + $artistResource = $included['artists'][$artistId] ?? null; + if (!is_array($artistResource)) { + continue; + } + + $name = trim((string) ($artistResource['attributes']['name'] ?? '')); + if ($name === '' || isset($seen[$name])) { + continue; + } + + $seen[$name] = true; + $artists[] = ['name' => $name]; + } + + return $artists; + } + + /** + * @param array $albumResource + * @param array>> $included + * @return list + */ + private function extractGenres(array $albumResource, array $included): array + { + foreach ($this->extractRelationshipData($albumResource, 'genres') as $ref) { + $genreId = trim((string) ($ref['id'] ?? '')); + if ($genreId === '') { + continue; + } + + $genreResource = $included['genres'][$genreId] ?? null; + if (!is_array($genreResource)) { + continue; + } + + $name = $this->toTitleCase((string) ($genreResource['attributes']['genreName'] ?? '')); + if ($name !== '') { + return [$name]; + } + } + + return []; + } + + /** + * @param array $document + * @return list + */ + private function extractGenresFromRelationshipDocument(array $document): array + { + $included = $this->indexIncluded($document['included'] ?? []); + foreach ($this->normalizeList($document['data'] ?? []) as $ref) { + $genreId = trim((string) ($ref['id'] ?? '')); + if ($genreId === '') { + continue; + } + + $genreResource = $included['genres'][$genreId] ?? null; + if (!is_array($genreResource)) { + continue; + } + + $name = $this->toTitleCase((string) ($genreResource['attributes']['genreName'] ?? '')); + if ($name !== '') { + return [$name]; + } + } + + return []; + } + + /** + * @param array $albumResource + * @param array>> $included + * @return list + */ + private function extractCoverArtImages(array $albumResource, array $included, string $imageProfile): array + { + $candidates = []; + foreach ($this->extractRelationshipData($albumResource, 'coverArt') as $ref) { + $artworkId = trim((string) ($ref['id'] ?? '')); + if ($artworkId === '') { + continue; + } + + $artworkResource = $included['artworks'][$artworkId] ?? null; + if (!is_array($artworkResource)) { + continue; + } + + $files = $this->normalizeList($artworkResource['attributes']['files'] ?? []); + foreach ($files as $file) { + $href = trim((string) ($file['href'] ?? '')); + if ($href === '') { + continue; + } + + $width = max(0, (int) ($file['meta']['width'] ?? 0)); + $candidates[] = ['url' => $href, 'width' => $width]; + } + } + + if ($candidates === []) { + return []; + } + + $selectedUrl = $this->selectCoverImageUrl($candidates, $imageProfile); + if ($selectedUrl === null) { + return []; + } + + return [['url' => $selectedUrl]]; + } + + /** + * @param list $candidates + */ + private function selectCoverImageUrl(array $candidates, string $imageProfile): ?string + { + if ($candidates === []) { + return null; + } + + if ($imageProfile !== 'list') { + usort($candidates, static fn(array $left, array $right): int => $right['width'] <=> $left['width']); + $url = trim((string) ($candidates[0]['url'] ?? '')); + return $url === '' ? null : $url; + } + + $targetWidth = 320; + usort($candidates, static function (array $left, array $right) use ($targetWidth): int { + $leftDistance = abs($left['width'] - $targetWidth); + $rightDistance = abs($right['width'] - $targetWidth); + if ($leftDistance === $rightDistance) { + return $right['width'] <=> $left['width']; + } + return $leftDistance <=> $rightDistance; + }); + + $url = trim((string) ($candidates[0]['url'] ?? '')); + return $url === '' ? null : $url; + } + + /** + * @param array $albumResource + * @param array>> $included + * @return list> + */ + private function extractTracks(array $albumResource, array $included, string $albumId): array + { + $trackRefs = $this->extractRelationshipData($albumResource, 'items'); + if ($trackRefs === []) { + return []; + } + + return $this->extractTracksFromRelationship($trackRefs, $included, $albumId); + } + + /** + * @param list> $trackRefs + * @param array>> $included + * @return list> + */ + private function extractTracksFromRelationship(array $trackRefs, array $included, string $albumId): array + { + $tracks = []; + $fallbackTrackNumber = 1; + + foreach ($trackRefs as $trackRef) { + $type = trim((string) ($trackRef['type'] ?? '')); + if ($type !== '' && $type !== 'tracks') { + continue; + } + + $trackId = trim((string) ($trackRef['id'] ?? '')); + if ($trackId === '') { + continue; + } + + $trackResource = $included['tracks'][$trackId] ?? null; + if (!is_array($trackResource)) { + continue; + } + + $attributes = is_array($trackResource['attributes'] ?? null) ? $trackResource['attributes'] : []; + $title = trim((string) ($attributes['title'] ?? '')); + if ($title === '') { + continue; + } + + $meta = is_array($trackRef['meta'] ?? null) ? $trackRef['meta'] : []; + $discNumber = max(1, (int) ($meta['volumeNumber'] ?? 1)); + $trackNumber = max(1, (int) ($meta['trackNumber'] ?? $fallbackTrackNumber)); + $durationMs = $this->parseDurationToMs((string) ($attributes['duration'] ?? '')); + + $tracks[] = [ + 'id' => $trackId !== '' ? $trackId : sha1($albumId . '|' . $discNumber . '|' . $trackNumber . '|' . $title), + 'disc_number' => $discNumber, + 'track_number' => $trackNumber, + 'name' => $title, + 'duration_ms' => $durationMs, + 'preview_url' => null, + ]; + + $fallbackTrackNumber++; + } + + return $tracks; + } + + private function parseDurationToMs(string $value): int + { + $value = trim($value); + if ($value === '') { + return 0; + } + + if (preg_match('/^\d+$/', $value) === 1) { + return max(0, (int) $value) * 1000; + } + + try { + $interval = new \DateInterval($value); + } catch (\Throwable) { + return 0; + } + + $seconds = ($interval->days !== false ? (int) $interval->days * 24 * 60 * 60 : 0) + + ((int) $interval->h * 60 * 60) + + ((int) $interval->i * 60) + + (int) $interval->s; + $milliseconds = (int) round(((float) $interval->f) * 1000); + + return max(0, ($seconds * 1000) + $milliseconds); + } + + 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; + } + } + + /** + * @param mixed $rawLinks + */ + private function extractExternalUrl(mixed $rawLinks, string $tidalAlbumId): string + { + $firstHref = ''; + $preferredTypes = [ + 'TIDAL_SHARING', + 'TIDAL_USER_SHARING', + 'TIDAL_AUTOPLAY_WEB', + ]; + + foreach ($this->normalizeList($rawLinks) as $row) { + $href = trim((string) ($row['href'] ?? '')); + if ($href === '') { + continue; + } + + if ($firstHref === '') { + $firstHref = $href; + } + + $type = mb_strtoupper(trim((string) ($row['meta']['type'] ?? ''))); + if (in_array($type, $preferredTypes, true)) { + return $href; + } + } + + if ($firstHref !== '') { + return $firstHref; + } + + return 'https://tidal.com/browse/album/' . $tidalAlbumId; + } + + /** + * @param array $resource + * @return list> + */ + private function extractRelationshipData(array $resource, string $relationshipName): array + { + $relationships = is_array($resource['relationships'] ?? null) ? $resource['relationships'] : []; + $relationship = is_array($relationships[$relationshipName] ?? null) ? $relationships[$relationshipName] : []; + return $this->normalizeList($relationship['data'] ?? []); + } + + /** + * @param mixed $value + * @return list> + */ + private function normalizeList(mixed $value): array + { + if (is_array($value) && array_is_list($value)) { + return array_values(array_filter($value, static fn($row): bool => is_array($row))); + } + + if (is_array($value) && $value !== []) { + return [array_filter($value, static fn($row) => true)]; + } + + return []; + } + + /** + * @param mixed $included + * @return array>> + */ + private function indexIncluded(mixed $included): array + { + $index = []; + foreach ($this->normalizeList($included) as $resource) { + $type = trim((string) ($resource['type'] ?? '')); + $id = trim((string) ($resource['id'] ?? '')); + if ($type === '' || $id === '') { + continue; + } + + if (!isset($index[$type])) { + $index[$type] = []; + } + $index[$type][$id] = $resource; + } + + return $index; + } + + /** + * @param array $query + * @return array + */ + private function requestJsonApi(string $path, array $query, int $cacheTtlSeconds): array + { + if (!$this->isConfigured()) { + return []; + } + + $normalizedQuery = []; + foreach ($query as $name => $value) { + if ($value === null) { + continue; + } + $stringValue = trim((string) $value); + if ($stringValue === '') { + continue; + } + $normalizedQuery[$name] = $stringValue; + } + ksort($normalizedQuery); + + $cacheKey = 'tidal_resp_' . sha1($path . '|' . (string) json_encode($normalizedQuery)); + + $payload = $this->cache->get($cacheKey, function (ItemInterface $item) use ($path, $normalizedQuery, $cacheTtlSeconds) { + $item->expiresAfter($cacheTtlSeconds); + return $this->sendJsonApiRequest($path, $normalizedQuery); + }); + + return is_array($payload) ? $payload : []; + } + + /** + * @param array $query + * @return array + */ + private function sendJsonApiRequest(string $path, array $query): array + { + $token = $this->getAccessToken(); + if ($token === null) { + return []; + } + + $response = $this->httpClient->request('GET', self::API_BASE_URL . $path, [ + 'query' => $query, + 'headers' => $this->buildApiHeaders($token), + ]); + + if ($response->getStatusCode() === 401) { + $token = $this->getAccessToken(true); + if ($token === null) { + return []; + } + $response = $this->httpClient->request('GET', self::API_BASE_URL . $path, [ + 'query' => $query, + 'headers' => $this->buildApiHeaders($token), + ]); + } + + $status = $response->getStatusCode(); + if ($status < 200 || $status >= 300) { + return []; + } + + $decoded = json_decode((string) $response->getBody(), true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @return array + */ + private function buildApiHeaders(string $accessToken): array + { + return [ + 'Accept' => 'application/vnd.api+json', + 'Authorization' => 'Bearer ' . $accessToken, + 'User-Agent' => self::DEFAULT_USER_AGENT, + ]; + } + + private function getAccessToken(bool $forceRefresh = false): ?string + { + if (!$this->isConfigured()) { + return null; + } + + $clientId = $this->getClientId(); + $clientSecret = $this->getClientSecret(); + if ($clientId === null || $clientSecret === null) { + return null; + } + + $cacheKey = 'tidal_auth_cc_' . sha1($clientId . '|' . $clientSecret); + if ($forceRefresh) { + $this->cache->delete($cacheKey); + } + + $token = $this->cache->get($cacheKey, function (ItemInterface $item) use ($clientId, $clientSecret) { + try { + $response = $this->httpClient->request('POST', self::AUTH_TOKEN_URL, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'User-Agent' => self::DEFAULT_USER_AGENT, + ], + 'form_params' => [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'grant_type' => 'client_credentials', + ], + ]); + } catch (\Throwable) { + $item->expiresAfter(self::TOKEN_MIN_TTL_SECONDS); + return ''; + } + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + $item->expiresAfter(self::TOKEN_MIN_TTL_SECONDS); + return ''; + } + + $decoded = json_decode((string) $response->getBody(), true); + $accessToken = trim((string) ($decoded['access_token'] ?? '')); + if ($accessToken === '') { + $item->expiresAfter(self::TOKEN_MIN_TTL_SECONDS); + return ''; + } + + $expiresIn = (int) ($decoded['expires_in'] ?? 3600); + $ttl = max(self::TOKEN_MIN_TTL_SECONDS, $expiresIn - self::TOKEN_REFRESH_HEADROOM_SECONDS); + $item->expiresAfter($ttl); + return $accessToken; + }); + + if (!is_string($token)) { + return null; + } + + $token = trim($token); + return $token === '' ? null : $token; + } + + private function getClientId(): ?string + { + if ($this->clientIdResolved) { + return $this->resolvedClientId; + } + $this->clientIdResolved = true; + + $this->resolvedClientId = $this->resolveCredential($this->clientId, self::SETTING_CLIENT_ID); + return $this->resolvedClientId; + } + + private function getClientSecret(): ?string + { + if ($this->clientSecretResolved) { + return $this->resolvedClientSecret; + } + $this->clientSecretResolved = true; + + $this->resolvedClientSecret = $this->resolveCredential($this->clientSecret, self::SETTING_CLIENT_SECRET); + return $this->resolvedClientSecret; + } + + private function getCountryCode(): string + { + if ($this->countryCodeResolved) { + return $this->resolvedCountryCode; + } + $this->countryCodeResolved = true; + + $envValue = $this->normalizeCountryCode($this->countryCode); + if ($envValue !== null) { + $this->resolvedCountryCode = $envValue; + return $this->resolvedCountryCode; + } + + $stored = $this->normalizeCountryCode($this->settings->getValue(self::SETTING_COUNTRY_CODE)); + $this->resolvedCountryCode = $stored ?? self::DEFAULT_COUNTRY_CODE; + return $this->resolvedCountryCode; + } + + private function normalizeCountryCode(?string $value): ?string + { + $normalized = mb_strtoupper(trim((string) ($value ?? ''))); + if ($normalized === '' || preg_match('/^[A-Z]{2}$/', $normalized) !== 1) { + return null; + } + + return $normalized; + } + + private function resolveCredential(?string $envValue, string $settingName): ?string + { + $resolvedEnv = trim((string) ($envValue ?? '')); + if ($resolvedEnv !== '') { + return $resolvedEnv; + } + + $stored = trim((string) ($this->settings->getValue($settingName) ?? '')); + return $stored === '' ? null : $stored; + } + + private function buildAlbumIdentifier(string $tidalAlbumId): string + { + return 'tidal:' . trim($tidalAlbumId); + } + + private function extractTidalAlbumId(string $albumId): ?string + { + $normalized = trim($albumId); + if ($normalized === '') { + return null; + } + + if (str_starts_with($normalized, 'tidal:')) { + $normalized = trim(substr($normalized, 6)); + } + + if ($normalized === '' || str_contains($normalized, '/')) { + return null; + } + + return $normalized; + } + + private function toTitleCase(string $value): string + { + $normalized = trim($value); + if ($normalized === '') { + return ''; + } + + return mb_convert_case(mb_strtolower($normalized), MB_CASE_TITLE, 'UTF-8'); + } +} + diff --git a/templates/account/dashboard.html.twig b/templates/account/dashboard.html.twig index c91d20d..ed46fa2 100644 --- a/templates/account/dashboard.html.twig +++ b/templates/account/dashboard.html.twig @@ -71,18 +71,27 @@

Your albums

{% for a in userAlbums %} + {% set publicId = a.externalId %}
- +
+ {% if publicId %} + {{ a.name }} + {% else %} + {{ a.name }} + {% endif %} +
{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}
-
- Edit -
- - -
-
+ {% if publicId %} +
+ Edit +
+ + +
+
+ {% endif %}
{% else %}
You haven't created any albums yet.
@@ -93,5 +102,3 @@
{% endblock %} - - diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index b56e6ab..554dd09 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -4,13 +4,58 @@

Site Settings

{% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} {% for msg in app.flashes('info') %}
{{ msg }}
{% endfor %} + {% for msg in app.flashes('warning') %}
{{ msg }}
{% endfor %} + {% for msg in app.flashes('danger') %}
{{ msg }}
{% endfor %}
- {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} + {{ form_start(form, {attr: {id: 'site-settings-form', class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
+ {{ form_label(form.EXTERNAL_ALBUM_PROVIDER) }} + {{ form_widget(form.EXTERNAL_ALBUM_PROVIDER, {attr: {class: 'form-select', 'data-initial-provider': storedProvider}}) }} +
Switching provider clears album/review catalog data to prevent mixed-source duplicates.
+
Stored provider: {{ providerLabelStored }} • Active provider: {{ providerLabelActive }} • Effective now: {{ providerLabelEffective }}
+ {% if envOverrideProvider %} +
Environment override EXTERNAL_ALBUM_PROVIDER={{ envOverrideProvider }} is active and takes priority over saved settings.
+ {% endif %} +
{{ form_label(form.LASTFM_API_KEY) }} {{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }} +
Environment override: LASTFM_API_KEY.
+
+
+
+ {{ form_label(form.MUSICBRAINZ_CLIENT_ID) }} + {{ form_widget(form.MUSICBRAINZ_CLIENT_ID, {attr: {class: 'form-control'}}) }} +
Optional. Not required for album search. Create OAuth app credentials at MusicBrainz account applications. Environment override: MUSICBRAINZ_CLIENT_ID.
+
+
+ {{ form_label(form.MUSICBRAINZ_CLIENT_SECRET) }} + {{ form_widget(form.MUSICBRAINZ_CLIENT_SECRET, {attr: {class: 'form-control'}}) }} +
Optional. Not required for album search. Pairs with client ID from MusicBrainz account applications. Environment override: MUSICBRAINZ_CLIENT_SECRET.
+
+
+ {{ form_label(form.MUSICBRAINZ_ACCESS_TOKEN) }} + {{ form_widget(form.MUSICBRAINZ_ACCESS_TOKEN, {attr: {class: 'form-control'}}) }} +
Optional. Leave empty for normal search.
+
If you need authenticated MusicBrainz features, create an OAuth app under MusicBrainz account applications, authorize it, then paste the returned access token here. Environment override: MUSICBRAINZ_ACCESS_TOKEN.
+
+
+
+ {{ form_label(form.TIDAL_CLIENT_ID) }} + {{ form_widget(form.TIDAL_CLIENT_ID, {attr: {class: 'form-control'}}) }} +
Required for TIDAL provider. Create credentials at developer.tidal.com. Environment override: TIDAL_CLIENT_ID.
+
+
+ {{ form_label(form.TIDAL_CLIENT_SECRET) }} + {{ form_widget(form.TIDAL_CLIENT_SECRET, {attr: {class: 'form-control'}}) }} +
Required for TIDAL provider. Environment override: TIDAL_CLIENT_SECRET.
+
+
+ {{ form_label(form.TIDAL_COUNTRY_CODE) }} + {{ form_widget(form.TIDAL_COUNTRY_CODE, {attr: {class: 'form-control', placeholder: 'US'}}) }} +
Optional ISO 3166-1 alpha-2 country code used for catalog availability (defaults to US). Environment override: TIDAL_COUNTRY_CODE.
{% set registrationToggleAttrs = {class: 'form-check-input'} %} @@ -88,3 +133,31 @@
{% endblock %} +{% block javascripts %} + {{ parent() }} + +{% endblock %} diff --git a/templates/admin/site_dashboard.html.twig b/templates/admin/site_dashboard.html.twig index a1efca9..f1bfe63 100644 --- a/templates/admin/site_dashboard.html.twig +++ b/templates/admin/site_dashboard.html.twig @@ -6,7 +6,7 @@
@@ -68,7 +68,7 @@

Latest albums (50)

{% for a in recentAlbums %} - {% set publicId = a.source == 'user' ? a.localId : a.externalId %} + {% set publicId = a.externalId %}
@@ -77,11 +77,11 @@ {% else %} {{ a.name }} {% endif %} - {% if a.source == 'user' %}User{% else %}Last.fm{% endif %} + {% if a.source == 'user' %}User{% else %}{{ externalProviderLabel }}{% endif %}
{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}
- {% if publicId %} + {% if publicId and a.source == 'user' %}
@@ -97,4 +97,3 @@
{% endblock %} - diff --git a/templates/album/_search_card.html.twig b/templates/album/_search_card.html.twig new file mode 100644 index 0000000..144e3df --- /dev/null +++ b/templates/album/_search_card.html.twig @@ -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 %} + +
+
+ {% 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 %} + + {{ album.name }} cover + + {% elseif needs_hydration %} + +
+ Loading cover... +
+
+ {% endif %} +
+
{{ album.name }}
+

{{ album.artists|map(a => a.name)|join(', ') }}

+

+ {% 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 %} +

+

+ {% if album.genres is defined and album.genres is not empty %} + Genre: {{ album.genres|join(', ') }} + {% endif %} +

+ {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} +

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

+
+ {% if album.external_urls.external is defined and album.external_urls.external and not needs_hydration %} + Open Source + {% endif %} + Reviews + {% 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 %} + + + + + {% endif %} + {% endif %} + {% if needs_hydration %} +

Loading details...

+ {% endif %} +
+
+
+
diff --git a/templates/album/edit.html.twig b/templates/album/edit.html.twig index 5681d82..064bb0d 100644 --- a/templates/album/edit.html.twig +++ b/templates/album/edit.html.twig @@ -3,16 +3,16 @@ {% block body %}

Edit album

{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} + {{ form_errors(form) }}
{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}
-
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}
+
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}
+
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}
+
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}
+
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}
+
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}
+
{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}
Cancel {{ form_end(form) }} {% endblock %} - diff --git a/templates/album/editor.html.twig b/templates/album/editor.html.twig new file mode 100644 index 0000000..54fe3e2 --- /dev/null +++ b/templates/album/editor.html.twig @@ -0,0 +1,261 @@ +{% extends 'base.html.twig' %} +{% block title %}{{ isEdit ? 'Edit Album' : 'Create Album' }}{% endblock %} +{% block body %} +

{{ isEdit ? 'Edit album' : 'Create album' }}

+ + + {{ form_start(form, {attr: {class: 'vstack gap-4', novalidate: 'novalidate'}}) }} + {{ form_errors(form) }} + +
+
+
+
+
+ {{ form_widget(form.coverUpload, {attr: {class: 'd-none', accept: 'image/*'}}) }} + {% set has_existing_cover = existingCoverUrl is defined and existingCoverUrl %} + + {{ form_errors(form.coverUpload) }} +
+
+ {{ form_label(form.name) }} + {{ form_widget(form.name, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.name) }} +
+
+ {{ form_label(form.artistsCsv) }} + {{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.artistsCsv) }} +
+
+ {{ form_label(form.releaseDate) }} + {{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.releaseDate) }} +
+
+ {{ form_label(form.genresCsv) }} + {{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.genresCsv) }} +
+
+ {{ form_label(form.externalUrl) }} + {{ form_widget(form.externalUrl, {attr: {class: 'form-control', placeholder: 'https://...'}}) }} + {{ form_errors(form.externalUrl) }} +
+
+ {{ form_widget(form.totalTracks) }} + {{ form_errors(form.totalTracks) }} +
+ {% if isEdit and albumOwner %} + + Owner: {{ albumOwner.displayName ?? albumOwner.userIdentifier }} + + {% endif %} +
+
+
+ +
+
+
+
+

Tracklist

+ {{ trackRows|filter(t => t.title is not empty)|length }} tracks +
+
+ + + + + + + + + + + + {% for track in trackRows %} + + + + + + + + {% endfor %} + +
Disc#TitleDuration
+ + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
+ + Cancel +
+ {{ form_end(form) }} + + + + +{% endblock %} diff --git a/templates/album/new.html.twig b/templates/album/new.html.twig index 0c865d6..fd715f5 100644 --- a/templates/album/new.html.twig +++ b/templates/album/new.html.twig @@ -3,15 +3,15 @@ {% block body %}

Create album

{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} + {{ form_errors(form) }}
{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}
-
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}
-
{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}
+
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.artistsCsv) }}
+
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}{{ form_errors(form.genresCsv) }}
+
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}{{ form_errors(form.releaseDate) }}
+
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}{{ form_errors(form.totalTracks) }}
+
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}{{ form_errors(form.coverUpload) }}
+
{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}{{ form_errors(form.externalUrl) }}
{{ form_end(form) }} {% endblock %} - diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig index eb6d1e8..858b80a 100644 --- a/templates/album/search.html.twig +++ b/templates/album/search.html.twig @@ -8,6 +8,7 @@ {% set year_to_value = year_to|default('') %} {% set genre_value = genre|default('') %} {% 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 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 %} @@ -48,12 +49,11 @@
-
- +
+
+ + +
@@ -65,10 +65,10 @@
- +
- +
@@ -83,12 +83,11 @@
-
- +
+
+ + +
@@ -100,10 +99,10 @@
- +
- +
@@ -111,49 +110,659 @@ {% if albums is defined and albums|length > 0 %} -
+
{% for album in albums %} -
-
- {% set image = (album.images[0] ?? album.images[1] ?? null) %} - {% if image %} - - {{ album.name }} cover - - {% endif %} -
-
{{ album.name }}
-

{{ album.artists|map(a => a.name)|join(', ') }}

-

- Released {{ album.release_date }} • {{ album.total_tracks }} tracks - {% if album.genres is defined and album.genres is not empty %} -
Genre: {{ album.genres|join(', ') }} - {% endif %} -

- {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} -

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

-
- {% if album.external_urls.external is defined and album.external_urls.external %} - Open Source - {% endif %} - Reviews - {% if app.user and (album.source is not defined or album.source != 'user') %} - {% if savedIds is defined and (album.id in savedIds) %} - {# Saved indicator intentionally omitted to reduce noise #} - {% else %} -
- - -
- {% endif %} - {% endif %} -
-
-
-
+ {% include 'album/_search_card.html.twig' with { album: album, stats: stats, savedIds: savedIds } %} {% endfor %}
+ {% if has_search %} +
+ +
+ {% endif %} {% elseif query or album or artist or genre or year_from or year_to %}

No albums found.

{% 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') %} + + {% endif %} {% endblock %} diff --git a/templates/api/docs.html.twig b/templates/api/docs.html.twig deleted file mode 100644 index a2d5e0c..0000000 --- a/templates/api/docs.html.twig +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - Tonehaus API Docs - - - - -
- - - - - diff --git a/templates/base.html.twig b/templates/base.html.twig index e77d1f1..266b7f0 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -46,4 +46,3 @@ - diff --git a/templates/review/show.html.twig b/templates/review/show.html.twig index 121ced8..55e0213 100644 --- a/templates/review/show.html.twig +++ b/templates/review/show.html.twig @@ -20,7 +20,9 @@

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

{{ review.content|nl2br }}

@@ -37,4 +39,3 @@ {% endif %} {% endblock %} - diff --git a/tests/Fixtures/jwt/private-test.pem b/tests/Fixtures/jwt/private-test.pem new file mode 100644 index 0000000..bbb120c --- /dev/null +++ b/tests/Fixtures/jwt/private-test.pem @@ -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----- diff --git a/tests/Fixtures/jwt/public-test.pem b/tests/Fixtures/jwt/public-test.pem new file mode 100644 index 0000000..a35c61e --- /dev/null +++ b/tests/Fixtures/jwt/public-test.pem @@ -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----- diff --git a/tests/Functional/Api/ApiHardeningFunctionalTest.php b/tests/Functional/Api/ApiHardeningFunctionalTest.php new file mode 100644 index 0000000..cf8609e --- /dev/null +++ b/tests/Functional/Api/ApiHardeningFunctionalTest.php @@ -0,0 +1,338 @@ +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 + */ + 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; + } +} diff --git a/tests/Functional/Web/AlbumWorkflowRegressionTest.php b/tests/Functional/Web/AlbumWorkflowRegressionTest.php new file mode 100644 index 0000000..95e1cf0 --- /dev/null +++ b/tests/Functional/Web/AlbumWorkflowRegressionTest.php @@ -0,0 +1,187 @@ +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 $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; + } +} diff --git a/tests/Unit/Service/ExternalAlbumClientTest.php b/tests/Unit/Service/ExternalAlbumClientTest.php new file mode 100644 index 0000000..c786d75 --- /dev/null +++ b/tests/Unit/Service/ExternalAlbumClientTest.php @@ -0,0 +1,114 @@ +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()); + } +} diff --git a/tests/Unit/Service/MusicBrainzClientTest.php b/tests/Unit/Service/MusicBrainzClientTest.php new file mode 100644 index 0000000..4724a45 --- /dev/null +++ b/tests/Unit/Service/MusicBrainzClientTest.php @@ -0,0 +1,65 @@ +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 + ); + } +} + diff --git a/tests/Unit/Service/TidalClientTest.php b/tests/Unit/Service/TidalClientTest.php new file mode 100644 index 0000000..b798db7 --- /dev/null +++ b/tests/Unit/Service/TidalClientTest.php @@ -0,0 +1,234 @@ +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('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); + } +} + diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ef5c9c4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,27 @@ +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');