Compare commits

..
23 Commits
Author SHA1 Message Date
boris 75756ac6ec test 2026-04-02 15:42:38 +01:00
boris 72a99a5bcd what the fuck 2026-04-02 13:46:07 +01:00
boris c1b1c2f372 Configured swagger-php for documentation at /docs, probably needs a refactor lol; paginated searchAlbums to fix limit bug with null filters. 2026-02-27 20:15:57 +00:00
boris 56dea097d6 Migration to last.fm, removed old spotify api client. FOSRestBundle API serving data driven front end (JWT). 2026-02-27 16:52:57 +00:00
boris 391ecf1732 Update Monolog action level to info, adjust Dockerfile permissions and logging configuration, and set supervisord user to root
CI (Gitea) / php-tests (push) Successful in 10m4s
CI (Gitea) / docker-image (push) Successful in 2m23s
2025-11-28 10:08:36 +00:00
boris 4ae7a44881 Update trusted proxies configuration to use default environment variable and add importmap block in base template
CI (Gitea) / php-tests (push) Successful in 10m16s
CI (Gitea) / docker-image (push) Successful in 2m29s
2025-11-28 08:59:28 +00:00
boris fa54cb4167 Enhance CI workflow by adding PHP setup step and caching for vendor directory
CI (Gitea) / php-tests (push) Failing after 6m22s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 08:49:59 +00:00
boris f109c933c1 add trusted proxies configuration
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 08:47:17 +00:00
boris d52eb6bd81 documentation and env changes
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s
2025-11-28 08:14:13 +00:00
boris f77f3a9e40 its 7am i havent slept i have no idea
CI (Gitea) / php-tests (push) Successful in 10m5s
CI (Gitea) / docker-image (push) Successful in 2m22s
2025-11-28 06:40:10 +00:00
boris 336dcc4d3a erm
CI (Gitea) / php-tests (push) Successful in 10m23s
CI (Gitea) / docker-image (push) Successful in 3m3s
2025-11-28 03:23:52 +00:00
boris 54b1908793 Add APP_SECRET to Dockerfile
CI (Gitea) / php-tests (push) Successful in 10m11s
CI (Gitea) / docker-image (push) Failing after 2m17s
2025-11-28 02:59:01 +00:00
boris dda9ff06b5 add temp build secret
CI (Gitea) / php-tests (push) Successful in 10m7s
CI (Gitea) / docker-image (push) Failing after 1m38s
2025-11-28 02:37:54 +00:00
boris 796acaa9c0 changed project name
CI (Gitea) / php-tests (push) Successful in 10m6s
CI (Gitea) / docker-image (push) Failing after 2m34s
2025-11-28 02:16:00 +00:00
boris 3879c6c312 update composer.json
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 02:14:49 +00:00
boris da9af888c0 Attempt to be prod ready
CI (Gitea) / php-tests (push) Failing after 1m30s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 02:11:23 +00:00
boris dae8f3d999 wtf
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
2025-11-28 02:00:11 +00:00
boris 1c98a634c3 eerrrrrr
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
2025-11-27 23:42:17 +00:00
boris 054e970df9 what the fuck
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m55s
2025-11-27 20:03:12 +00:00
boris f15d9a9cfd Added admin dashboard, refactored user dashboard. Removed old reviews route.
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m53s
2025-11-20 20:40:49 +00:00
boris cd04fa5212 CRUD Albums + Spotify API requests into DB.
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
2025-11-20 19:54:31 +00:00
boris cd13f1478a Update .gitea/workflows/ci.yml
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m1s
2025-11-07 12:15:13 +00:00
boris 0cd77f8b30 Updated composer.json to remove the translation package and add the stopwatch package. Cleaned up configuration files by removing unused mailer, notifier, translation, and ux_turbo settings, and adjusted CSRF protection settings. Modified messenger.yaml to remove email message routing.
CI - Build Tonehaus Docker image / build (push) Failing after 2m6s
2025-11-07 12:08:58 +00:00
173 changed files with 22364 additions and 1149 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+19
View File
@@ -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 -14
View File
@@ -1,16 +1,32 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
# Uncomment to override stored setting.
#LASTFM_API_KEY=
#MUSICBRAINZ_CLIENT_ID=
#MUSICBRAINZ_CLIENT_SECRET=
# TIDAL uses OAuth client-credentials; set client ID + secret and token is fetched automatically.
#TIDAL_CLIENT_ID=
#TIDAL_CLIENT_SECRET=
# Optional ISO-2 country code for TIDAL catalog availability (defaults to US).
#TIDAL_COUNTRY_CODE=US
# Optional: not required for album search.
# If needed for authenticated MusicBrainz features, obtain via OAuth from:
# https://musicbrainz.org/account/applications
#MUSICBRAINZ_ACCESS_TOKEN=
# Optional provider override. Leave unset to use the admin setting.
# Valid values: lastfm, musicbrainz, tidal.
# Safety: when the effective provider changes to a different valid source,
# catalog data (albums/tracks/reviews) is reset automatically to prevent duplicates.
#EXTERNAL_ALBUM_PROVIDER=lastfm
APP_ENV=prod
APP_SECRET=changeme
DEFAULT_URI=http://localhost:8000
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
# 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
# POSTGRES_DB=
# POSTGRES_USER=
# POSTGRES_PASSWORD=
PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=password
SPOTIFY_RATE_WINDOW_SECONDS=30
SPOTIFY_RATE_MAX_REQUESTS=50
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
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
+89 -39
View File
@@ -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,linux/arm64
IMAGE_NAME: tonehaus-app
jobs:
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,44 +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: Build single-arch images for artifacts (no registry)
if: ${{ env.REGISTRY == '' }}
run: |
TAG_SHA=${{ steps.meta.outputs.short_sha }}
for P in linux/amd64; 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
- name: Upload artifacts
if: ${{ env.REGISTRY == '' }}
uses: actions/upload-artifact@v4
- name: Push prod image
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
uses: docker/build-push-action@v6
with:
name: tonehaus-images
path: |
tonehaus-image-amd64.tar
context: .
file: ${{ env.DOCKERFILE }}
target: ${{ env.BUILD_TARGET }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:ci
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
+134
View File
@@ -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
+27
View File
@@ -15,6 +15,7 @@
!var/cache/.gitkeep
!var/logs/.gitkeep
!var/sessions/.gitkeep
/var/data/
# Logs (Symfony4)
/var/log/*
@@ -78,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 ###
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="framework" type="frameworkType"/>
<xs:complexType name="commandType">
<xs:all>
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
</xs:all>
</xs:complexType>
<xs:complexType name="frameworkType">
<xs:sequence>
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="invoke" use="required"/>
<xs:attribute type="xs:string" name="alias" use="required"/>
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
<xs:attribute type="xs:integer" name="version" use="required"/>
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
</xs:complexType>
<xs:complexType name="optionsBeforeType">
<xs:sequence>
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="optionType">
<xs:sequence>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
<xs:attribute name="pattern" use="optional">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="space"/>
<xs:enumeration value="equals"/>
<xs:enumeration value="unknown"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:schema>
-16
View File
@@ -18,17 +18,12 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<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" />
@@ -39,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" />
@@ -70,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,13 +79,10 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<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" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
@@ -114,26 +103,21 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stimulus-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-turbo" />
<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
-16
View File
@@ -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" />
@@ -79,7 +73,6 @@
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
@@ -89,10 +82,7 @@
<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/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
@@ -106,7 +96,6 @@
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
@@ -116,17 +105,13 @@
<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" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<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" />
@@ -135,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" />
+53 -48
View File
@@ -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,67 +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.
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.
- Requests pass through a throttle and 429 Retry-After backoff. GET responses are cached.
- Tunables (optional):
- 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`
```bash
# seconds per window (default 30)
SPOTIFY_RATE_WINDOW_SECONDS=30
# max requests per window (default 50)
SPOTIFY_RATE_MAX_REQUESTS=50
# max requests for sensitive endpoints (default 20)
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
```
## Environment overview
## Docs
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`
- Rate limits & caching: `docs/07-rate-limits-and-caching.md`
- Troubleshooting: `docs/08-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
+1
View File
@@ -0,0 +1 @@
// Intentionally empty importmap entrypoint.
+9 -7
View File
@@ -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,14 +13,14 @@
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.5",
"doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"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.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
@@ -33,14 +35,13 @@
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"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": {
@@ -99,6 +100,7 @@
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.64",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}
-109
View File
@@ -1,109 +0,0 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.5",
"doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.31",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.31",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/string-extra": "^3.22",
"twig/twig": "^2.12|^3.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
}
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.64",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}
+3
View File
@@ -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
View File
@@ -5,6 +5,7 @@ framework:
token_id: submit
csrf_protection:
check_header: true
stateless_token_ids:
- submit
- authenticate
+84
View File
@@ -0,0 +1,84 @@
<?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'];
if (!in_array($driver, $supportedDrivers, true)) {
throw new \InvalidArgumentException(sprintf(
'Unsupported DATABASE_DRIVER "%s". Allowed values: %s',
$driver,
implode(', ', $supportedDrivers)
));
}
// Configure the default DBAL connection.
$dbal = $doctrine->dbal();
$dbal->defaultConnection('default');
$connection = $dbal->connection('default');
$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);
$databasePath = sprintf('%s/var/data/database.sqlite', $projectDir);
$databaseDir = dirname($databasePath);
$filesystem = new Filesystem();
if (!$filesystem->exists($databaseDir)) {
$filesystem->mkdir($databaseDir, 0o775);
}
if (!$filesystem->exists($databasePath)) {
$filesystem->touch($databasePath);
}
$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');
}
};
-9
View File
@@ -1,13 +1,4 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
+10
View File
@@ -0,0 +1,10 @@
fos_rest:
format_listener:
rules:
- { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] }
- { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] }
body_listener: true
view:
view_response_listener: force
formats:
json: true
+2
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
-30
View File
@@ -1,30 +0,0 @@
framework:
messenger:
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
#dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
dsn: 'sync://' # meh
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
default_bus: messenger.bus.default
buses:
messenger.bus.default: []
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async
+1 -1
View File
@@ -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
+27
View File
@@ -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)
-12
View File
@@ -1,12 +0,0 @@
framework:
notifier:
chatter_transports:
texter_transports:
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }
+28 -4
View File
@@ -1,4 +1,7 @@
security:
role_hierarchy:
ROLE_ADMIN: ['ROLE_MODERATOR']
ROLE_MODERATOR: ['ROLE_USER']
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -12,16 +15,31 @@ 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
form_login:
login_path: album_search
login_path: app_login
check_path: app_login
enable_csrf: true
default_target_path: album_search
failure_path: album_search
failure_path: app_login
username_parameter: _username
password_parameter: _password
csrf_parameter: _csrf_token
@@ -45,8 +63,14 @@ 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: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { 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 }
when@test:
security:
@@ -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
-5
View File
@@ -1,5 +0,0 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:
-4
View File
@@ -1,4 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
csrf_protection:
check_header: true
+27
View File
@@ -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
+46 -3
View File
@@ -4,6 +4,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# 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
@@ -19,7 +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\LastFmClient:
arguments:
$httpClient: '@GuzzleHttp\Client'
$apiKey: '%env(default::LASTFM_API_KEY)%'
App\Service\MusicBrainzClient:
arguments:
$httpClient: '@GuzzleHttp\Client'
$clientId: '%env(default::MUSICBRAINZ_CLIENT_ID)%'
$clientSecret: '%env(default::MUSICBRAINZ_CLIENT_SECRET)%'
$accessToken: '%env(default::MUSICBRAINZ_ACCESS_TOKEN)%'
App\Service\TidalClient:
arguments:
$httpClient: '@GuzzleHttp\Client'
$clientId: '%env(default::TIDAL_CLIENT_ID)%'
$clientSecret: '%env(default::TIDAL_CLIENT_SECRET)%'
$countryCode: '%env(default::TIDAL_COUNTRY_CODE)%'
App\Service\ExternalAlbumClient:
arguments: []
App\Service\ExternalAlbumClientInterface: '@App\Service\ExternalAlbumClient'
App\Service\UploadStorage:
arguments:
$storageRoot: '%kernel.project_dir%/public/uploads'
$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 -83
View File
@@ -1,98 +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 # change to "prod" for production build
target: prod
args:
- APP_ENV=dev
container_name: app-php
APP_ENV: ${APP_ENV:-prod}
container_name: tonehaus
restart: unless-stopped
environment:
# Symfony Messenger (dev-safe default so CLI commands don't fail)
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://}
# 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:
+2 -2
View File
@@ -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;
+71 -44
View File
@@ -1,48 +1,44 @@
# 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
ARG APP_ENV=dev
ENV APP_ENV=${APP_ENV}
WORKDIR /var/www/html
# System dependencies
# System dependencies shared across images
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
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
# PHP extensions commonly used by Symfony
# 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
# Composer available in the running container (dev + prod)
# Composer available in every stage (dev + prod)
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# Recommended PHP settings (tweak as needed)
@@ -63,20 +59,22 @@
echo "opcache.jit_buffer_size=128M"; \
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
# Small healthcheck file for Nginx
# Small healthcheck file for HTTP probes
RUN mkdir -p public && printf "OK" > public/healthz
# 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
# 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
# -----------------------------------------------------------------------------
# Development image (mount your code via docker-compose volumes)
# -----------------------------------------------------------------------------
FROM base AS dev
ENV APP_ENV=dev
# Optional: enable Xdebug (uncomment to use)
ARG APP_ENV=dev
ENV APP_ENV=${APP_ENV}
ENV APP_DEBUG=1
# Optional: enable Xdebug by uncommenting below
# RUN apk add --no-cache $PHPIZE_DEPS \
# && pecl install xdebug \
# && docker-php-ext-enable xdebug \
@@ -84,7 +82,7 @@
# 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"]
@@ -92,27 +90,56 @@
# 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
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 (no scripts here; run later with console if needed)
# 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
# If Symfony console exists, finalize install & warm cache
# 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; \
php bin/console cache:clear --no-warmup; \
php bin/console cache:warmup; \
mkdir -p var && chown -R www-data:www-data var; \
fi; \
mkdir -p var var/data public/uploads; \
chown -R www-data:www-data var public/uploads; \
fi
USER www-data
CMD ["php-fpm"]
# 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"]
+102
View File
@@ -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 "$@"
+37
View File
@@ -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;
}
+22
View File
@@ -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
-32
View File
@@ -1,32 +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
```
## Admin user
```bash
docker compose exec php php bin/console app:promote-admin you@example.com
```
## Spotify credentials
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret.
- Fallback to env vars:
```bash
export SPOTIFY_CLIENT_ID=your_client_id
export SPOTIFY_CLIENT_SECRET=your_client_secret
```
-14
View File
@@ -1,14 +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 (author vs admin)
- Admin Site Settings to manage Spotify credentials
- User Dashboard for profile changes (email, display name, password)
- Light/Dark theme toggle (cookie-backed)
- Bootstrap UI
-19
View File
@@ -1,19 +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_ADMIN`: promoted via console `app:promote-admin`.
## Password changes
- On `/dashboard`, users can change email/display name.
- To set a new password, the current password must be provided.
## Logout
- `/logout` (link in user menu).
-19
View File
@@ -1,19 +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)` and `getAlbums([ids])`
- Centralized request pipeline: throttling, 429 backoff, response caching
## Advanced search
- The search page builds Spotify fielded queries:
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
- Optional free-text added to the query
-16
View File
@@ -1,16 +0,0 @@
# Reviews & Albums
## Album page
- Shows album artwork, metadata, average rating and review count.
- Lists reviews newest-first.
- Logged-in users can submit a review inline.
## Permissions
- Anyone can view.
- Authors can edit/delete their own reviews.
- Admins can edit/delete any review.
## UI
- Rating uses a slider (1–10) with ticks; badge shows current value.
-17
View File
@@ -1,17 +0,0 @@
# Admin & Settings
## Site settings (ROLE_ADMIN)
- URL: `/admin/settings`
- Manage Spotify credentials stored in DB.
## User management
- Promote an admin:
```bash
docker compose exec php php bin/console app:promote-admin user@example.com
```
## Appearance
- `/settings` provides a dark/light mode toggle.
- Preference saved in a cookie; applied via `data-bs-theme`.
-23
View File
@@ -1,23 +0,0 @@
# Rate Limits & Caching
## Throttling
- Requests are throttled per window (default 30s) to avoid bursts.
- Separate caps for sensitive endpoints.
- Configure via env:
```bash
SPOTIFY_RATE_WINDOW_SECONDS=30
SPOTIFY_RATE_MAX_REQUESTS=50
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
```
## 429 handling
- If Spotify returns 429, respects `Retry-After` and retries (up to 3 attempts).
## Response caching
- GET responses cached: search ~10 minutes, album ~1 hour.
- Token responses are cached separately.
## Batching
- `getAlbums([ids])` provided for batch lookups.
-19
View File
@@ -1,19 +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`).
## Rate limits / 429
- Client backs off using `Retry-After`. Reduce concurrent requests; increase window env vars if needed.
+48
View File
@@ -0,0 +1,48 @@
# Admin & Settings
## Access control
- All `/admin/*` pages require authentication; unauthorized visitors get redirected through `/login`, which opens the auth modal automatically.
- `ROLE_MODERATOR` grants dashboard + user list access.
- `ROLE_ADMIN` adds settings access and moderator promotion/demotion abilities.
## Site dashboard (ROLE_MODERATOR)
- URL: `/admin/dashboard`
- Shows total counts plus the most recent reviews and albums so staff can moderate activity quickly.
## User management (ROLE_MODERATOR)
- URL: `/admin/users`
- Table columns:
- Name/email/roles + album/review counts (queried via aggregates).
- Action buttons always render; disabled buttons show tooltips describing why (e.g., "Administrators cannot be deleted").
- Moderators:
- Create new accounts via the inline form without logging themselves out.
- Delete standard users or other moderators (except themselves).
- Admins:
- Toggle moderator role (Promote/Demote) for non-admin accounts.
- Cannot delete or demote other admins—admin privileges supersede moderator status.
## Site settings (ROLE_ADMIN)
- URL: `/admin/settings`
- 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.
## User management
- Promote an admin:
```bash
docker compose exec php php bin/console app:promote-admin user@example.com
```
- Promote a moderator:
```bash
docker compose exec php php bin/console app:promote-moderator user@example.com
```
## Appearance
- `/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.
+88
View File
@@ -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
+969
View File
@@ -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.
+611
View File
@@ -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.
+48
View File
@@ -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).
+68
View File
@@ -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
+31
View File
@@ -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)
+26
View File
@@ -0,0 +1,26 @@
# Last.fm Integration
## Credentials
- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required).
- Env fallback: `LASTFM_API_KEY`.
## API Client
- `src/Service/LastFmClient.php` (uses Guzzle)
- `searchAlbums(query, limit)`
- `getAlbum(id)` / `getAlbums([ids])`
- `getAlbumWithTracks(id)`
- `getAlbumTracks(id)`
## Caching & Limits
- Last.fm responses are cached in Symfony cache pools to reduce repeated calls.
- Search/detail payloads are normalized to one internal album shape before persistence.
## Ingestion Flow
1. Search Last.fm
2. Resolve tags/genres via `LastFmTagResolver`
3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()`
4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()`
## Admin Settings
- Set the Last.fm API key in `/admin/settings`.
- Key changes apply immediately.
+31
View File
@@ -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.
+68
View File
@@ -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
```
+45
View File
@@ -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
```
+14
View File
@@ -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,
],
];
+18 -3
View File
@@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
if ($this->isSqlite()) {
// SQLite uses the dedicated schema bootstrap migration later in the chain.
return;
}
// Skip if the tables somehow already exist to keep reruns idempotent.
if ($schema->hasTable('reviews') || $schema->hasTable('users') || $schema->hasTable('messenger_messages')) {
return;
}
$this->addSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
$this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\'');
@@ -46,11 +55,17 @@ final class Version20251031224841 extends AbstractMigration
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
$this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE users');
$this->addSql('DROP TABLE messenger_messages');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+8 -1
View File
@@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+8 -1
View File
@@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+15 -3
View File
@@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
if ($this->isSqlite()) {
// SQLite bootstraps settings in Version20251127235840.
return;
}
if ($schema->hasTable('settings')) {
return;
}
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
if ($this->isSqlite()) {
return;
}
$this->addSql('DROP TABLE settings');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114111853 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite installs get albums from Version20251127235840.
return;
}
// Idempotent guard: if table already exists (from previous migration), skip
if ($schema->hasTable('albums')) {
return;
}
$this->addSql('CREATE TABLE albums (id SERIAL NOT NULL, spotify_id VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, artists JSON NOT NULL, release_date VARCHAR(20) DEFAULT NULL, total_tracks INT NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
// Be defensive: only drop the table if it exists
if ($schema->hasTable('albums')) {
$this->addSql('DROP TABLE albums');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114112016 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX uniq_album_spotify_id RENAME TO UNIQ_F4E2474FA905FC5C');
}
public function down(Schema $schema): void
{
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS NULL');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS NULL');
$this->addSql('ALTER INDEX uniq_f4e2474fa905fc5c RENAME TO uniq_album_spotify_id');
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Normalize reviews: add album_id FK, backfill from albums.spotify_id';
}
public function up(Schema $schema): void
{
if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if ($reviews->hasColumn('album_id')) {
// Already migrated (common for SQLite dev DBs)
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
// SQLite cannot add FK constraints after table creation; add the column + index and rely on app-level validation.
$this->addSql('ALTER TABLE reviews ADD COLUMN album_id INTEGER DEFAULT NULL');
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_6970EF78E0C31AF9 ON reviews (album_id)');
$this->addSql('UPDATE reviews SET album_id = (SELECT a.id FROM albums a WHERE a.spotify_id = reviews.spotify_album_id) WHERE album_id IS NULL');
return;
}
// Add nullable album_id first (PostgreSQL / others that support full DDL)
$this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
// Backfill using existing spotify_album_id if both columns exist
// Some environments may not have the legacy column; guard with DO blocks
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='reviews' AND column_name='spotify_album_id'
) THEN
UPDATE reviews r
SET album_id = a.id
FROM albums a
WHERE a.spotify_id = r.spotify_album_id
AND r.album_id IS NULL;
END IF;
END $$;
SQL);
// Optionally set NOT NULL if all rows are linked
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM reviews WHERE album_id IS NULL) THEN
ALTER TABLE reviews ALTER COLUMN album_id SET NOT NULL;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if (!$reviews->hasColumn('album_id')) {
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114114000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop legacy duplicated review columns: spotify_album_id, album_name, album_artist';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite schema never created the legacy columns.
return;
}
// Guard: drop columns only if they exist
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='spotify_album_id') THEN
ALTER TABLE reviews DROP COLUMN spotify_album_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_name') THEN
ALTER TABLE reviews DROP COLUMN album_name;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_artist') THEN
ALTER TABLE reviews DROP COLUMN album_artist;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
// Recreate columns as nullable in down migration
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user-created album fields: local_id, source, created_by_id; make spotify_id nullable';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite schema already ships with these columns/defaults.
return;
}
$this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL");
$this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
$this->addSql("ALTER TABLE albums ALTER spotify_id DROP NOT NULL");
$this->addSql("CREATE UNIQUE INDEX uniq_album_local_id ON albums (local_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");
$this->addSql("CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)");
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source IS NULL");
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
$this->addSql("DROP INDEX IF EXISTS uniq_album_local_id");
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
$this->addSql("ALTER TABLE albums DROP COLUMN local_id");
$this->addSql("ALTER TABLE albums DROP COLUMN source");
$this->addSql("ALTER TABLE albums DROP COLUMN created_by_id");
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120174722 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL');
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120175034 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251127191813 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT');
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251127235840 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
if ($schema->hasTable('users')) {
// Already initialized.
return;
}
$this->addSql('CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER DEFAULT NULL, spotify_id VARCHAR(64) DEFAULT NULL, local_id VARCHAR(64) DEFAULT NULL, source VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, artists CLOB NOT NULL --(DC2Type:json)
, release_date VARCHAR(20) DEFAULT NULL, total_tracks INTEGER NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, cover_image_path VARCHAR(255) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
$this->addSql('CREATE TABLE reviews (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, album_id INTEGER NOT NULL, title VARCHAR(160) NOT NULL, content CLOB NOT NULL, rating SMALLINT NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6970EB0F1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
$this->addSql('CREATE INDEX IDX_6970EB0F1137ABCF ON reviews (album_id)');
$this->addSql('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value CLOB DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
$this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, profile_image_path VARCHAR(255) DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
}
public function down(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
$this->addSql('DROP TABLE albums');
$this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE settings');
$this->addSql('DROP TABLE users');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205123000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add profile image path to users and cover image path to albums';
}
public function up(Schema $schema): void
{
if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) {
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
}
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite cannot drop columns; leave them in place.
return;
}
if ($schema->hasTable('users') && $schema->getTable('users')->hasColumn('profile_image_path')) {
$this->addSql('ALTER TABLE users DROP profile_image_path');
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('cover_image_path')) {
$this->addSql('ALTER TABLE albums DROP cover_image_path');
}
}
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);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205133000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create album_tracks table to persist Spotify tracklists';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
if ($schema->hasTable('album_tracks')) {
return;
}
$this->addSql('CREATE TABLE album_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, album_id INTEGER NOT NULL, spotify_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('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
return;
}
$this->addSql('CREATE TABLE album_tracks (id SERIAL NOT NULL, album_id INT NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INT NOT NULL, track_number INT NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INT NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
$this->addSql('ALTER TABLE album_tracks ADD CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
$this->addSql('DROP TABLE IF EXISTS album_tracks');
return;
}
$this->addSql('ALTER TABLE album_tracks DROP CONSTRAINT FK_5E4A3B3B1137ABCF');
$this->addSql('DROP TABLE album_tracks');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+58
View File
@@ -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);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
}
public function up(Schema $schema): void
{
if ($this->shouldAddColumn($schema, 'albums', 'external_id')) {
$this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) {
$this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) {
$this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL');
}
if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) {
$this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL');
}
if ($this->hasColumn($schema, 'albums', 'source')) {
$this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'");
if (!$this->isSqlite()) {
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'");
}
}
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL');
}
if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
$this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)');
}
if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
$this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)');
}
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite column drops require table rebuilds; keep schema forward-only here.
return;
}
if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
$this->addSql('DROP INDEX uniq_users_oauth_identity');
}
if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
$this->addSql('DROP INDEX uniq_album_external_id');
}
if ($this->hasColumn($schema, 'users', 'oauth_id')) {
$this->addSql('ALTER TABLE users DROP COLUMN oauth_id');
}
if ($this->hasColumn($schema, 'users', 'oauth_provider')) {
$this->addSql('ALTER TABLE users DROP COLUMN oauth_provider');
}
if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id');
}
if ($this->hasColumn($schema, 'albums', 'external_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN external_id');
}
if ($this->hasColumn($schema, 'albums', 'source')) {
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'");
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'");
}
}
private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName);
}
private function hasTable(Schema $schema, string $tableName): bool
{
return $schema->hasTable($tableName);
}
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
}
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+122
View File
@@ -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';
}
}
+165
View File
@@ -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';
}
}
+47
View File
@@ -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';
}
}
+58
View File
@@ -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';
}
}
+50
View File
@@ -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&amp;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>
+303
View File
@@ -0,0 +1,303 @@
:root {
color-scheme: light;
--accent-color: #6750a4;
--accent-on-color: #ffffff;
--md-surface: color-mix(in srgb, var(--accent-color) 6%, #ffffff);
--md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #f5f4fa);
--md-card: #ffffff;
--md-card-border: color-mix(in srgb, var(--accent-color) 24%, transparent);
--md-outline: color-mix(in srgb, var(--accent-color) 18%, #d9d5ea);
--md-text-primary: #1c1b20;
--md-text-secondary: color-mix(in srgb, var(--accent-color) 30%, #4a4458);
--md-muted-bg: color-mix(in srgb, var(--accent-color) 18%, transparent);
--md-focus-ring: color-mix(in srgb, var(--accent-color) 45%, transparent);
--md-shadow-ambient: 0 12px 32px color-mix(in srgb, rgba(15, 13, 33, 0.2) 70%, var(--accent-color) 10%);
}
[data-bs-theme='dark'] {
color-scheme: dark;
--md-surface: color-mix(in srgb, var(--accent-color) 5%, #131217);
--md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #1f1e25);
--md-card: color-mix(in srgb, var(--accent-color) 8%, #1f1e25);
--md-card-border: color-mix(in srgb, var(--accent-color) 35%, transparent);
--md-outline: color-mix(in srgb, var(--accent-color) 35%, #6c6772);
--md-text-primary: color-mix(in srgb, var(--accent-color) 6%, #f5f2ff);
--md-text-secondary: color-mix(in srgb, var(--accent-color) 24%, #cfc6dc);
--md-muted-bg: color-mix(in srgb, var(--accent-color) 22%, transparent);
--md-shadow-ambient: 0 16px 40px rgba(0, 0, 0, 0.55);
}
body {
background-color: var(--md-surface);
color: var(--md-text-primary);
font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif;
transition: background-color 0.2s ease, color 0.2s ease;
}
main.container {
max-width: 1100px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--md-text-primary);
font-weight: 600;
}
.text-secondary {
color: var(--md-text-secondary) !important;
}
.card {
background-color: var(--md-card);
border: 1px solid var(--md-card-border);
border-radius: 20px;
box-shadow: var(--md-shadow-ambient);
overflow: hidden;
}
.navbar.bg-body-tertiary {
background-color: color-mix(in srgb, var(--accent-color) 6%, rgba(255, 255, 255, 0.92)) !important;
backdrop-filter: blur(16px);
border: 1px solid var(--md-card-border);
border-radius: 0 0 28px 28px;
box-shadow: 0 12px 32px rgba(15, 13, 33, 0.1);
margin: 0 auto 2rem auto;
max-width: 1100px;
width: calc(100% - 2rem);
position: relative;
z-index: 1040;
}
@media (max-width: 1199px) {
.navbar.bg-body-tertiary {
border-radius: 0;
width: 100%;
margin: 0 0 1.5rem 0;
border-left: none;
border-right: none;
}
}
[data-bs-theme='dark'] .navbar.bg-body-tertiary {
background-color: color-mix(in srgb, var(--accent-color) 8%, rgba(26, 25, 32, 0.96)) !important;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
}
.dropdown-menu {
border-radius: 16px;
border: 1px solid var(--md-card-border);
background-color: var(--md-card);
}
.btn-success,
.btn-primary,
.btn-accent {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: var(--accent-on-color);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-success:hover,
.btn-primary:hover,
.btn-accent:hover {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: var(--accent-on-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
transform: translateY(-1px);
}
.btn-outline-secondary,
.btn-outline-success,
.btn-outline-primary {
color: var(--accent-color);
border-color: var(--accent-color);
}
.btn-outline-secondary:hover,
.btn-outline-success:hover,
.btn-outline-primary:hover {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: var(--accent-on-color);
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
color: var(--accent-color);
opacity: 0.9;
text-decoration: underline;
}
.form-control,
.form-select {
border-radius: 14px;
border-color: var(--md-outline);
background-color: var(--md-card);
color: var(--md-text-primary);
}
.form-control:focus,
.form-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 0.2rem var(--md-focus-ring);
}
.form-check-input:checked {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
.badge.text-bg-primary,
.badge.text-bg-secondary {
background-color: var(--accent-color) !important;
color: var(--accent-on-color) !important;
}
.landing-search-input {
border: 1px solid var(--md-card-border);
}
.card .btn.btn-outline-primary,
.card .btn.btn-outline-success {
border-radius: 999px;
}
.card .btn.btn-success,
.card .btn.btn-outline-primary,
.card .btn.btn-outline-success {
font-weight: 600;
}
.alert {
border-radius: 16px;
border: none;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
}
/* Material-inspired data tables */
.mui-table-wrapper {
border: 1px solid var(--md-card-border);
border-radius: 20px;
background-color: var(--md-card);
box-shadow: var(--md-shadow-ambient);
overflow-x: auto;
}
.mui-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 620px;
}
.mui-table thead th {
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
color: var(--md-text-secondary);
padding: 0.85rem 1.25rem;
background-color: var(--md-surface-variant);
border-bottom: 1px solid var(--md-outline);
}
.mui-table tbody td {
padding: 1rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--md-outline) 75%, transparent);
color: var(--md-text-primary);
}
.mui-table--compact tbody td {
padding: 0.65rem 1rem;
}
.mui-table tbody tr:last-child td {
border-bottom: none;
}
.mui-table tbody tr {
transition: background-color 0.15s ease, transform 0.15s ease;
}
.mui-table tbody tr:hover {
background-color: color-mix(in srgb, var(--accent-color) 6%, transparent);
}
.mui-table--striped tbody tr:nth-child(even) {
background-color: color-mix(in srgb, var(--accent-color) 4%, transparent);
}
.mui-table--striped tbody tr:nth-child(even):hover {
background-color: color-mix(in srgb, var(--accent-color) 9%, transparent);
}
.mui-table__number {
font-variant-numeric: tabular-nums;
color: var(--md-text-secondary);
width: 72px;
}
.mui-table__metric {
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--md-text-secondary);
}
.mui-table__title {
font-weight: 600;
}
.mui-table__title-avatar {
display: flex;
align-items: center;
gap: 0.85rem;
}
.mui-table__title-avatar img {
width: 40px;
height: 40px;
border-radius: 12px;
object-fit: cover;
border: 1px solid var(--md-card-border);
}
.mui-table__subtitle {
font-size: 0.85rem;
color: var(--md-text-secondary);
}
.mui-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--md-outline);
color: var(--accent-color);
background-color: transparent;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.mui-icon-button:hover {
background-color: color-mix(in srgb, var(--accent-color) 12%, transparent);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.mui-icon-button svg,
.mui-icon-button span {
font-size: 1rem;
line-height: 1;
}
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
+15 -3
View File
@@ -13,20 +13,32 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
class PromoteAdminCommand extends Command
{
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
/**
* Stores injected dependencies for later use.
*/
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
)
{
parent::__construct();
}
/**
* Declares the required email argument.
*/
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
}
/**
* Promotes the provided account to administrator if found.
*/
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;
@@ -36,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>');
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:promote-moderator', description: 'Grant ROLE_MODERATOR to a user by email')]
class PromoteModeratorCommand extends Command
{
/**
* Stores dependencies for the console handler.
*/
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
)
{
parent::__construct();
}
/**
* Declares the required email argument.
*/
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
}
/**
* Grants the moderator role if the user exists.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
$user = $this->userRepository->findOneByEmail($email);
if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE;
}
$roles = $user->getRoles();
if (!in_array('ROLE_MODERATOR', $roles, true)) {
$roles[] = 'ROLE_MODERATOR';
$user->setRoles($roles);
$this->entityManager->flush();
}
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
return Command::SUCCESS;
}
}
@@ -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;
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\AlbumTrack;
use App\Entity\User;
use App\Repository\AlbumRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
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 = [
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
];
private const ADJECTIVES = [
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
];
private const NOUNS = [
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
->addOption('attach-users', null, InputOption::VALUE_NONE, 'Assign each generated album to a random existing user');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = max(1, (int) $input->getOption('count'));
$attachUsers = (bool) $input->getOption('attach-users');
$owners = [];
if ($attachUsers) {
$owners = $this->userRepository->findAll();
if ($owners === []) {
$io->warning('No users available for --attach-users; albums will be created without owners.');
}
}
$created = 0;
// Track generated album IDs so we never attempt to persist obvious duplicates.
$seenAlbumIds = [];
while ($created < $count) {
// 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->setExternalId($albumId);
$album->setName($this->generateAlbumName());
$album->setArtists($this->generateArtists());
$album->setGenres($this->generateGenres());
$album->setReleaseDate($this->generateReleaseDate());
$trackCount = random_int(6, 16);
$album->setTotalTracks($trackCount);
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $albumId));
if ($owners !== []) {
/** @var User $owner */
$owner = $owners[array_rand($owners)];
$album->setOwner($owner);
}
$this->addSyntheticTracks($album, $trackCount);
$this->entityManager->persist($album);
$seenAlbumIds[$albumId] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo albums.', $created));
return Command::SUCCESS;
}
private function generateAlbumId(): string
{
return 'demo_' . bin2hex(random_bytes(4));
}
private function generateAlbumName(): string
{
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
$genre = self::GENRES[random_int(0, count(self::GENRES) - 1)];
return sprintf('%s %s of %s', $adj, $noun, $genre);
}
/**
* @return list<string>
*/
private function generateArtists(): array
{
$artists = [];
$artistCount = random_int(1, 3);
for ($i = 0; $i < $artistCount; $i++) {
$artists[] = sprintf(
'%s %s',
self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)],
self::NOUNS[random_int(0, count(self::NOUNS) - 1)]
);
}
return array_values(array_unique($artists));
}
private function generateReleaseDate(): string
{
$year = random_int(1990, (int) date('Y'));
$month = random_int(1, 12);
$day = random_int(1, 28);
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* @return list<string>
*/
private function generateGenres(): array
{
$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);
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Repository\AlbumRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
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 = [
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
];
private const VERBS = [
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
];
private const QUALIFIERS = [
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
];
public function __construct(
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10)
->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1)
->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60)
->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// 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 === []) {
$io->warning('Need at least one album and one user to seed reviews.');
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)) {
continue;
}
$targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum));
$created += $this->seedForAlbum($album, $users, $targetReviews);
$processedAlbums++;
}
$this->entityManager->flush();
if ($created === 0) {
$io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.');
return Command::SUCCESS;
}
$io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1)));
return Command::SUCCESS;
}
/**
* @param list<Album> $albums
* @return list<Album>
*/
private function selectAlbums(array $albums, int $coverPercent): array
{
if ($coverPercent >= 100) {
return $albums;
}
// Randomly sample albums until the requested coverage threshold is met.
$selected = [];
foreach ($albums as $album) {
if (random_int(1, 100) <= $coverPercent) {
$selected[] = $album;
}
}
// Ensure we always seed at least one album when any albums exist.
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
}
/**
* @param list<User> $users
*/
private function seedForAlbum(Album $album, array $users, int $targetReviews): int
{
$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);
$review->setRating(random_int(4, 10));
$review->setTitle($this->generateTitle());
$review->setContent($this->generateContent($album));
$this->entityManager->persist($review);
$created++;
}
return $created;
}
/**
* @return array<int,bool>
*/
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')
->where('r.album = :album')
->setParameter('album', $album);
$rows = $qb->getQuery()->getScalarResult();
$out = [];
foreach ($rows as $row) {
$out[(int) $row['authorId']] = true;
}
return $out;
}
private function albumHasReviews(Album $album): bool
{
$count = (int) $this->entityManager->createQueryBuilder()
->select('COUNT(r.id)')
->from(Review::class, 'r')
->where('r.album = :album')
->setParameter('album', $album)
->getQuery()
->getSingleScalarResult();
return $count > 0;
}
private function generateTitle(): string
{
$subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)];
$verb = self::VERBS[random_int(0, count(self::VERBS) - 1)];
return sprintf('%s %s the vibe', $subject, $verb);
}
private function generateContent(Album $album): string
{
$qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)];
return sprintf(
'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.',
$album->getName(),
$qualifier,
self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)],
self::VERBS[random_int(0, count(self::VERBS) - 1)],
self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]
);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
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 = [
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
];
private const LAST_NAMES = [
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50)
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = (int) $input->getOption('count');
$count = $count > 0 ? $count : 50;
$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;
}
$user = new User();
$user->setEmail($email);
$user->setDisplayName($this->generateDisplayName());
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setRoles(['ROLE_USER']);
$this->entityManager->persist($user);
$seenEmails[$email] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword));
return Command::SUCCESS;
}
private function generateEmail(): string
{
$token = bin2hex(random_bytes(4));
return sprintf('demo+%s@example.com', $token);
}
private function generateDisplayName(): string
{
$first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)];
$last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)];
return sprintf('%s %s', $first, $last);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
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(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set')
->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs')
->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$overwrite = (bool) $input->getOption('overwrite');
$style = (string) $input->getOption('style');
$seedPrefix = (string) $input->getOption('seed-prefix');
$users = $this->userRepository->findAll();
if ($users === []) {
$io->warning('No users found.');
return Command::FAILURE;
}
$updated = 0;
foreach ($users as $user) {
if (!$user instanceof User) {
continue;
}
if (!$overwrite && $user->getProfileImagePath()) {
// Respect existing uploads unless the operator explicitly allows clobbering them.
continue;
}
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
$updated++;
}
if ($updated === 0) {
$io->info('No avatars needed updating.');
return Command::SUCCESS;
}
$this->entityManager->flush();
$io->success(sprintf('Assigned avatars to %d user(s).', $updated));
return Command::SUCCESS;
}
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;
}
}
+85 -14
View File
@@ -4,53 +4,124 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface;
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;
use Symfony\Component\Form\FormError;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* AccountController hosts authenticated self-service pages.
*/
#[IsGranted('ROLE_USER')]
class AccountController extends AbstractController
{
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])]
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
/**
* Summarizes the signed-in user's recent activity.
*/
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
{
/** @var User $user */
$user = $this->getUser();
$reviewCount = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.author = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$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';
} elseif ($this->isGranted('ROLE_MODERATOR')) {
$userType = 'Moderator';
} else {
$userType = 'User';
}
$userReviews = $reviews->createQueryBuilder('r')
->where('r.author = :u')->setParameter('u', $user)
->orderBy('r.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
$userAlbums = $albums->createQueryBuilder('a')
->where('a.owner = :u')
->andWhere('a.source = :src')
->setParameter('u', $user)
->setParameter('src', 'user')
->orderBy('a.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
return $this->render('account/dashboard.html.twig', [
'email' => $user->getEmail(),
'displayName' => $user->getDisplayName(),
'profileImage' => $user->getProfileImagePath(),
'reviewCount' => $reviewCount,
'albumCount' => $albumCount,
'userType' => $userType,
'userReviews' => $userReviews,
'userAlbums' => $userAlbums,
]);
}
/**
* 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, 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/dashboard.html.twig', [
'form' => $form->createView(),
]);
}
} else {
// Allow password updates inside the same form submission instead of forcing a separate flow.
$user->setPassword($hasher->hashPassword($user, $newPassword));
}
$em->flush();
$this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_dashboard');
}
return $this->render('account/dashboard.html.twig', [
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');
}
}
return $this->render('account/profile.html.twig', [
'form' => $form->createView(),
'profileImage' => $user->getProfileImagePath(),
]);
}
/**
* Shows account-level settings options.
*/
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
public function settings(): Response
{
return $this->render('account/settings.html.twig');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Controller\Admin;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Repository\UserRepository;
use App\Service\ExternalProviderManager;
use App\Service\LastFmMetadataRefresher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* DashboardController shows high-level site activity to admins.
*/
#[IsGranted('ROLE_MODERATOR')]
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,
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();
$totalAlbums = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->getQuery()->getSingleScalarResult();
$totalUsers = (int) $users->createQueryBuilder('u')
->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')
->setMaxResults(50)
->getQuery()->getResult();
return $this->render('admin/site_dashboard.html.twig', [
'totalReviews' => $totalReviews,
'totalAlbums' => $totalAlbums,
'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');
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
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\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.
*/
#[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 API credential/provider settings.
*/
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
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('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->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.');
}
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'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;
}
}
@@ -1,37 +0,0 @@
<?php
namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
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;
#[IsGranted('ROLE_ADMIN')]
class SiteSettingsController extends AbstractController
{
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings): Response
{
$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'));
$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());
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'form' => $form->createView(),
]);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Controller\Admin;
use App\Dto\AdminUserData;
use App\Entity\User;
use App\Form\AdminUserType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* UserController exposes moderator/admin user management tools.
*/
#[IsGranted('ROLE_MODERATOR')]
class UserController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
}
/**
* Lists all users and handles manual account creation.
*/
#[Route('/admin/users', name: 'admin_users', methods: ['GET', 'POST'])]
public function index(Request $request, UserRepository $users): Response
{
$formData = new AdminUserData();
$form = $this->createForm(AdminUserType::class, $formData);
$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->passwordHasher->hashPassword($newUser, $plainPassword));
$this->entityManager->persist($newUser);
$this->entityManager->flush();
$this->addFlash('success', 'User account created.');
return $this->redirectToRoute('admin_users');
}
return $this->render('admin/users.html.twig', [
'form' => $form->createView(),
'rows' => $users->findAllWithStats(),
]);
}
/**
* Deletes a user account (moderators cannot delete admins).
*/
#[Route('/admin/users/{id}/delete', name: 'admin_users_delete', methods: ['POST'])]
public function delete(User $target, Request $request): Response
{
if (!$this->isCsrfTokenValid('delete-user-' . $target->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
/** @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');
}
if (in_array('ROLE_ADMIN', $target->getRoles(), true)) {
$this->addFlash('danger', 'Administrators cannot delete other administrators.');
return $this->redirectToRoute('admin_users');
}
$this->entityManager->remove($target);
$this->entityManager->flush();
$this->addFlash('success', 'User deleted.');
return $this->redirectToRoute('admin_users');
}
/**
* Promotes a user to moderator (admins only).
*/
#[Route('/admin/users/{id}/promote', name: 'admin_users_promote', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function promote(User $target, Request $request): Response
{
if (!$this->isCsrfTokenValid('promote-user-' . $target->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$roles = $target->getRoles();
if (in_array('ROLE_ADMIN', $roles, true)) {
$this->addFlash('danger', 'Administrators already include moderator permissions.');
return $this->redirectToRoute('admin_users');
}
$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->entityManager->flush();
$this->addFlash('success', 'Moderator privileges removed.');
} else {
$roles[] = 'ROLE_MODERATOR';
$target->setRoles(array_values(array_unique($roles)));
$this->entityManager->flush();
$this->addFlash('success', 'User promoted to moderator.');
}
return $this->redirectToRoute('admin_users');
}
}
+762 -59
View File
@@ -2,84 +2,303 @@
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Entity\AlbumTrack;
use App\Entity\Review;
use App\Entity\User;
use App\Form\ReviewType;
use App\Form\AlbumType;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\ExternalAlbumClientInterface;
use App\Service\LastFmTagResolver;
use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
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 AlbumSearchService $albumSearch,
private readonly LastFmTagResolver $genreResolver,
private readonly LoggerInterface $logger,
private readonly int $searchLimit = 20
) {
}
/**
* Searches external plus local albums and decorates results with review stats.
*/
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
public function search(Request $request): Response
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
$artist = trim($request->query->getString('artist', ''));
// Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
$yearToRaw = trim((string) $request->query->get('year_to', ''));
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
$albums = [];
$stats = [];
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
$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', ''));
// Build Spotify fielded search if advanced inputs are supplied
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
$q = $query;
if ($advancedUsed) {
$parts = [];
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
if ($yearFrom > 0 || $yearTo > 0) {
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
} else {
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
$parts[] = 'year:' . $y;
}
}
// also include free-text if provided
if ($query !== '') { $parts[] = $query; }
$q = implode(' ', $parts);
$response = $this->render('album/search.html.twig', [
'query' => $criteria->query,
'album' => $criteria->albumName,
'artist' => $criteria->artist,
'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;
}
if ($q !== '') {
$result = $spotifyClient->searchAlbums($q, 20);
$albums = $result['albums']['items'] ?? [];
if ($albums) {
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
if ($ids) {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
}
}
/**
* 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;
}
return $this->render('album/search.html.twig', [
'query' => $query,
'album' => $albumName,
'artist' => $artist,
'year_from' => $yearFrom ?: '',
'year_to' => $yearTo ?: '',
'albums' => $albums,
$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,
]);
}
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
{
$album = $spotifyClient->getAlbum($id);
if ($album === null) {
throw $this->createNotFoundException('Album not found');
return new JsonResponse([
'ok' => true,
'html' => $html,
'count' => count($albums),
'next_offset' => $offset + count($albums),
'limit' => $batchLimit,
'exhausted' => count($albums) < $batchLimit,
]);
}
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
/**
* 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,
]);
}
/**
* Creates a user-authored album entry.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
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->normalizeAlbumFormData($album);
$album->setSource('user');
$album->setExternalId($this->generateUserAlbumId($albumRepo));
$owner = $this->getUser();
if ($owner instanceof User) {
$album->setOwner($owner);
}
$em->persist($album);
if (!$this->syncTracksFromEditorRequest($album, $request, $trackRepo)) {
$form->addError(new FormError('At least one track is required.'));
} else {
$uploadedCoverPath = null;
try {
$uploadedCoverPath = $this->storeCoverUpload($form->get('coverUpload')->getData(), $uploadStorage);
if ($uploadedCoverPath !== null) {
$album->setCoverImagePath($uploadedCoverPath);
}
$em->flush();
$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/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'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
public function show(string $id, Request $request, ExternalAlbumClientInterface $externalAlbumClient, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
{
$albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null;
if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via the configured external provider.
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
throw $this->createNotFoundException('Album not found');
}
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
$em->flush();
} else {
if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $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());
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing);
$avg = 0.0;
if ($count > 0) {
@@ -88,11 +307,8 @@ class AlbumController extends AbstractController
$avg = round($sum / $count, 1);
}
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review();
$review->setSpotifyAlbumId($id);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$review->setAlbum($albumEntity);
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
@@ -106,14 +322,501 @@ class AlbumController extends AbstractController
}
return $this->render('album/show.html.twig', [
'album' => $album,
'album' => $albumCard,
'albumId' => $id,
'isSaved' => $isSaved,
'allowedEdit' => $canManage,
'allowedDelete' => $canManage,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
'albumOwner' => $albumEntity->getOwner(),
'albumCreatedAt' => $albumEntity->getCreatedAt(),
'tracks' => $trackRows,
]);
}
/**
* Saves an external album locally for quicker access.
*/
#[IsGranted('ROLE_USER')]
#[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->findOneByExternalId($id);
if (!$existing) {
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($id);
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
throw $this->createNotFoundException('Album not found');
}
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
$em->flush();
$this->addFlash('success', 'Album saved.');
} else {
$this->addFlash('info', 'Album already saved.');
}
return $this->redirectToRoute('album_show', ['id' => $id]);
}
/**
* Deletes a user-created album when authorized.
*/
#[IsGranted('ROLE_USER')]
#[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');
if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$album = $this->findAlbum($id, $albumRepo);
if ($album) {
$this->ensureCanManageAlbum($album);
$em->remove($album);
$em->flush();
$this->addFlash('success', 'Album deleted.');
} else {
$this->addFlash('info', 'Album not found.');
}
return $this->redirectToRoute('album_search');
}
/**
* Generates a unique user album identifier.
*/
private function generateUserAlbumId(AlbumRepository $albumRepo): string
{
do {
$id = 'u_' . bin2hex(random_bytes(6));
} while ($albumRepo->findOneByExternalId($id) !== null);
return $id;
}
/**
* Normalizes a human-entered release date (YYYY[-MM[-DD]]).
*/
private function normalizeReleaseDate(?string $input): ?string
{
if ($input === null || trim($input) === '') {
return null;
}
$s = trim($input);
// YYYY
if (preg_match('/^\d{4}$/', $s)) {
return $s . '-01-01';
}
// YYYY-MM
if (preg_match('/^\d{4}-\d{2}$/', $s)) {
return $s . '-01';
}
// YYYY-MM-DD
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
return $s;
}
// 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) {
return null;
}
}
/**
* Edits a saved album when the current user may manage it.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'], requirements: ['id' => self::ALBUM_ID_REQUIREMENT])]
public function edit(
string $id,
Request $request,
AlbumRepository $albumRepo,
AlbumTrackRepository $trackRepo,
EntityManagerInterface $em,
UploadStorage $uploadStorage
): Response
{
$album = $this->findAlbum($id, $albumRepo);
if (!$album) {
throw $this->createNotFoundException('Album not found');
}
$this->ensureCanManageAlbum($album);
$this->normalizeAlbumFormData($album);
$form = $this->createForm(AlbumType::class, $album);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->normalizeAlbumFormData($album);
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/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 public identifier.
*/
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
{
return $albumRepo->findOneByExternalId($id);
}
/**
* Returns true when the authenticated user can manage the album.
*/
private function canManageAlbum(Album $album): bool
{
if ($album->getSource() !== 'user') {
return false;
}
if ($this->isGranted('ROLE_ADMIN') || $this->isGranted('ROLE_MODERATOR')) {
return true;
}
$owner = $album->getOwner();
$user = $this->getUser();
if (!$owner instanceof User || !$user instanceof User) {
return false;
}
$ownerId = $owner->getId();
$userId = $user->getId();
return $ownerId !== null && $userId !== null && $ownerId === $userId;
}
/**
* Throws if the authenticated user cannot manage the album.
*/
private function ensureCanManageAlbum(Album $album): void
{
if (!$this->canManageAlbum($album)) {
throw $this->createAccessDeniedException();
}
}
private function normalizeAlbumFormData(Album $album): void
{
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
}
/**
* @return list<array{disc:int,track:int,title:string,duration:string}>
*/
private function buildTrackEditorRows(Album $album, int $minimumRows = 10): array
{
$rows = [];
$position = 1;
foreach ($album->getTracks()->toArray() as $track) {
if (!$track instanceof AlbumTrack) {
continue;
}
$durationMs = (int) $track->getDurationMs();
$seconds = max(0, (int) floor($durationMs / 1000));
$minutes = intdiv($seconds, 60);
$remainingSeconds = $seconds % 60;
$rows[] = [
'disc' => max(1, (int) $track->getDiscNumber()),
'track' => max(1, (int) $track->getTrackNumber()),
'title' => trim((string) $track->getName()),
'duration' => sprintf('%d:%02d', $minutes, $remainingSeconds),
];
$position++;
}
while (count($rows) < $minimumRows) {
$rows[] = [
'disc' => 1,
'track' => count($rows) + 1,
'title' => '',
'duration' => '',
];
}
return $rows;
}
private function syncTracksFromEditorRequest(Album $album, Request $request, AlbumTrackRepository $trackRepo): bool
{
$trackPayloads = $this->extractTrackPayloadsFromRequest($request);
if ($trackPayloads === []) {
return false;
}
$trackRepo->replaceAlbumTracks($album, $trackPayloads);
$album->setTotalTracks($album->getTracks()->count());
return true;
}
/**
* @return list<array<string,mixed>>
*/
private function extractTrackPayloadsFromRequest(Request $request): array
{
$titles = $request->request->all('track_title');
$durations = $request->request->all('track_duration');
$discNumbers = $request->request->all('track_disc');
$trackNumbers = $request->request->all('track_number');
if (!is_array($titles)) {
return [];
}
$payloads = [];
$position = 1;
foreach ($titles as $idx => $rawTitle) {
$title = trim((string) $rawTitle);
if (mb_strlen($title) > 512) {
$title = mb_substr($title, 0, 512);
}
if ($title === '') {
continue;
}
$disc = isset($discNumbers[$idx]) ? (int) $discNumbers[$idx] : 1;
$track = isset($trackNumbers[$idx]) ? (int) $trackNumbers[$idx] : $position;
$durationRaw = isset($durations[$idx]) ? (string) $durations[$idx] : '';
$payloads[] = [
'disc_number' => max(1, $disc),
'track_number' => max(1, $track),
'name' => $title,
'duration_ms' => $this->parseTrackDurationToMs($durationRaw),
'preview_url' => null,
];
$position++;
}
return $payloads;
}
private function parseTrackDurationToMs(string $raw): int
{
$raw = trim($raw);
if ($raw === '') {
return 0;
}
if (preg_match('/^\d+$/', $raw)) {
return max(0, (int) $raw) * 1000;
}
if (!str_contains($raw, ':')) {
return 0;
}
$parts = array_values(array_filter(array_map('trim', explode(':', $raw)), static fn(string $value) => $value !== ''));
if ($parts === []) {
return 0;
}
$seconds = 0;
$multiplier = 1;
for ($i = count($parts) - 1; $i >= 0; $i--) {
if (!preg_match('/^\d+$/', $parts[$i])) {
return 0;
}
$seconds += ((int) $parts[$i]) * $multiplier;
$multiplier *= 60;
}
return max(0, $seconds) * 1000;
}
private function storeCoverUpload(mixed $uploadValue, UploadStorage $uploadStorage): ?string
{
if (!$uploadValue instanceof UploadedFile) {
return null;
}
return $uploadStorage->storeAlbumCover($uploadValue);
}
/**
* @param array<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 syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, ExternalAlbumClientInterface $externalAlbumClient): bool
{
if ($album->getSource() !== 'external') {
return false;
}
$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;
}
$externalAlbum = $externalAlbumClient->getAlbumWithTracks($externalId);
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
return false;
}
// 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($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
)
);
}
}
+79
View File
@@ -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
);
}
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more