Compare commits
16
Commits
main
...
75756ac6ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75756ac6ec | ||
|
|
72a99a5bcd | ||
|
|
c1b1c2f372 | ||
|
|
56dea097d6 | ||
|
|
391ecf1732 | ||
|
|
4ae7a44881 | ||
|
|
fa54cb4167 | ||
|
|
f109c933c1 | ||
|
|
d52eb6bd81 | ||
|
|
f77f3a9e40 | ||
|
|
336dcc4d3a | ||
|
|
54b1908793 | ||
|
|
dda9ff06b5 | ||
|
|
796acaa9c0 | ||
|
|
3879c6c312 | ||
|
|
da9af888c0 |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
.DS_Store
|
||||
.cursor
|
||||
.env.local
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.idea
|
||||
.public
|
||||
backup_manifests/
|
||||
docs/
|
||||
tests/
|
||||
var/
|
||||
# Symfony cache/logs generated inside the container should not come from the host
|
||||
var/cache/
|
||||
var/log/
|
||||
|
||||
# Uploaded files stay on the host, not baked into images
|
||||
public/uploads/
|
||||
|
||||
+30
-15
@@ -1,17 +1,32 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changeme # Arbitrary secret. Ideally a long random string.
|
||||
APP_ALLOW_REGISTRATION=1 #
|
||||
DEFAULT_URI=http://localhost:8000 # Should match external URI of application.
|
||||
DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||
#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite
|
||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits.
|
||||
# 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
|
||||
|
||||
# POSTGRES_DB=
|
||||
# POSTGRES_USER=
|
||||
# POSTGRES_PASSWORD=
|
||||
APP_ENV=prod
|
||||
APP_SECRET=changeme
|
||||
# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
|
||||
DEFAULT_URI=http://localhost:8085
|
||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by external APIs.
|
||||
# API benchmark toggle:
|
||||
# 1 = keep API image hydration/fallback calls enabled.
|
||||
# 0 = disable API-side image hydration for raw upstream timing.
|
||||
API_IMAGE_HYDRATION_ENABLED=1
|
||||
JWT_PASSPHRASE=changeme
|
||||
|
||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD=password
|
||||
DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
|
||||
# DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||
+90
-58
@@ -1,20 +1,74 @@
|
||||
name: CI - Build Tonehaus Docker image
|
||||
name: CI (Gitea)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: tonehaus
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret
|
||||
DATABASE_DRIVER: sqlite
|
||||
DATABASE_SQLITE_PATH: ${{ gitea.workspace }}/var/data/database.test.sqlite
|
||||
DOCKERFILE: docker/php/Dockerfile
|
||||
BUILD_TARGET: prod
|
||||
PLATFORMS: linux/amd64
|
||||
IMAGE_NAME: tonehaus-app
|
||||
|
||||
jobs:
|
||||
tonehaus-ci-build:
|
||||
php-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||
coverage: none
|
||||
ini-values: memory_limit=512M
|
||||
tools: composer:v2
|
||||
|
||||
- name: Validate Composer manifest
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/composer/files
|
||||
~/.cache/composer/vcs
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Prepare SQLite database
|
||||
run: |
|
||||
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||
touch "$DATABASE_SQLITE_PATH"
|
||||
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --colors=always
|
||||
|
||||
docker-image:
|
||||
needs: php-tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -22,62 +76,40 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
run: |
|
||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-unknown}}"
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Build prod image (local)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
target: ${{ env.BUILD_TARGET }}
|
||||
tags: ${{ env.IMAGE_NAME }}:ci
|
||||
load: true
|
||||
|
||||
- name: Optional registry login
|
||||
- name: Verify baked APP_ENV
|
||||
run: docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test "$APP_ENV" = "prod"'
|
||||
|
||||
- name: Verify Symfony artifacts exist
|
||||
run: |
|
||||
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/public/index.php'
|
||||
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/bin/console'
|
||||
|
||||
- name: Smoke-test entrypoint & migrations
|
||||
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh ${{ env.IMAGE_NAME }}:ci true
|
||||
|
||||
- name: Login to registry
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Docker Build
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' }}
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
|
||||
run: |
|
||||
TAG_SHA=${{ steps.meta.outputs.short_sha }}
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--file "$DOCKERFILE" \
|
||||
--target "$BUILD_TARGET" \
|
||||
--build-arg APP_ENV=prod \
|
||||
--tag "$REGISTRY/$REGISTRY_IMAGE:$TAG_SHA" \
|
||||
--tag "$REGISTRY/$REGISTRY_IMAGE:ci" \
|
||||
--push \
|
||||
.
|
||||
|
||||
# - name: Build single-arch images for artifacts (no registry)
|
||||
# if: ${{ env.REGISTRY == '' }}
|
||||
# run: |
|
||||
# TAG_SHA=${{ steps.meta.outputs.short_sha }}
|
||||
# for P in $PLATFORMS; do \
|
||||
# ARCH=${P#linux/}; \
|
||||
# docker buildx build \
|
||||
# --platform "$P" \
|
||||
# --file "$DOCKERFILE" \
|
||||
# --target "$BUILD_TARGET" \
|
||||
# --build-arg APP_ENV=prod \
|
||||
# --output type=docker \
|
||||
# --tag "$IMAGE_NAME:$TAG_SHA-$ARCH" \
|
||||
# . ; \
|
||||
# docker save "$IMAGE_NAME:$TAG_SHA-$ARCH" -o "tonehaus-image-$ARCH.tar" ; \
|
||||
# done
|
||||
|
||||
## Artifacts not configured yet..
|
||||
# - name: Upload artifacts
|
||||
# if: ${{ env.REGISTRY == '' }}
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: tonehaus-images
|
||||
# path: |
|
||||
# tonehaus-image-amd64.tar
|
||||
- name: Push prod image
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
target: ${{ env.BUILD_TARGET }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:ci
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret
|
||||
DATABASE_DRIVER: sqlite
|
||||
DATABASE_SQLITE_PATH: ${{ github.workspace }}/var/data/database.test.sqlite
|
||||
|
||||
jobs:
|
||||
php-tests:
|
||||
name: PHPUnit + migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
id: setup-php
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||
coverage: none
|
||||
ini-values: memory_limit=512M
|
||||
tools: composer:v2
|
||||
|
||||
- name: Validate Composer manifest
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/composer/files
|
||||
~/.cache/composer/vcs
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Cache vendor directory
|
||||
id: cache-vendor
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
env:
|
||||
COMPOSER_NO_INTERACTION: 1
|
||||
COMPOSER_MEMORY_LIMIT: -1
|
||||
|
||||
- name: Prepare SQLite database
|
||||
run: |
|
||||
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||
touch "$DATABASE_SQLITE_PATH"
|
||||
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --colors=always
|
||||
|
||||
docker-image:
|
||||
name: Build production image
|
||||
needs: php-tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build prod image
|
||||
id: build-prod
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/php/Dockerfile
|
||||
target: prod
|
||||
load: true
|
||||
push: false
|
||||
tags: tonehaus-app:ci
|
||||
|
||||
- name: Verify baked APP_ENV
|
||||
run: docker run --rm --entrypoint sh tonehaus-app:ci -c 'test "$APP_ENV" = "prod"'
|
||||
|
||||
- name: Verify Symfony artifacts exist
|
||||
run: |
|
||||
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/public/index.php'
|
||||
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/bin/console'
|
||||
|
||||
- name: Smoke-test entrypoint & migrations
|
||||
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Tag latest image
|
||||
if: github.event_name == 'push'
|
||||
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
|
||||
|
||||
- name: Push latest image
|
||||
if: github.event_name == 'push'
|
||||
run: docker push $IMAGE_NAME:latest
|
||||
|
||||
+26
@@ -79,3 +79,29 @@ composer.phar
|
||||
.env
|
||||
*.pdf
|
||||
*.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 ###
|
||||
Generated
-10
@@ -24,10 +24,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
||||
@@ -38,7 +34,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
||||
@@ -69,13 +64,11 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
||||
@@ -87,7 +80,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||
@@ -120,14 +112,12 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
-10
@@ -12,11 +12,9 @@
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
@@ -31,11 +29,8 @@
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
@@ -43,7 +38,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
@@ -88,7 +82,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
||||
@@ -112,7 +105,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
@@ -120,7 +112,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
@@ -129,7 +120,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Tonehaus — Music Ratings
|
||||
# Tonehaus — Music Ratings (Symfony 7)
|
||||
|
||||
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||
Discover albums via Last.fm, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||
|
||||
## Quick start
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
1) Start the stack
|
||||
|
||||
@@ -10,69 +10,72 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
2) Create the database schema
|
||||
2) Open the app
|
||||
|
||||
- App URL: `http://localhost:8085`
|
||||
- Health: `http://localhost:8085/healthz`
|
||||
|
||||
3) Create your first admin
|
||||
|
||||
- Sign Up through Tonehaus
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
3) Promote an admin (to access Site Settings)
|
||||
4) Configure Last.fm
|
||||
|
||||
- Go to `http://localhost:8085/admin/settings` and enter your Last.fm API key, or
|
||||
- Set env var in `.env`: `LASTFM_API_KEY`
|
||||
|
||||
5) (Optional) Seed demo data
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
4) Configure Spotify API credentials (admin only)
|
||||
|
||||
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
|
||||
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
|
||||
|
||||
5) Visit `http://localhost:8000` to search for albums.
|
||||
|
||||
6) (Optional) Seed demo data
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
## Database driver
|
||||
|
||||
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
||||
- Set `DATABASE_DRIVER=sqlite` to run against a self-contained SQLite file stored at `var/data/database.sqlite`.
|
||||
- When `DATABASE_DRIVER=sqlite`, the `DATABASE_URL` env var is ignored. Doctrine will automatically create and use the SQLite file; override the default location with `DATABASE_SQLITE_PATH` if needed.
|
||||
Notes:
|
||||
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
|
||||
- To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
|
||||
|
||||
## Features
|
||||
|
||||
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
|
||||
- Album page with details, reviews list, and inline new review (logged in)
|
||||
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
|
||||
- Role-based access: authors manage their own reviews, admins can manage any
|
||||
- Admin Site Settings to manage Spotify credentials in DB
|
||||
- User Dashboard to update profile and change password (requires current password)
|
||||
- Light/Dark theme toggle in Settings (cookie-backed)
|
||||
- Bootstrap UI
|
||||
- Last.fm-backed album search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count)
|
||||
- REST API under `/api/v1` for albums/reviews with JSON responses and proper HTTP verbs
|
||||
- JWT authentication for protected API endpoints (`/api/v1/auth/login`) via LexikJWTAuthenticationBundle
|
||||
- REST response handling via FOSRestBundle
|
||||
- Automatic OpenAPI documentation at `/api/docs` and `/api/v1/docs` (JSON at `/api/docs.json` and `/api/v1/docs.json`)
|
||||
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
|
||||
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
||||
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
||||
- Admin Site Settings: manage Last.fm API key and public registration toggle
|
||||
- User Dashboard: profile updates and password change
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
|
||||
## Rate limiting & caching
|
||||
## Documentation
|
||||
|
||||
- Server-side Client Credentials; access tokens are cached.
|
||||
- Setup and configuration: `docs/setup.md`
|
||||
- Feature overview: `docs/features.md`
|
||||
- Authentication and users: `docs/auth-and-users.md`
|
||||
- Last.fm integration: `docs/lastfm-integration.md`
|
||||
- Reviews and albums: `docs/reviews-and-albums.md`
|
||||
- Admin & site settings: `docs/admin-and-settings.md`
|
||||
- Troubleshooting: `docs/troubleshooting.md`
|
||||
- Architecture: `docs/architecture.md`
|
||||
- Deployment: `docs/deployment.md`
|
||||
|
||||
## Docs
|
||||
## Environment overview
|
||||
|
||||
See `/docs` for how-tos and deeper notes:
|
||||
|
||||
- Setup and configuration: `docs/01-setup.md`
|
||||
- Features and UX: `docs/02-features.md`
|
||||
- Authentication and users: `docs/03-auth-and-users.md`
|
||||
- Spotify integration: `docs/04-spotify-integration.md`
|
||||
- Reviews and albums: `docs/05-reviews-and-albums.md`
|
||||
- Admin & site settings: `docs/06-admin-and-settings.md`
|
||||
- Troubleshooting: `docs/07-troubleshooting.md`
|
||||
- `APP_ENV` (dev|prod), `APP_SECRET` (required)
|
||||
- `LASTFM_API_KEY`
|
||||
- `JWT_PASSPHRASE` (used for JWT private key encryption)
|
||||
- `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
|
||||
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
|
||||
- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
|
||||
- `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Intentionally empty importmap entrypoint.
|
||||
+8
-1
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"name": "tonehaus/tonehaus",
|
||||
"description": "Tonehaus — discover albums, manage reviews, and administer site settings with a Symfony 7 stack.",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
@@ -11,6 +13,10 @@
|
||||
"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.*",
|
||||
@@ -34,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": {
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
|
||||
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
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],
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dynamic Doctrine DBAL configuration.
|
||||
*
|
||||
* This file complements `config/packages/doctrine.yaml`, not replacing it!:
|
||||
* - YAML handles ORM mappings, naming strategy, caches, and env-specific tweaks.
|
||||
* - This PHP config focuses on DBAL and runtime driver selection.
|
||||
*
|
||||
* Behavior:
|
||||
* - Chooses the database driver from `DATABASE_DRIVER` (`postgres` or `sqlite`).
|
||||
* - For Postgres:
|
||||
* - Uses `DATABASE_URL` (e.g. `postgresql://user:pass@host:5432/dbname`).
|
||||
* - Pins `serverVersion` (currently `16`) to avoid auto-detection issues.
|
||||
* - For SQLite:
|
||||
* - Uses `DATABASE_SQLITE_PATH` when provided.
|
||||
* - Otherwise, defaults to `<projectDir>/var/data/database.sqlite`, creating the
|
||||
* directory and file if they do not already exist. (Recommended)
|
||||
*
|
||||
* This split keeps the mapping/caching config in YAML while allowing
|
||||
* DBAL to adapt between Docker/postgres and local sqlite setups.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Config\DoctrineConfig;
|
||||
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
|
||||
|
||||
return static function (DoctrineConfig $doctrine): void {
|
||||
// Normalize DATABASE_DRIVER and validate allowed values up front.
|
||||
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
|
||||
$supportedDrivers = ['postgres', 'sqlite'];
|
||||
|
||||
@@ -17,20 +40,23 @@ return static function (DoctrineConfig $doctrine): void {
|
||||
));
|
||||
}
|
||||
|
||||
// Configure the default DBAL connection.
|
||||
$dbal = $doctrine->dbal();
|
||||
$dbal->defaultConnection('default');
|
||||
|
||||
$connection = $dbal->connection('default');
|
||||
$connection->profilingCollectBacktrace('%kernel.debug%');
|
||||
$connection->profilingCollectBacktrace(param('kernel.debug'));
|
||||
$connection->useSavepoints(true);
|
||||
|
||||
if ('sqlite' === $driver) {
|
||||
// SQLite: use a file-backed database by default.
|
||||
$connection->driver('pdo_sqlite');
|
||||
|
||||
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
||||
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
||||
|
||||
if ($hasCustomPath) {
|
||||
// Allow explicit database path via env overrides.
|
||||
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||
} else {
|
||||
$projectDir = dirname(__DIR__, 2);
|
||||
@@ -49,7 +75,9 @@ return static function (DoctrineConfig $doctrine): void {
|
||||
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||
}
|
||||
} else {
|
||||
// Postgres (or other server-based driver) via DATABASE_URL.
|
||||
$connection->url('%env(resolve:DATABASE_URL)%');
|
||||
// Keep the server version explicit so Doctrine does not need network calls to detect it.
|
||||
$connection->serverVersion('16');
|
||||
$connection->charset('utf8');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
fos_rest:
|
||||
format_listener:
|
||||
rules:
|
||||
- { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] }
|
||||
- { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] }
|
||||
body_listener: true
|
||||
view:
|
||||
view_response_listener: force
|
||||
formats:
|
||||
json: true
|
||||
@@ -1,6 +1,8 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
|
||||
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
|
||||
public_key: '%kernel.project_dir%/config/jwt/public.pem'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 3600
|
||||
@@ -42,7 +42,7 @@ when@prod:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
action_level: info
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
nelmio_api_doc:
|
||||
documentation:
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Tonehaus API
|
||||
description: REST API for albums, reviews, ingestion, and JWT authentication.
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /
|
||||
description: Current application host
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
html_config:
|
||||
assets_mode: cdn
|
||||
swagger_ui_config:
|
||||
docExpansion: list
|
||||
defaultModelsExpandDepth: 1
|
||||
displayRequestDuration: true
|
||||
filter: true
|
||||
areas:
|
||||
default:
|
||||
path_patterns:
|
||||
- ^/api(?!/docs|/v1/docs)
|
||||
@@ -15,6 +15,21 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api_login:
|
||||
pattern: ^/api/v1/auth/login$
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
json_login:
|
||||
check_path: /api/v1/auth/login
|
||||
username_path: email
|
||||
password_path: password
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
jwt: ~
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -48,6 +63,11 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/reviews$, roles: ROLE_USER, methods: [POST] }
|
||||
- { path: ^/api/v1/reviews/[0-9]+$, roles: ROLE_USER, methods: [PUT, PATCH, DELETE] }
|
||||
- { path: ^/api/v1/albums/ingest$, roles: ROLE_USER, methods: [POST] }
|
||||
- { path: ^/api, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin/settings, roles: ROLE_ADMIN }
|
||||
- { path: ^/admin/users, roles: ROLE_MODERATOR }
|
||||
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%kernel.project_dir%/tests/Fixtures/jwt/private-test.pem'
|
||||
public_key: '%kernel.project_dir%/tests/Fixtures/jwt/public-test.pem'
|
||||
pass_phrase: 'test'
|
||||
token_ttl: 3600
|
||||
@@ -0,0 +1,27 @@
|
||||
api_docs_ui:
|
||||
path: /api/docs
|
||||
methods: [GET]
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
area: default
|
||||
|
||||
api_v1_docs_ui:
|
||||
path: /api/v1/docs
|
||||
methods: [GET]
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
area: default
|
||||
|
||||
api_docs_json:
|
||||
path: /api/docs.json
|
||||
methods: [GET]
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
area: default
|
||||
|
||||
api_v1_docs_json:
|
||||
path: /api/v1/docs.json
|
||||
methods: [GET]
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
area: default
|
||||
+39
-5
@@ -5,6 +5,7 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
|
||||
api_image_hydration_enabled: '%env(bool:API_IMAGE_HYDRATION_ENABLED)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -20,15 +21,48 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\Service\SpotifyClient:
|
||||
GuzzleHttp\Client:
|
||||
arguments:
|
||||
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
|
||||
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
|
||||
$config: { timeout: 4.0, connect_timeout: 1.5, http_errors: false }
|
||||
|
||||
App\Service\ImageStorage:
|
||||
App\Service\LastFmClient:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$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'
|
||||
$publicPrefix: '/uploads'
|
||||
|
||||
App\Controller\AlbumController:
|
||||
arguments:
|
||||
$searchLimit: '%album_search_limit%'
|
||||
|
||||
App\Controller\Api\V1\AlbumApiController:
|
||||
arguments:
|
||||
$searchLimit: '%album_search_limit%'
|
||||
$apiImageHydrationEnabled: '%api_image_hydration_enabled%'
|
||||
|
||||
App\EventSubscriber\ApiExceptionSubscriber:
|
||||
arguments:
|
||||
$debug: '%kernel.debug%'
|
||||
+12
-81
@@ -1,96 +1,27 @@
|
||||
## Docker Compose stack for Symfony app
|
||||
## - php: PHP-FPM container that runs Symfony and Composer
|
||||
## - nginx: Frontend web server proxying PHP to php-fpm
|
||||
## - db: Postgres database for Doctrine
|
||||
## - pgadmin: Optional UI to inspect the database
|
||||
services:
|
||||
php:
|
||||
# Build multi-stage image defined in docker/php/Dockerfile
|
||||
tonehaus:
|
||||
image: tonehaus:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
target: dev
|
||||
target: prod
|
||||
args:
|
||||
- APP_ENV=dev
|
||||
container_name: php
|
||||
APP_ENV: ${APP_ENV:-prod}
|
||||
container_name: tonehaus
|
||||
restart: unless-stopped
|
||||
#environment:
|
||||
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
|
||||
#DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
|
||||
volumes:
|
||||
# Mount only source and config; vendors are installed in-container
|
||||
- ./bin:/var/www/html/bin
|
||||
- ./config:/var/www/html/config
|
||||
- ./migrations:/var/www/html/migrations
|
||||
- ./public:/var/www/html/public
|
||||
- ./templates:/var/www/html/templates
|
||||
- ./src:/var/www/html/src
|
||||
- ./var:/var/www/html/var
|
||||
- ./.env:/var/www/html/.env:ro
|
||||
- ./vendor:/var/www/html/vendor
|
||||
# Keep composer manifests on host for version control
|
||||
- ./composer.json:/var/www/html/composer.json
|
||||
- ./composer.lock:/var/www/html/composer.lock
|
||||
- ./symfony.lock:/var/www/html/symfony.lock
|
||||
# Speed up composer installs by caching download artifacts
|
||||
- composer_cache:/tmp/composer
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php -v || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- db
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
- ./public/uploads:/var/www/html/public/uploads
|
||||
- ./var/data:/var/www/html/var/data
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
# Serve built assets and front controller from Symfony public dir
|
||||
- ./public:/var/www/html/public
|
||||
# Custom vhost with PHP FastCGI proxy
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- php
|
||||
- "8085:80"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-symfony}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-symfony}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
container_name: pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password}
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
composer_cache:
|
||||
pgadmin_data:
|
||||
sqlite_data:
|
||||
uploads:
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost; # host header (not used locally)
|
||||
server_name _; # host header (not used locally)
|
||||
root /var/www/html/public; # Symfony's public/ dir (front controller)
|
||||
|
||||
location / {
|
||||
@@ -11,7 +11,7 @@ server {
|
||||
location ~ \.php$ {
|
||||
# Forward PHP requests to php-fpm service
|
||||
include fastcgi_params;
|
||||
fastcgi_pass php:9000;
|
||||
fastcgi_pass tonehaus:9000;
|
||||
# Use resolved path to avoid path traversal issues
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
|
||||
+130
-103
@@ -1,118 +1,145 @@
|
||||
# FROM php:8.2-fpm
|
||||
|
||||
# # Install dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# git \
|
||||
# unzip \
|
||||
# libzip-dev \
|
||||
# libpng-dev \
|
||||
# libjpeg-dev \
|
||||
# libonig-dev \
|
||||
# libxml2-dev \
|
||||
# zip \
|
||||
# && docker-php-ext-install pdo pdo_mysql zip gd mbstring exif pcntl bcmath intl opcache
|
||||
|
||||
# # Install Composer
|
||||
# COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# # Copy PHP config
|
||||
# COPY docker/php/php.ini /usr/local/etc/php/
|
||||
|
||||
# WORKDIR /var/www/html
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Base PHP-FPM with Composer + Symfony-friendly extensions
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM php:8.2-fpm-alpine AS base
|
||||
WORKDIR /var/www/html
|
||||
FROM php:8.2-fpm-alpine AS base
|
||||
|
||||
# System dependencies
|
||||
RUN apk add --no-cache \
|
||||
bash git unzip icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev \
|
||||
libzip-dev oniguruma-dev libxml2-dev postgresql-dev zlib-dev
|
||||
ARG APP_ENV=dev
|
||||
ENV APP_ENV=${APP_ENV}
|
||||
|
||||
# PHP extensions commonly used by Symfony
|
||||
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
intl \
|
||||
gd \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
mbstring \
|
||||
zip \
|
||||
xml
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Composer available in the running container (dev + prod)
|
||||
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||
# System dependencies shared across images
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
git \
|
||||
openssl \
|
||||
unzip \
|
||||
icu-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
libwebp-dev \
|
||||
libzip-dev \
|
||||
oniguruma-dev \
|
||||
libxml2-dev \
|
||||
libpq-dev \
|
||||
sqlite-dev \
|
||||
zlib-dev \
|
||||
su-exec
|
||||
|
||||
# Recommended PHP settings (tweak as needed)
|
||||
RUN { \
|
||||
echo "memory_limit=512M"; \
|
||||
echo "upload_max_filesize=50M"; \
|
||||
echo "post_max_size=50M"; \
|
||||
echo "date.timezone=UTC"; \
|
||||
} > /usr/local/etc/php/conf.d/php-recommended.ini \
|
||||
&& { \
|
||||
echo "opcache.enable=1"; \
|
||||
echo "opcache.enable_cli=1"; \
|
||||
echo "opcache.memory_consumption=256"; \
|
||||
echo "opcache.interned_strings_buffer=16"; \
|
||||
echo "opcache.max_accelerated_files=20000"; \
|
||||
echo "opcache.validate_timestamps=0"; \
|
||||
echo "opcache.jit=tracing"; \
|
||||
echo "opcache.jit_buffer_size=128M"; \
|
||||
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||
# PHP extensions commonly used by Symfony (plus both Postgres + SQLite)
|
||||
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
intl \
|
||||
gd \
|
||||
pdo_pgsql \
|
||||
pdo_sqlite \
|
||||
opcache \
|
||||
mbstring \
|
||||
zip \
|
||||
xml
|
||||
|
||||
# Small healthcheck file for Nginx
|
||||
RUN mkdir -p public && printf "OK" > public/healthz
|
||||
# Composer available in every stage (dev + prod)
|
||||
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Ensure correct user
|
||||
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app
|
||||
# php-fpm uses www-data; keep both available
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
# Recommended PHP settings (tweak as needed)
|
||||
RUN { \
|
||||
echo "memory_limit=512M"; \
|
||||
echo "upload_max_filesize=50M"; \
|
||||
echo "post_max_size=50M"; \
|
||||
echo "date.timezone=UTC"; \
|
||||
} > /usr/local/etc/php/conf.d/php-recommended.ini \
|
||||
&& { \
|
||||
echo "opcache.enable=1"; \
|
||||
echo "opcache.enable_cli=1"; \
|
||||
echo "opcache.memory_consumption=256"; \
|
||||
echo "opcache.interned_strings_buffer=16"; \
|
||||
echo "opcache.max_accelerated_files=20000"; \
|
||||
echo "opcache.validate_timestamps=0"; \
|
||||
echo "opcache.jit=tracing"; \
|
||||
echo "opcache.jit_buffer_size=128M"; \
|
||||
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Development image (mount your code via docker-compose volumes)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS dev
|
||||
ENV APP_ENV=dev
|
||||
# Optional: enable Xdebug (uncomment to use)
|
||||
# RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
# && pecl install xdebug \
|
||||
# && docker-php-ext-enable xdebug \
|
||||
# && { \
|
||||
# echo "xdebug.mode=debug,develop"; \
|
||||
# echo "xdebug.client_host=host.docker.internal"; \
|
||||
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
||||
# Composer cache directory (faster installs inside container)
|
||||
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
||||
CMD ["php-fpm"]
|
||||
# Small healthcheck file for HTTP probes
|
||||
RUN mkdir -p public && printf "OK" > public/healthz
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production image (copies your app + installs deps + warms cache)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS prod
|
||||
ENV APP_ENV=prod
|
||||
# Copy only manifests first (better layer caching); ignore if missing
|
||||
COPY composer.json composer.lock* symfony.lock* ./
|
||||
# Install vendors (no scripts here; run later with console if needed)
|
||||
RUN --mount=type=cache,target=/tmp/composer \
|
||||
if [ -f composer.json ]; then \
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||
fi
|
||||
# Ensure unprivileged app user exists
|
||||
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app \
|
||||
&& chown -R www-data:www-data /var/www
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . /var/www/html
|
||||
# -----------------------------------------------------------------------------
|
||||
# Development image (mount your code via docker-compose volumes)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS dev
|
||||
ARG APP_ENV=dev
|
||||
ENV APP_ENV=${APP_ENV}
|
||||
ENV APP_DEBUG=1
|
||||
|
||||
# If Symfony console exists, finalize install & warm cache
|
||||
RUN if [ -f bin/console ]; then \
|
||||
set -ex; \
|
||||
composer dump-autoload --no-dev --optimize; \
|
||||
php bin/console cache:clear --no-warmup; \
|
||||
php bin/console cache:warmup; \
|
||||
mkdir -p var && chown -R www-data:www-data var; \
|
||||
fi
|
||||
# Optional: enable Xdebug by uncommenting below
|
||||
# RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
# && pecl install xdebug \
|
||||
# && docker-php-ext-enable xdebug \
|
||||
# && { \
|
||||
# echo "xdebug.mode=debug,develop"; \
|
||||
# echo "xdebug.client_host=host.docker.internal"; \
|
||||
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
||||
|
||||
USER www-data
|
||||
CMD ["php-fpm"]
|
||||
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
||||
CMD ["php-fpm"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production image (copies your app + installs deps + warms cache)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS prod
|
||||
ARG APP_ENV=prod
|
||||
ENV APP_ENV=${APP_ENV}
|
||||
ENV APP_DEBUG=0 \
|
||||
DATABASE_DRIVER=sqlite \
|
||||
DATABASE_SQLITE_PATH=/var/www/html/var/data/database.sqlite \
|
||||
RUN_MIGRATIONS_ON_START=1
|
||||
|
||||
# Copy only composer manifests for layer caching
|
||||
COPY composer.json composer.lock* symfony.lock* ./
|
||||
|
||||
# Install vendors (cached)
|
||||
RUN --mount=type=cache,target=/tmp/composer \
|
||||
if [ -f composer.json ]; then \
|
||||
if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \
|
||||
composer install --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||
else \
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . /var/www/html
|
||||
|
||||
# Finalize install & warm cache
|
||||
RUN if [ -f bin/console ]; then \
|
||||
set -ex; \
|
||||
if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \
|
||||
composer dump-autoload --optimize; \
|
||||
else \
|
||||
composer dump-autoload --no-dev --optimize; \
|
||||
fi; \
|
||||
mkdir -p var var/data public/uploads; \
|
||||
chown -R www-data:www-data var public/uploads; \
|
||||
fi
|
||||
|
||||
# Runtime web stack (nginx + supervisor) for a single immutable container
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
|
||||
COPY docker/prod/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY docker/prod/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /run/nginx /var/log/supervisor \
|
||||
&& chown -R www-data:www-data /run/nginx /var/log/supervisor /var/www/html
|
||||
RUN mkdir -p /var/lib/nginx /var/log/nginx && chown -R www-data:www-data /var/lib/nginx /var/log/nginx
|
||||
RUN sed -i 's|^error_log =.*|error_log = /proc/self/fd/2|' /usr/local/etc/php-fpm.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
prepare_runtime_dirs() {
|
||||
cache_dir="var/cache/${APP_ENV:-prod}/asset_mapper"
|
||||
mkdir -p "${cache_dir}" var/log var/data public/uploads
|
||||
|
||||
chown -R www-data:www-data var public/uploads
|
||||
chmod -R u+rwX,g+rwX var public/uploads
|
||||
|
||||
# Keep group ownership sticky so subsequent writes inherit www-data group.
|
||||
find var public/uploads -type d -exec chmod g+s {} \;
|
||||
}
|
||||
|
||||
require_app_secret() {
|
||||
if [ -z "${APP_SECRET:-}" ]; then
|
||||
echo "APP_SECRET environment variable is required at runtime" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_jwt_keys() {
|
||||
if [ ! -f bin/console ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p config/jwt
|
||||
|
||||
if [ -f config/jwt/private.pem ] && [ -f config/jwt/public.pem ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "${JWT_PASSPHRASE:-}" ]; then
|
||||
JWT_PASSPHRASE="${APP_SECRET}"
|
||||
fi
|
||||
|
||||
openssl genpkey \
|
||||
-out config/jwt/private.pem \
|
||||
-algorithm rsa \
|
||||
-pkeyopt rsa_keygen_bits:4096 \
|
||||
-aes-256-cbc \
|
||||
-pass pass:"${JWT_PASSPHRASE}"
|
||||
|
||||
openssl pkey \
|
||||
-in config/jwt/private.pem \
|
||||
-passin pass:"${JWT_PASSPHRASE}" \
|
||||
-pubout \
|
||||
-out config/jwt/public.pem
|
||||
|
||||
chmod 600 config/jwt/private.pem
|
||||
chmod 644 config/jwt/public.pem
|
||||
chown -R www-data:www-data config/jwt
|
||||
}
|
||||
|
||||
install_runtime() {
|
||||
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Installing Composer dependencies..."
|
||||
composer_flags="--prefer-dist --no-interaction --no-progress"
|
||||
if [ "${APP_ENV:-prod}" = "prod" ]; then
|
||||
composer_flags="${composer_flags} --no-dev"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
su-exec www-data composer install ${composer_flags}
|
||||
}
|
||||
|
||||
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
|
||||
if [ "${DATABASE_DRIVER:-sqlite}" = "sqlite" ]; then
|
||||
SQLITE_PATH="${DATABASE_SQLITE_PATH:-/var/www/html/var/data/database.sqlite}"
|
||||
SQLITE_DIR=$(dirname "${SQLITE_PATH}")
|
||||
mkdir -p "${SQLITE_DIR}"
|
||||
if [ ! -f "${SQLITE_PATH}" ]; then
|
||||
touch "${SQLITE_PATH}"
|
||||
fi
|
||||
chown -R www-data:www-data "${SQLITE_DIR}"
|
||||
fi
|
||||
|
||||
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
|
||||
prepare_runtime_dirs
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "OK";
|
||||
}
|
||||
|
||||
location ^/api/v1 {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
user=root
|
||||
|
||||
[program:php-fpm]
|
||||
command=/usr/local/sbin/php-fpm --nodaemonize
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Setup
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose
|
||||
- Spotify Developer account (for a Client ID/Secret)
|
||||
|
||||
## Start services
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Database
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
### Switching database drivers
|
||||
- `DATABASE_DRIVER=postgres` (default) continues to use the Postgres 16 service from `docker-compose.yml` and reads credentials from `DATABASE_URL`.
|
||||
- `DATABASE_DRIVER=sqlite` runs Doctrine against a local SQLite file at `var/data/database.sqlite`. `DATABASE_URL` is ignored; override the SQLite file path with `DATABASE_SQLITE_PATH` if desired.
|
||||
|
||||
## Admin user
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## Moderator (optional)
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
|
||||
## Spotify credentials
|
||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. (Stored in DB)
|
||||
- Fallback to env vars:
|
||||
```bash
|
||||
export SPOTIFY_CLIENT_ID=your_client_id
|
||||
export SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Optional feature flags
|
||||
- Disable public registration by setting an env variable before starting Symfony:
|
||||
```bash
|
||||
export APP_ALLOW_REGISTRATION=0 # set to 1 (default) to re-enable
|
||||
```
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Features
|
||||
|
||||
- Spotify album search with Advanced filters (album, artist, year range)
|
||||
- Album page with details, list of reviews, and inline new review
|
||||
- Review rating slider (1–10) with live badge
|
||||
- Per-album aggregates: average rating and total review count
|
||||
- Auth modal (Login/Sign up) with remember-me cookie
|
||||
- Role-based access (user, moderator, admin) with protected admin routes
|
||||
- Admin Site Settings to manage Spotify credentials
|
||||
- Moderator/Admin dashboard with latest activity snapshots
|
||||
- User management table (create/delete accounts, promote/demote moderators)
|
||||
- User Dashboard for profile changes (email, display name, password)
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- Bootstrap UI
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Authentication & Users
|
||||
|
||||
## Modal auth
|
||||
- Login and registration happen in a Bootstrap modal.
|
||||
- AJAX submits keep users on the same page; state updates after reload.
|
||||
- Remember-me cookie keeps users logged in across sessions.
|
||||
|
||||
## Roles
|
||||
- `ROLE_USER`: default for registered users.
|
||||
- `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings.
|
||||
- `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access.
|
||||
|
||||
### Demo accounts
|
||||
- Generate placeholder accounts locally with `php bin/console app:seed-demo-users --count=50` (default password: `password`).
|
||||
- Emails use the pattern `demo+<token>@example.com`, making them easy to spot in the admin UI.
|
||||
- Give existing accounts avatars with `php bin/console app:seed-user-avatars`; pass `--overwrite` to refresh everyone or tweak `--style` to try other DiceBear sets.
|
||||
|
||||
### Access flow
|
||||
- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically.
|
||||
- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy.
|
||||
- Admin-only actions (site settings, moderator toggling, deleting other admins) are additionally guarded in controllers/templates to avoid accidental misuse.
|
||||
|
||||
### User management UI
|
||||
- `/admin/users` (moderator+) lists every account along with album/review counts.
|
||||
- Moderators can create new accounts (without affecting their own login session.. ).
|
||||
- Delete buttons are disabled (with tooltip hints) for protected rows such as the current user or any admin.
|
||||
- Admins see a Promote/Demote toggle: promoting grants `ROLE_MODERATOR`; demoting removes that role unless the target is an admin (admins always outrank moderators).
|
||||
- Admins can disable public registration from `/admin/settings`; when disabled, the “Sign up” button in the auth modal is replaced with a tooltip explaining that registration is closed, but `/admin/users` remains fully functional.
|
||||
- Registration can also be enforced via `APP_ALLOW_REGISTRATION=0/1` in the environment; the DB setting syncs on each Symfony boot, so flips take effect after the next restart.
|
||||
|
||||
## Password changes
|
||||
- On `/profile`, users can change email/display name.
|
||||
- To set a new password, the current password must be provided.
|
||||
|
||||
## Logout
|
||||
- `/logout` (link in user menu).
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Spotify Integration
|
||||
|
||||
## Credentials
|
||||
- Prefer configuring via `/admin/settings` (stored in DB).
|
||||
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
|
||||
|
||||
## API client
|
||||
- `src/Service/SpotifyClient.php`
|
||||
- Client Credentials token fetch (cached)
|
||||
- `searchAlbums(q, limit)`
|
||||
- `getAlbum(id)` / `getAlbums([ids])`
|
||||
- `getAlbumWithTracks(id)` fetches metadata plus a hydrated tracklist
|
||||
- `getAlbumTracks(id)` provides the raw paginated track payload when needed
|
||||
|
||||
## Advanced search
|
||||
- The search page builds Spotify fielded queries:
|
||||
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
||||
- Optional free-text added to the query
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Reviews & Albums
|
||||
|
||||
## Album page
|
||||
- Shows album artwork, metadata, average rating and review count.
|
||||
- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
|
||||
- Lists reviews newest-first.
|
||||
- Logged-in users can submit a review inline.
|
||||
|
||||
## Permissions
|
||||
- Anyone can view.
|
||||
- Authors can edit/delete their own reviews.
|
||||
- Moderators and admins can edit/delete any review or user-created album.
|
||||
|
||||
## UI
|
||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
||||
|
||||
## Demo data
|
||||
- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately.
|
||||
- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity.
|
||||
- Use `--only-empty` when you want to focus on albums that currently have no reviews.
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Cannot find template or routes
|
||||
- Clear cache: `docker compose exec php php bin/console cache:clear`
|
||||
- List routes: `docker compose exec php php bin/console debug:router`
|
||||
|
||||
## Missing vendors
|
||||
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
|
||||
|
||||
## .env not read in container
|
||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||
|
||||
## Login modal shows blank
|
||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `06-admin-and-settings.md` to grant roles.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## Site settings (ROLE_ADMIN)
|
||||
- URL: `/admin/settings`
|
||||
- Form persists Spotify Client ID/Secret in the DB (no restart needed).
|
||||
- Form persists Last.fm API key in the DB (no restart needed).
|
||||
- Toggle “Allow self-service registration” to pause public sign-ups while keeping `/admin/users` creation available to staff.
|
||||
- The setting syncs with the `APP_ALLOW_REGISTRATION` environment variable each time Symfony boots (change the env value and restart to enforce). UI changes persist while the process runs.
|
||||
- CSRF + role guards prevent unauthorized updates.
|
||||
@@ -42,4 +42,7 @@ docker compose exec php php bin/console app:promote-moderator user@example.com
|
||||
- `/settings` provides a dark/light mode toggle.
|
||||
- Preference saved in a cookie; applied via `data-bs-theme`.
|
||||
|
||||
|
||||
## Useful tips
|
||||
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
|
||||
- Changing Last.fm credentials in settings is effective immediately; no restart is required.
|
||||
- Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Architecture
|
||||
|
||||
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
|
||||
|
||||
## Naming & reusability standards (PHP)
|
||||
|
||||
- **Classes**
|
||||
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
|
||||
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `LastFmClient`, `LastFmMetadataRefresher`).
|
||||
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
|
||||
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
|
||||
|
||||
- **Methods**
|
||||
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllExternalAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
|
||||
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isExternalConfigured()`).
|
||||
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
|
||||
|
||||
- **Variables & parameters**
|
||||
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$lastfmAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
|
||||
- Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`).
|
||||
- Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`).
|
||||
|
||||
- **Files & namespaces**
|
||||
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
|
||||
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
|
||||
|
||||
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
|
||||
|
||||
## High-level flow
|
||||
1. Visitors search for albums (Last.fm) and view an album page
|
||||
2. Logged‑in users can write, edit, and delete reviews
|
||||
3. Moderators and admins can moderate content and manage users
|
||||
4. Admins configure site settings (Last.fm credentials, registration toggle)
|
||||
|
||||
## Layers & components
|
||||
|
||||
### Controllers (`src/Controller/*`)
|
||||
- `AlbumController` — search, album detail, inline review creation
|
||||
- `ReviewController` — view, edit, and delete reviews
|
||||
- `AccountController` — profile, password, and user settings pages
|
||||
- `Admin/*` — site dashboard, user management, and settings
|
||||
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
|
||||
|
||||
### Entities (`src/Entity/*`)
|
||||
- `User` — authentication principal and roles
|
||||
- `Album`, `AlbumTrack` — normalized album metadata and track list
|
||||
- `Review` — user‑authored review with rating and timestamps
|
||||
- `Setting` — key/value store for site configuration (e.g., Last.fm credentials)
|
||||
|
||||
### Repositories (`src/Repository/*`)
|
||||
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
|
||||
|
||||
### Forms (`src/Form/*`)
|
||||
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
|
||||
- Leverage Symfony validation constraints for robust server‑side validation
|
||||
|
||||
### Services (`src/Service/*`)
|
||||
- `LastFmClient` — Last.fm API integration with caching
|
||||
- `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data
|
||||
- `CatalogResetService` — admin action to reset/sync catalog state safely
|
||||
- `UploadStorage` — avatar/album image uploads and related file handling
|
||||
- `RegistrationToggle` — DB‑backed registration flag with env override
|
||||
|
||||
### Security (`config/packages/security.yaml`, `src/Security/*`)
|
||||
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
|
||||
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
|
||||
- Access control for `/admin/*` enforced via routes and controllers
|
||||
|
||||
### Views (`templates/*`)
|
||||
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
|
||||
- Auth modal in `templates/_partials/auth_modal.html.twig`
|
||||
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
|
||||
|
||||
### DTOs (`src/Dto/*`)
|
||||
- Simple data transfer objects for admin tables and search results
|
||||
|
||||
## Data & persistence
|
||||
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
|
||||
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
|
||||
|
||||
## Error handling & UX
|
||||
- 404 for missing albums
|
||||
- Flash messages for success/error on actions
|
||||
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
|
||||
|
||||
## Testing & tooling
|
||||
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
|
||||
- Web Profiler enabled in dev
|
||||
@@ -0,0 +1,969 @@
|
||||
# Tonehaus Project Briefing
|
||||
|
||||
## Purpose
|
||||
|
||||
Tonehaus is a Symfony 7 music album review application extended for the Assignment 2 RESTful API brief. It combines a server-rendered website with a versioned JSON API, JWT authentication, external music metadata providers, and generated API documentation. The project is designed so the web UI and API share the same domain model and service layer rather than existing as separate systems.
|
||||
|
||||
## Core Stack
|
||||
|
||||
- Symfony 7
|
||||
- PHP with Doctrine ORM
|
||||
- Twig templates
|
||||
- SQLite by default, with Postgres support
|
||||
- Guzzle for external API consumption
|
||||
- LexikJWTAuthenticationBundle for JWT auth
|
||||
- FOSRestBundle for API response handling
|
||||
- NelmioApiDocBundle for OpenAPI documentation
|
||||
- PHPUnit for functional and unit tests
|
||||
|
||||
## Main Domain Model
|
||||
|
||||
- `User`: authenticated account with `ROLE_USER`, `ROLE_MODERATOR`, or `ROLE_ADMIN`
|
||||
- `Album`: normalized album record, either external or user-created
|
||||
- `AlbumTrack`: persisted track list for each album
|
||||
- `Review`: user-authored review with title, content, rating, and timestamps
|
||||
- `Setting`: key-value storage for site configuration and provider credentials
|
||||
|
||||
## What The Application Does
|
||||
|
||||
### Web Application
|
||||
|
||||
- lets users search for albums
|
||||
- shows album pages with cover art, metadata, tracklists, review averages, and reviews
|
||||
- allows logged-in users to create reviews from the album page
|
||||
- allows users to create, edit, and delete their own user-created albums
|
||||
- provides profile, password, and settings pages
|
||||
- gives moderators/admins dashboards for moderation and site administration
|
||||
- includes admin settings for external provider credentials and registration control
|
||||
|
||||
### REST API
|
||||
|
||||
The API is versioned under `/api/v1` and returns JSON.
|
||||
|
||||
Implemented endpoints:
|
||||
|
||||
- `GET /api/v1`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `GET /api/v1/albums`
|
||||
- `POST /api/v1/albums/ingest`
|
||||
- `GET /api/v1/albums/{id}`
|
||||
- `GET /api/v1/albums/{id}/reviews`
|
||||
- `GET /api/v1/reviews`
|
||||
- `GET /api/v1/reviews/{id}`
|
||||
- `POST /api/v1/reviews`
|
||||
- `PUT /api/v1/reviews/{id}`
|
||||
- `PATCH /api/v1/reviews/{id}`
|
||||
- `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
Automatic OpenAPI documentation is available at:
|
||||
|
||||
- `/api/docs`
|
||||
- `/api/v1/docs`
|
||||
- `/api/docs.json`
|
||||
- `/api/v1/docs.json`
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
This section documents every implemented API endpoint briefly, with purpose, authentication, key inputs, and the main response shape.
|
||||
|
||||
Example values below are representative of the current implementation and use realistic sample IDs such as `tidal:251380836` and numeric review IDs.
|
||||
|
||||
### `GET /api/v1`
|
||||
|
||||
- purpose: API index and discovery endpoint
|
||||
- auth: none
|
||||
- returns: API name, version, endpoint groups, top-level `links`, and top-level `actions`
|
||||
- HATEOAS: exposes `self`, `auth_login`, `albums`, `reviews`, `docs`, `docs_v1`, `schema`, and `schema_v1`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Tonehaus API",
|
||||
"version": "v1",
|
||||
"endpoints": {
|
||||
"auth": ["POST /api/v1/auth/login"],
|
||||
"albums": [
|
||||
"GET /api/v1/albums",
|
||||
"POST /api/v1/albums/ingest (JWT)",
|
||||
"GET /api/v1/albums/{id}",
|
||||
"GET /api/v1/albums/{id}/reviews"
|
||||
],
|
||||
"reviews": [
|
||||
"GET /api/v1/reviews",
|
||||
"GET /api/v1/reviews/{id}",
|
||||
"POST /api/v1/reviews (JWT)",
|
||||
"PUT /api/v1/reviews/{id} (JWT)",
|
||||
"PATCH /api/v1/reviews/{id} (JWT)",
|
||||
"DELETE /api/v1/reviews/{id} (JWT)"
|
||||
]
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1",
|
||||
"auth_login": "/api/v1/auth/login",
|
||||
"albums": "/api/v1/albums",
|
||||
"reviews": "/api/v1/reviews",
|
||||
"docs": "/api/docs"
|
||||
},
|
||||
"actions": {
|
||||
"login": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/auth/login"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
- purpose: authenticate a user and issue a JWT for protected routes
|
||||
- auth: none
|
||||
- request body: `email`, `password`
|
||||
- success response: `200` with `{ "token": "..." }`
|
||||
- error response: `401` for invalid credentials
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
Example success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
Example error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_credentials",
|
||||
"message": "Invalid credentials.",
|
||||
"status": 401
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums`
|
||||
|
||||
- purpose: search albums from the local catalog and the currently active external provider
|
||||
- auth: none
|
||||
- query parameters: `query`, `album`, `artist`, `genre`, `year_from`, `year_to`, `source`, `limit`, `mode`
|
||||
- returns:
|
||||
- `meta` with source, limit, query, mode, and search profile
|
||||
- `data` as album resources
|
||||
- `stats` for review aggregates
|
||||
- `saved_ids` for already-ingested external albums
|
||||
- collection-level `links` and `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `root`, and `ingest`
|
||||
- action metadata includes `ingest_album`
|
||||
- each album resource is decorated with its own `links` and `actions`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"source": "all",
|
||||
"limit": 5,
|
||||
"query": "4:44",
|
||||
"mode": "fast"
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/320.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"open_source": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"album_id": "tidal:251380836"
|
||||
}
|
||||
},
|
||||
"ingest": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"tidal:251380836": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
},
|
||||
"saved_ids": ["tidal:251380836"],
|
||||
"links": {
|
||||
"self": "/api/v1/albums?source=all&limit=5&mode=fast&query=4%3A44&artist=JAY-Z",
|
||||
"root": "/api/v1",
|
||||
"ingest": "/api/v1/albums/ingest"
|
||||
},
|
||||
"actions": {
|
||||
"ingest_album": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/albums/ingest`
|
||||
|
||||
- purpose: persist an external album into the local database
|
||||
- auth: JWT required
|
||||
- request body:
|
||||
- either `external_id`
|
||||
- or `query`
|
||||
- or `artist` and `album`
|
||||
- success response: `201` with `data` for the ingested album and `tracks`
|
||||
- main error responses:
|
||||
- `400` invalid JSON or missing lookup input
|
||||
- `401` missing/invalid JWT
|
||||
- `404` external album not found
|
||||
- `422` album missing required metadata or tracks
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/albums/ingest
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc": 1,
|
||||
"track": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}`
|
||||
|
||||
- purpose: fetch one album by public identifier
|
||||
- auth: none
|
||||
- path parameter: `id` string public album ID such as a provider ID or user album ID
|
||||
- returns:
|
||||
- `data` for one album
|
||||
- `tracks` list
|
||||
- `stats` with review count and average rating
|
||||
- behaviour:
|
||||
- if the album is not yet local but is available from the external provider, it can be hydrated and persisted automatically
|
||||
- HATEOAS on `data`:
|
||||
- links include `self`, `reviews`, and `open_source` when available
|
||||
- actions include `create_review`
|
||||
- external albums also expose `ingest`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums/tidal%3A251380836
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/640.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"open_source": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"album_id": "tidal:251380836"
|
||||
}
|
||||
},
|
||||
"ingest": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc": 1,
|
||||
"track": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
- purpose: list reviews for a specific album
|
||||
- auth: none
|
||||
- path parameter: `id` string public album ID
|
||||
- returns:
|
||||
- `data` as review resources scoped to that album
|
||||
- collection `links`
|
||||
- collection `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `album`, and `reviews`
|
||||
- collection actions include `create_review`
|
||||
- each review item includes links back to its own item route and related album routes
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums/tidal%3A251380836/reviews
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/reviews"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews`
|
||||
|
||||
- purpose: list the latest reviews
|
||||
- auth: none
|
||||
- query parameters: `limit` from `1` to `100`, default `50`
|
||||
- returns:
|
||||
- `data` as review resources
|
||||
- collection `links`
|
||||
- collection `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `root`, and `create`
|
||||
- collection actions include `create_review`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews?limit=10
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/reviews?limit=10",
|
||||
"root": "/api/v1",
|
||||
"create": "/api/v1/reviews"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: fetch one review by numeric ID
|
||||
- auth: none
|
||||
- path parameter: `id` integer review ID
|
||||
- returns: one review resource in `data`
|
||||
- HATEOAS on the review resource:
|
||||
- links include `self`, `album`, and `album_reviews`
|
||||
- if the current user is allowed to change the review, action metadata can include `update` and `delete`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews/12
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/reviews`
|
||||
|
||||
- purpose: create a new review for an existing album
|
||||
- auth: JWT required
|
||||
- request body:
|
||||
- `album_id`
|
||||
- `title`
|
||||
- `content`
|
||||
- `rating`
|
||||
- validation rules:
|
||||
- `title` required
|
||||
- `content` required and minimum 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
- `album_id` must resolve to a valid album with tracks
|
||||
- success response: `201` with the created review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` authentication required
|
||||
- `404` album not found
|
||||
- `422` validation failure
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/reviews
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"album_id": "tidal:251380836",
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: update an existing review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- accepted body fields: `title`, `content`, `rating`
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `200` with updated review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `422` validation failure
|
||||
- implementation note: `PUT` currently shares the same update handler as `PATCH`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
PUT /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"title": "Revisited after a week",
|
||||
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||
"rating": 8
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "Revisited after a week",
|
||||
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||
"rating": 8,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T12:05:44+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: partially update an existing review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- accepted body fields: `title`, `content`, `rating`
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `200` with updated review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `422` validation failure
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
PATCH /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"rating": 10
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 10,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T12:10:03+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: delete a review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `204 No Content`
|
||||
- main error responses:
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `404` not found
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
DELETE /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
The project follows standard Symfony separation of concerns.
|
||||
|
||||
- controllers handle HTTP orchestration only
|
||||
- services contain business logic and external API integration
|
||||
- repositories handle domain-specific queries
|
||||
- entities define persisted state
|
||||
- forms handle server-side validation for web submissions
|
||||
- security config and voters enforce route access and ownership rules
|
||||
- event subscribers normalize API errors into JSON responses
|
||||
|
||||
This matters for the brief because the API is not implemented as one large controller. It is structured in a maintainable way using framework conventions and reusable services.
|
||||
|
||||
## API Implementation Summary
|
||||
|
||||
The API implementation is centered around resource-based routes rather than action-style routes. Review endpoints expose collection and item resources, while album endpoints expose search, detail, ingestion, and album-specific review resources.
|
||||
|
||||
Key implementation points:
|
||||
|
||||
- uses descriptive REST-style URIs such as `/api/v1/reviews` and `/api/v1/albums/{id}/reviews`
|
||||
- uses correct HTTP verbs for retrieval, creation, update, and deletion
|
||||
- returns JSON for both success and failure cases
|
||||
- includes HATEOAS-style hypermedia in API responses through `links` and `actions`
|
||||
- uses JWT authentication for protected write operations
|
||||
- excludes sensitive data such as user email and password hashes from API responses
|
||||
- uses structured status codes including `200`, `201`, `204`, `401`, `403`, `404`, and `422`
|
||||
- provides generated OpenAPI documentation through Nelmio
|
||||
|
||||
The review API supports full CRUD behaviour for third-party clients. Public reads are available without authentication, while create, update, and delete require authenticated users. Authorization is more specific than simple login checks because review ownership is enforced with a `ReviewVoter`, allowing moderators and admins to manage any review while normal users can only manage their own.
|
||||
|
||||
The API also includes a useful sub-resource:
|
||||
|
||||
- `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
This improves the resource model because reviews can be queried globally or in the context of a specific album.
|
||||
|
||||
The API also now includes a lightweight HATEOAS layer. The base API index exposes navigational links such as `self`, `albums`, `reviews`, and `docs`, plus action metadata for login. Album and review resources expose their own `links` and, where relevant, `actions`, for example:
|
||||
|
||||
- album resources link to `self`, `reviews`, and `open_source`
|
||||
- album resources expose actions such as `create_review` and `ingest`
|
||||
- review resources link back to their own item route and related album routes
|
||||
- review collections expose a `create_review` action for discoverability
|
||||
|
||||
This improves the API from a client perspective because consumers do not need to hard-code every related route manually. Instead, the response itself advertises relevant next operations.
|
||||
|
||||
## API Consumption Summary
|
||||
|
||||
The application consumes external music APIs through Guzzle and a provider abstraction rather than binding the UI directly to one upstream service.
|
||||
|
||||
Implemented external providers:
|
||||
|
||||
- Last.fm
|
||||
- MusicBrainz
|
||||
- TIDAL
|
||||
|
||||
Provider logic is normalized behind `ExternalAlbumClientInterface`, with concrete clients for each provider. This means the rest of the application receives one consistent album payload structure regardless of the source.
|
||||
|
||||
The consumed API data is used to:
|
||||
|
||||
- search external album catalogs
|
||||
- populate album metadata
|
||||
- fetch artists, genres, release dates, and track lists
|
||||
- fetch cover art
|
||||
- ingest external albums into the local database
|
||||
- enrich album detail pages and search results in the UI
|
||||
|
||||
This is deep integration rather than a token demo call. External data directly affects the search experience, album display, save/ingest flow, and review context.
|
||||
|
||||
## External Integration Details
|
||||
|
||||
### Last.fm
|
||||
|
||||
Used for album search and metadata enrichment. Responses are cached and normalized into the project’s album format.
|
||||
|
||||
### MusicBrainz
|
||||
|
||||
Used as an alternative provider and for structured release metadata. The project supports optional authenticated requests and cover-art URL generation via normalized output.
|
||||
|
||||
### TIDAL
|
||||
|
||||
Used as another full external provider. The `TidalClient` implements OAuth 2.0 client credentials, caches access tokens, retries on expired auth, and converts JSON:API responses into the same normalized album contract used elsewhere.
|
||||
|
||||
This strengthens the project against the brief because it goes beyond a single trivial API call and includes a second non-trivial external integration path.
|
||||
|
||||
## Security Summary
|
||||
|
||||
Security is handled at multiple layers:
|
||||
|
||||
- JWT authentication for protected API routes
|
||||
- Symfony access control rules for route-level protection
|
||||
- role hierarchy: admin > moderator > user
|
||||
- `ReviewVoter` for ownership-based edit/delete permissions
|
||||
- normalized JSON authentication failures for API clients
|
||||
- exclusion of sensitive user fields from serialized API responses
|
||||
|
||||
This is stronger than basic coursework authentication because it combines framework security with resource-level authorization.
|
||||
|
||||
## Error Handling and Robustness
|
||||
|
||||
API error handling is centralized so clients receive JSON errors rather than HTML exception pages.
|
||||
|
||||
The project includes:
|
||||
|
||||
- a shared API response helper
|
||||
- exception subscribers that normalize failures
|
||||
- JWT failure subscribers for missing, invalid, or expired tokens
|
||||
- validation logic for malformed JSON and invalid field values
|
||||
- server-side handling for missing albums, forbidden edits, and unsupported methods
|
||||
|
||||
This improves client usability and supports a more production-like API design.
|
||||
|
||||
## Testing Summary
|
||||
|
||||
The project includes both functional and unit tests.
|
||||
|
||||
Functional API tests cover:
|
||||
|
||||
- missing JWT
|
||||
- invalid JWT
|
||||
- malformed JSON
|
||||
- validation failures
|
||||
- forbidden review updates
|
||||
- missing album resources
|
||||
- protection of sensitive author data
|
||||
- hypermedia links and action metadata on API index, album, and album-review responses
|
||||
|
||||
Unit tests cover provider-specific service behaviour, including TIDAL payload normalization and provider routing.
|
||||
|
||||
This supports the brief’s emphasis on working, demonstrable features rather than partially implemented code.
|
||||
|
||||
## How The Project Meets The Brief
|
||||
|
||||
### API Implementation
|
||||
|
||||
The brief requires a usable RESTful API capable of listing, reading, creating, editing, and deleting album reviews in JSON. This project meets that with:
|
||||
|
||||
- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` review endpoints
|
||||
- JWT-protected write routes
|
||||
- appropriate resource names
|
||||
- JSON request and response formats
|
||||
- HATEOAS-style `links` and `actions` to improve discoverability
|
||||
- status-code-based error handling
|
||||
- sub-resource support through album review listing
|
||||
- automatically generated OpenAPI documentation
|
||||
|
||||
### API Consumption
|
||||
|
||||
The brief requires at least two significantly different examples of API interaction and integration into the review website. This project meets that with:
|
||||
|
||||
- external album search
|
||||
- album metadata retrieval
|
||||
- track list retrieval
|
||||
- cover art integration
|
||||
- album ingestion into local storage
|
||||
- multiple provider implementations
|
||||
|
||||
The consumed data is visible in the website itself, especially on search results and album detail pages.
|
||||
|
||||
### Documentation and Report Potential
|
||||
|
||||
The project already has strong source material for the report because it includes:
|
||||
|
||||
- generated OpenAPI docs
|
||||
- explicit controller routes and OpenAPI attributes
|
||||
- centralized security configuration
|
||||
- reusable external service classes
|
||||
- tests that demonstrate expected API behaviour
|
||||
|
||||
## Honest Technical Caveats
|
||||
|
||||
- `PUT` and `PATCH` currently share the same update action, so `PUT` behaves more like a flexible update than a strict full replacement
|
||||
- local verification in this shell was limited because `php` is not installed here, so PHPUnit was not run live during this session
|
||||
- some observability headers in album search are still named with `Lastfm` even though the provider layer now supports multiple providers
|
||||
|
||||
These are not major blockers, but they are worth understanding if you want the final report to sound precise and credible.
|
||||
|
||||
## Strongest Points To Emphasize In A Final Report
|
||||
|
||||
- the API is versioned and resource-oriented
|
||||
- the API now includes lightweight HATEOAS support through response `links` and `actions`
|
||||
- write operations are protected with JWT rather than session auth
|
||||
- review ownership is enforced through a voter, not just a login check
|
||||
- external APIs are abstracted behind a provider interface
|
||||
- external data is deeply integrated into the UI and persisted catalog
|
||||
- OpenAPI documentation is generated automatically
|
||||
- the project includes automated tests for important API behaviour
|
||||
|
||||
## Short Project Summary
|
||||
|
||||
Tonehaus is a Symfony-based music review platform extended into a RESTful web application. It implements a versioned JSON API for review CRUD and album access, secures write routes with JWT, consumes multiple external music providers through Guzzle and a normalized service layer, and integrates that data directly into album discovery, metadata display, tracklists, and review workflows. The architecture is modular, test-backed, and aligned with the main goals of the Assignment 2 brief.
|
||||
@@ -0,0 +1,611 @@
|
||||
# Assignment 2 Report: Tonehaus REST API
|
||||
|
||||
## Part 1: API Documentation
|
||||
|
||||
### 1. API Overview
|
||||
|
||||
Tonehaus extends the album review website with a versioned JSON API under `/api/v1`. The API supports two main concerns:
|
||||
|
||||
- discovery and ingestion of album data
|
||||
- creation and management of user reviews
|
||||
|
||||
The implementation is resource-oriented. Public read endpoints are available without authentication, while write operations require JWT authentication. The API is documented manually in this report and automatically through Nelmio OpenAPI documentation at:
|
||||
|
||||
- `/api/docs`
|
||||
- `/api/v1/docs`
|
||||
- `/api/docs.json`
|
||||
- `/api/v1/docs.json`
|
||||
|
||||
### 2. Authentication
|
||||
|
||||
Protected endpoints use bearer-token authentication with JSON Web Tokens (JWT). Clients obtain a token by sending credentials to:
|
||||
|
||||
- `POST /api/v1/auth/login`
|
||||
|
||||
The token must then be included on protected requests:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <jwt-token>
|
||||
```
|
||||
|
||||
Protected routes:
|
||||
|
||||
- `POST /api/v1/albums/ingest`
|
||||
- `POST /api/v1/reviews`
|
||||
- `PUT /api/v1/reviews/{id}`
|
||||
- `PATCH /api/v1/reviews/{id}`
|
||||
- `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
### 3. Common Response Format
|
||||
|
||||
#### Success
|
||||
|
||||
Success payloads are JSON objects. Most collection/detail routes return a top-level `data` key. Some album endpoints also return `meta`, `tracks`, `stats`, or `saved_ids`.
|
||||
|
||||
#### Error
|
||||
|
||||
API failures are normalized into one JSON error envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Review payload failed validation.",
|
||||
"status": 422,
|
||||
"details": {
|
||||
"fields": {
|
||||
"content": "Content must be at least 20 characters."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes include:
|
||||
|
||||
- `authentication_required`
|
||||
- `invalid_credentials`
|
||||
- `invalid_token`
|
||||
- `token_expired`
|
||||
- `validation_error`
|
||||
- `album_not_found`
|
||||
- `external_album_not_found`
|
||||
- `forbidden`
|
||||
- `not_found`
|
||||
- `method_not_allowed`
|
||||
- `internal_server_error`
|
||||
|
||||
### 4. Resource Models
|
||||
|
||||
#### Review Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen...",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Album Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/640.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
}
|
||||
```
|
||||
|
||||
#### Track Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc_number": 1,
|
||||
"track_number": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000,
|
||||
"preview_url": null
|
||||
}
|
||||
```
|
||||
|
||||
Album identifiers are public string IDs rather than numeric database IDs. Depending on provider and source, examples include:
|
||||
|
||||
- `tidal:251380836`
|
||||
- `mbid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||
- `u_ab12cd34ef56` for user-created albums
|
||||
|
||||
### 5. Endpoint Reference
|
||||
|
||||
### `GET /api/v1`
|
||||
|
||||
Purpose: API index and discovery endpoint.
|
||||
|
||||
Auth: None
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Tonehaus API",
|
||||
"version": "v1",
|
||||
"endpoints": {
|
||||
"auth": ["POST /api/v1/auth/login"],
|
||||
"albums": [
|
||||
"GET /api/v1/albums",
|
||||
"POST /api/v1/albums/ingest (JWT)",
|
||||
"GET /api/v1/albums/{id}",
|
||||
"GET /api/v1/albums/{id}/reviews"
|
||||
],
|
||||
"reviews": [
|
||||
"GET /api/v1/reviews",
|
||||
"GET /api/v1/reviews/{id}",
|
||||
"POST /api/v1/reviews (JWT)",
|
||||
"PUT /api/v1/reviews/{id} (JWT)",
|
||||
"PATCH /api/v1/reviews/{id} (JWT)",
|
||||
"DELETE /api/v1/reviews/{id} (JWT)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
Purpose: authenticate a user and issue a JWT.
|
||||
|
||||
Auth: None
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK` token issued
|
||||
- `401 Unauthorized` invalid credentials
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums`
|
||||
|
||||
Purpose: search albums from the local catalog and the currently configured external provider.
|
||||
|
||||
Auth: None
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `query` free-text search
|
||||
- `album` album title filter
|
||||
- `artist` artist filter
|
||||
- `genre` genre filter
|
||||
- `year_from` inclusive lower year bound
|
||||
- `year_to` inclusive upper year bound
|
||||
- `source` `all`, `external`, or `user`
|
||||
- `limit` result limit, capped server-side
|
||||
- `mode` `full` or `fast`
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"source": "all",
|
||||
"limit": 5,
|
||||
"query": "4:44",
|
||||
"mode": "fast",
|
||||
"profile": {
|
||||
"external_provider": "tidal"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [{ "name": "JAY-Z" }],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"images": [{ "url": "https://cdn.example.test/320.jpg" }],
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"tidal:251380836": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
},
|
||||
"saved_ids": ["tidal:251380836"]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/albums/ingest`
|
||||
|
||||
Purpose: persist an external album into local storage.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Request body rules:
|
||||
|
||||
- either `external_id` must be supplied
|
||||
- or `query`
|
||||
- or a combination of `artist` and `album`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `201 Created`
|
||||
- `400 Bad Request` invalid JSON or missing identifier/query
|
||||
- `401 Unauthorized`
|
||||
- `404 Not Found` external album not found
|
||||
- `422 Unprocessable Entity` album missing metadata or tracks
|
||||
|
||||
Success response includes:
|
||||
|
||||
- `data` album payload
|
||||
- `tracks` normalized track list
|
||||
|
||||
### `GET /api/v1/albums/{id}`
|
||||
|
||||
Purpose: return one album by public ID.
|
||||
|
||||
Auth: None
|
||||
|
||||
Path parameter:
|
||||
|
||||
- `id` public album identifier string
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity` album payload exists upstream but is not ingestable
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [{ "name": "JAY-Z" }],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"images": [{ "url": "https://cdn.example.test/640.jpg" }],
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc_number": 1,
|
||||
"track_number": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000,
|
||||
"preview_url": null
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
Purpose: list reviews for one album.
|
||||
|
||||
Auth: None
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen...",
|
||||
"rating": 9,
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews`
|
||||
|
||||
Purpose: list recent reviews.
|
||||
|
||||
Auth: None
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `limit` integer, clamped to `1-100`, default `50`
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews?limit=10
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: fetch one review by numeric ID.
|
||||
|
||||
Auth: None
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
### `POST /api/v1/reviews`
|
||||
|
||||
Purpose: create a review for an existing album.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"album_id": "tidal:251380836",
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `album_id` must resolve to an existing album with a track list
|
||||
- `title` is required
|
||||
- `content` is required and must be at least 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
|
||||
Responses:
|
||||
|
||||
- `201 Created`
|
||||
- `400 Bad Request` invalid JSON
|
||||
- `401 Unauthorized`
|
||||
- `404 Not Found` album not found
|
||||
- `422 Unprocessable Entity` validation failure
|
||||
|
||||
### `PUT /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: update an existing review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Current implementation note: `PUT` and `PATCH` are handled by the same update action. In practice, the API currently accepts partial payloads on both methods rather than enforcing full replacement semantics for `PUT`.
|
||||
|
||||
Accepted fields:
|
||||
|
||||
- `title`
|
||||
- `content`
|
||||
- `rating`
|
||||
|
||||
Validation:
|
||||
|
||||
- at least one updatable field is required
|
||||
- `title` cannot be empty
|
||||
- `content` cannot be empty and must be at least 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
- only the review author, moderator, or admin may modify the review
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `400 Bad Request`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Revisited after a few weeks",
|
||||
"content": "The sequencing is much better than I first thought, and the writing is sharper too.",
|
||||
"rating": 8
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: partially update an existing review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"rating": 10
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `400 Bad Request`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity`
|
||||
|
||||
### `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: delete a review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Authorization:
|
||||
|
||||
- author of the review
|
||||
- moderator
|
||||
- admin
|
||||
|
||||
Responses:
|
||||
|
||||
- `204 No Content`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
|
||||
### 6. Automatic Documentation and Testing Support
|
||||
|
||||
The API is additionally exposed through generated OpenAPI documentation with Nelmio. This gives the project a professional, interactive documentation layer on top of the written report. The application also includes automated tests for:
|
||||
|
||||
- JWT protection
|
||||
- normalized JSON error envelopes
|
||||
- validation failures
|
||||
- forbidden review updates
|
||||
- protection of sensitive user data in API responses
|
||||
- provider-specific payload normalization, including TIDAL
|
||||
|
||||
## Part 2: Code Report
|
||||
|
||||
### Introduction
|
||||
|
||||
The Tonehaus application was originally a server-rendered album review website, but for Assignment 2 it has been extended into a REST-oriented application with both API implementation and API consumption. The final system does not simply expose review data as JSON. It also uses external music services to build and enrich the album catalog that reviews depend on. As a result, the API and the web UI now share the same underlying album model, review model, and security rules.
|
||||
|
||||
### API Implementation
|
||||
|
||||
The implemented API is versioned under `/api/v1`, which is preferable to exposing unversioned endpoints because it leaves room for future iterations without breaking existing clients. The main resources are `reviews` and `albums`. Review routes cover list, read, create, update, and delete operations. Album routes cover search, detail retrieval, album-specific review retrieval, and explicit ingestion of external albums into local storage.
|
||||
|
||||
The review API is centered on `ReviewApiController`. Read operations are public, while write operations require `ROLE_USER`. Responses are serialized into a stable JSON shape that includes the review itself plus minimal nested album and author information. Sensitive fields such as email addresses, passwords, and role data are intentionally excluded. This is important because the assignment criteria explicitly reward avoiding unnecessary or sensitive output.
|
||||
|
||||
Shared response behaviour is centralized in `ApiController`, which provides helper methods for successful responses and consistent error envelopes. This reduces duplication across controllers and makes the API easier to maintain. Errors are further normalized by `ApiExceptionSubscriber` and `ApiJwtFailureSubscriber`, so authentication failures, invalid tokens, missing credentials, validation errors, and unexpected server errors are all returned as JSON instead of HTML exception pages. That is a significant usability improvement for third-party clients.
|
||||
|
||||
### Authentication and Authorization
|
||||
|
||||
Authentication is implemented with LexikJWTAuthenticationBundle. The login endpoint is handled through Symfony security `json_login` at `POST /api/v1/auth/login`. This design is stateless because the client submits credentials once, receives a token, and then supplies that token on later requests. The server does not need to keep session state for API consumers.
|
||||
|
||||
Authorization is layered on top of authentication. Access control rules in the security configuration protect the write routes, but review ownership is enforced more precisely by `ReviewVoter`. A normal user may edit or delete only their own review, while moderators and administrators may manage any review. This is stronger than basic route-level protection because it applies business rules at the resource level.
|
||||
|
||||
### RESTful Design Criteria
|
||||
|
||||
The API satisfies several core REST criteria. First, resources are named with nouns rather than action verbs: `/reviews`, `/reviews/{id}`, `/albums`, and `/albums/{id}/reviews`. Second, HTTP methods are used according to intent: `GET` for retrieval, `POST` for creation and ingestion, `DELETE` for removal, and `PUT`/`PATCH` for updates. Third, the API uses JSON consistently for request and response bodies, which keeps the contract simple for clients.
|
||||
|
||||
The service is also stateless for protected routes because every authenticated request carries its own bearer token. Status codes are generally used correctly: `200` for successful reads and updates, `201` for creation, `204` for deletion, `401` for authentication failures, `403` for authorization failures, `404` for missing resources, and `422` for validation problems.
|
||||
|
||||
One limitation is that `PUT` and `PATCH` are currently routed to the same update handler. In strict REST terms, `PUT` would usually imply full replacement of editable state, whereas `PATCH` implies partial modification. The current implementation is still practical and easy for clients to consume, but it is better described as REST-oriented than perfectly REST-pure in that one area.
|
||||
|
||||
### External API Consumption
|
||||
|
||||
External API consumption is implemented with Guzzle and a provider abstraction. Rather than hard-coding one service into controllers, the application uses `ExternalAlbumClientInterface` and an `ExternalAlbumClient` router that resolves the currently active provider. This allows the application to work with Last.fm, MusicBrainz, or TIDAL while keeping the rest of the code independent from provider-specific payload formats.
|
||||
|
||||
This abstraction is one of the strongest architectural parts of the project. `LastFmClient`, `MusicBrainzClient`, and `TidalClient` all normalize external responses into the same internal album contract: album ID, title, artists, genres, release date, cover image URLs, external URL, and track list. Because of this, the UI and the API can render albums without knowing which provider originally supplied the data.
|
||||
|
||||
The integration is also deep rather than superficial. Album search merges local and external results, attaches review aggregates, supports filtering by artist, genre, year range, and source, and can run in fast or full modes. Opening an unsaved external album detail page can automatically hydrate and persist it locally. The album page then displays cover art, release metadata, genres, tracklists, and user reviews in one place.
|
||||
|
||||
The latest provider addition is TIDAL. Its client uses OAuth 2.0 client credentials, caches access tokens, retries once on a `401`, and normalizes JSON:API documents into the same internal shape used by the other providers. This is a non-trivial second external integration and improves the project’s depth against the assignment rubric.
|
||||
|
||||
### Code Organization and Maintainability
|
||||
|
||||
The codebase is organized around separation of concerns. Controllers are thin orchestration layers. Services handle provider logic, search composition, uploads, registration toggling, and provider switching. Entities define the core data model. Event subscribers centralize error handling. Security rules and JWT behaviour are configured declaratively rather than embedded into controller code.
|
||||
|
||||
Maintainability is strengthened further by tests. Functional API tests cover authentication, malformed JSON, validation failures, forbidden updates, missing resources, and protection of personally identifiable information. Unit tests cover provider routing and TIDAL normalization behaviour. Although I was not able to execute PHPUnit in this shell because `php` is not installed locally, the presence of these tests is still relevant evidence of intended behaviour and regression control.
|
||||
|
||||
### Limitations and Further Work
|
||||
|
||||
The current API is solid for the project scope, but there are clear next steps. Pagination metadata could be formalized on list endpoints. `PUT` and `PATCH` semantics could be separated more strictly. JWT refresh token support could be added for longer-lived API clients. Rate-limiting and request-throttling at the API boundary would also strengthen production readiness. Finally, album search response headers still use Last.fm-oriented metric names even though multiple providers are now supported, so those observability labels could be generalized.
|
||||
|
||||
### Conclusion
|
||||
|
||||
Overall, Tonehaus meets the assignment brief with a realistic REST API, JWT-protected write operations, structured error handling, and meaningful external API consumption. The project goes beyond a minimal CRUD exercise because the consumed APIs actively shape the web application experience: they power album discovery, metadata enrichment, cover art, tracklists, and ingestion into the local review catalog. The result is a more maintainable and scalable application that treats the API as part of the system architecture rather than an isolated add-on.
|
||||
|
||||
## References
|
||||
|
||||
1. Module brief: *Assignment 2 - RESTful APIs*, Advanced Web Development, CRN 31802.
|
||||
2. Symfony Documentation.
|
||||
3. LexikJWTAuthenticationBundle documentation.
|
||||
4. FOSRestBundle documentation.
|
||||
5. NelmioApiDocBundle documentation.
|
||||
6. Guzzle documentation.
|
||||
7. Last.fm API documentation.
|
||||
8. MusicBrainz Web Service documentation and Cover Art Archive.
|
||||
9. TIDAL Open API documentation.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Authentication & Users
|
||||
|
||||
## Login & Registration (modal)
|
||||
- Login and sign‑up are handled in a Bootstrap modal.
|
||||
- AJAX submits keep users on the page; a successful login refreshes state.
|
||||
- Remember‑me cookie keeps users logged in across sessions.
|
||||
|
||||
## Roles & Permissions
|
||||
- `ROLE_USER` — default for registered users
|
||||
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
|
||||
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
|
||||
|
||||
Promotion (from your host):
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
|
||||
```
|
||||
|
||||
### Access flow
|
||||
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
|
||||
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
|
||||
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
|
||||
|
||||
## Public registration toggle
|
||||
- Toggle in UI: `/admin/settings` (stored in DB)
|
||||
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
|
||||
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
|
||||
|
||||
## User management (moderator+)
|
||||
- `/admin/users` lists accounts with album/review counts and actions:
|
||||
- Create accounts inline (does not affect the current session)
|
||||
- Delete users (guards prevent deleting self or administrators)
|
||||
- Admins can Promote/Demote Moderator on non‑admins
|
||||
|
||||
## Profiles & Passwords
|
||||
- `/account/profile`: update email and display name
|
||||
- `/account/password`: change password (requires current password)
|
||||
|
||||
## Demo accounts & avatars
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
|
||||
```
|
||||
|
||||
## Logout
|
||||
- Link in the user menu calls `/logout` (handled by Symfony security).
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Deployment
|
||||
|
||||
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
|
||||
|
||||
## Build (locally)
|
||||
```bash
|
||||
docker build \
|
||||
--target=prod \
|
||||
-t tonehaus-app:latest \
|
||||
-f docker/php/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
## Run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tonehaus \
|
||||
-p 8080:8080 \
|
||||
-e APP_ENV=prod \
|
||||
-e APP_SECRET=change_me \
|
||||
-e LASTFM_API_KEY=your_api_key \
|
||||
tonehaus-app:latest
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
|
||||
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
|
||||
- Cache warmup is executed on boot; `APP_SECRET` is required
|
||||
|
||||
## Persistence options
|
||||
### SQLite (default)
|
||||
- Data file at `var/data/database.sqlite`
|
||||
- Use a volume for durability:
|
||||
```bash
|
||||
docker run -d \
|
||||
-v tonehaus_sqlite:/var/www/html/var/data \
|
||||
...
|
||||
```
|
||||
|
||||
### Postgres
|
||||
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
|
||||
```
|
||||
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
|
||||
```
|
||||
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
|
||||
```bash
|
||||
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV` (`prod` recommended in production)
|
||||
- `APP_SECRET` (required; random string)
|
||||
- `LASTFM_API_KEY`
|
||||
- `APP_ALLOW_REGISTRATION` (env override for public registration)
|
||||
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
|
||||
- `DATABASE_URL` (when using Postgres)
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START` (default `1`)
|
||||
|
||||
## Reverse proxy / TLS
|
||||
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
|
||||
- Terminate TLS at the proxy and forward to the container’s port 8080
|
||||
- Ensure proxy sends `X-Forwarded-*` headers
|
||||
|
||||
## Zero‑downtime tips
|
||||
- Build then run a new container alongside the old one, switch traffic at the proxy
|
||||
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Features
|
||||
|
||||
## Albums & Reviews
|
||||
- Last.fm album search with advanced filters (album, artist, year range)
|
||||
- Album page: cover art, metadata, full tracklist (when available)
|
||||
- Reviews list (newest first) and inline new review form (logged-in)
|
||||
- Rating slider (1–10) with live badge
|
||||
- Per‑album aggregates: average rating and total review count
|
||||
|
||||
## Authentication & Users
|
||||
- Bootstrap auth modal for login/sign-up with AJAX submits
|
||||
- Remember‑me cookie keeps users signed in
|
||||
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
|
||||
- Profile: update email, display name, and password (requires current password)
|
||||
|
||||
## Administration
|
||||
- Dashboard: latest reviews/albums and key counts (moderator+)
|
||||
- Users: create/delete users, promote/demote moderators (admin constraints)
|
||||
- Settings: manage Last.fm credentials, toggle public registration (admin)
|
||||
|
||||
## Design & UX
|
||||
- Responsive Bootstrap UI
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- CSRF protection on forms
|
||||
- Access control via role hierarchy and security voters
|
||||
|
||||
## Screenshots (placeholders)
|
||||
- Search page — `docs/img/search.png` (optional)
|
||||
- Album page — `docs/img/album.png` (optional)
|
||||
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Last.fm Integration
|
||||
|
||||
## Credentials
|
||||
- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required).
|
||||
- Env fallback: `LASTFM_API_KEY`.
|
||||
|
||||
## API Client
|
||||
- `src/Service/LastFmClient.php` (uses Guzzle)
|
||||
- `searchAlbums(query, limit)`
|
||||
- `getAlbum(id)` / `getAlbums([ids])`
|
||||
- `getAlbumWithTracks(id)`
|
||||
- `getAlbumTracks(id)`
|
||||
|
||||
## Caching & Limits
|
||||
- Last.fm responses are cached in Symfony cache pools to reduce repeated calls.
|
||||
- Search/detail payloads are normalized to one internal album shape before persistence.
|
||||
|
||||
## Ingestion Flow
|
||||
1. Search Last.fm
|
||||
2. Resolve tags/genres via `LastFmTagResolver`
|
||||
3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()`
|
||||
4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()`
|
||||
|
||||
## Admin Settings
|
||||
- Set the Last.fm API key in `/admin/settings`.
|
||||
- Key changes apply immediately.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Reviews & Albums
|
||||
|
||||
## Album page
|
||||
- Artwork, metadata, average rating, and review count
|
||||
- Full Last.fm tracklist when available
|
||||
- Reviews list (newest first)
|
||||
- Inline new review form for logged‑in users
|
||||
|
||||
## Writing a review
|
||||
- Rating slider from 1–10
|
||||
- Title (max 160 chars) and body (20–5000 chars)
|
||||
- Server-side validation provides inline errors on failure
|
||||
- Successful submissions persist, flash a success message, and reload the album page
|
||||
|
||||
## Editing & deleting reviews
|
||||
- Authors can edit/delete their own reviews
|
||||
- Moderators/Admins can edit/delete any review
|
||||
- CSRF protection is required for deletion
|
||||
|
||||
## Aggregates
|
||||
- The album page computes:
|
||||
- Total number of reviews for the album
|
||||
- Average rating rounded to one decimal
|
||||
|
||||
## Demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
- Use `--only-empty` to focus on albums that currently have no reviews.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Setup
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose
|
||||
- Last.fm API key
|
||||
- A unique `APP_SECRET` value in your environment (for prod builds)
|
||||
|
||||
## 1) Start the stack
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
App: `http://localhost:8085`
|
||||
Health: `http://localhost:8085/healthz`
|
||||
|
||||
## 2) Create an admin
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## 3) Configure Last.fm
|
||||
- Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB)
|
||||
- Env fallback (in `.env` or your shell):
|
||||
```bash
|
||||
LASTFM_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
## 4) JWT auth keys
|
||||
- API login endpoint: `POST /api/v1/auth/login`
|
||||
- JWT keys are generated automatically on container start into `config/jwt/`.
|
||||
- Set `JWT_PASSPHRASE` in `.env` to your production secret.
|
||||
|
||||
## 5) (Optional) Seed demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
## Database drivers
|
||||
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
|
||||
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
|
||||
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV=dev|prod`
|
||||
- `APP_SECRET=<random_string>`
|
||||
- `LASTFM_API_KEY`
|
||||
- `JWT_PASSPHRASE=<random_string>`
|
||||
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
|
||||
- `DATABASE_DRIVER=sqlite|postgres`
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
|
||||
|
||||
## Useful commands
|
||||
```bash
|
||||
# Symfony cache
|
||||
docker compose exec tonehaus php bin/console cache:clear
|
||||
|
||||
# Inspect routes
|
||||
docker compose exec tonehaus php bin/console debug:router
|
||||
|
||||
# Promote moderator
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Cannot find template or routes
|
||||
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
|
||||
- List routes: `docker compose exec tonehaus php bin/console debug:router`
|
||||
|
||||
## Missing vendors
|
||||
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
|
||||
|
||||
## .env not read in container
|
||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||
|
||||
## Login modal shows blank
|
||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `admin-and-settings.md` to grant roles.
|
||||
|
||||
## SQLite file permissions
|
||||
- The default SQLite path is `var/data/database.sqlite`.
|
||||
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
|
||||
|
||||
## Postgres connection issues
|
||||
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
|
||||
- Example URL:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Last.fm errors
|
||||
- Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`.
|
||||
|
||||
## ARM64 Build
|
||||
|
||||
```bash
|
||||
sudo docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--target prod \
|
||||
-t tonehaus/tonehaus:dev-arm64 \
|
||||
-f docker/php/Dockerfile \
|
||||
. \
|
||||
--load
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Importmap configuration for the asset mapper.
|
||||
*
|
||||
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
|
||||
*/
|
||||
return [
|
||||
'app' => [
|
||||
'path' => './assets/app.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251205134500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add genres JSON column to albums for advanced search filters';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite cannot drop columns without rebuilding the table; leave as-is.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
|
||||
$this->addSql('ALTER TABLE albums DROP genres');
|
||||
}
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
|
||||
{
|
||||
if (!$schema->hasTable($tableName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$schema->getTable($tableName)->hasColumn($column);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227120500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->shouldAddColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'");
|
||||
if (!$this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column drops require table rebuilds; keep schema forward-only here.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('DROP INDEX uniq_users_oauth_identity');
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('DROP INDEX uniq_album_external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_provider');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'");
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'");
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName);
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227174500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy Spotify and unused album columns (created_by_id, cover_url, local_id, cover_image_path)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column drops/constraint rewrites are intentionally skipped in this migration.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
if (!$this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD spotify_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD created_by_id INT DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums ADD cover_url VARCHAR(1024) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474FA905FC5C')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
|
||||
}
|
||||
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474F5D5A2101')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
|
||||
}
|
||||
if (!$this->indexExists($schema, 'albums', 'IDX_F4E2474FB03A8386')) {
|
||||
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
if (!$this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks ADD spotify_track_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if (!$this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227181000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Apply SQLite-safe table rebuilds to remove legacy Spotify and unused album columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
$this->migrateSqlite();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Forward-only migration: reverting would require data-loss-prone table rewrites.
|
||||
}
|
||||
|
||||
private function migrateSqlite(): void
|
||||
{
|
||||
$this->addSql('PRAGMA foreign_keys = OFF');
|
||||
|
||||
$this->addSql(
|
||||
"CREATE TABLE albums_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
external_id VARCHAR(64) DEFAULT NULL,
|
||||
source VARCHAR(16) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
artists CLOB NOT NULL,
|
||||
release_date VARCHAR(20) DEFAULT NULL,
|
||||
total_tracks INTEGER NOT NULL,
|
||||
external_url VARCHAR(1024) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
genres CLOB NOT NULL DEFAULT '[]'
|
||||
)"
|
||||
);
|
||||
$this->addSql(
|
||||
"INSERT INTO albums_new (
|
||||
id, external_id, source, name, artists, release_date, total_tracks, external_url, created_at, updated_at, genres
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN external_id IS NOT NULL AND TRIM(external_id) <> '' THEN external_id
|
||||
WHEN local_id IS NOT NULL AND TRIM(local_id) <> '' THEN local_id
|
||||
ELSE 'legacy:' || id
|
||||
END AS external_id,
|
||||
CASE
|
||||
WHEN source = 'spotify' OR source IS NULL OR TRIM(source) = '' THEN 'external'
|
||||
ELSE source
|
||||
END AS source,
|
||||
name,
|
||||
artists,
|
||||
release_date,
|
||||
total_tracks,
|
||||
external_url,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(genres, '[]') AS genres
|
||||
FROM albums"
|
||||
);
|
||||
$this->addSql('DROP TABLE albums');
|
||||
$this->addSql('ALTER TABLE albums_new RENAME TO albums');
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_external_id ON albums (external_id)');
|
||||
|
||||
$this->addSql(
|
||||
"CREATE TABLE album_tracks_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
album_id INTEGER NOT NULL,
|
||||
external_track_id VARCHAR(64) DEFAULT NULL,
|
||||
disc_number INTEGER NOT NULL,
|
||||
track_number INTEGER NOT NULL,
|
||||
name VARCHAR(512) NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
preview_url VARCHAR(1024) DEFAULT NULL,
|
||||
CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)"
|
||||
);
|
||||
$this->addSql(
|
||||
"INSERT INTO album_tracks_new (
|
||||
id, album_id, external_track_id, disc_number, track_number, name, duration_ms, preview_url
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
album_id,
|
||||
CASE
|
||||
WHEN external_track_id IS NOT NULL AND TRIM(external_track_id) <> '' THEN external_track_id
|
||||
WHEN spotify_track_id IS NOT NULL AND TRIM(spotify_track_id) <> '' THEN spotify_track_id
|
||||
ELSE NULL
|
||||
END AS external_track_id,
|
||||
disc_number,
|
||||
track_number,
|
||||
name,
|
||||
duration_ms,
|
||||
preview_url
|
||||
FROM album_tracks"
|
||||
);
|
||||
$this->addSql('DROP TABLE album_tracks');
|
||||
$this->addSql('ALTER TABLE album_tracks_new RENAME TO album_tracks');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
|
||||
|
||||
$this->addSql('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260401101500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable album owner relation (albums.owner_id -> users.id)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql('ALTER TABLE albums ADD COLUMN owner_id INTEGER DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD owner_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_ALBUM_OWNER FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite down-migration omitted to avoid lossy table rebuild.
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT FK_ALBUM_OWNER');
|
||||
$this->addSql('DROP INDEX IDX_ALBUM_OWNER');
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN owner_id');
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260402120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable cover_image_path to albums for user-uploaded album artwork';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if (!$this->hasTable($schema, 'albums') || $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql('ALTER TABLE albums ADD COLUMN cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column removal requires a table rebuild; keep this migration forward-only on SQLite.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums') && $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Tonehaus Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<php>
|
||||
<server name="APP_ENV" value="test" force="true"/>
|
||||
<server name="APP_DEBUG" value="1" force="true"/>
|
||||
<server name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<server name="DATABASE_DRIVER" value="sqlite" force="true"/>
|
||||
<server name="DATABASE_SQLITE_PATH" value="/tmp/tonehaus_test.sqlite" force="true"/>
|
||||
<server name="JWT_PASSPHRASE" value="test" force="true"/>
|
||||
|
||||
<!-- ###+ symfony/framework-bundle ### -->
|
||||
<env name="APP_ENV" value="dev"/>
|
||||
<env name="APP_SECRET" value="test-secret"/>
|
||||
<!-- ###- symfony/framework-bundle ### -->
|
||||
|
||||
<!-- ###+ symfony/routing ### -->
|
||||
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
|
||||
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
|
||||
<env name="DEFAULT_URI" value="http://localhost"/>
|
||||
<!-- ###- symfony/routing ### -->
|
||||
|
||||
<!-- ###+ doctrine/doctrine-bundle ### -->
|
||||
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
|
||||
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
|
||||
<!-- -->
|
||||
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
|
||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
|
||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
|
||||
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"/>
|
||||
<!-- ###- doctrine/doctrine-bundle ### -->
|
||||
|
||||
<!-- ###+ lexik/jwt-authentication-bundle ### -->
|
||||
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
|
||||
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
|
||||
<env name="JWT_PASSPHRASE" value="f5b6626c282780b036de5dad4b5aa9a42ef5ec4632ecfbefc840eb02a60fae0c"/>
|
||||
<!-- ###- lexik/jwt-authentication-bundle ### -->
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -16,7 +16,10 @@ class PromoteAdminCommand extends Command
|
||||
/**
|
||||
* Stores injected dependencies for later use.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -35,7 +38,7 @@ class PromoteAdminCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
$user = $this->userRepository->findOneByEmail($email);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||
return Command::FAILURE;
|
||||
@@ -45,7 +48,7 @@ class PromoteAdminCommand extends Command
|
||||
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
||||
$roles[] = 'ROLE_ADMIN';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
||||
|
||||
@@ -16,7 +16,10 @@ class PromoteModeratorCommand extends Command
|
||||
/**
|
||||
* Stores dependencies for the console handler.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -35,7 +38,7 @@ class PromoteModeratorCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
$user = $this->userRepository->findOneByEmail($email);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||
return Command::FAILURE;
|
||||
@@ -45,7 +48,7 @@ class PromoteModeratorCommand extends Command
|
||||
if (!in_array('ROLE_MODERATOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\ExternalProviderManager;
|
||||
use App\Service\LastFmMetadataRefresher;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:refresh-external-albums',
|
||||
description: 'Refresh all saved external albums from the configured metadata provider.'
|
||||
)]
|
||||
class RefreshExternalAlbumsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LastFmMetadataRefresher $refresher,
|
||||
private readonly ExternalProviderManager $providerManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$providerLabel = $this->providerManager->getProviderLabel($this->providerManager->getEffectiveProvider());
|
||||
$updated = $this->refresher->refreshAllExternalAlbums();
|
||||
|
||||
if ($updated === 0) {
|
||||
$io->warning(sprintf('No external albums were refreshed from %s.', $providerLabel));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Refreshed %d external albums from %s.', $updated, $providerLabel));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\AlbumTrack;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\UserRepository;
|
||||
@@ -20,6 +21,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-demo-albums',
|
||||
description: 'Create demo albums with randomized metadata for local development.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with synthetic user-sourced albums.
|
||||
*
|
||||
* - Always marked as "user" source with a unique public ID.
|
||||
* - Include randomized names, artists, genres, and release dates.
|
||||
*/
|
||||
class SeedDemoAlbumsCommand extends Command
|
||||
{
|
||||
private const GENRES = [
|
||||
@@ -48,7 +55,7 @@ class SeedDemoAlbumsCommand extends Command
|
||||
{
|
||||
$this
|
||||
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
||||
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
||||
->addOption('attach-users', null, InputOption::VALUE_NONE, 'Assign each generated album to a random existing user');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -56,46 +63,57 @@ class SeedDemoAlbumsCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$count = max(1, (int) $input->getOption('count'));
|
||||
$attachUsers = (bool) $input->getOption('attach-users');
|
||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
||||
$owners = [];
|
||||
if ($attachUsers) {
|
||||
$owners = $this->userRepository->findAll();
|
||||
if ($owners === []) {
|
||||
$io->warning('No users available for --attach-users; albums will be created without owners.');
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$seenLocalIds = [];
|
||||
// Track generated album IDs so we never attempt to persist obvious duplicates.
|
||||
$seenAlbumIds = [];
|
||||
|
||||
while ($created < $count) {
|
||||
$localId = $this->generateLocalId();
|
||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
||||
// Generate an ID that is unique in-memory and in the database to avoid constraint violations.
|
||||
$albumId = $this->generateAlbumId();
|
||||
if (isset($seenAlbumIds[$albumId]) || $this->albumRepository->findOneByExternalId($albumId) !== null) {
|
||||
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||
continue;
|
||||
}
|
||||
|
||||
$album = new Album();
|
||||
$album->setSource('user');
|
||||
$album->setLocalId($localId);
|
||||
$album->setExternalId($albumId);
|
||||
$album->setName($this->generateAlbumName());
|
||||
$album->setArtists($this->generateArtists());
|
||||
$album->setGenres($this->generateGenres());
|
||||
$album->setReleaseDate($this->generateReleaseDate());
|
||||
$album->setTotalTracks(random_int(6, 16));
|
||||
$album->setCoverUrl($this->generateCoverUrl($localId));
|
||||
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
|
||||
|
||||
if ($attachUsers && $users !== []) {
|
||||
/** @var User $user */
|
||||
$user = $users[array_rand($users)];
|
||||
$album->setCreatedBy($user);
|
||||
$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);
|
||||
$seenLocalIds[$localId] = true;
|
||||
$seenAlbumIds[$albumId] = true;
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
|
||||
$io->success(sprintf('Created %d demo albums.', $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function generateLocalId(): string
|
||||
private function generateAlbumId(): string
|
||||
{
|
||||
return 'demo_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
@@ -136,9 +154,37 @@ class SeedDemoAlbumsCommand extends Command
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
private function generateCoverUrl(string $seed): string
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function generateGenres(): array
|
||||
{
|
||||
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
||||
$count = random_int(1, 3);
|
||||
$genres = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$genres[] = self::GENRES[random_int(0, count(self::GENRES) - 1)];
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,6 +21,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-demo-reviews',
|
||||
description: 'Generate demo reviews across existing albums.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with demo reviews attached to existing albums and users.
|
||||
*
|
||||
* Controls:
|
||||
* - --cover-percent: roughly what percentage of albums receive reviews.
|
||||
* - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
|
||||
* - --only-empty: restricts seeding to albums that currently have no reviews.
|
||||
*
|
||||
* The command avoids:
|
||||
* - Creating multiple reviews from the same user on a single album.
|
||||
* - Touching albums/users when there is no suitable data to seed.
|
||||
*/
|
||||
class SeedDemoReviewsCommand extends Command
|
||||
{
|
||||
private const SUBJECTS = [
|
||||
@@ -59,7 +71,11 @@ class SeedDemoReviewsCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$albums = $this->albumRepository->findAll();
|
||||
// Pull all albums/users once up front so downstream helpers filter as needed.
|
||||
$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 === []) {
|
||||
@@ -67,14 +83,17 @@ class SeedDemoReviewsCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
|
||||
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
||||
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
||||
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
||||
|
||||
// Apply coverage and "only empty" filters before creating any Review entities. (filter)
|
||||
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
||||
$onlyEmpty = (bool) $input->getOption('only-empty');
|
||||
|
||||
$created = 0;
|
||||
// Count how many albums actually received new reviews for clearer operator feedback. (count)
|
||||
$processedAlbums = 0;
|
||||
foreach ($selectedAlbums as $album) {
|
||||
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
||||
@@ -107,6 +126,7 @@ class SeedDemoReviewsCommand extends Command
|
||||
return $albums;
|
||||
}
|
||||
|
||||
// Randomly sample albums until the requested coverage threshold is met.
|
||||
$selected = [];
|
||||
foreach ($albums as $album) {
|
||||
if (random_int(1, 100) <= $coverPercent) {
|
||||
@@ -114,6 +134,7 @@ class SeedDemoReviewsCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we always seed at least one album when any albums exist.
|
||||
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
||||
}
|
||||
|
||||
@@ -124,17 +145,22 @@ class SeedDemoReviewsCommand extends Command
|
||||
{
|
||||
$created = 0;
|
||||
$existingAuthors = $this->fetchExistingAuthors($album);
|
||||
// Filter out users who have already reviewed this album so we only ever
|
||||
// create one review per (album, author) pair.
|
||||
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
||||
|
||||
if ($availableUsers === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Limit requested reviews to the number of eligible authors, then randomly
|
||||
// choose a stable subset for this run.
|
||||
$targetReviews = min($targetReviews, count($availableUsers));
|
||||
shuffle($availableUsers);
|
||||
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
||||
|
||||
foreach ($selectedUsers as $user) {
|
||||
// Prevent duplicate reviews per author by only iterating over filtered unique users.
|
||||
$review = new Review();
|
||||
$review->setAlbum($album);
|
||||
$review->setAuthor($user);
|
||||
@@ -154,6 +180,8 @@ class SeedDemoReviewsCommand extends Command
|
||||
*/
|
||||
private function fetchExistingAuthors(Album $album): array
|
||||
{
|
||||
// Fetch all distinct author IDs that have already reviewed this album so we
|
||||
// can cheaply check for duplicates in PHP without loading full Review objects.
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(r.author) AS authorId')
|
||||
->from(Review::class, 'r')
|
||||
@@ -204,4 +232,3 @@ class SeedDemoReviewsCommand extends Command
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
name: 'app:seed-demo-users',
|
||||
description: 'Create demo users with random emails and display names.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with demo users for local development and testing.
|
||||
*
|
||||
* - Generates unique, non-conflicting demo email addresses.
|
||||
* - Assigns a predictable default password (overridable via --password).
|
||||
* - Creates users with a single ROLE_USER role.
|
||||
*/
|
||||
class SeedDemoUsersCommand extends Command
|
||||
{
|
||||
private const FIRST_NAMES = [
|
||||
@@ -54,11 +61,15 @@ class SeedDemoUsersCommand extends Command
|
||||
$plainPassword = (string) $input->getOption('password');
|
||||
|
||||
$created = 0;
|
||||
// Track generated emails so we never attempt to persist obvious duplicates.
|
||||
$seenEmails = [];
|
||||
|
||||
while ($created < $count) {
|
||||
// Keep generating new tokens until we find an email that is unique
|
||||
// for both this run and the existing database.
|
||||
$email = $this->generateEmail();
|
||||
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
||||
// Collisions are rare but possible because we only randomize 8 hex chars; try again.
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-user-avatars',
|
||||
description: 'Assign generated profile images to existing users.'
|
||||
)]
|
||||
/**
|
||||
* Seeds or refreshes user profile images using the DiceBear avatar API.
|
||||
*
|
||||
* - Skips users that already have an image unless --overwrite is provided.
|
||||
* - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
|
||||
* - Does not download or cache the avatars locally; URLs are stored directly.
|
||||
*/
|
||||
class SeedUserAvatarsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
@@ -54,6 +61,7 @@ class SeedUserAvatarsCommand extends Command
|
||||
continue;
|
||||
}
|
||||
if (!$overwrite && $user->getProfileImagePath()) {
|
||||
// Respect existing uploads unless the operator explicitly allows clobbering them.
|
||||
continue;
|
||||
}
|
||||
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
||||
@@ -73,7 +81,11 @@ class SeedUserAvatarsCommand extends Command
|
||||
|
||||
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
||||
{
|
||||
// Use a stable identifier (display name when present, email as fallback)
|
||||
// so the same user is always mapped to the same avatar for a given prefix.
|
||||
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
||||
// Combine prefix, identifier, and primary key into a deterministic hash
|
||||
// and trim it to a shorter seed value accepted by DiceBear.
|
||||
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
||||
|
||||
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\ExternalProviderManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:sync-external-provider',
|
||||
description: 'Applies effective external provider and resets catalog if provider changed.'
|
||||
)]
|
||||
class SyncExternalProviderCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExternalProviderManager $providerManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$result = $this->providerManager->syncEffectiveProviderAtStartup();
|
||||
|
||||
if ($result['reset']) {
|
||||
$io->warning(sprintf(
|
||||
'External provider changed from %s to %s. Reset catalog: deleted %d albums and %d reviews.',
|
||||
$result['previous_provider'],
|
||||
$result['effective_provider'],
|
||||
$result['albums'],
|
||||
$result['reviews']
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'External provider unchanged (%s). Catalog reset not required.',
|
||||
$result['effective_provider']
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\ProfileFormType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\UploadStorage;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -36,10 +36,10 @@ class AccountController extends AbstractController
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.author = :u')->setParameter('u', $user)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
$albumCount = (int) $albums->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.source = :src')->setParameter('src', 'user')
|
||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
||||
$albumCount = (int) $reviews->createQueryBuilder('r')
|
||||
->select('COUNT(DISTINCT a.id)')
|
||||
->innerJoin('r.album', 'a')
|
||||
->where('r.author = :u')->setParameter('u', $user)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
if ($this->isGranted('ROLE_ADMIN')) {
|
||||
$userType = 'Admin';
|
||||
@@ -54,8 +54,10 @@ class AccountController extends AbstractController
|
||||
->setMaxResults(10)
|
||||
->getQuery()->getResult();
|
||||
$userAlbums = $albums->createQueryBuilder('a')
|
||||
->where('a.source = :src')->setParameter('src', 'user')
|
||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
||||
->where('a.owner = :u')
|
||||
->andWhere('a.source = :src')
|
||||
->setParameter('u', $user)
|
||||
->setParameter('src', 'user')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
->setMaxResults(10)
|
||||
->getQuery()->getResult();
|
||||
@@ -76,36 +78,36 @@ class AccountController extends AbstractController
|
||||
* Allows users to update profile details and avatar.
|
||||
*/
|
||||
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ImageStorage $images): Response
|
||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$form = $this->createForm(ProfileFormType::class, $user);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($form->isSubmitted()) {
|
||||
$newPassword = (string) $form->get('newPassword')->getData();
|
||||
if ($newPassword !== '') {
|
||||
$current = (string) $form->get('currentPassword')->getData();
|
||||
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
||||
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
||||
return $this->render('account/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'profileImage' => $user->getProfileImagePath(),
|
||||
]);
|
||||
} else {
|
||||
// Allow password updates inside the same form submission instead of forcing a separate flow.
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
|
||||
$upload = $form->get('profileImage')->getData();
|
||||
if ($upload instanceof UploadedFile) {
|
||||
$images->remove($user->getProfileImagePath());
|
||||
$user->setProfileImagePath($images->storeProfileImage($upload));
|
||||
}
|
||||
if ($form->isValid()) {
|
||||
$upload = $form->get('profileImage')->getData();
|
||||
if ($upload instanceof UploadedFile) {
|
||||
$uploadStorage->remove($user->getProfileImagePath());
|
||||
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_profile');
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_profile');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('account/profile.html.twig', [
|
||||
@@ -123,5 +125,3 @@ class AccountController extends AbstractController
|
||||
return $this->render('account/settings.html.twig');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ 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\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -20,8 +23,14 @@ 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')
|
||||
->select('COUNT(r.id)')
|
||||
->getQuery()->getSingleScalarResult();
|
||||
@@ -34,6 +43,7 @@ class DashboardController extends AbstractController
|
||||
->select('COUNT(u.id)')
|
||||
->getQuery()->getSingleScalarResult();
|
||||
|
||||
// Latest rows are pulled separately so the dashboard can show concrete activity.
|
||||
$recentReviews = $reviews->findLatest(50);
|
||||
$recentAlbums = $albums->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
@@ -46,6 +56,30 @@ 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,
|
||||
ExternalProviderManager $providerManager
|
||||
): Response {
|
||||
$token = (string) $request->request->get('_token');
|
||||
if (!$this->isCsrfTokenValid('dashboard_refresh_external', $token)) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
|
||||
$updated = $refresher->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 %s.', $updated, $providerLabel));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ namespace App\Controller\Admin;
|
||||
|
||||
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\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* SettingsController lets admins adjust key integration settings.
|
||||
@@ -17,23 +20,115 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
|
||||
private const DEMO_COMMANDS = [
|
||||
'users' => [
|
||||
'command' => 'app:seed-demo-users',
|
||||
'label' => 'Demo users',
|
||||
'description' => 'Creates demo accounts with randomized emails.',
|
||||
'fields' => [
|
||||
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||
['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'],
|
||||
],
|
||||
],
|
||||
'albums' => [
|
||||
'command' => 'app:seed-demo-albums',
|
||||
'label' => 'Demo albums',
|
||||
'description' => 'Creates user albums with randomized metadata.',
|
||||
'fields' => [
|
||||
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40],
|
||||
['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true],
|
||||
],
|
||||
],
|
||||
'reviews' => [
|
||||
'command' => 'app:seed-demo-reviews',
|
||||
'label' => 'Demo reviews',
|
||||
'description' => 'Adds sample reviews for existing albums.',
|
||||
'fields' => [
|
||||
['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||
['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1],
|
||||
['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3],
|
||||
['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
'avatars' => [
|
||||
'command' => 'app:seed-user-avatars',
|
||||
'label' => 'Profile pictures',
|
||||
'description' => 'Assigns generated avatars to users (skips existing).',
|
||||
'fields' => [
|
||||
['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'],
|
||||
['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Displays and persists Spotify 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('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
||||
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
||||
$registrationOverride = $registrationToggle->envOverride();
|
||||
$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('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->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());
|
||||
} else {
|
||||
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
|
||||
@@ -46,6 +141,104 @@ 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,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/reset-catalog', name: 'admin_settings_reset_catalog', methods: ['POST'])]
|
||||
public function resetCatalog(Request $request, CatalogResetService $resetService): Response
|
||||
{
|
||||
$token = (string) $request->request->get('_token');
|
||||
if (!$this->isCsrfTokenValid('admin_settings_reset_catalog', $token)) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$result = $resetService->resetCatalog();
|
||||
$this->addFlash('success', sprintf(
|
||||
'Reset catalog: deleted %d reviews and %d albums.',
|
||||
$result['reviews'],
|
||||
$result['albums']
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('admin_settings');
|
||||
}
|
||||
|
||||
#[Route('/admin/settings/generate-demo/{type}', name: 'admin_settings_generate_demo', methods: ['POST'])]
|
||||
public function generateDemo(
|
||||
string $type,
|
||||
Request $request,
|
||||
CommandRunner $runner
|
||||
): Response {
|
||||
$config = self::DEMO_COMMANDS[$type] ?? null;
|
||||
if ($config === null) {
|
||||
throw $this->createNotFoundException('Unknown demo data type.');
|
||||
}
|
||||
$token = (string) $request->request->get('_token');
|
||||
if (!$this->isCsrfTokenValid('admin_settings_generate_' . $type, $token)) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
try {
|
||||
$options = $this->buildCommandOptions($config, $request);
|
||||
$runner->runConsoleCommand($config['command'], $options);
|
||||
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('danger', sprintf(
|
||||
'%s failed: %s',
|
||||
$config['label'],
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildCommandOptions(array $config, Request $request): array
|
||||
{
|
||||
$options = [];
|
||||
foreach (($config['fields'] ?? []) as $field) {
|
||||
$name = (string) $field['name'];
|
||||
$type = $field['type'] ?? 'text';
|
||||
$value = $request->request->get($name);
|
||||
if ($type === 'checkbox') {
|
||||
if ($value) {
|
||||
// Symfony console options expect "--flag" style boolean toggles.
|
||||
$options['--' . $name] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$options['--' . $name] = $value;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use App\Form\AdminUserType;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -21,8 +21,8 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,13 +37,14 @@ class UserController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Form collects only high-level metadata; everything else is defaulted here.
|
||||
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||
$newUser = new User();
|
||||
$newUser->setEmail($formData->email);
|
||||
$newUser->setDisplayName($formData->displayName);
|
||||
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
|
||||
$this->em->persist($newUser);
|
||||
$this->em->flush();
|
||||
$newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
|
||||
$this->entityManager->persist($newUser);
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User account created.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
@@ -67,6 +68,7 @@ class UserController extends AbstractController
|
||||
/** @var User|null $current */
|
||||
$current = $this->getUser();
|
||||
if ($current && $target->getId() === $current->getId()) {
|
||||
// Protect against accidental lockouts by blocking self-deletes.
|
||||
$this->addFlash('danger', 'You cannot delete your own account.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
@@ -76,8 +78,8 @@ class UserController extends AbstractController
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
$this->em->remove($target);
|
||||
$this->em->flush();
|
||||
$this->entityManager->remove($target);
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User deleted.');
|
||||
|
||||
return $this->redirectToRoute('admin_users');
|
||||
@@ -102,14 +104,15 @@ class UserController extends AbstractController
|
||||
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
|
||||
|
||||
if ($isModerator) {
|
||||
// Toggle-style UX: hitting the endpoint again demotes the moderator.
|
||||
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
||||
$target->setRoles($filtered);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'Moderator privileges removed.');
|
||||
} else {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$target->setRoles(array_values(array_unique($roles)));
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User promoted to moderator.');
|
||||
}
|
||||
|
||||
|
||||
+531
-105
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -12,48 +13,199 @@ use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Service\SpotifyClient;
|
||||
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\Form\FormInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* AlbumController orchestrates search, CRUD, and review entry on albums.
|
||||
*/
|
||||
class AlbumController extends AbstractController
|
||||
{
|
||||
private const ALBUM_ID_REQUIREMENT = '(?!new$)[^/]+';
|
||||
|
||||
public function __construct(
|
||||
private readonly ImageStorage $imageStorage,
|
||||
private readonly AlbumSearchService $albumSearch,
|
||||
private readonly LastFmTagResolver $genreResolver,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $searchLimit = 20
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches Spotify plus local albums and decorates results with review stats.
|
||||
* Searches external plus local albums and decorates results with review stats.
|
||||
*/
|
||||
#[Route('/', name: 'album_search', methods: ['GET'])]
|
||||
public function search(Request $request): Response
|
||||
{
|
||||
$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,
|
||||
'year_from' => $criteria->yearFrom ?? '',
|
||||
'year_to' => $criteria->yearTo ?? '',
|
||||
'genre' => $criteria->genre,
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,51 +214,87 @@ 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');
|
||||
$form = $this->createForm(AlbumType::class, $album);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$user = $this->getUser();
|
||||
if ($user instanceof User) {
|
||||
$album->setCreatedBy($user);
|
||||
$this->normalizeAlbumFormData($album);
|
||||
$album->setSource('user');
|
||||
$album->setExternalId($this->generateUserAlbumId($albumRepo));
|
||||
$owner = $this->getUser();
|
||||
if ($owner instanceof User) {
|
||||
$album->setOwner($owner);
|
||||
}
|
||||
$this->handleAlbumCoverUpload($album, $form);
|
||||
$album->setLocalId($this->generateLocalId($albumRepo));
|
||||
$em->persist($album);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album created.');
|
||||
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
|
||||
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, SpotifyClient $spotify, 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) {
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
// 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->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
} else {
|
||||
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||
if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $externalAlbumClient)) {
|
||||
// Track sync mutated the entity: persist before we build template arrays.
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
$albumCard = $albumEntity->toTemplateArray();
|
||||
$albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $externalAlbumClient);
|
||||
$canManage = $this->canManageAlbum($albumEntity);
|
||||
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
|
||||
|
||||
@@ -143,30 +331,30 @@ class AlbumController extends AbstractController
|
||||
'avg' => $avg,
|
||||
'count' => $count,
|
||||
'form' => $form->createView(),
|
||||
'albumOwner' => $albumEntity->getCreatedBy(),
|
||||
'albumOwner' => $albumEntity->getOwner(),
|
||||
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||
'tracks' => $trackRows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a Spotify album locally for quicker access.
|
||||
* Saves an external album locally for quicker access.
|
||||
*/
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
|
||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
#[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)) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||
$existing = $albumRepo->findOneByExternalId($id);
|
||||
if (!$existing) {
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
|
||||
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album saved.');
|
||||
} else {
|
||||
@@ -179,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');
|
||||
@@ -189,9 +377,6 @@ class AlbumController extends AbstractController
|
||||
$album = $this->findAlbum($id, $albumRepo);
|
||||
if ($album) {
|
||||
$this->ensureCanManageAlbum($album);
|
||||
if ($album->getSource() === 'user') {
|
||||
$this->imageStorage->remove($album->getCoverImagePath());
|
||||
}
|
||||
$em->remove($album);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album deleted.');
|
||||
@@ -204,11 +389,11 @@ class AlbumController extends AbstractController
|
||||
/**
|
||||
* Generates a unique user album identifier.
|
||||
*/
|
||||
private function generateLocalId(AlbumRepository $albumRepo): string
|
||||
private function generateUserAlbumId(AlbumRepository $albumRepo): string
|
||||
{
|
||||
do {
|
||||
$id = 'u_' . bin2hex(random_bytes(6));
|
||||
} while ($albumRepo->findOneByLocalId($id) !== null);
|
||||
} while ($albumRepo->findOneByExternalId($id) !== null);
|
||||
return $id;
|
||||
}
|
||||
|
||||
@@ -235,6 +420,7 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
// Fallback: attempt to parse
|
||||
try {
|
||||
// Trust PHP's parser only as a last resort (it accepts many human formats).
|
||||
$dt = new \DateTimeImmutable($s);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
@@ -246,42 +432,74 @@ 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->get('artistsCsv')->setData(implode(', ', $album->getArtists()));
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$this->handleAlbumCoverUpload($album, $form);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album updated.');
|
||||
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||
$this->normalizeAlbumFormData($album);
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an album by either local or Spotify identifier.
|
||||
* Looks up an album by public identifier.
|
||||
*/
|
||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||
{
|
||||
$local = $albumRepo->findOneByLocalId($id);
|
||||
if ($local instanceof Album) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
return $albumRepo->findOneBySpotifyId($id);
|
||||
return $albumRepo->findOneByExternalId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,10 +507,23 @@ class AlbumController extends AbstractController
|
||||
*/
|
||||
private function canManageAlbum(Album $album): bool
|
||||
{
|
||||
if ($this->isGranted('ROLE_MODERATOR')) {
|
||||
if ($album->getSource() !== 'user') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_MODERATOR')) {
|
||||
return true;
|
||||
}
|
||||
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
|
||||
|
||||
$owner = $album->getOwner();
|
||||
$user = $this->getUser();
|
||||
if (!$owner instanceof User || !$user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownerId = $owner->getId();
|
||||
$userId = $user->getId();
|
||||
return $ownerId !== null && $userId !== null && $ownerId === $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,92 +536,287 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current user created the album.
|
||||
*/
|
||||
private function isAlbumOwner(Album $album): bool
|
||||
private function normalizeAlbumFormData(Album $album): void
|
||||
{
|
||||
$user = $this->getUser();
|
||||
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies normalized metadata from the album form.
|
||||
*/
|
||||
private function applyAlbumFormData(Album $album, FormInterface $form): void
|
||||
{
|
||||
$album->setArtists($this->parseArtistsCsv((string) $form->get('artistsCsv')->getData()));
|
||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the artists CSV input into a normalized list.
|
||||
*
|
||||
* @return list<string>
|
||||
* @return list<array{disc:int,track:int,title:string,duration:string}>
|
||||
*/
|
||||
private function parseArtistsCsv(string $csv): array
|
||||
private function buildTrackEditorRows(Album $album, int $minimumRows = 10): array
|
||||
{
|
||||
$parts = array_map(static fn($s) => trim((string) $s), explode(',', $csv));
|
||||
return array_values(array_filter($parts, static fn($s) => $s !== ''));
|
||||
$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 handleAlbumCoverUpload(Album $album, FormInterface $form): void
|
||||
private function syncTracksFromEditorRequest(Album $album, Request $request, AlbumTrackRepository $trackRepo): bool
|
||||
{
|
||||
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
|
||||
return;
|
||||
}
|
||||
$file = $form->get('coverUpload')->getData();
|
||||
if ($file instanceof UploadedFile) {
|
||||
$this->imageStorage->remove($album->getCoverImagePath());
|
||||
$album->setCoverImagePath($this->imageStorage->storeAlbumCover($file));
|
||||
$trackPayloads = $this->extractTrackPayloadsFromRequest($request);
|
||||
if ($trackPayloads === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trackRepo->replaceAlbumTracks($album, $trackPayloads);
|
||||
$album->setTotalTracks($album->getTracks()->count());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||
private function extractTrackPayloadsFromRequest(Request $request): array
|
||||
{
|
||||
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
if (is_array($tracks) && $tracks !== []) {
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
$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<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||
{
|
||||
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
throw new \InvalidArgumentException('Album payload must include at least one track.');
|
||||
}
|
||||
|
||||
// Bring genres along when we persist external albums so templates can display them immediately.
|
||||
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||
$album = $albumRepo->upsertFromExternalAlbum(
|
||||
$externalAlbum,
|
||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||
);
|
||||
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks($album->getTracks()->count());
|
||||
return $album;
|
||||
}
|
||||
|
||||
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
|
||||
private function syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, ExternalAlbumClientInterface $externalAlbumClient): bool
|
||||
{
|
||||
if ($album->getSource() !== 'spotify') {
|
||||
if ($album->getSource() !== 'external') {
|
||||
return false;
|
||||
}
|
||||
$spotifyId = $album->getSpotifyId();
|
||||
if ($spotifyId === null) {
|
||||
$externalId = $album->getExternalId();
|
||||
if ($externalId === null) {
|
||||
return false;
|
||||
}
|
||||
$storedCount = $album->getTracks()->count();
|
||||
$needsSync = $storedCount === 0;
|
||||
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
||||
// 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;
|
||||
}
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
|
||||
if ($spotifyAlbum === null) {
|
||||
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($externalId);
|
||||
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
return false;
|
||||
}
|
||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
// Rehydrate genres during syncs as well, in case source metadata has updated.
|
||||
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||
$albumGenres = $genresMap[$externalId] ?? [];
|
||||
$albumRepo->upsertFromExternalAlbum($externalAlbum, $albumGenres);
|
||||
$tracks = $externalAlbum['tracks']['items'] ?? [];
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
$album->setTotalTracks($album->getTracks()->count());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function isIngestableExternalAlbum(array $externalAlbum): bool
|
||||
{
|
||||
$tracks = $externalAlbum['tracks']['items'] ?? null;
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tracks as $track) {
|
||||
$name = trim((string) ($track['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function hydrateExternalAlbumImages(array $payload, Album $album, ExternalAlbumClientInterface $externalAlbumClient): array
|
||||
{
|
||||
if ($album->getSource() !== 'external') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$externalId = trim((string) $album->getExternalId());
|
||||
if ($externalId === '') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$fresh = $externalAlbumClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($fresh)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$images = $fresh['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$payload['images'] = $images;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $profile
|
||||
*/
|
||||
private function attachSearchProfileHeaders(Response $response, array $profile): void
|
||||
{
|
||||
$timings = is_array($profile['timings_ms'] ?? null) ? $profile['timings_ms'] : [];
|
||||
$counts = is_array($profile['counts'] ?? null) ? $profile['counts'] : [];
|
||||
|
||||
$total = (float) ($timings['total'] ?? 0.0);
|
||||
$external = (float) ($timings['external'] ?? 0.0);
|
||||
$user = (float) ($timings['user'] ?? 0.0);
|
||||
$externalProvider = (string) ($profile['external_provider'] ?? 'lastfm');
|
||||
$searchApi = (float) ($timings['lastfm_search_total'] ?? 0.0);
|
||||
$getAlbumsApi = (float) ($timings['lastfm_getalbums_total'] ?? 0.0);
|
||||
|
||||
$response->headers->set('X-Tonehaus-Search-Ms', (string) $total);
|
||||
$response->headers->set('X-Tonehaus-External-Ms', (string) $external);
|
||||
$response->headers->set('X-Tonehaus-External-Provider', $externalProvider);
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Search-Calls', (string) ((int) ($counts['lastfm_search_calls'] ?? 0)));
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Calls', (string) ((int) ($counts['lastfm_getalbums_calls'] ?? 0)));
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Ids', (string) ((int) ($counts['lastfm_getalbums_requested_ids'] ?? 0)));
|
||||
|
||||
$response->headers->set(
|
||||
'Server-Timing',
|
||||
sprintf(
|
||||
'search;dur=%.2f, external;dur=%.2f, user;dur=%.2f, lastfm_search;dur=%.2f, lastfm_getalbums;dur=%.2f',
|
||||
$total,
|
||||
$external,
|
||||
$user,
|
||||
$searchApi,
|
||||
$getAlbumsApi
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use App\Dto\AlbumSearchCriteria;
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\ExternalAlbumClientInterface;
|
||||
use App\Service\LastFmTagResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/v1/albums')]
|
||||
class AlbumApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AlbumSearchService $albumSearchService,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly AlbumTrackRepository $trackRepository,
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly ExternalAlbumClientInterface $externalAlbumClient,
|
||||
private readonly LastFmTagResolver $tagResolver,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly int $searchLimit = 20,
|
||||
private readonly bool $apiImageHydrationEnabled = true,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1/albums',
|
||||
operationId: 'api_v1_album_index',
|
||||
summary: 'List albums from local catalog and configured external provider',
|
||||
tags: ['Albums'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'query', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'album', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'artist', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'genre', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'year_from', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'year_to', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'source', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['all', 'external', 'user'])),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'mode', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['full', 'fast'])),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Album list response',
|
||||
content: new OA\JsonContent(type: 'object')
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||
$searchMode = $this->resolveSearchMode($request);
|
||||
$result = $this->albumSearchService->search(
|
||||
$criteria,
|
||||
$searchMode === 'fast',
|
||||
$this->apiImageHydrationEnabled
|
||||
);
|
||||
$profile = $this->albumSearchService->getLastSearchProfile();
|
||||
$queryParameters = $this->buildAlbumIndexQueryParameters($criteria, $searchMode);
|
||||
$albums = array_map(
|
||||
fn(array $album): array => $this->decorateAlbumResource($album),
|
||||
$result->albums
|
||||
);
|
||||
|
||||
$response = $this->apiResponse([
|
||||
'meta' => [
|
||||
'source' => $criteria->source,
|
||||
'limit' => $criteria->limit,
|
||||
'query' => $criteria->query,
|
||||
'mode' => $searchMode,
|
||||
'profile' => $profile,
|
||||
],
|
||||
'data' => $albums,
|
||||
'stats' => $result->stats,
|
||||
'saved_ids' => $result->savedIds,
|
||||
'links' => [
|
||||
'self' => $this->apiHref('api_v1_album_index', $queryParameters),
|
||||
'root' => $this->apiHref('api_v1_index'),
|
||||
'ingest' => $this->apiHref('api_v1_album_ingest'),
|
||||
],
|
||||
'actions' => [
|
||||
'ingest_album' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_album_ingest'),
|
||||
'requires_auth' => true,
|
||||
],
|
||||
],
|
||||
], Response::HTTP_OK);
|
||||
$this->attachSearchProfileHeaders($response, $profile);
|
||||
$response->headers->set('X-Tonehaus-Api-Image-Hydration', $this->apiImageHydrationEnabled ? '1' : '0');
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function resolveSearchMode(Request $request): string
|
||||
{
|
||||
$mode = strtolower(trim((string) $request->query->get('mode', 'full')));
|
||||
return in_array($mode, ['full', 'fast'], true) ? $mode : 'full';
|
||||
}
|
||||
|
||||
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[OA\Post(
|
||||
path: '/api/v1/albums/ingest',
|
||||
operationId: 'api_v1_album_ingest',
|
||||
summary: 'Ingest an external album into local storage',
|
||||
tags: ['Albums'],
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'external_id', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'query', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'artist', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'album', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Album ingested', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, description: 'Invalid request', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 404, description: 'External album not found', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 422, description: 'Album missing required metadata/tracks', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function ingest(Request $request): Response
|
||||
{
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->apiError(
|
||||
'invalid_json',
|
||||
'Request body must be valid JSON.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$externalId = trim((string) ($payload['external_id'] ?? ''));
|
||||
if ($externalId === '') {
|
||||
$query = trim((string) ($payload['query'] ?? ''));
|
||||
if ($query === '') {
|
||||
$artist = trim((string) ($payload['artist'] ?? ''));
|
||||
$album = trim((string) ($payload['album'] ?? ''));
|
||||
$query = trim($artist . ' ' . $album);
|
||||
}
|
||||
if ($query !== '') {
|
||||
$search = $this->externalAlbumClient->searchAlbums($query, 1);
|
||||
$externalId = trim((string) ($search['albums']['items'][0]['id'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
if ($externalId === '') {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Either external_id or query/artist/album is required.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($externalAlbum)) {
|
||||
return $this->apiError(
|
||||
'external_album_not_found',
|
||||
'External album not found.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
return $this->apiError(
|
||||
'album_not_ingestable',
|
||||
'Album is missing required metadata or tracks.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||
$this->entityManager->flush();
|
||||
$payload = $album->toTemplateArray();
|
||||
if ($this->apiImageHydrationEnabled) {
|
||||
$payload = $this->hydrateExternalAlbumImages($payload, $album, $externalAlbum);
|
||||
}
|
||||
|
||||
return $this->apiResponse([
|
||||
'data' => $this->decorateAlbumResource($payload, $album),
|
||||
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1/albums/{id}',
|
||||
operationId: 'api_v1_album_show',
|
||||
summary: 'Get album details by public ID',
|
||||
tags: ['Albums'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Album details', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 422, description: 'Album missing required metadata/tracks', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function show(string $id): Response
|
||||
{
|
||||
$album = $this->findAlbum($id);
|
||||
if (!$album instanceof Album) {
|
||||
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks($id);
|
||||
if (!is_array($externalAlbum)) {
|
||||
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
return $this->apiError(
|
||||
'album_not_ingestable',
|
||||
'Album is missing required metadata or tracks.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||
$this->entityManager->flush();
|
||||
} elseif ($album->getSource() === 'external' && trim((string) $album->getReleaseDate()) === '') {
|
||||
$externalAlbum = $this->externalAlbumClient->getAlbumWithTracks((string) $album->getExternalId());
|
||||
if (is_array($externalAlbum) && $this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
$albumId = (string) $album->getExternalId();
|
||||
$stats = $albumId !== '' ? $this->reviewRepository->getAggregatesForAlbumIds([$albumId]) : [];
|
||||
$payload = $album->toTemplateArray();
|
||||
if ($this->apiImageHydrationEnabled) {
|
||||
$payload = $this->hydrateExternalAlbumImages($payload, $album);
|
||||
}
|
||||
|
||||
return $this->apiResponse([
|
||||
'data' => $this->decorateAlbumResource($payload, $album),
|
||||
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||
'stats' => $stats[$albumId] ?? ['count' => 0, 'avg' => 0.0],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
#[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1/albums/{id}/reviews',
|
||||
operationId: 'api_v1_album_reviews',
|
||||
summary: 'List reviews for an album',
|
||||
tags: ['Albums'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function reviews(string $id): Response
|
||||
{
|
||||
$album = $this->findAlbum($id);
|
||||
if (!$album instanceof Album) {
|
||||
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$reviews = $this->reviewRepository->findBy(['album' => $album], ['createdAt' => 'DESC']);
|
||||
return $this->apiResponse([
|
||||
'data' => array_map(fn($review): array => $this->serializeAlbumReview($review, $album), $reviews),
|
||||
'links' => [
|
||||
'self' => $this->apiHref('api_v1_album_reviews', ['id' => (string) $album->getExternalId()]),
|
||||
'album' => $this->apiHref('api_v1_album_show', ['id' => (string) $album->getExternalId()]),
|
||||
'reviews' => $this->apiHref('api_v1_review_index'),
|
||||
],
|
||||
'actions' => [
|
||||
'create_review' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_review_create'),
|
||||
'requires_auth' => true,
|
||||
],
|
||||
],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function decorateAlbumResource(array $payload, ?Album $album = null): array
|
||||
{
|
||||
$albumId = trim((string) ($payload['id'] ?? $album?->getExternalId() ?? ''));
|
||||
if ($albumId === '') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$links = [
|
||||
'self' => $this->apiHref('api_v1_album_show', ['id' => $albumId]),
|
||||
'reviews' => $this->apiHref('api_v1_album_reviews', ['id' => $albumId]),
|
||||
];
|
||||
|
||||
$sourceUrl = trim((string) ($payload['external_urls']['external'] ?? $album?->getExternalUrl() ?? ''));
|
||||
if ($sourceUrl !== '') {
|
||||
$links['open_source'] = $sourceUrl;
|
||||
}
|
||||
|
||||
$actions = [
|
||||
'create_review' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_review_create'),
|
||||
'requires_auth' => true,
|
||||
'payload' => ['album_id' => $albumId],
|
||||
],
|
||||
];
|
||||
|
||||
$source = strtolower(trim((string) ($payload['source'] ?? $album?->getSource() ?? '')));
|
||||
if ($source === 'external') {
|
||||
$actions['ingest'] = [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_album_ingest'),
|
||||
'requires_auth' => true,
|
||||
'payload' => ['external_id' => $albumId],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->withHypermedia($payload, $links, $actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function serializeAlbumReview(Review $review, Album $album): array
|
||||
{
|
||||
$payload = [
|
||||
'id' => $review->getId(),
|
||||
'title' => $review->getTitle(),
|
||||
'content' => $review->getContent(),
|
||||
'rating' => $review->getRating(),
|
||||
'author' => [
|
||||
'id' => $review->getAuthor()?->getId(),
|
||||
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||
],
|
||||
'created_at' => $review->getCreatedAt()?->format(DATE_ATOM),
|
||||
'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM),
|
||||
];
|
||||
|
||||
$reviewId = $review->getId();
|
||||
if (!is_int($reviewId)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$albumId = trim((string) $album->getExternalId());
|
||||
$links = [
|
||||
'self' => $this->apiHref('api_v1_review_show', ['id' => $reviewId]),
|
||||
];
|
||||
|
||||
if ($albumId !== '') {
|
||||
$links['album'] = $this->apiHref('api_v1_album_show', ['id' => $albumId]);
|
||||
$links['album_reviews'] = $this->apiHref('api_v1_album_reviews', ['id' => $albumId]);
|
||||
}
|
||||
|
||||
return $this->withHypermedia($payload, $links);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildAlbumIndexQueryParameters(AlbumSearchCriteria $criteria, string $mode): array
|
||||
{
|
||||
$parameters = [
|
||||
'source' => $criteria->source,
|
||||
'limit' => $criteria->limit,
|
||||
'mode' => $mode,
|
||||
];
|
||||
|
||||
if ($criteria->query !== '') {
|
||||
$parameters['query'] = $criteria->query;
|
||||
}
|
||||
if ($criteria->albumName !== '') {
|
||||
$parameters['album'] = $criteria->albumName;
|
||||
}
|
||||
if ($criteria->artist !== '') {
|
||||
$parameters['artist'] = $criteria->artist;
|
||||
}
|
||||
if ($criteria->genre !== '') {
|
||||
$parameters['genre'] = $criteria->genre;
|
||||
}
|
||||
if ($criteria->yearFrom !== null) {
|
||||
$parameters['year_from'] = $criteria->yearFrom;
|
||||
}
|
||||
if ($criteria->yearTo !== null) {
|
||||
$parameters['year_to'] = $criteria->yearTo;
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
private function findAlbum(string $id): ?Album
|
||||
{
|
||||
$normalizedId = trim($id);
|
||||
if ($normalizedId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$album = $this->albumRepository->findOneByExternalId($normalizedId);
|
||||
if (!$album instanceof Album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $album->getTotalTracks() > 0 ? $album : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function persistExternalAlbumPayload(array $externalAlbum): Album
|
||||
{
|
||||
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||
throw new \InvalidArgumentException('Album payload must include at least one track.');
|
||||
}
|
||||
|
||||
$genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||
|
||||
$album = $this->albumRepository->upsertFromExternalAlbum(
|
||||
$externalAlbum,
|
||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||
);
|
||||
|
||||
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||
$this->trackRepository->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks($album->getTracks()->count());
|
||||
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function isIngestableExternalAlbum(array $externalAlbum): bool
|
||||
{
|
||||
$albumId = trim((string) ($externalAlbum['id'] ?? ''));
|
||||
$albumName = trim((string) ($externalAlbum['name'] ?? ''));
|
||||
if ($albumId === '' || $albumName === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tracks = $externalAlbum['tracks']['items'] ?? null;
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($tracks as $track) {
|
||||
$name = trim((string) ($track['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,mixed>|null $externalAlbum
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function hydrateExternalAlbumImages(array $payload, Album $album, ?array $externalAlbum = null): array
|
||||
{
|
||||
if ($album->getSource() !== 'external') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$fresh = $externalAlbum;
|
||||
if ($fresh === null) {
|
||||
$externalId = trim((string) $album->getExternalId());
|
||||
if ($externalId === '') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$resolved = $this->externalAlbumClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($resolved)) {
|
||||
return $payload;
|
||||
}
|
||||
$fresh = $resolved;
|
||||
}
|
||||
|
||||
$images = $fresh['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$payload['images'] = $images;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $profile
|
||||
*/
|
||||
private function attachSearchProfileHeaders(Response $response, array $profile): void
|
||||
{
|
||||
$timings = is_array($profile['timings_ms'] ?? null) ? $profile['timings_ms'] : [];
|
||||
$counts = is_array($profile['counts'] ?? null) ? $profile['counts'] : [];
|
||||
|
||||
$total = (float) ($timings['total'] ?? 0.0);
|
||||
$external = (float) ($timings['external'] ?? 0.0);
|
||||
$user = (float) ($timings['user'] ?? 0.0);
|
||||
$externalProvider = (string) ($profile['external_provider'] ?? 'lastfm');
|
||||
$searchApi = (float) ($timings['lastfm_search_total'] ?? 0.0);
|
||||
$getAlbumsApi = (float) ($timings['lastfm_getalbums_total'] ?? 0.0);
|
||||
|
||||
$response->headers->set('X-Tonehaus-Search-Ms', (string) $total);
|
||||
$response->headers->set('X-Tonehaus-External-Ms', (string) $external);
|
||||
$response->headers->set('X-Tonehaus-External-Provider', $externalProvider);
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Search-Calls', (string) ((int) ($counts['lastfm_search_calls'] ?? 0)));
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Calls', (string) ((int) ($counts['lastfm_getalbums_calls'] ?? 0)));
|
||||
$response->headers->set('X-Tonehaus-Lastfm-Getalbums-Ids', (string) ((int) ($counts['lastfm_getalbums_requested_ids'] ?? 0)));
|
||||
|
||||
$response->headers->set(
|
||||
'Server-Timing',
|
||||
sprintf(
|
||||
'search;dur=%.2f, external;dur=%.2f, user;dur=%.2f, lastfm_search;dur=%.2f, lastfm_getalbums;dur=%.2f',
|
||||
$total,
|
||||
$external,
|
||||
$user,
|
||||
$searchApi,
|
||||
$getAlbumsApi
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ApiController centralizes shared API response helpers.
|
||||
*/
|
||||
abstract class ApiController extends AbstractFOSRestController
|
||||
{
|
||||
protected function apiResponse(mixed $payload, int $status = Response::HTTP_OK): Response
|
||||
{
|
||||
return $this->handleView($this->view($payload, $status));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $details
|
||||
*/
|
||||
protected function apiError(string $code, string $message, int $status, array $details = []): Response
|
||||
{
|
||||
$error = [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($details !== []) {
|
||||
$error['details'] = $details;
|
||||
}
|
||||
|
||||
return $this->apiResponse(['error' => $error], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
protected function readJsonPayload(Request $request): ?array
|
||||
{
|
||||
$content = trim($request->getContent());
|
||||
if ($content === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $request->toArray();
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
protected function apiHref(string $route, array $parameters = []): string
|
||||
{
|
||||
return $this->generateUrl($route, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $resource
|
||||
* @param array<string,string> $links
|
||||
* @param array<string,array<string,mixed>> $actions
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function withHypermedia(array $resource, array $links = [], array $actions = []): array
|
||||
{
|
||||
if ($links !== []) {
|
||||
$resource['links'] = $links;
|
||||
}
|
||||
|
||||
if ($actions !== []) {
|
||||
$resource['actions'] = $actions;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/v1')]
|
||||
class ApiIndexController extends ApiController
|
||||
{
|
||||
#[Route('', name: 'api_v1_index', methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1',
|
||||
operationId: 'api_v1_index',
|
||||
summary: 'API index',
|
||||
tags: ['Auth'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'API metadata and available endpoints',
|
||||
content: new OA\JsonContent(type: 'object')
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->apiResponse([
|
||||
'name' => 'Tonehaus API',
|
||||
'version' => 'v1',
|
||||
'endpoints' => [
|
||||
'auth' => [
|
||||
'POST /api/v1/auth/login',
|
||||
],
|
||||
'albums' => [
|
||||
'GET /api/v1/albums',
|
||||
'POST /api/v1/albums/ingest (JWT)',
|
||||
'GET /api/v1/albums/{id}',
|
||||
'GET /api/v1/albums/{id}/reviews',
|
||||
],
|
||||
'reviews' => [
|
||||
'GET /api/v1/reviews',
|
||||
'GET /api/v1/reviews/{id}',
|
||||
'POST /api/v1/reviews (JWT)',
|
||||
'PUT /api/v1/reviews/{id} (JWT)',
|
||||
'PATCH /api/v1/reviews/{id} (JWT)',
|
||||
'DELETE /api/v1/reviews/{id} (JWT)',
|
||||
],
|
||||
],
|
||||
'links' => [
|
||||
'self' => $this->apiHref('api_v1_index'),
|
||||
'auth_login' => $this->apiHref('api_v1_auth_login'),
|
||||
'albums' => $this->apiHref('api_v1_album_index'),
|
||||
'reviews' => $this->apiHref('api_v1_review_index'),
|
||||
'docs' => $this->apiHref('api_docs_ui'),
|
||||
'docs_v1' => $this->apiHref('api_v1_docs_ui'),
|
||||
'schema' => $this->apiHref('api_docs_json'),
|
||||
'schema_v1' => $this->apiHref('api_v1_docs_json'),
|
||||
],
|
||||
'actions' => [
|
||||
'login' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_auth_login'),
|
||||
],
|
||||
'search_albums' => [
|
||||
'method' => 'GET',
|
||||
'href' => $this->apiHref('api_v1_album_index'),
|
||||
],
|
||||
'create_review' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_review_create'),
|
||||
'requires_auth' => true,
|
||||
],
|
||||
],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/v1/auth')]
|
||||
final class AuthApiController extends ApiController
|
||||
{
|
||||
#[Route('/login', name: 'api_v1_auth_login', methods: ['POST'])]
|
||||
#[OA\Post(
|
||||
path: '/api/v1/auth/login',
|
||||
operationId: 'api_v1_auth_login',
|
||||
summary: 'Obtain JWT access token',
|
||||
tags: ['Auth'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email', 'password'],
|
||||
properties: [
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
|
||||
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'JWT token response',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'token', type: 'string', example: 'eyJ...')
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Invalid credentials', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function loginPlaceholder(): Response
|
||||
{
|
||||
return $this->apiError(
|
||||
'unsupported_operation',
|
||||
'This route is handled by Symfony security json_login.',
|
||||
Response::HTTP_METHOD_NOT_ALLOWED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/v1/reviews')]
|
||||
class ReviewApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1/reviews',
|
||||
operationId: 'api_v1_review_index',
|
||||
summary: 'List latest reviews',
|
||||
tags: ['Reviews'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 50)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$limit = max(1, min(100, (int) $request->query->get('limit', 50)));
|
||||
$reviews = $this->reviewRepository->findLatest($limit);
|
||||
|
||||
return $this->apiResponse([
|
||||
'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews),
|
||||
'links' => [
|
||||
'self' => $this->apiHref('api_v1_review_index', ['limit' => $limit]),
|
||||
'root' => $this->apiHref('api_v1_index'),
|
||||
'create' => $this->apiHref('api_v1_review_create'),
|
||||
],
|
||||
'actions' => [
|
||||
'create_review' => [
|
||||
'method' => 'POST',
|
||||
'href' => $this->apiHref('api_v1_review_create'),
|
||||
'requires_auth' => true,
|
||||
],
|
||||
],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
#[OA\Get(
|
||||
path: '/api/v1/reviews/{id}',
|
||||
operationId: 'api_v1_review_show',
|
||||
summary: 'Get a review by ID',
|
||||
tags: ['Reviews'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Review details', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function show(Review $review): Response
|
||||
{
|
||||
return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_review_create', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[OA\Post(
|
||||
path: '/api/v1/reviews',
|
||||
operationId: 'api_v1_review_create',
|
||||
summary: 'Create a review',
|
||||
tags: ['Reviews'],
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['album_id', 'title', 'content', 'rating'],
|
||||
properties: [
|
||||
new OA\Property(property: 'album_id', type: 'string'),
|
||||
new OA\Property(property: 'title', type: 'string'),
|
||||
new OA\Property(property: 'content', type: 'string'),
|
||||
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Review created', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->apiError(
|
||||
'invalid_json',
|
||||
'Request body must be valid JSON.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$albumId = trim((string) ($payload['album_id'] ?? ''));
|
||||
$album = $this->resolveAlbum($albumId);
|
||||
if (!$album instanceof Album) {
|
||||
return $this->apiError('album_not_found', 'Album not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$title = trim((string) ($payload['title'] ?? ''));
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$rating = (int) ($payload['rating'] ?? 0);
|
||||
|
||||
$fieldErrors = $this->validateCreatePayload($title, $content, $rating);
|
||||
if ($fieldErrors !== []) {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Review payload failed validation.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => $fieldErrors]
|
||||
);
|
||||
}
|
||||
|
||||
$author = $this->getUser();
|
||||
if (!$author instanceof User) {
|
||||
return $this->apiError(
|
||||
'authentication_required',
|
||||
'Authentication is required.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$review = new Review();
|
||||
$review->setAlbum($album);
|
||||
$review->setAuthor($author);
|
||||
$review->setTitle($title);
|
||||
$review->setContent($content);
|
||||
$review->setRating($rating);
|
||||
|
||||
$this->entityManager->persist($review);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[OA\Put(
|
||||
path: '/api/v1/reviews/{id}',
|
||||
operationId: 'api_v1_review_update_put',
|
||||
summary: 'Replace a review',
|
||||
tags: ['Reviews'],
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'title', type: 'string'),
|
||||
new OA\Property(property: 'content', type: 'string'),
|
||||
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
#[OA\Patch(
|
||||
path: '/api/v1/reviews/{id}',
|
||||
operationId: 'api_v1_review_update_patch',
|
||||
summary: 'Partially update a review',
|
||||
tags: ['Reviews'],
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'title', type: 'string'),
|
||||
new OA\Property(property: 'content', type: 'string'),
|
||||
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 400, description: 'Invalid JSON body', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 422, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function update(Review $review, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
|
||||
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->apiError(
|
||||
'invalid_json',
|
||||
'Request body must be valid JSON.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->payloadContainsUpdateField($payload)) {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'At least one updatable field is required.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => ['title', 'content', 'rating']]
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('title', $payload)) {
|
||||
$title = trim((string) $payload['title']);
|
||||
if ($title === '') {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Review payload failed validation.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => ['title' => 'Title cannot be empty.']]
|
||||
);
|
||||
}
|
||||
$review->setTitle($title);
|
||||
}
|
||||
|
||||
if (array_key_exists('content', $payload)) {
|
||||
$content = trim((string) $payload['content']);
|
||||
if ($content === '') {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Review payload failed validation.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => ['content' => 'Content cannot be empty.']]
|
||||
);
|
||||
}
|
||||
if (mb_strlen($content) < 20) {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Review payload failed validation.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => ['content' => 'Content must be at least 20 characters.']]
|
||||
);
|
||||
}
|
||||
$review->setContent($content);
|
||||
}
|
||||
|
||||
if (array_key_exists('rating', $payload)) {
|
||||
$rating = (int) $payload['rating'];
|
||||
if ($rating < 1 || $rating > 10) {
|
||||
return $this->apiError(
|
||||
'validation_error',
|
||||
'Review payload failed validation.',
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
['fields' => ['rating' => 'Rating must be between 1 and 10.']]
|
||||
);
|
||||
}
|
||||
$review->setRating($rating);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->apiResponse(['data' => $this->serializeReview($review)], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[OA\Delete(
|
||||
path: '/api/v1/reviews/{id}',
|
||||
operationId: 'api_v1_review_delete',
|
||||
summary: 'Delete a review',
|
||||
tags: ['Reviews'],
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 204, description: 'Review deleted'),
|
||||
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
|
||||
]
|
||||
)]
|
||||
public function delete(Review $review): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
||||
|
||||
$this->entityManager->remove($review);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->apiResponse(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function serializeReview(Review $review): array
|
||||
{
|
||||
$album = $review->getAlbum();
|
||||
$reviewId = $review->getId();
|
||||
$albumId = trim((string) $album?->getExternalId());
|
||||
|
||||
$payload = [
|
||||
'id' => $review->getId(),
|
||||
'title' => $review->getTitle(),
|
||||
'content' => $review->getContent(),
|
||||
'rating' => $review->getRating(),
|
||||
'created_at' => $review->getCreatedAt()?->format(DATE_ATOM),
|
||||
'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM),
|
||||
'author' => [
|
||||
'id' => $review->getAuthor()?->getId(),
|
||||
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||
],
|
||||
'album' => [
|
||||
'id' => $album?->getExternalId(),
|
||||
'name' => $album?->getName(),
|
||||
'source' => $album?->getSource(),
|
||||
],
|
||||
];
|
||||
|
||||
$links = [];
|
||||
if (is_int($reviewId)) {
|
||||
$links['self'] = $this->apiHref('api_v1_review_show', ['id' => $reviewId]);
|
||||
}
|
||||
if ($albumId !== '') {
|
||||
$links['album'] = $this->apiHref('api_v1_album_show', ['id' => $albumId]);
|
||||
$links['album_reviews'] = $this->apiHref('api_v1_album_reviews', ['id' => $albumId]);
|
||||
}
|
||||
|
||||
$actions = [];
|
||||
if (is_int($reviewId) && $this->isGranted('REVIEW_EDIT', $review)) {
|
||||
$actions['update'] = [
|
||||
'method' => 'PATCH',
|
||||
'href' => $this->apiHref('api_v1_review_update', ['id' => $reviewId]),
|
||||
'requires_auth' => true,
|
||||
];
|
||||
}
|
||||
if (is_int($reviewId) && $this->isGranted('REVIEW_DELETE', $review)) {
|
||||
$actions['delete'] = [
|
||||
'method' => 'DELETE',
|
||||
'href' => $this->apiHref('api_v1_review_delete', ['id' => $reviewId]),
|
||||
'requires_auth' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->withHypermedia($payload, $links, $actions);
|
||||
}
|
||||
|
||||
private function resolveAlbum(string $albumId): ?Album
|
||||
{
|
||||
if ($albumId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$album = $this->albumRepository->findOneByExternalId($albumId);
|
||||
if (!$album instanceof Album) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $album->getTotalTracks() > 0 ? $album : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function validateCreatePayload(string $title, string $content, int $rating): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if ($title === '') {
|
||||
$errors['title'] = 'Title is required.';
|
||||
}
|
||||
if ($content === '') {
|
||||
$errors['content'] = 'Content is required.';
|
||||
} elseif (mb_strlen($content) < 20) {
|
||||
$errors['content'] = 'Content must be at least 20 characters.';
|
||||
}
|
||||
if ($rating < 1 || $rating > 10) {
|
||||
$errors['rating'] = 'Rating must be between 1 and 10.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function payloadContainsUpdateField(array $payload): bool
|
||||
{
|
||||
return array_key_exists('title', $payload)
|
||||
|| array_key_exists('content', $payload)
|
||||
|| array_key_exists('rating', $payload);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ use App\Entity\Review;
|
||||
use App\Form\ReviewType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* ReviewController funnels CRUD flows through album pages and simple routes.
|
||||
* ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow.
|
||||
*/
|
||||
#[Route('/reviews')]
|
||||
class ReviewController extends AbstractController
|
||||
@@ -28,7 +28,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects users to the album flow when starting a review.
|
||||
* Nudges users back to the album flow so new reviews always start from a specific album.
|
||||
*/
|
||||
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
@@ -43,7 +43,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a standalone review page.
|
||||
* Shows a read-only page for a single review.
|
||||
*/
|
||||
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
public function show(Review $review): Response
|
||||
@@ -54,7 +54,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles review form edits with authorization.
|
||||
* Handles review edits by running the voter, binding the form, and saving valid updates.
|
||||
*/
|
||||
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
@@ -78,7 +78,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a review when the CSRF token and permission check pass.
|
||||
* Deletes a review after both the CSRF token and voter checks pass.
|
||||
*/
|
||||
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
|
||||
@@ -5,17 +5,24 @@ namespace App\Dto;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AdminUserData transports user creation input from the admin form.
|
||||
* This is a Data Transfer Object to avoid direct entity manipulation.
|
||||
* AdminUserData carries the lightweight fields needed when admins create or edit
|
||||
* users from the back office without touching the `User` entity directly.
|
||||
* Used to allow user creation in the user management panel without invalidating active token.
|
||||
* (This took too long to figure out)
|
||||
|
||||
*/
|
||||
class AdminUserData
|
||||
{
|
||||
/**
|
||||
* Email address for the managed user.
|
||||
*/
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Optional public display name.
|
||||
*/
|
||||
#[Assert\Length(max: 120)]
|
||||
public ?string $displayName = null;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,35 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
*/
|
||||
final class AlbumSearchCriteria
|
||||
{
|
||||
/** Free-form query that mixes album, artist, and keyword matches. */
|
||||
public readonly string $query;
|
||||
|
||||
/** Explicit album title filter supplied via the advanced panel. */
|
||||
public readonly string $albumName;
|
||||
|
||||
/** Explicit artist filter supplied via the advanced panel. */
|
||||
public readonly string $artist;
|
||||
|
||||
/** Genre substring to match within stored external/user genres. */
|
||||
public readonly string $genre;
|
||||
|
||||
/** Lower bound (inclusive) of the release year filter, if any. */
|
||||
public readonly ?int $yearFrom;
|
||||
|
||||
/** Upper bound (inclusive) of the release year filter, if any. */
|
||||
public readonly ?int $yearTo;
|
||||
|
||||
/** Requested source scope: `all`, `external`, or `user` (`external` kept for legacy links). */
|
||||
public readonly string $source;
|
||||
|
||||
/** Maximum number of results the search should return. */
|
||||
public readonly int $limit;
|
||||
|
||||
public function __construct(
|
||||
string $query,
|
||||
string $albumName,
|
||||
string $artist,
|
||||
string $genre,
|
||||
?int $yearFrom,
|
||||
?int $yearTo,
|
||||
string $source,
|
||||
@@ -29,9 +46,10 @@ final class AlbumSearchCriteria
|
||||
$this->query = $query;
|
||||
$this->albumName = $albumName;
|
||||
$this->artist = $artist;
|
||||
$this->genre = $genre;
|
||||
$this->yearFrom = $yearFrom;
|
||||
$this->yearTo = $yearTo;
|
||||
$this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
$this->source = in_array($source, ['all', 'external', 'user'], true) ? $source : 'all';
|
||||
$this->limit = max(1, $limit);
|
||||
}
|
||||
|
||||
@@ -40,23 +58,32 @@ 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)
|
||||
);
|
||||
}
|
||||
|
||||
public function useSpotify(): bool
|
||||
/**
|
||||
* Determines whether the search should include externally sourced albums.
|
||||
*/
|
||||
public function shouldUseExternal(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'spotify';
|
||||
return $this->source === 'all' || $this->source === 'external';
|
||||
}
|
||||
|
||||
public function useUser(): bool
|
||||
/**
|
||||
* Determines whether the search should include user-created albums.
|
||||
*/
|
||||
public function shouldUseUserCatalog(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'user';
|
||||
}
|
||||
@@ -70,13 +97,58 @@ 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
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
return in_array($source, ['all', 'external', 'user'], true) ? $source : 'all';
|
||||
}
|
||||
|
||||
private static function resolveSourceFromRequest(Request $request): string
|
||||
{
|
||||
// New faceted UX uses a single user-only toggle.
|
||||
if ($request->query->has('user_only')) {
|
||||
return $request->query->getBoolean('user_only') ? 'user' : 'all';
|
||||
}
|
||||
|
||||
// Keep compatibility for any existing links using `source=...`.
|
||||
return self::normalizeSource($request->query->getString('source', 'all'));
|
||||
}
|
||||
|
||||
private static function resolveQueryFromRequest(Request $request): string
|
||||
{
|
||||
// API uses `query`; web UI currently uses `q`. Accept both.
|
||||
$query = trim($request->query->getString('query', ''));
|
||||
if ($query !== '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return trim((string) $request->query->get('q', ''));
|
||||
}
|
||||
|
||||
private static function resolveLimitFromRequest(Request $request, int $defaultLimit): int
|
||||
{
|
||||
$maxLimit = max(1, $defaultLimit);
|
||||
$raw = trim((string) $request->query->get('limit', ''));
|
||||
if ($raw === '' || !preg_match('/^\d+$/', $raw)) {
|
||||
return $maxLimit;
|
||||
}
|
||||
|
||||
$limit = (int) $raw;
|
||||
if ($limit <= 0) {
|
||||
return $maxLimit;
|
||||
}
|
||||
|
||||
return min($limit, $maxLimit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Dto;
|
||||
|
||||
/**
|
||||
* AlbumSearchResult encapsulates merged payloads for presentation layers.
|
||||
* AlbumSearchResult is the value object returned by AlbumSearchService.
|
||||
* It keeps the original criteria alongside the merged album payloads,
|
||||
* per-album review stats, and a list of externally sourced IDs stored locally.
|
||||
*/
|
||||
final class AlbumSearchResult
|
||||
{
|
||||
@@ -13,11 +15,14 @@ final class AlbumSearchResult
|
||||
* @param array<int,string> $savedIds
|
||||
*/
|
||||
public function __construct(
|
||||
/** Filters that produced this result set. */
|
||||
public readonly AlbumSearchCriteria $criteria,
|
||||
/** Album payloads ready for Twig rendering. */
|
||||
public readonly array $albums,
|
||||
/** Per-album review aggregates keyed by album ID. */
|
||||
public readonly array $stats,
|
||||
/** List of externally sourced IDs saved locally for quick lookup. */
|
||||
public readonly array $savedIds
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+105
-108
@@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Album aggregates Spotify or user-submitted metadata persisted in the catalog.
|
||||
* Album aggregates external or user-submitted metadata persisted in the catalog.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
|
||||
#[ORM\Table(name: 'albums')]
|
||||
@@ -27,17 +27,13 @@ class Album
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
// For Spotify-sourced albums; null for user-created
|
||||
// Public identifier used by APIs and UI.
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||
private ?string $spotifyId = null;
|
||||
private ?string $externalId = null;
|
||||
|
||||
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||
private ?string $localId = null;
|
||||
|
||||
// 'spotify' or 'user'
|
||||
// 'external' or 'user'
|
||||
#[ORM\Column(type: 'string', length: 16)]
|
||||
private string $source = 'spotify';
|
||||
private string $source = 'external';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
@@ -49,32 +45,35 @@ class Album
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $artists = [];
|
||||
|
||||
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $genres = [];
|
||||
|
||||
// Stored as provided by the source: YYYY or YYYY-MM or YYYY-MM-DD
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $releaseDate = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $totalTracks = 0;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||
private ?string $coverUrl = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $coverImagePath = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||
private ?string $externalUrl = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[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();
|
||||
@@ -109,39 +108,23 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Spotify album identifier when sourced from Spotify.
|
||||
* Gets the external album identifier when sourced externally.
|
||||
*/
|
||||
public function getSpotifyId(): ?string
|
||||
public function getExternalId(): ?string
|
||||
{
|
||||
return $this->spotifyId;
|
||||
return $this->externalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the Spotify album identifier.
|
||||
* Stores the external album identifier.
|
||||
*/
|
||||
public function setSpotifyId(?string $spotifyId): void
|
||||
public function setExternalId(?string $externalId): void
|
||||
{
|
||||
$this->spotifyId = $spotifyId;
|
||||
$this->externalId = $externalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local unique identifier for user-created albums.
|
||||
*/
|
||||
public function getLocalId(): ?string
|
||||
{
|
||||
return $this->localId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the local identifier for user-created albums.
|
||||
*/
|
||||
public function setLocalId(?string $localId): void
|
||||
{
|
||||
$this->localId = $localId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the album source flag ("spotify" or "user").
|
||||
* Returns the album source flag ("external" or "user").
|
||||
*/
|
||||
public function getSource(): string
|
||||
{
|
||||
@@ -149,7 +132,7 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the album source flag ("spotify" or "user").
|
||||
* Sets the album source flag ("external" or "user").
|
||||
*/
|
||||
public function setSource(string $source): void
|
||||
{
|
||||
@@ -188,6 +171,27 @@ class Album
|
||||
$this->artists = array_values($artists);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getGenres(): array
|
||||
{
|
||||
return $this->genres;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $genres
|
||||
*/
|
||||
public function setGenres(array $genres): void
|
||||
{
|
||||
$normalized = array_map(
|
||||
static fn($genre) => trim((string) $genre),
|
||||
$genres
|
||||
);
|
||||
$filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== ''));
|
||||
$this->genres = $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored release date string.
|
||||
*/
|
||||
@@ -221,29 +225,29 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferred cover art URL.
|
||||
*/
|
||||
public function getCoverUrl(): ?string
|
||||
{
|
||||
return $this->coverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the preferred cover art URL.
|
||||
*/
|
||||
public function setCoverUrl(?string $coverUrl): void
|
||||
{
|
||||
$this->coverUrl = $coverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an external link (defaults to Spotify).
|
||||
* Returns an external link.
|
||||
*/
|
||||
public function getExternalUrl(): ?string
|
||||
{
|
||||
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.
|
||||
*/
|
||||
@@ -252,38 +256,6 @@ class Album
|
||||
$this->externalUrl = $externalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user that created the album, when applicable.
|
||||
*/
|
||||
public function getCreatedBy(): ?User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locally stored cover image path for user albums.
|
||||
*/
|
||||
public function getCoverImagePath(): ?string
|
||||
{
|
||||
return $this->coverImagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the locally stored cover image path for user albums.
|
||||
*/
|
||||
public function setCoverImagePath(?string $coverImagePath): void
|
||||
{
|
||||
$this->coverImagePath = $coverImagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the owner responsible for the album.
|
||||
*/
|
||||
public function setCreatedBy(?User $user): void
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the creation timestamp.
|
||||
*/
|
||||
@@ -300,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.
|
||||
*
|
||||
@@ -307,36 +295,47 @@ class Album
|
||||
*/
|
||||
public function toTemplateArray(): array
|
||||
{
|
||||
$images = [];
|
||||
$imageUrl = $this->coverUrl;
|
||||
if ($this->source === 'user' && $this->coverImagePath) {
|
||||
$imageUrl = $this->coverImagePath;
|
||||
}
|
||||
if ($imageUrl) {
|
||||
$images = [
|
||||
['url' => $imageUrl],
|
||||
['url' => $imageUrl],
|
||||
];
|
||||
}
|
||||
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
|
||||
$external = $this->externalUrl;
|
||||
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
|
||||
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
|
||||
$genres = $this->source === 'external'
|
||||
? $this->normalizeTopGenreForOutput($this->genres)
|
||||
: array_slice($this->genres, 0, 5);
|
||||
|
||||
$images = [];
|
||||
$coverImagePath = trim((string) $this->coverImagePath);
|
||||
if ($coverImagePath !== '') {
|
||||
$images[] = ['url' => $coverImagePath];
|
||||
}
|
||||
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
|
||||
|
||||
return [
|
||||
'id' => $publicId,
|
||||
'id' => (string) $this->externalId,
|
||||
'name' => $this->name,
|
||||
'images' => $images,
|
||||
'artists' => $artists,
|
||||
'genres' => $genres,
|
||||
'release_date' => $this->releaseDate,
|
||||
'total_tracks' => $this->totalTracks,
|
||||
'external_urls' => [ 'spotify' => $external ],
|
||||
'external_urls' => ['external' => $external],
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $genres
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeTopGenreForOutput(array $genres): array
|
||||
{
|
||||
foreach ($genres as $genre) {
|
||||
$value = trim((string) $genre);
|
||||
if ($value !== '') {
|
||||
return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AlbumTrack>
|
||||
*/
|
||||
@@ -360,5 +359,3 @@ class Album
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Repository\AlbumTrackRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* AlbumTrack persists individual tracks fetched from Spotify.
|
||||
* AlbumTrack persists individual tracks fetched from External.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
|
||||
#[ORM\Table(name: 'album_tracks')]
|
||||
@@ -23,7 +23,7 @@ class AlbumTrack
|
||||
private ?Album $album = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $spotifyTrackId = null;
|
||||
private ?string $externalTrackId = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $discNumber = 1;
|
||||
@@ -55,14 +55,14 @@ class AlbumTrack
|
||||
$this->album = $album;
|
||||
}
|
||||
|
||||
public function getSpotifyTrackId(): ?string
|
||||
public function getExternalTrackId(): ?string
|
||||
{
|
||||
return $this->spotifyTrackId;
|
||||
return $this->externalTrackId;
|
||||
}
|
||||
|
||||
public function setSpotifyTrackId(?string $spotifyTrackId): void
|
||||
public function setExternalTrackId(?string $externalTrackId): void
|
||||
{
|
||||
$this->spotifyTrackId = $spotifyTrackId;
|
||||
$this->externalTrackId = $externalTrackId;
|
||||
}
|
||||
|
||||
public function getDiscNumber(): int
|
||||
|
||||
+27
-1
@@ -46,6 +46,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $profileImagePath = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50, nullable: true)]
|
||||
private ?string $oauthProvider = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 190, nullable: true)]
|
||||
private ?string $oauthId = null;
|
||||
|
||||
/**
|
||||
* Returns the database identifier.
|
||||
*/
|
||||
@@ -133,6 +139,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
/**
|
||||
* Removes any sensitive transient data (no-op here).
|
||||
*/
|
||||
#[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// no-op
|
||||
@@ -163,6 +170,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
$this->profileImagePath = $profileImagePath;
|
||||
}
|
||||
|
||||
public function getOauthProvider(): ?string
|
||||
{
|
||||
return $this->oauthProvider;
|
||||
}
|
||||
|
||||
public function setOauthProvider(?string $oauthProvider): void
|
||||
{
|
||||
$this->oauthProvider = $oauthProvider === null ? null : strtolower(trim($oauthProvider));
|
||||
}
|
||||
|
||||
public function getOauthId(): ?string
|
||||
{
|
||||
return $this->oauthId;
|
||||
}
|
||||
|
||||
public function setOauthId(?string $oauthId): void
|
||||
{
|
||||
$this->oauthId = $oauthId === null ? null : trim($oauthId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Validator\Exception\ValidationFailedException;
|
||||
|
||||
/**
|
||||
* ApiExceptionSubscriber normalizes API failures into one JSON error envelope.
|
||||
*/
|
||||
class ApiExceptionSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $debug = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::EXCEPTION => 'onKernelException',
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
if (!str_starts_with($request->getPathInfo(), '/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$throwable = $event->getThrowable();
|
||||
$status = $this->resolveStatusCode($throwable);
|
||||
[$code, $message] = $this->resolveCodeAndMessage($status, $throwable);
|
||||
|
||||
$payload = [
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'status' => $status,
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->debug && $status >= 500) {
|
||||
$payload['error']['details'] = [
|
||||
'exception' => $throwable::class,
|
||||
];
|
||||
}
|
||||
|
||||
$event->setResponse(new JsonResponse($payload, $status));
|
||||
}
|
||||
|
||||
private function resolveStatusCode(\Throwable $throwable): int
|
||||
{
|
||||
if ($throwable instanceof AuthenticationException) {
|
||||
return Response::HTTP_UNAUTHORIZED;
|
||||
}
|
||||
|
||||
if ($throwable instanceof AccessDeniedException) {
|
||||
return Response::HTTP_FORBIDDEN;
|
||||
}
|
||||
|
||||
if ($throwable instanceof ValidationFailedException) {
|
||||
return Response::HTTP_UNPROCESSABLE_ENTITY;
|
||||
}
|
||||
|
||||
if ($throwable instanceof HttpExceptionInterface) {
|
||||
return $throwable->getStatusCode();
|
||||
}
|
||||
|
||||
if ($throwable instanceof \InvalidArgumentException || $throwable instanceof \JsonException) {
|
||||
return Response::HTTP_BAD_REQUEST;
|
||||
}
|
||||
|
||||
return Response::HTTP_INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveCodeAndMessage(int $status, \Throwable $throwable): array
|
||||
{
|
||||
return match ($status) {
|
||||
Response::HTTP_BAD_REQUEST => ['invalid_request', 'Request validation failed.'],
|
||||
Response::HTTP_UNAUTHORIZED => ['authentication_required', 'Authentication is required.'],
|
||||
Response::HTTP_FORBIDDEN => ['forbidden', 'You do not have permission to perform this action.'],
|
||||
Response::HTTP_NOT_FOUND => ['not_found', 'Requested resource was not found.'],
|
||||
Response::HTTP_METHOD_NOT_ALLOWED => ['method_not_allowed', 'HTTP method is not allowed for this endpoint.'],
|
||||
Response::HTTP_UNSUPPORTED_MEDIA_TYPE => ['unsupported_media_type', 'Content type is not supported.'],
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY => ['validation_error', 'Request validation failed.'],
|
||||
default => $this->resolveFallbackCodeAndMessage($status, $throwable),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function resolveFallbackCodeAndMessage(int $status, \Throwable $throwable): array
|
||||
{
|
||||
if ($status >= 500) {
|
||||
return ['internal_server_error', 'An unexpected server error occurred.'];
|
||||
}
|
||||
|
||||
$fallbackMessage = trim($throwable->getMessage());
|
||||
if ($fallbackMessage === '') {
|
||||
$fallbackMessage = 'Request could not be processed.';
|
||||
}
|
||||
|
||||
return ['request_error', $fallbackMessage];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationFailureEvent;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Events;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* ApiJwtFailureSubscriber normalizes Lexik JWT failures to the API error envelope.
|
||||
*/
|
||||
class ApiJwtFailureSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::AUTHENTICATION_FAILURE => 'onAuthenticationFailure',
|
||||
Events::JWT_NOT_FOUND => 'onJwtNotFound',
|
||||
Events::JWT_INVALID => 'onJwtInvalid',
|
||||
Events::JWT_EXPIRED => 'onJwtExpired',
|
||||
];
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(AuthenticationFailureEvent $event): void
|
||||
{
|
||||
if (!$this->isApiRequest($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $event->getResponse()?->getStatusCode() ?? Response::HTTP_UNAUTHORIZED;
|
||||
$message = trim((string) $event->getException()->getMessageKey());
|
||||
if ($message === '') {
|
||||
$message = 'Authentication failed.';
|
||||
}
|
||||
|
||||
$event->setResponse($this->buildErrorResponse('invalid_credentials', $message, $status));
|
||||
}
|
||||
|
||||
public function onJwtNotFound(JWTNotFoundEvent $event): void
|
||||
{
|
||||
if (!$this->isApiRequest($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->buildErrorResponse(
|
||||
'authentication_required',
|
||||
'Authentication is required.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
));
|
||||
}
|
||||
|
||||
public function onJwtInvalid(JWTInvalidEvent $event): void
|
||||
{
|
||||
if (!$this->isApiRequest($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->buildErrorResponse(
|
||||
'invalid_token',
|
||||
'Invalid authentication token.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
));
|
||||
}
|
||||
|
||||
public function onJwtExpired(JWTExpiredEvent $event): void
|
||||
{
|
||||
if (!$this->isApiRequest($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->buildErrorResponse(
|
||||
'token_expired',
|
||||
'Authentication token has expired.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
));
|
||||
}
|
||||
|
||||
private function buildErrorResponse(string $code, string $message, int $status): JsonResponse
|
||||
{
|
||||
return new JsonResponse([
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'status' => $status,
|
||||
],
|
||||
], $status);
|
||||
}
|
||||
|
||||
private function isApiRequest(
|
||||
AuthenticationFailureEvent|JWTNotFoundEvent|JWTInvalidEvent|JWTExpiredEvent $event
|
||||
): bool
|
||||
{
|
||||
$path = $event->getRequest()?->getPathInfo();
|
||||
|
||||
return is_string($path) && str_starts_with($path, '/api/');
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
class AdminUserType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Declares the admin-only account fields plus password confirmation.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -46,6 +49,9 @@ class AdminUserType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the AdminUserData DTO as the underlying data object.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
+85
-5
@@ -4,14 +4,19 @@ 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\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
|
||||
*/
|
||||
class AlbumType extends AbstractType
|
||||
{
|
||||
/**
|
||||
@@ -28,11 +33,23 @@ class AlbumType extends AbstractType
|
||||
'label' => 'Artists (comma-separated)',
|
||||
'constraints' => [new Assert\NotBlank()],
|
||||
])
|
||||
->add('releaseDate', TextType::class, [
|
||||
->add('genresCsv', TextType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
|
||||
'label' => 'Genres (comma-separated)',
|
||||
'help' => 'Optional: e.g. Dream pop, Shoegaze',
|
||||
])
|
||||
->add('releaseDate', DateType::class, [
|
||||
'required' => false,
|
||||
'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, [
|
||||
@@ -45,7 +62,41 @@ 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.
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
$album = $event->getData();
|
||||
if (!$album instanceof Album) {
|
||||
return;
|
||||
}
|
||||
$form = $event->getForm();
|
||||
if ($form->has('artistsCsv')) {
|
||||
$form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists()));
|
||||
}
|
||||
if ($form->has('genresCsv')) {
|
||||
$form->get('genresCsv')->setData($this->implodeCsv($album->getGenres()));
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the CSV helper fields back into normalized arrays when saving.
|
||||
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
||||
$album = $event->getData();
|
||||
if (!$album instanceof Album) {
|
||||
return;
|
||||
}
|
||||
$form = $event->getForm();
|
||||
if ($form->has('artistsCsv')) {
|
||||
$album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData()));
|
||||
}
|
||||
if ($form->has('genresCsv')) {
|
||||
$album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +108,35 @@ class AlbumType extends AbstractType
|
||||
'data_class' => Album::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a comma-separated string into a normalized, de-duplicated list.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitCsv(string $input): array
|
||||
{
|
||||
if ($input === '') {
|
||||
return [];
|
||||
}
|
||||
$normalized = str_replace(["\n", "\r", ';'], ',', $input);
|
||||
$parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized));
|
||||
$filtered = array_values(array_filter($parts, static fn(string $value) => $value !== ''));
|
||||
|
||||
return array_values(array_unique($filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a list back into a user-friendly CSV string for display.
|
||||
*
|
||||
* @param list<string> $items
|
||||
*/
|
||||
private function implodeCsv(array $items): string
|
||||
{
|
||||
if ($items === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* ProfileFormType lets authenticated users edit their account details and password.
|
||||
*/
|
||||
class ProfileFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* RegistrationFormType defines the public signup form and its validation rules.
|
||||
*/
|
||||
class RegistrationFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* ReviewType captures the fields needed to author or edit a review.
|
||||
*/
|
||||
class ReviewType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -4,26 +4,65 @@ 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 external API/provider credentials and registration controls.
|
||||
*/
|
||||
class SiteSettingsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Exposes Spotify credential inputs for administrators.
|
||||
* Exposes provider + credential inputs for administrators.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('SPOTIFY_CLIENT_ID', TextType::class, [
|
||||
->add('EXTERNAL_ALBUM_PROVIDER', ChoiceType::class, [
|
||||
'required' => true,
|
||||
'label' => 'External album provider',
|
||||
'mapped' => false,
|
||||
'choices' => [
|
||||
'Last.fm' => 'lastfm',
|
||||
'MusicBrainz' => 'musicbrainz',
|
||||
'TIDAL' => 'tidal',
|
||||
],
|
||||
])
|
||||
->add('LASTFM_API_KEY', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client ID',
|
||||
'label' => 'Last.fm API key',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
|
||||
->add('MUSICBRAINZ_CLIENT_ID', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client Secret',
|
||||
'label' => 'MusicBrainz client ID (optional)',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('MUSICBRAINZ_CLIENT_SECRET', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'MusicBrainz client secret (optional)',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('MUSICBRAINZ_ACCESS_TOKEN', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'MusicBrainz access token (optional)',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('TIDAL_CLIENT_ID', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'TIDAL client ID',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('TIDAL_CLIENT_SECRET', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'TIDAL client secret',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('TIDAL_COUNTRY_CODE', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'TIDAL country code (optional, ISO-2)',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
||||
@@ -41,5 +80,3 @@ class SiteSettingsType extends AbstractType
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,61 +16,97 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
/**
|
||||
* Wires the repository to Doctrine's registry.
|
||||
*/
|
||||
/**
|
||||
* Provides the Doctrine registry so we can build query builders on demand.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Album::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one album by Spotify identifier.
|
||||
* Finds one album by External identifier.
|
||||
*/
|
||||
public function findOneBySpotifyId(string $spotifyId): ?Album
|
||||
public function findOneByExternalId(string $externalId): ?Album
|
||||
{
|
||||
return $this->findOneBy(['spotifyId' => $spotifyId]);
|
||||
$normalized = trim($externalId);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findOneBy(['externalId' => $normalized]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one album by user-local identifier.
|
||||
*/
|
||||
public function findOneByLocalId(string $localId): ?Album
|
||||
{
|
||||
return $this->findOneBy(['localId' => $localId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns albums keyed by Spotify identifiers for quick lookup.
|
||||
* Returns albums keyed by External identifiers for quick lookup.
|
||||
*
|
||||
* @param list<string> $spotifyIds
|
||||
* @param list<string> $externalIds
|
||||
* @return array<string,Album>
|
||||
*/
|
||||
public function findBySpotifyIdsKeyed(array $spotifyIds): array
|
||||
public function findByExternalIdsKeyed(array $externalIds): array
|
||||
{
|
||||
if ($spotifyIds === []) {
|
||||
$normalizedIds = array_values(array_unique(array_filter(
|
||||
array_map(static fn(mixed $id): string => trim((string) $id), $externalIds),
|
||||
static fn(string $id): bool => $id !== ''
|
||||
)));
|
||||
|
||||
if ($normalizedIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = $this->createQueryBuilder('a')
|
||||
->where('a.spotifyId IN (:ids)')
|
||||
->setParameter('ids', $spotifyIds)
|
||||
->where('a.externalId IN (:ids)')
|
||||
->setParameter('ids', $normalizedIds)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row instanceof Album && $row->getSpotifyId() !== null) {
|
||||
$out[$row->getSpotifyId()] = $row;
|
||||
if ($row instanceof Album && $row->getExternalId() !== null) {
|
||||
$out[$row->getExternalId()] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all stored External album IDs so background jobs can iterate over them.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function findAllExternalIds(): array
|
||||
{
|
||||
$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();
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (string) ($row['externalId'] ?? '');
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters user albums by optional metadata.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$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 !== '') {
|
||||
@@ -91,19 +127,22 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
$results = $qb->getQuery()->getResult();
|
||||
$artistNeedle = $artist ?? $freeText;
|
||||
return $this->filterByArtistAndLimit($results, $artistNeedle, $limit);
|
||||
return $this->filterByArtistAndGenreAndLimit($results, $artistNeedle, $genre, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters persisted Spotify albums before falling back to the API.
|
||||
* Filters persisted External albums before falling back to the API.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
public function searchExternalAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.source = :src')
|
||||
->setParameter('src', 'spotify')
|
||||
->andWhere('a.totalTracks > 0')
|
||||
->andWhere('a.externalId IS NOT NULL')
|
||||
->andWhere("a.externalId <> ''")
|
||||
->setParameter('src', 'external')
|
||||
->setMaxResults($limit * 2);
|
||||
if ($freeText !== null && $freeText !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :qName')
|
||||
@@ -123,62 +162,98 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
}
|
||||
$results = $qb->getQuery()->getResult();
|
||||
return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit);
|
||||
return $this->filterByArtistAndGenreAndLimit($results, $artist ?? $freeText, $genre, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts data from a Spotify album payload and keeps DB entities in sync.
|
||||
* Upserts data from an external album payload and keeps DB entities in sync.
|
||||
*
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
* @param array<string,mixed> $externalAlbum Raw external album payload.
|
||||
* @param list<string> $resolvedGenres Optional, precomputed genres.
|
||||
*/
|
||||
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album
|
||||
public function upsertFromExternalAlbum(array $externalAlbum, array $resolvedGenres = []): Album
|
||||
{
|
||||
$spotifyId = (string) ($spotifyAlbum['id'] ?? '');
|
||||
$name = (string) ($spotifyAlbum['name'] ?? '');
|
||||
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? [])));
|
||||
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
|
||||
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
|
||||
|
||||
$images = (array) ($spotifyAlbum['images'] ?? []);
|
||||
$coverUrl = null;
|
||||
if (isset($images[1]['url'])) {
|
||||
$coverUrl = (string) $images[1]['url'];
|
||||
} elseif (isset($images[0]['url'])) {
|
||||
$coverUrl = (string) $images[0]['url'];
|
||||
$externalId = (string) ($externalAlbum['id'] ?? '');
|
||||
$name = (string) ($externalAlbum['name'] ?? '');
|
||||
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($externalAlbum['artists'] ?? [])));
|
||||
$rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($externalAlbum['genres'] ?? []);
|
||||
$genres = $this->normalizeTopGenre($rawGenres);
|
||||
$releaseDate = isset($externalAlbum['release_date']) ? trim((string) $externalAlbum['release_date']) : null;
|
||||
if ($releaseDate === '') {
|
||||
$releaseDate = null;
|
||||
}
|
||||
$totalTracks = (int) ($externalAlbum['total_tracks'] ?? 0);
|
||||
$external = null;
|
||||
if (isset($spotifyAlbum['external_urls']['spotify'])) {
|
||||
$external = (string) $spotifyAlbum['external_urls']['spotify'];
|
||||
if (isset($externalAlbum['external_urls']['external'])) {
|
||||
$external = (string) $externalAlbum['external_urls']['external'];
|
||||
}
|
||||
|
||||
$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(['spotifyId' => $spotifyId]) ?? new Album();
|
||||
$album->setSource('spotify');
|
||||
$album->setSpotifyId($spotifyId);
|
||||
$album = $this->findOneBy(['externalId' => $externalId]) ?? new Album();
|
||||
$album->setSource('external');
|
||||
$album->setExternalId($externalId);
|
||||
$album->setName($name);
|
||||
$album->setArtists($artists);
|
||||
$album->setReleaseDate($releaseDate);
|
||||
$album->setGenres($genres);
|
||||
// Do not wipe a previously known release date when upstream omits it.
|
||||
if ($releaseDate !== null || $album->getReleaseDate() === null) {
|
||||
$album->setReleaseDate($releaseDate);
|
||||
}
|
||||
$album->setTotalTracks($totalTracks);
|
||||
$album->setCoverUrl($coverUrl);
|
||||
$album->setExternalUrl($external);
|
||||
$em->persist($album);
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies artist/genre substring filters and the requested limit in the environment.
|
||||
*
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array
|
||||
private function filterByArtistAndGenreAndLimit(array $albums, ?string $artistNeedle, ?string $genreNeedle, int $limit): array
|
||||
{
|
||||
if ($needle === null || trim($needle) === '') {
|
||||
$filtered = $albums;
|
||||
$appliedFilter = false;
|
||||
$normalizedArtist = $this->normalizeNeedle($artistNeedle);
|
||||
$normalizedGenre = $this->normalizeNeedle($genreNeedle);
|
||||
|
||||
if ($normalizedArtist !== null) {
|
||||
$filtered = $this->filterByNeedle($filtered, $normalizedArtist, static fn(Album $album) => $album->getArtists(), $limit);
|
||||
$appliedFilter = true;
|
||||
}
|
||||
if ($normalizedGenre !== null) {
|
||||
$filtered = $this->filterByNeedle($filtered, $normalizedGenre, static fn(Album $album) => $album->getGenres(), $limit);
|
||||
$appliedFilter = true;
|
||||
}
|
||||
|
||||
if (!$appliedFilter) {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
$needle = mb_strtolower(trim($needle));
|
||||
|
||||
return array_slice($filtered, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters albums by matching a lowercase needle against extracted values.
|
||||
*
|
||||
* @param callable(Album):list<string> $valueExtractor
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function filterByNeedle(array $albums, string $needle, callable $valueExtractor, int $limit): array
|
||||
{
|
||||
$filtered = [];
|
||||
foreach ($albums as $album) {
|
||||
foreach ($album->getArtists() as $artist) {
|
||||
if (str_contains(mb_strtolower($artist), $needle)) {
|
||||
$haystack = $valueExtractor($album);
|
||||
foreach ($haystack as $value) {
|
||||
if (str_contains(mb_strtolower($value), $needle)) {
|
||||
$filtered[] = $album;
|
||||
break;
|
||||
}
|
||||
@@ -187,11 +262,39 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($filtered === []) {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a filter needle to lowercase so comparisons stay consistent.
|
||||
*/
|
||||
private function normalizeNeedle(?string $needle): ?string
|
||||
{
|
||||
if ($needle === null) {
|
||||
return null;
|
||||
}
|
||||
$trimmed = trim($needle);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
return mb_strtolower($trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $rawGenres
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeTopGenre(array $rawGenres): array
|
||||
{
|
||||
foreach ($rawGenres as $genre) {
|
||||
$value = trim((string) $genre);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,27 +14,36 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
class AlbumTrackRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Registers the repository with Doctrine's manager registry.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AlbumTrack::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an album's stored tracklist with the provided Spotify payload.
|
||||
* Replaces an album's stored tracklist with the provided External payload.
|
||||
*
|
||||
* @param list<array<string,mixed>> $trackPayloads
|
||||
*/
|
||||
public function replaceAlbumTracks(Album $album, array $trackPayloads): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$albumId = $album->getId();
|
||||
|
||||
foreach ($album->getTracks()->toArray() as $existing) {
|
||||
if ($existing instanceof AlbumTrack) {
|
||||
$album->removeTrack($existing);
|
||||
}
|
||||
// Remove existing rows with a single query so unique constraints don't conflict during reinsert.
|
||||
// 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;
|
||||
$occupied = [];
|
||||
foreach ($trackPayloads as $payload) {
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
@@ -43,9 +52,13 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
|
||||
$track = new AlbumTrack();
|
||||
$track->setAlbum($album);
|
||||
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null));
|
||||
$track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1));
|
||||
$track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position));
|
||||
$track->setExternalTrackId($this->stringOrNull($payload['id'] ?? null));
|
||||
$disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1);
|
||||
$trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position);
|
||||
// Some External payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique.
|
||||
$trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber);
|
||||
$track->setDiscNumber($disc);
|
||||
$track->setTrackNumber($trackNumber);
|
||||
$track->setName($name);
|
||||
$track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0)));
|
||||
$track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null));
|
||||
@@ -56,6 +69,9 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims user/External data to a nullable string, collapsing empty values to null.
|
||||
*/
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -65,11 +81,30 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a positive integer (defaults to 1) for disc/track numbers.
|
||||
*/
|
||||
private function normalizePositiveInt(mixed $value): int
|
||||
{
|
||||
$int = (int) $value;
|
||||
return $int > 0 ? $int : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps disc/track combinations unique within the upsert operation.
|
||||
*
|
||||
* @param array<int,array<int,bool>> $occupied
|
||||
*/
|
||||
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
|
||||
{
|
||||
// Track which disc/track slots have already been claimed in this upsert run.
|
||||
$discMap = $occupied[$disc] ?? [];
|
||||
while (isset($discMap[$track])) {
|
||||
$track++;
|
||||
}
|
||||
$discMap[$track] = true;
|
||||
$occupied[$disc] = $discMap;
|
||||
return $track;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates review counts and averages for Spotify IDs.
|
||||
* Aggregates review counts and averages for External IDs.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
@@ -49,10 +49,10 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->innerJoin('r.album', 'a')
|
||||
->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
|
||||
->where('a.spotifyId IN (:ids)')
|
||||
->select('a.externalId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
|
||||
->where('a.externalId IN (:ids)')
|
||||
->setParameter('ids', $albumIds)
|
||||
->groupBy('a.spotifyId');
|
||||
->groupBy('a.externalId');
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
$out = [];
|
||||
|
||||
@@ -20,7 +20,7 @@ class SettingRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a setting value falling back to the supplied default.
|
||||
* Returns a setting value, falling back to the caller's default when missing.
|
||||
*/
|
||||
public function getValue(string $name, ?string $default = null): ?string
|
||||
{
|
||||
@@ -29,7 +29,7 @@ class SettingRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the supplied configuration value.
|
||||
* Persists or updates the supplied configuration value.
|
||||
*/
|
||||
public function setValue(string $name, ?string $value): void
|
||||
{
|
||||
@@ -43,6 +43,79 @@ class SettingRepository extends ServiceEntityRepository
|
||||
$em->persist($setting);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns multiple setting values keyed by setting name.
|
||||
*
|
||||
* @param list<string> $names
|
||||
* @return array<string,?string>
|
||||
*/
|
||||
public function getValues(array $names): array
|
||||
{
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
static fn(mixed $name): string => trim((string) $name),
|
||||
$names
|
||||
), static fn(string $name): bool => $name !== '')));
|
||||
|
||||
if ($normalized === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('s')
|
||||
->where('s.name IN (:names)')
|
||||
->setParameter('names', $normalized)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$values = [];
|
||||
foreach ($normalized as $name) {
|
||||
$values[$name] = null;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!$row instanceof Setting) {
|
||||
continue;
|
||||
}
|
||||
$values[$row->getName()] = $row->getValue();
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists multiple configuration values in one transaction.
|
||||
*
|
||||
* @param array<string,?string> $values
|
||||
*/
|
||||
public function setValues(array $values): void
|
||||
{
|
||||
if ($values === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$existing = $this->getValues(array_keys($values));
|
||||
|
||||
foreach ($values as $name => $value) {
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$setting = null;
|
||||
if (array_key_exists($normalizedName, $existing)) {
|
||||
$setting = $this->findOneBy(['name' => $normalizedName]);
|
||||
}
|
||||
if (!$setting instanceof Setting) {
|
||||
$setting = new Setting();
|
||||
$setting->setName($normalizedName);
|
||||
}
|
||||
|
||||
$setting->setValue($value);
|
||||
$em->persist($setting);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,20 @@ class UserRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a user by OAuth provider and provider user ID.
|
||||
*/
|
||||
public function findOneByOauthIdentity(string $provider, string $oauthId): ?User
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->andWhere('u.oauthProvider = :provider')
|
||||
->andWhere('u.oauthId = :oauthId')
|
||||
->setParameter('provider', strtolower($provider))
|
||||
->setParameter('oauthId', $oauthId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every user with aggregated album/review counts.
|
||||
*
|
||||
@@ -44,8 +58,8 @@ class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
$rows = $this->createQueryBuilder('u')
|
||||
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
|
||||
->leftJoin(Album::class, 'a', 'WITH', 'a.createdBy = u')
|
||||
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
|
||||
->leftJoin(Album::class, 'a', 'WITH', 'a = r.album')
|
||||
->groupBy('u.id')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
@@ -65,5 +79,3 @@ class UserRepository extends ServiceEntityRepository
|
||||
}, $rows);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* ReviewVoter grants edit/delete access to review owners or admins.
|
||||
* ReviewVoter determines whether the authenticated user may edit or delete a review.
|
||||
* Moderators/admins always pass; otherwise the review author must match the current user.
|
||||
*/
|
||||
class ReviewVoter extends Voter
|
||||
{
|
||||
@@ -24,7 +25,7 @@ class ReviewVoter extends Voter
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants access to admins or the review author.
|
||||
* Evaluates the permission for the given attribute/subject pair.
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
@@ -40,6 +41,7 @@ class ReviewVoter extends Voter
|
||||
|
||||
/** @var Review $review */
|
||||
$review = $subject;
|
||||
// Only the author may edit/delete their own review.
|
||||
return $review->getAuthor()?->getId() === $user->getId();
|
||||
}
|
||||
}
|
||||
|
||||
+916
-112
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\AlbumRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class CatalogResetService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all reviews and albums from the catalog and returns summary counts.
|
||||
*
|
||||
* @return array{albums:int,reviews:int}
|
||||
*/
|
||||
public function resetCatalog(): array
|
||||
{
|
||||
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
|
||||
|
||||
$albums = $this->albumRepository->findAll();
|
||||
$albumCount = count($albums);
|
||||
foreach ($albums as $album) {
|
||||
// Remove entities one-by-one so Doctrine cascades delete related tracks/reviews as configured.
|
||||
$this->entityManager->remove($album);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
return [
|
||||
'albums' => $albumCount,
|
||||
'reviews' => $deletedReviews,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* CommandRunner executes Symfony console commands from non-CLI contexts
|
||||
* and returns their buffered output.
|
||||
*/
|
||||
class CommandRunner
|
||||
{
|
||||
public function __construct(private readonly KernelInterface $kernel)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Symfony console command and returns its buffered output.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
public function runConsoleCommand(string $commandName, array $options = []): string
|
||||
{
|
||||
$application = new Application($this->kernel);
|
||||
$application->setAutoExit(false);
|
||||
|
||||
$input = new ArrayInput(array_merge(['command' => $commandName], $options));
|
||||
$output = new BufferedOutput();
|
||||
|
||||
$exitCode = $application->run($input, $output);
|
||||
if ($exitCode !== Command::SUCCESS) {
|
||||
// Surface the underlying command failure so the caller can surface the message in UI.
|
||||
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
|
||||
}
|
||||
|
||||
return trim($output->fetch());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* ExternalAlbumClient routes all external album requests to the configured provider.
|
||||
*/
|
||||
class ExternalAlbumClient implements ExternalAlbumClientInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LastFmClient $lastFmClient,
|
||||
private readonly MusicBrainzClient $musicBrainzClient,
|
||||
private readonly TidalClient $tidalClient,
|
||||
private readonly ExternalProviderManager $providerManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array
|
||||
{
|
||||
return $this->resolveProvider()->searchAlbums($query, $limit, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array
|
||||
{
|
||||
return $this->resolveProvider()->getAlbumWithTracks($albumId, $imageProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $albumIds
|
||||
* @return array{albums:list<array<string,mixed>>}|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array
|
||||
{
|
||||
return $this->resolveProvider()->getAlbums($albumIds, $imageProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array
|
||||
{
|
||||
return $this->resolveProvider()->getAlbumTracks($albumId);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->resolveProvider()->isConfigured();
|
||||
}
|
||||
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return $this->resolveProvider()->getProviderName();
|
||||
}
|
||||
|
||||
private function resolveProvider(): ExternalAlbumClientInterface
|
||||
{
|
||||
$provider = $this->providerManager->getEffectiveProvider();
|
||||
|
||||
if ($provider === 'musicbrainz') {
|
||||
return $this->musicBrainzClient;
|
||||
}
|
||||
if ($provider === 'tidal') {
|
||||
return $this->tidalClient;
|
||||
}
|
||||
|
||||
return $this->lastFmClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* ExternalAlbumClientInterface defines a normalized album API surface.
|
||||
*/
|
||||
interface ExternalAlbumClientInterface
|
||||
{
|
||||
/**
|
||||
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array;
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId, string $imageProfile = 'detail'): ?array;
|
||||
|
||||
/**
|
||||
* @param list<string> $albumIds
|
||||
* @return array{albums:list<array<string,mixed>>}|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds, string $imageProfile = 'detail'): ?array;
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array;
|
||||
|
||||
public function isConfigured(): bool;
|
||||
|
||||
public function getProviderName(): string;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* ExternalProviderManager resolves active provider state and enforces catalog reset rules.
|
||||
*/
|
||||
class ExternalProviderManager
|
||||
{
|
||||
public const PROVIDER_LASTFM = 'lastfm';
|
||||
public const PROVIDER_MUSICBRAINZ = 'musicbrainz';
|
||||
public const PROVIDER_TIDAL = 'tidal';
|
||||
public const SETTING_PROVIDER = 'EXTERNAL_ALBUM_PROVIDER';
|
||||
public const SETTING_ACTIVE_PROVIDER = 'EXTERNAL_ALBUM_PROVIDER_ACTIVE';
|
||||
private const VALID_PROVIDERS = [
|
||||
self::PROVIDER_LASTFM,
|
||||
self::PROVIDER_MUSICBRAINZ,
|
||||
self::PROVIDER_TIDAL,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingRepository $settings,
|
||||
private readonly CatalogResetService $catalogResetService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getValidProviders(): array
|
||||
{
|
||||
return self::VALID_PROVIDERS;
|
||||
}
|
||||
|
||||
public function isValidProvider(?string $provider): bool
|
||||
{
|
||||
return $this->normalizeProvider($provider) !== null;
|
||||
}
|
||||
|
||||
public function getStoredProvider(): string
|
||||
{
|
||||
$stored = $this->normalizeProvider($this->settings->getValue(self::SETTING_PROVIDER));
|
||||
return $stored ?? self::PROVIDER_LASTFM;
|
||||
}
|
||||
|
||||
public function getEnvOverrideProvider(): ?string
|
||||
{
|
||||
$raw = $_ENV['EXTERNAL_ALBUM_PROVIDER'] ?? $_SERVER['EXTERNAL_ALBUM_PROVIDER'] ?? null;
|
||||
return $this->normalizeProvider(is_string($raw) ? $raw : null);
|
||||
}
|
||||
|
||||
public function isEnvOverrideActive(): bool
|
||||
{
|
||||
return $this->getEnvOverrideProvider() !== null;
|
||||
}
|
||||
|
||||
public function getEffectiveProvider(): string
|
||||
{
|
||||
$override = $this->getEnvOverrideProvider();
|
||||
return $override ?? $this->getStoredProvider();
|
||||
}
|
||||
|
||||
public function getActiveProvider(): string
|
||||
{
|
||||
$active = $this->normalizeProvider($this->settings->getValue(self::SETTING_ACTIVE_PROVIDER));
|
||||
return $active ?? $this->getStoredProvider();
|
||||
}
|
||||
|
||||
public function getProviderLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
self::PROVIDER_MUSICBRAINZ => 'MusicBrainz',
|
||||
self::PROVIDER_TIDAL => 'TIDAL',
|
||||
default => 'Last.fm',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies provider state at startup and resets catalog if effective provider changed.
|
||||
*
|
||||
* @return array{
|
||||
* effective_provider:string,
|
||||
* previous_provider:string,
|
||||
* changed:bool,
|
||||
* reset:bool,
|
||||
* albums:int,
|
||||
* reviews:int
|
||||
* }
|
||||
*/
|
||||
public function syncEffectiveProviderAtStartup(): array
|
||||
{
|
||||
$effective = $this->getEffectiveProvider();
|
||||
$previous = $this->getActiveProvider();
|
||||
|
||||
if ($effective === $previous) {
|
||||
$this->settings->setValues([
|
||||
self::SETTING_ACTIVE_PROVIDER => $effective,
|
||||
]);
|
||||
|
||||
return [
|
||||
'effective_provider' => $effective,
|
||||
'previous_provider' => $previous,
|
||||
'changed' => false,
|
||||
'reset' => false,
|
||||
'albums' => 0,
|
||||
'reviews' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$reset = $this->catalogResetService->resetCatalog();
|
||||
$this->settings->setValues([
|
||||
self::SETTING_ACTIVE_PROVIDER => $effective,
|
||||
]);
|
||||
|
||||
$this->logger->warning('External provider changed between starts; catalog reset applied.', [
|
||||
'previous_provider' => $previous,
|
||||
'effective_provider' => $effective,
|
||||
'deleted_albums' => $reset['albums'],
|
||||
'deleted_reviews' => $reset['reviews'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'effective_provider' => $effective,
|
||||
'previous_provider' => $previous,
|
||||
'changed' => true,
|
||||
'reset' => true,
|
||||
'albums' => (int) $reset['albums'],
|
||||
'reviews' => (int) $reset['reviews'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists admin-selected provider and resets catalog only when effective provider changes.
|
||||
*
|
||||
* @return array{
|
||||
* effective_provider:string,
|
||||
* previous_provider:string,
|
||||
* changed:bool,
|
||||
* reset:bool,
|
||||
* albums:int,
|
||||
* reviews:int,
|
||||
* env_override_active:bool
|
||||
* }
|
||||
*/
|
||||
public function applyAdminSelectedProvider(string $selectedProvider): array
|
||||
{
|
||||
$normalizedSelected = $this->normalizeProvider($selectedProvider);
|
||||
if ($normalizedSelected === null) {
|
||||
throw new \InvalidArgumentException('Unsupported provider selected.');
|
||||
}
|
||||
|
||||
$previous = $this->getActiveProvider();
|
||||
$override = $this->getEnvOverrideProvider();
|
||||
$effective = $override ?? $normalizedSelected;
|
||||
|
||||
$updates = [
|
||||
self::SETTING_PROVIDER => $normalizedSelected,
|
||||
];
|
||||
|
||||
$deletedAlbums = 0;
|
||||
$deletedReviews = 0;
|
||||
$didReset = false;
|
||||
|
||||
if ($effective !== $previous) {
|
||||
$reset = $this->catalogResetService->resetCatalog();
|
||||
$deletedAlbums = (int) $reset['albums'];
|
||||
$deletedReviews = (int) $reset['reviews'];
|
||||
$didReset = true;
|
||||
$updates[self::SETTING_ACTIVE_PROVIDER] = $effective;
|
||||
} else {
|
||||
$updates[self::SETTING_ACTIVE_PROVIDER] = $previous;
|
||||
}
|
||||
|
||||
$this->settings->setValues($updates);
|
||||
|
||||
return [
|
||||
'effective_provider' => $effective,
|
||||
'previous_provider' => $previous,
|
||||
'changed' => $effective !== $previous,
|
||||
'reset' => $didReset,
|
||||
'albums' => $deletedAlbums,
|
||||
'reviews' => $deletedReviews,
|
||||
'env_override_active' => $override !== null,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProvider(?string $provider): ?string
|
||||
{
|
||||
if ($provider === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = mb_strtolower(trim($provider));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return in_array($normalized, self::VALID_PROVIDERS, true) ? $normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class ImageStorage
|
||||
{
|
||||
private Filesystem $fs;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly SluggerInterface $slugger
|
||||
) {
|
||||
$this->fs = new Filesystem();
|
||||
}
|
||||
|
||||
public function storeProfileImage(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'avatars');
|
||||
}
|
||||
|
||||
public function storeAlbumCover(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'album_covers');
|
||||
}
|
||||
|
||||
public function remove(?string $webPath): void
|
||||
{
|
||||
if ($webPath === null || $webPath === '') {
|
||||
return;
|
||||
}
|
||||
$path = $this->projectDir . '/public' . $webPath;
|
||||
if ($this->fs->exists($path)) {
|
||||
$this->fs->remove($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function store(UploadedFile $file, string $subDirectory): string
|
||||
{
|
||||
$targetDir = $this->projectDir . '/public/uploads/' . $subDirectory;
|
||||
if (!$this->fs->exists($targetDir)) {
|
||||
$this->fs->mkdir($targetDir);
|
||||
}
|
||||
|
||||
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$safeName = $this->slugger->slug($originalName ?: 'image');
|
||||
$extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
|
||||
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension);
|
||||
|
||||
$file->move($targetDir, $filename);
|
||||
|
||||
return '/uploads/' . $subDirectory . '/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Utils;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* LastFmClient wraps Last.fm API calls with caching and payload normalization.
|
||||
*/
|
||||
class LastFmClient implements ExternalAlbumClientInterface
|
||||
{
|
||||
private const API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||
private const IMAGE_PROFILE_LIST = 'list';
|
||||
private const IMAGE_PROFILE_DETAIL = 'detail';
|
||||
private const BULK_ALBUM_REQUEST_CONCURRENCY = 6;
|
||||
private const SEARCH_CACHE_TTL_SECONDS = 600;
|
||||
private const ALBUM_INFO_CACHE_TTL_SECONDS = 1800;
|
||||
private const ALBUM_PAGE_RELEASE_CACHE_TTL_SECONDS = 86400;
|
||||
private const IMAGE_SIZE_RANK = [
|
||||
'mega' => 5,
|
||||
'extralarge' => 4,
|
||||
'large' => 3,
|
||||
'medium' => 2,
|
||||
'small' => 1,
|
||||
];
|
||||
private bool $apiKeyResolved = false;
|
||||
private ?string $resolvedApiKey = null;
|
||||
/** @var array<string,array<string,mixed>> */
|
||||
private array $inMemoryResponseCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ClientInterface $httpClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly ?string $apiKey,
|
||||
private readonly SettingRepository $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Last.fm albums by query string.
|
||||
*
|
||||
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null || trim($query) === '') {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->sendRequest([
|
||||
'method' => 'album.search',
|
||||
'album' => $query,
|
||||
'limit' => max(1, $limit),
|
||||
'page' => max(1, $page),
|
||||
], self::SEARCH_CACHE_TTL_SECONDS);
|
||||
} catch (\Throwable) {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
$rawAlbums = $payload['results']['albummatches']['album'] ?? [];
|
||||
$items = [];
|
||||
foreach ($this->normalizeList($rawAlbums) as $album) {
|
||||
$normalized = $this->normalizeSearchAlbum((array) $album);
|
||||
if ($normalized !== null) {
|
||||
$items[] = $normalized;
|
||||
}
|
||||
if (count($items) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['albums' => ['items' => $items]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches album metadata plus tracklist from Last.fm.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||
{
|
||||
return $this->getAlbum($albumId, $imageProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single album by normalized external identifier.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbum(string $albumId, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->buildAlbumInfoQuery($albumId);
|
||||
if ($query === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->sendRequest($query, self::ALBUM_INFO_CACHE_TTL_SECONDS);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$album = $payload['album'] ?? null;
|
||||
if (!is_array($album)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeDetailedAlbum($album, $albumId, $imageProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple albums using bounded concurrency to avoid serial network latency.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array{albums:list<array<string,mixed>>}|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds, string $imageProfile = self::IMAGE_PROFILE_DETAIL): ?array
|
||||
{
|
||||
if ($albumIds === []) {
|
||||
return ['albums' => []];
|
||||
}
|
||||
if ($this->getApiKey() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->cache instanceof CacheItemPoolInterface) {
|
||||
$albums = [];
|
||||
foreach ($albumIds as $albumId) {
|
||||
$album = $this->getAlbumWithTracks((string) $albumId, $imageProfile);
|
||||
if ($album !== null) {
|
||||
$albums[] = $album;
|
||||
}
|
||||
}
|
||||
|
||||
return ['albums' => $albums];
|
||||
}
|
||||
|
||||
$orderedIds = [];
|
||||
$queriesById = [];
|
||||
foreach ($albumIds as $albumId) {
|
||||
$normalizedId = trim((string) $albumId);
|
||||
if ($normalizedId === '' || isset($queriesById[$normalizedId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = $this->buildAlbumInfoQuery($normalizedId);
|
||||
if ($query === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderedIds[] = $normalizedId;
|
||||
$queriesById[$normalizedId] = $query;
|
||||
}
|
||||
|
||||
if ($orderedIds === []) {
|
||||
return ['albums' => []];
|
||||
}
|
||||
|
||||
$responsesById = [];
|
||||
$pendingById = [];
|
||||
|
||||
foreach ($orderedIds as $albumId) {
|
||||
$prepared = $this->prepareQuery($queriesById[$albumId]);
|
||||
if ($prepared === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = $this->buildCacheKey($prepared);
|
||||
if (isset($this->inMemoryResponseCache[$cacheKey])) {
|
||||
$responsesById[$albumId] = $this->inMemoryResponseCache[$cacheKey];
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheItem = $this->cache->getItem($cacheKey);
|
||||
if ($cacheItem->isHit()) {
|
||||
$cached = $cacheItem->get();
|
||||
if (is_array($cached)) {
|
||||
$responsesById[$albumId] = $cached;
|
||||
$this->inMemoryResponseCache[$cacheKey] = $cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$pendingById[$albumId] = [
|
||||
'query' => $prepared,
|
||||
'cache_key' => $cacheKey,
|
||||
];
|
||||
}
|
||||
|
||||
if ($pendingById !== []) {
|
||||
foreach (array_chunk($pendingById, self::BULK_ALBUM_REQUEST_CONCURRENCY, true) as $chunk) {
|
||||
$promises = [];
|
||||
foreach ($chunk as $albumId => $requestData) {
|
||||
$promises[$albumId] = $this->httpClient
|
||||
->requestAsync('GET', self::API_BASE_URL, ['query' => $requestData['query']])
|
||||
->then(static function ($response): array {
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
});
|
||||
}
|
||||
|
||||
$settled = Utils::settle($promises)->wait();
|
||||
foreach ($settled as $albumId => $result) {
|
||||
$payload = [];
|
||||
$canCache = ($result['state'] ?? '') === 'fulfilled' && is_array($result['value'] ?? null);
|
||||
if ($canCache) {
|
||||
$payload = $result['value'];
|
||||
}
|
||||
|
||||
$requestData = $chunk[$albumId] ?? null;
|
||||
if (!is_array($requestData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = (string) ($requestData['cache_key'] ?? '');
|
||||
if ($canCache && $cacheKey !== '') {
|
||||
$cacheItem = $this->cache->getItem($cacheKey);
|
||||
$cacheItem->set($payload);
|
||||
$cacheItem->expiresAfter(self::ALBUM_INFO_CACHE_TTL_SECONDS);
|
||||
$this->cache->save($cacheItem);
|
||||
$this->inMemoryResponseCache[$cacheKey] = $payload;
|
||||
}
|
||||
|
||||
$responsesById[$albumId] = $payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$albums = [];
|
||||
foreach ($orderedIds as $albumId) {
|
||||
$albumPayload = $responsesById[$albumId]['album'] ?? null;
|
||||
if (!is_array($albumPayload)) {
|
||||
continue;
|
||||
}
|
||||
$albums[] = $this->normalizeDetailedAlbum($albumPayload, $albumId, $imageProfile);
|
||||
}
|
||||
|
||||
return ['albums' => $albums];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array
|
||||
{
|
||||
$album = $this->getAlbumWithTracks($albumId);
|
||||
if ($album === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = $album['tracks']['items'] ?? [];
|
||||
return is_array($items) ? array_values($items) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when Last.fm API key is available from DB or environment.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->getApiKey() !== null;
|
||||
}
|
||||
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return 'lastfm';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $query
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function sendRequest(array $query, int $cacheTtlSeconds = 0): array
|
||||
{
|
||||
$preparedQuery = $this->prepareQuery($query);
|
||||
if ($preparedQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cacheKey = $this->buildCacheKey($preparedQuery);
|
||||
if (isset($this->inMemoryResponseCache[$cacheKey])) {
|
||||
return $this->inMemoryResponseCache[$cacheKey];
|
||||
}
|
||||
|
||||
$request = function () use ($preparedQuery): array {
|
||||
$response = $this->httpClient->request('GET', self::API_BASE_URL, ['query' => $preparedQuery]);
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
};
|
||||
|
||||
$payload = [];
|
||||
if ($cacheTtlSeconds > 0) {
|
||||
$payload = $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $request();
|
||||
});
|
||||
} else {
|
||||
$payload = $request();
|
||||
}
|
||||
|
||||
$normalized = is_array($payload) ? $payload : [];
|
||||
$this->inMemoryResponseCache[$cacheKey] = $normalized;
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function getApiKey(): ?string
|
||||
{
|
||||
if ($this->apiKeyResolved) {
|
||||
return $this->resolvedApiKey;
|
||||
}
|
||||
|
||||
$this->apiKeyResolved = true;
|
||||
$envKey = trim((string) ($this->apiKey ?? ''));
|
||||
if ($envKey !== '') {
|
||||
$this->resolvedApiKey = $envKey;
|
||||
return $this->resolvedApiKey;
|
||||
}
|
||||
|
||||
$key = trim((string) $this->settings->getValue('LASTFM_API_KEY', ''));
|
||||
$this->resolvedApiKey = $key === '' ? null : $key;
|
||||
return $this->resolvedApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $query
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function prepareQuery(array $query): ?array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query['api_key'] = $apiKey;
|
||||
$query['format'] = 'json';
|
||||
$query['autocorrect'] = 1;
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $query
|
||||
*/
|
||||
private function buildCacheKey(array $query): string
|
||||
{
|
||||
return 'lastfm_resp_' . sha1((string) json_encode($query));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{method:string,mbid:string}|array{method:string,artist:string,album:string}|null
|
||||
*/
|
||||
private function buildAlbumInfoQuery(string $albumId): ?array
|
||||
{
|
||||
$lookup = $this->parseAlbumIdentifier($albumId);
|
||||
if ($lookup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = ['method' => 'album.getinfo'];
|
||||
if (isset($lookup['mbid'])) {
|
||||
$query['mbid'] = (string) $lookup['mbid'];
|
||||
return $query;
|
||||
}
|
||||
|
||||
$artist = trim((string) ($lookup['artist'] ?? ''));
|
||||
$name = trim((string) ($lookup['album'] ?? ''));
|
||||
if ($artist === '' || $name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query['artist'] = $artist;
|
||||
$query['album'] = $name;
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function normalizeList(mixed $value): array
|
||||
{
|
||||
if (is_array($value) && array_is_list($value)) {
|
||||
return array_values(array_filter($value, static fn($row) => is_array($row)));
|
||||
}
|
||||
|
||||
if (is_array($value) && $value !== []) {
|
||||
return [array_filter($value, static fn($row) => true)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rawAlbum
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function normalizeSearchAlbum(array $rawAlbum): ?array
|
||||
{
|
||||
$name = trim((string) ($rawAlbum['name'] ?? ''));
|
||||
$artist = trim((string) ($rawAlbum['artist'] ?? ''));
|
||||
if ($name === '' || $artist === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mbid = trim((string) ($rawAlbum['mbid'] ?? ''));
|
||||
$id = $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'artists' => [['name' => $artist]],
|
||||
'genres' => [],
|
||||
'release_date' => null,
|
||||
'total_tracks' => 0,
|
||||
'images' => $this->normalizeImages($rawAlbum['image'] ?? [], self::IMAGE_PROFILE_LIST),
|
||||
'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)],
|
||||
'source' => 'external',
|
||||
'tracks' => ['items' => [], 'total' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rawAlbum
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalizeDetailedAlbum(array $rawAlbum, ?string $preferredId = null, string $imageProfile = self::IMAGE_PROFILE_DETAIL): array
|
||||
{
|
||||
$name = trim((string) ($rawAlbum['name'] ?? ''));
|
||||
$artistField = $rawAlbum['artist'] ?? '';
|
||||
$artist = '';
|
||||
if (is_array($artistField)) {
|
||||
$artist = trim((string) ($artistField['name'] ?? ''));
|
||||
} else {
|
||||
$artist = trim((string) $artistField);
|
||||
}
|
||||
|
||||
$mbid = trim((string) ($rawAlbum['mbid'] ?? ''));
|
||||
$id = $preferredId ?? $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name);
|
||||
|
||||
$tracks = $this->normalizeTracks($rawAlbum['tracks']['track'] ?? [], $id);
|
||||
$genres = [];
|
||||
foreach ($this->normalizeList($rawAlbum['tags']['tag'] ?? []) as $tag) {
|
||||
$label = $this->normalizeGenreLabel((string) ($tag['name'] ?? ''));
|
||||
if ($label !== '') {
|
||||
$genres = [$label];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$allowPageFallback = $imageProfile === self::IMAGE_PROFILE_DETAIL;
|
||||
$releaseDate = $this->resolveReleaseDate($rawAlbum, $allowPageFallback);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'artists' => [['name' => $artist]],
|
||||
'genres' => $genres,
|
||||
'release_date' => $releaseDate,
|
||||
'total_tracks' => count($tracks),
|
||||
'images' => $this->normalizeImages($rawAlbum['image'] ?? [], $imageProfile),
|
||||
'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)],
|
||||
'source' => 'external',
|
||||
'tracks' => [
|
||||
'items' => $tracks,
|
||||
'total' => count($tracks),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rawTracks
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function normalizeTracks(mixed $rawTracks, string $albumId): array
|
||||
{
|
||||
$tracks = [];
|
||||
$rows = $this->normalizeList($rawTracks);
|
||||
$index = 1;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rank = (int) ($row['@attr']['rank'] ?? $row['rank'] ?? $index);
|
||||
$seconds = (int) ($row['duration'] ?? 0);
|
||||
$tracks[] = [
|
||||
'id' => sha1($albumId . '|' . $rank . '|' . $name),
|
||||
'disc_number' => 1,
|
||||
'track_number' => max(1, $rank),
|
||||
'name' => $name,
|
||||
'duration_ms' => max(0, $seconds) * 1000,
|
||||
'preview_url' => null,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rawImages
|
||||
* @return list<array{url:string}>
|
||||
*/
|
||||
private function normalizeImages(mixed $rawImages, string $imageProfile = self::IMAGE_PROFILE_DETAIL): array
|
||||
{
|
||||
$preferredSizes = $this->resolvePreferredSizes($imageProfile);
|
||||
$bySize = [];
|
||||
$ranked = [];
|
||||
$seenUrls = [];
|
||||
|
||||
foreach ($this->normalizeList($rawImages) as $image) {
|
||||
$url = trim((string) ($image['#text'] ?? $image['url'] ?? ''));
|
||||
if ($url === '' || isset($seenUrls[$url])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenUrls[$url] = true;
|
||||
$size = mb_strtolower(trim((string) ($image['size'] ?? '')));
|
||||
if ($size !== '' && !isset($bySize[$size])) {
|
||||
$bySize[$size] = $url;
|
||||
}
|
||||
|
||||
$ranked[] = [
|
||||
'url' => $url,
|
||||
'rank' => self::IMAGE_SIZE_RANK[$size] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
if ($ranked === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($preferredSizes as $size) {
|
||||
if (isset($bySize[$size])) {
|
||||
return [['url' => $this->transformImageUrlForProfile($bySize[$size], $imageProfile)]];
|
||||
}
|
||||
}
|
||||
|
||||
usort($ranked, static fn(array $a, array $b) => $b['rank'] <=> $a['rank']);
|
||||
$fallbackUrl = (string) ($ranked[0]['url'] ?? '');
|
||||
if ($fallbackUrl === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [['url' => $this->transformImageUrlForProfile($fallbackUrl, $imageProfile)]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolvePreferredSizes(string $imageProfile): array
|
||||
{
|
||||
if ($imageProfile === self::IMAGE_PROFILE_LIST) {
|
||||
// Cards render around 300px wide; prefer a sharper source while keeping payloads moderate.
|
||||
return ['extralarge', 'large', 'medium', 'small', 'mega'];
|
||||
}
|
||||
|
||||
return ['mega', 'extralarge', 'large', 'medium', 'small'];
|
||||
}
|
||||
|
||||
private function transformImageUrlForProfile(string $url, string $imageProfile): string
|
||||
{
|
||||
if ($imageProfile !== self::IMAGE_PROFILE_LIST) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Last.fm image hashes are stable across size paths; upgrading to 300x300
|
||||
// gives substantially better card quality without extra API round-trips.
|
||||
return preg_replace('#/i/u/(34s|64s|174s)/#', '/i/u/300x300/', $url) ?? $url;
|
||||
}
|
||||
|
||||
private function normalizePublishedDate(string $published): ?string
|
||||
{
|
||||
$published = trim($published);
|
||||
if ($published === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($published))->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rawAlbum
|
||||
*/
|
||||
private function resolveReleaseDate(array $rawAlbum, bool $allowPageFallback = false): ?string
|
||||
{
|
||||
$releaseDate = $this->normalizePublishedDate((string) ($rawAlbum['releasedate'] ?? ''));
|
||||
$wikiReleaseDate = $this->extractReleaseDateFromWiki($rawAlbum['wiki'] ?? null);
|
||||
|
||||
if ($releaseDate !== null && $wikiReleaseDate !== null) {
|
||||
$releaseYear = (int) substr($releaseDate, 0, 4);
|
||||
$wikiYear = (int) substr($wikiReleaseDate, 0, 4);
|
||||
|
||||
// Last.fm often returns reissue/future MBID dates in `releasedate`.
|
||||
// When wiki text clearly points to a much earlier canonical release,
|
||||
// prefer that canonical date for album browsing and faceting.
|
||||
if ($releaseYear > 0 && $wikiYear > 0 && ($releaseYear - $wikiYear) >= 5) {
|
||||
return $wikiReleaseDate;
|
||||
}
|
||||
|
||||
return $releaseDate;
|
||||
}
|
||||
|
||||
$resolved = $releaseDate ?? $wikiReleaseDate;
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if (!$allowPageFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->extractReleaseDateFromAlbumPage($this->stringOrNull($rawAlbum['url'] ?? null));
|
||||
}
|
||||
|
||||
private function extractReleaseDateFromWiki(mixed $wiki): ?string
|
||||
{
|
||||
if (!is_array($wiki)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Last.fm often omits explicit release fields; wiki publication date is a useful fallback.
|
||||
$publishedFallback = $this->normalizePublishedDate((string) ($wiki['published'] ?? ''));
|
||||
|
||||
foreach (['summary', 'content'] as $field) {
|
||||
$raw = (string) ($wiki[$field] ?? '');
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = html_entity_decode(strip_tags($raw), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'/\breleased(?:\s+on|\s+in)?\s+([A-Za-z]+\s+\d{1,2},\s+\d{4})/iu',
|
||||
'/\breleased(?:\s+on|\s+in)?\s+(\d{1,2}\s+[A-Za-z]+\s+\d{4})/iu',
|
||||
'/\breleased(?:\s+on|\s+in)?\s+([A-Za-z]+\s+\d{4})/iu',
|
||||
'/\breleased(?:\s+on|\s+in)?\s+(\d{4})/iu',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$matches = [];
|
||||
if (preg_match($pattern, $text, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = trim((string) ($matches[1] ?? ''));
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->normalizePublishedDate($candidate);
|
||||
if ($parsed !== null) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $publishedFallback;
|
||||
}
|
||||
|
||||
private function extractReleaseDateFromAlbumPage(?string $albumUrl): ?string
|
||||
{
|
||||
$url = trim((string) $albumUrl);
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = 'lastfm_album_page_release_' . sha1($url);
|
||||
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($url): string {
|
||||
$item->expiresAfter(self::ALBUM_PAGE_RELEASE_CACHE_TTL_SECONDS);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $url);
|
||||
$html = (string) $response->getBody();
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'/<dt[^>]*catalogue-metadata-heading[^>]*>\s*Release Date\s*<\/dt>\s*<dd[^>]*catalogue-metadata-description[^>]*>\s*([^<]+)\s*<\/dd>/iu',
|
||||
'/"releaseDate"\s*:\s*"([^"]+)"/i',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
$matches = [];
|
||||
if (preg_match($pattern, $html, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = trim((string) ($matches[1] ?? ''));
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->normalizePublishedDate($candidate);
|
||||
if ($parsed !== null) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
if (!is_string($cached)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim($cached);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function buildAlbumIdentifier(?string $mbid, string $artist, string $album): string
|
||||
{
|
||||
if ($mbid !== null && trim($mbid) !== '') {
|
||||
return 'mbid:' . trim($mbid);
|
||||
}
|
||||
|
||||
$slugSource = mb_strtolower(trim($artist) . '|' . trim($album));
|
||||
$encoded = rtrim(strtr(base64_encode($slugSource), '+/', '-_'), '=');
|
||||
return 'slug:' . $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mbid:string}|array{artist:string,album:string}|null
|
||||
*/
|
||||
private function parseAlbumIdentifier(string $identifier): ?array
|
||||
{
|
||||
if (str_starts_with($identifier, 'mbid:')) {
|
||||
$mbid = trim(substr($identifier, 5));
|
||||
return $mbid === '' ? null : ['mbid' => $mbid];
|
||||
}
|
||||
|
||||
if (str_starts_with($identifier, 'slug:')) {
|
||||
$payload = substr($identifier, 5);
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
if ($decoded === false || !str_contains($decoded, '|')) {
|
||||
return null;
|
||||
}
|
||||
[$artist, $album] = explode('|', $decoded, 2);
|
||||
$artist = trim($artist);
|
||||
$album = trim($album);
|
||||
if ($artist === '' || $album === '') {
|
||||
return null;
|
||||
}
|
||||
return ['artist' => $artist, 'album' => $album];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
private function normalizeGenreLabel(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user