Compare commits
23
Commits
6cccc3746d
...
v2-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75756ac6ec | ||
|
|
72a99a5bcd | ||
|
|
c1b1c2f372 | ||
|
|
56dea097d6 | ||
|
|
391ecf1732 | ||
|
|
4ae7a44881 | ||
|
|
fa54cb4167 | ||
|
|
f109c933c1 | ||
|
|
d52eb6bd81 | ||
|
|
f77f3a9e40 | ||
|
|
336dcc4d3a | ||
|
|
54b1908793 | ||
|
|
dda9ff06b5 | ||
|
|
796acaa9c0 | ||
|
|
3879c6c312 | ||
|
|
da9af888c0 | ||
|
|
dae8f3d999 | ||
|
|
1c98a634c3 | ||
|
|
054e970df9 | ||
|
|
f15d9a9cfd | ||
|
|
cd04fa5212 | ||
|
|
cd13f1478a | ||
|
|
0cd77f8b30 |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
.DS_Store
|
||||
.cursor
|
||||
.env.local
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.idea
|
||||
.public
|
||||
backup_manifests/
|
||||
docs/
|
||||
tests/
|
||||
var/
|
||||
# Symfony cache/logs generated inside the container should not come from the host
|
||||
var/cache/
|
||||
var/log/
|
||||
|
||||
# Uploaded files stay on the host, not baked into images
|
||||
public/uploads/
|
||||
|
||||
+30
-14
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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 ###
|
||||
+5313
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>
|
||||
Generated
-16
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Intentionally empty importmap entrypoint.
|
||||
+9
-7
@@ -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.*"
|
||||
}
|
||||
}
|
||||
@@ -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.*"
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -5,6 +5,7 @@ framework:
|
||||
token_id: submit
|
||||
|
||||
csrf_protection:
|
||||
check_header: true
|
||||
stateless_token_ids:
|
||||
- submit
|
||||
- authenticate
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
fos_rest:
|
||||
format_listener:
|
||||
rules:
|
||||
- { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] }
|
||||
- { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] }
|
||||
body_listener: true
|
||||
view:
|
||||
view_response_listener: force
|
||||
formats:
|
||||
json: true
|
||||
@@ -1,6 +1,8 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
|
||||
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
|
||||
public_key: '%kernel.project_dir%/config/jwt/public.pem'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 3600
|
||||
@@ -1,3 +0,0 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -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
|
||||
@@ -42,7 +42,7 @@ when@prod:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
action_level: info
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
nelmio_api_doc:
|
||||
documentation:
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Tonehaus API
|
||||
description: REST API for albums, reviews, ingestion, and JWT authentication.
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /
|
||||
description: Current application host
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
html_config:
|
||||
assets_mode: cdn
|
||||
swagger_ui_config:
|
||||
docExpansion: list
|
||||
defaultModelsExpandDepth: 1
|
||||
displayRequestDuration: true
|
||||
filter: true
|
||||
areas:
|
||||
default:
|
||||
path_patterns:
|
||||
- ^/api(?!/docs|/v1/docs)
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -1,4 +0,0 @@
|
||||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
csrf_protection:
|
||||
check_header: true
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
prepare_runtime_dirs() {
|
||||
cache_dir="var/cache/${APP_ENV:-prod}/asset_mapper"
|
||||
mkdir -p "${cache_dir}" var/log var/data public/uploads
|
||||
|
||||
chown -R www-data:www-data var public/uploads
|
||||
chmod -R u+rwX,g+rwX var public/uploads
|
||||
|
||||
# Keep group ownership sticky so subsequent writes inherit www-data group.
|
||||
find var public/uploads -type d -exec chmod g+s {} \;
|
||||
}
|
||||
|
||||
require_app_secret() {
|
||||
if [ -z "${APP_SECRET:-}" ]; then
|
||||
echo "APP_SECRET environment variable is required at runtime" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_jwt_keys() {
|
||||
if [ ! -f bin/console ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p config/jwt
|
||||
|
||||
if [ -f config/jwt/private.pem ] && [ -f config/jwt/public.pem ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "${JWT_PASSPHRASE:-}" ]; then
|
||||
JWT_PASSPHRASE="${APP_SECRET}"
|
||||
fi
|
||||
|
||||
openssl genpkey \
|
||||
-out config/jwt/private.pem \
|
||||
-algorithm rsa \
|
||||
-pkeyopt rsa_keygen_bits:4096 \
|
||||
-aes-256-cbc \
|
||||
-pass pass:"${JWT_PASSPHRASE}"
|
||||
|
||||
openssl pkey \
|
||||
-in config/jwt/private.pem \
|
||||
-passin pass:"${JWT_PASSPHRASE}" \
|
||||
-pubout \
|
||||
-out config/jwt/public.pem
|
||||
|
||||
chmod 600 config/jwt/private.pem
|
||||
chmod 644 config/jwt/public.pem
|
||||
chown -R www-data:www-data config/jwt
|
||||
}
|
||||
|
||||
install_runtime() {
|
||||
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Installing Composer dependencies..."
|
||||
composer_flags="--prefer-dist --no-interaction --no-progress"
|
||||
if [ "${APP_ENV:-prod}" = "prod" ]; then
|
||||
composer_flags="${composer_flags} --no-dev"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
su-exec www-data composer install ${composer_flags}
|
||||
}
|
||||
|
||||
install_runtime
|
||||
|
||||
if [ -f bin/console ]; then
|
||||
require_app_secret
|
||||
ensure_jwt_keys
|
||||
prepare_runtime_dirs
|
||||
fi
|
||||
|
||||
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
|
||||
if [ "${DATABASE_DRIVER:-sqlite}" = "sqlite" ]; then
|
||||
SQLITE_PATH="${DATABASE_SQLITE_PATH:-/var/www/html/var/data/database.sqlite}"
|
||||
SQLITE_DIR=$(dirname "${SQLITE_PATH}")
|
||||
mkdir -p "${SQLITE_DIR}"
|
||||
if [ ! -f "${SQLITE_PATH}" ]; then
|
||||
touch "${SQLITE_PATH}"
|
||||
fi
|
||||
chown -R www-data:www-data "${SQLITE_DIR}"
|
||||
fi
|
||||
|
||||
su-exec www-data php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
fi
|
||||
|
||||
if [ -f bin/console ]; then
|
||||
su-exec www-data php bin/console app:sync-external-provider --no-interaction
|
||||
fi
|
||||
|
||||
if [ -f bin/console ]; then
|
||||
su-exec www-data php bin/console cache:clear --no-warmup
|
||||
su-exec www-data php bin/console cache:warmup
|
||||
prepare_runtime_dirs
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "OK";
|
||||
}
|
||||
|
||||
location ^/api/v1 {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
user=root
|
||||
|
||||
[program:php-fpm]
|
||||
command=/usr/local/sbin/php-fpm --nodaemonize
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Architecture
|
||||
|
||||
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
|
||||
|
||||
## Naming & reusability standards (PHP)
|
||||
|
||||
- **Classes**
|
||||
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
|
||||
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `LastFmClient`, `LastFmMetadataRefresher`).
|
||||
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
|
||||
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
|
||||
|
||||
- **Methods**
|
||||
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllExternalAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
|
||||
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isExternalConfigured()`).
|
||||
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
|
||||
|
||||
- **Variables & parameters**
|
||||
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$lastfmAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
|
||||
- Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`).
|
||||
- Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`).
|
||||
|
||||
- **Files & namespaces**
|
||||
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
|
||||
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
|
||||
|
||||
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
|
||||
|
||||
## High-level flow
|
||||
1. Visitors search for albums (Last.fm) and view an album page
|
||||
2. Logged‑in users can write, edit, and delete reviews
|
||||
3. Moderators and admins can moderate content and manage users
|
||||
4. Admins configure site settings (Last.fm credentials, registration toggle)
|
||||
|
||||
## Layers & components
|
||||
|
||||
### Controllers (`src/Controller/*`)
|
||||
- `AlbumController` — search, album detail, inline review creation
|
||||
- `ReviewController` — view, edit, and delete reviews
|
||||
- `AccountController` — profile, password, and user settings pages
|
||||
- `Admin/*` — site dashboard, user management, and settings
|
||||
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
|
||||
|
||||
### Entities (`src/Entity/*`)
|
||||
- `User` — authentication principal and roles
|
||||
- `Album`, `AlbumTrack` — normalized album metadata and track list
|
||||
- `Review` — user‑authored review with rating and timestamps
|
||||
- `Setting` — key/value store for site configuration (e.g., Last.fm credentials)
|
||||
|
||||
### Repositories (`src/Repository/*`)
|
||||
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
|
||||
|
||||
### Forms (`src/Form/*`)
|
||||
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
|
||||
- Leverage Symfony validation constraints for robust server‑side validation
|
||||
|
||||
### Services (`src/Service/*`)
|
||||
- `LastFmClient` — Last.fm API integration with caching
|
||||
- `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data
|
||||
- `CatalogResetService` — admin action to reset/sync catalog state safely
|
||||
- `UploadStorage` — avatar/album image uploads and related file handling
|
||||
- `RegistrationToggle` — DB‑backed registration flag with env override
|
||||
|
||||
### Security (`config/packages/security.yaml`, `src/Security/*`)
|
||||
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
|
||||
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
|
||||
- Access control for `/admin/*` enforced via routes and controllers
|
||||
|
||||
### Views (`templates/*`)
|
||||
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
|
||||
- Auth modal in `templates/_partials/auth_modal.html.twig`
|
||||
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
|
||||
|
||||
### DTOs (`src/Dto/*`)
|
||||
- Simple data transfer objects for admin tables and search results
|
||||
|
||||
## Data & persistence
|
||||
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
|
||||
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
|
||||
|
||||
## Error handling & UX
|
||||
- 404 for missing albums
|
||||
- Flash messages for success/error on actions
|
||||
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
|
||||
|
||||
## Testing & tooling
|
||||
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
|
||||
- Web Profiler enabled in dev
|
||||
@@ -0,0 +1,969 @@
|
||||
# Tonehaus Project Briefing
|
||||
|
||||
## Purpose
|
||||
|
||||
Tonehaus is a Symfony 7 music album review application extended for the Assignment 2 RESTful API brief. It combines a server-rendered website with a versioned JSON API, JWT authentication, external music metadata providers, and generated API documentation. The project is designed so the web UI and API share the same domain model and service layer rather than existing as separate systems.
|
||||
|
||||
## Core Stack
|
||||
|
||||
- Symfony 7
|
||||
- PHP with Doctrine ORM
|
||||
- Twig templates
|
||||
- SQLite by default, with Postgres support
|
||||
- Guzzle for external API consumption
|
||||
- LexikJWTAuthenticationBundle for JWT auth
|
||||
- FOSRestBundle for API response handling
|
||||
- NelmioApiDocBundle for OpenAPI documentation
|
||||
- PHPUnit for functional and unit tests
|
||||
|
||||
## Main Domain Model
|
||||
|
||||
- `User`: authenticated account with `ROLE_USER`, `ROLE_MODERATOR`, or `ROLE_ADMIN`
|
||||
- `Album`: normalized album record, either external or user-created
|
||||
- `AlbumTrack`: persisted track list for each album
|
||||
- `Review`: user-authored review with title, content, rating, and timestamps
|
||||
- `Setting`: key-value storage for site configuration and provider credentials
|
||||
|
||||
## What The Application Does
|
||||
|
||||
### Web Application
|
||||
|
||||
- lets users search for albums
|
||||
- shows album pages with cover art, metadata, tracklists, review averages, and reviews
|
||||
- allows logged-in users to create reviews from the album page
|
||||
- allows users to create, edit, and delete their own user-created albums
|
||||
- provides profile, password, and settings pages
|
||||
- gives moderators/admins dashboards for moderation and site administration
|
||||
- includes admin settings for external provider credentials and registration control
|
||||
|
||||
### REST API
|
||||
|
||||
The API is versioned under `/api/v1` and returns JSON.
|
||||
|
||||
Implemented endpoints:
|
||||
|
||||
- `GET /api/v1`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `GET /api/v1/albums`
|
||||
- `POST /api/v1/albums/ingest`
|
||||
- `GET /api/v1/albums/{id}`
|
||||
- `GET /api/v1/albums/{id}/reviews`
|
||||
- `GET /api/v1/reviews`
|
||||
- `GET /api/v1/reviews/{id}`
|
||||
- `POST /api/v1/reviews`
|
||||
- `PUT /api/v1/reviews/{id}`
|
||||
- `PATCH /api/v1/reviews/{id}`
|
||||
- `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
Automatic OpenAPI documentation is available at:
|
||||
|
||||
- `/api/docs`
|
||||
- `/api/v1/docs`
|
||||
- `/api/docs.json`
|
||||
- `/api/v1/docs.json`
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
This section documents every implemented API endpoint briefly, with purpose, authentication, key inputs, and the main response shape.
|
||||
|
||||
Example values below are representative of the current implementation and use realistic sample IDs such as `tidal:251380836` and numeric review IDs.
|
||||
|
||||
### `GET /api/v1`
|
||||
|
||||
- purpose: API index and discovery endpoint
|
||||
- auth: none
|
||||
- returns: API name, version, endpoint groups, top-level `links`, and top-level `actions`
|
||||
- HATEOAS: exposes `self`, `auth_login`, `albums`, `reviews`, `docs`, `docs_v1`, `schema`, and `schema_v1`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Tonehaus API",
|
||||
"version": "v1",
|
||||
"endpoints": {
|
||||
"auth": ["POST /api/v1/auth/login"],
|
||||
"albums": [
|
||||
"GET /api/v1/albums",
|
||||
"POST /api/v1/albums/ingest (JWT)",
|
||||
"GET /api/v1/albums/{id}",
|
||||
"GET /api/v1/albums/{id}/reviews"
|
||||
],
|
||||
"reviews": [
|
||||
"GET /api/v1/reviews",
|
||||
"GET /api/v1/reviews/{id}",
|
||||
"POST /api/v1/reviews (JWT)",
|
||||
"PUT /api/v1/reviews/{id} (JWT)",
|
||||
"PATCH /api/v1/reviews/{id} (JWT)",
|
||||
"DELETE /api/v1/reviews/{id} (JWT)"
|
||||
]
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1",
|
||||
"auth_login": "/api/v1/auth/login",
|
||||
"albums": "/api/v1/albums",
|
||||
"reviews": "/api/v1/reviews",
|
||||
"docs": "/api/docs"
|
||||
},
|
||||
"actions": {
|
||||
"login": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/auth/login"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
- purpose: authenticate a user and issue a JWT for protected routes
|
||||
- auth: none
|
||||
- request body: `email`, `password`
|
||||
- success response: `200` with `{ "token": "..." }`
|
||||
- error response: `401` for invalid credentials
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
Example success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
Example error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_credentials",
|
||||
"message": "Invalid credentials.",
|
||||
"status": 401
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums`
|
||||
|
||||
- purpose: search albums from the local catalog and the currently active external provider
|
||||
- auth: none
|
||||
- query parameters: `query`, `album`, `artist`, `genre`, `year_from`, `year_to`, `source`, `limit`, `mode`
|
||||
- returns:
|
||||
- `meta` with source, limit, query, mode, and search profile
|
||||
- `data` as album resources
|
||||
- `stats` for review aggregates
|
||||
- `saved_ids` for already-ingested external albums
|
||||
- collection-level `links` and `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `root`, and `ingest`
|
||||
- action metadata includes `ingest_album`
|
||||
- each album resource is decorated with its own `links` and `actions`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"source": "all",
|
||||
"limit": 5,
|
||||
"query": "4:44",
|
||||
"mode": "fast"
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/320.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"open_source": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"album_id": "tidal:251380836"
|
||||
}
|
||||
},
|
||||
"ingest": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"tidal:251380836": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
},
|
||||
"saved_ids": ["tidal:251380836"],
|
||||
"links": {
|
||||
"self": "/api/v1/albums?source=all&limit=5&mode=fast&query=4%3A44&artist=JAY-Z",
|
||||
"root": "/api/v1",
|
||||
"ingest": "/api/v1/albums/ingest"
|
||||
},
|
||||
"actions": {
|
||||
"ingest_album": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/albums/ingest`
|
||||
|
||||
- purpose: persist an external album into the local database
|
||||
- auth: JWT required
|
||||
- request body:
|
||||
- either `external_id`
|
||||
- or `query`
|
||||
- or `artist` and `album`
|
||||
- success response: `201` with `data` for the ingested album and `tracks`
|
||||
- main error responses:
|
||||
- `400` invalid JSON or missing lookup input
|
||||
- `401` missing/invalid JWT
|
||||
- `404` external album not found
|
||||
- `422` album missing required metadata or tracks
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/albums/ingest
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc": 1,
|
||||
"track": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}`
|
||||
|
||||
- purpose: fetch one album by public identifier
|
||||
- auth: none
|
||||
- path parameter: `id` string public album ID such as a provider ID or user album ID
|
||||
- returns:
|
||||
- `data` for one album
|
||||
- `tracks` list
|
||||
- `stats` with review count and average rating
|
||||
- behaviour:
|
||||
- if the album is not yet local but is available from the external provider, it can be hydrated and persisted automatically
|
||||
- HATEOAS on `data`:
|
||||
- links include `self`, `reviews`, and `open_source` when available
|
||||
- actions include `create_review`
|
||||
- external albums also expose `ingest`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums/tidal%3A251380836
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/640.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external",
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"open_source": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"album_id": "tidal:251380836"
|
||||
}
|
||||
},
|
||||
"ingest": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/albums/ingest",
|
||||
"requires_auth": true,
|
||||
"payload": {
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc": 1,
|
||||
"track": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
- purpose: list reviews for a specific album
|
||||
- auth: none
|
||||
- path parameter: `id` string public album ID
|
||||
- returns:
|
||||
- `data` as review resources scoped to that album
|
||||
- collection `links`
|
||||
- collection `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `album`, and `reviews`
|
||||
- collection actions include `create_review`
|
||||
- each review item includes links back to its own item route and related album routes
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums/tidal%3A251380836/reviews
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/albums/tidal%3A251380836/reviews",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"reviews": "/api/v1/reviews"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews`
|
||||
|
||||
- purpose: list the latest reviews
|
||||
- auth: none
|
||||
- query parameters: `limit` from `1` to `100`, default `50`
|
||||
- returns:
|
||||
- `data` as review resources
|
||||
- collection `links`
|
||||
- collection `actions`
|
||||
- HATEOAS:
|
||||
- collection links include `self`, `root`, and `create`
|
||||
- collection actions include `create_review`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews?limit=10
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/reviews?limit=10",
|
||||
"root": "/api/v1",
|
||||
"create": "/api/v1/reviews"
|
||||
},
|
||||
"actions": {
|
||||
"create_review": {
|
||||
"method": "POST",
|
||||
"href": "/api/v1/reviews",
|
||||
"requires_auth": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: fetch one review by numeric ID
|
||||
- auth: none
|
||||
- path parameter: `id` integer review ID
|
||||
- returns: one review resource in `data`
|
||||
- HATEOAS on the review resource:
|
||||
- links include `self`, `album`, and `album_reviews`
|
||||
- if the current user is allowed to change the review, action metadata can include `update` and `delete`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews/12
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/reviews`
|
||||
|
||||
- purpose: create a new review for an existing album
|
||||
- auth: JWT required
|
||||
- request body:
|
||||
- `album_id`
|
||||
- `title`
|
||||
- `content`
|
||||
- `rating`
|
||||
- validation rules:
|
||||
- `title` required
|
||||
- `content` required and minimum 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
- `album_id` must resolve to a valid album with tracks
|
||||
- success response: `201` with the created review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` authentication required
|
||||
- `404` album not found
|
||||
- `422` validation failure
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
POST /api/v1/reviews
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"album_id": "tidal:251380836",
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: update an existing review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- accepted body fields: `title`, `content`, `rating`
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `200` with updated review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `422` validation failure
|
||||
- implementation note: `PUT` currently shares the same update handler as `PATCH`
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
PUT /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"title": "Revisited after a week",
|
||||
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||
"rating": 8
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "Revisited after a week",
|
||||
"content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.",
|
||||
"rating": 8,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T12:05:44+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: partially update an existing review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- accepted body fields: `title`, `content`, `rating`
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `200` with updated review resource
|
||||
- main error responses:
|
||||
- `400` invalid JSON
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `422` validation failure
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
PATCH /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
|
||||
{
|
||||
"rating": 10
|
||||
}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 10,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T12:10:03+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/v1/reviews/12",
|
||||
"album": "/api/v1/albums/tidal%3A251380836",
|
||||
"album_reviews": "/api/v1/albums/tidal%3A251380836/reviews"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
- purpose: delete a review
|
||||
- auth: JWT required
|
||||
- path parameter: `id` integer review ID
|
||||
- authorization: review author, moderator, or admin
|
||||
- success response: `204 No Content`
|
||||
- main error responses:
|
||||
- `401` missing/invalid JWT
|
||||
- `403` forbidden
|
||||
- `404` not found
|
||||
|
||||
Example request:
|
||||
|
||||
```http
|
||||
DELETE /api/v1/reviews/12
|
||||
Authorization: Bearer <jwt-token>
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
The project follows standard Symfony separation of concerns.
|
||||
|
||||
- controllers handle HTTP orchestration only
|
||||
- services contain business logic and external API integration
|
||||
- repositories handle domain-specific queries
|
||||
- entities define persisted state
|
||||
- forms handle server-side validation for web submissions
|
||||
- security config and voters enforce route access and ownership rules
|
||||
- event subscribers normalize API errors into JSON responses
|
||||
|
||||
This matters for the brief because the API is not implemented as one large controller. It is structured in a maintainable way using framework conventions and reusable services.
|
||||
|
||||
## API Implementation Summary
|
||||
|
||||
The API implementation is centered around resource-based routes rather than action-style routes. Review endpoints expose collection and item resources, while album endpoints expose search, detail, ingestion, and album-specific review resources.
|
||||
|
||||
Key implementation points:
|
||||
|
||||
- uses descriptive REST-style URIs such as `/api/v1/reviews` and `/api/v1/albums/{id}/reviews`
|
||||
- uses correct HTTP verbs for retrieval, creation, update, and deletion
|
||||
- returns JSON for both success and failure cases
|
||||
- includes HATEOAS-style hypermedia in API responses through `links` and `actions`
|
||||
- uses JWT authentication for protected write operations
|
||||
- excludes sensitive data such as user email and password hashes from API responses
|
||||
- uses structured status codes including `200`, `201`, `204`, `401`, `403`, `404`, and `422`
|
||||
- provides generated OpenAPI documentation through Nelmio
|
||||
|
||||
The review API supports full CRUD behaviour for third-party clients. Public reads are available without authentication, while create, update, and delete require authenticated users. Authorization is more specific than simple login checks because review ownership is enforced with a `ReviewVoter`, allowing moderators and admins to manage any review while normal users can only manage their own.
|
||||
|
||||
The API also includes a useful sub-resource:
|
||||
|
||||
- `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
This improves the resource model because reviews can be queried globally or in the context of a specific album.
|
||||
|
||||
The API also now includes a lightweight HATEOAS layer. The base API index exposes navigational links such as `self`, `albums`, `reviews`, and `docs`, plus action metadata for login. Album and review resources expose their own `links` and, where relevant, `actions`, for example:
|
||||
|
||||
- album resources link to `self`, `reviews`, and `open_source`
|
||||
- album resources expose actions such as `create_review` and `ingest`
|
||||
- review resources link back to their own item route and related album routes
|
||||
- review collections expose a `create_review` action for discoverability
|
||||
|
||||
This improves the API from a client perspective because consumers do not need to hard-code every related route manually. Instead, the response itself advertises relevant next operations.
|
||||
|
||||
## API Consumption Summary
|
||||
|
||||
The application consumes external music APIs through Guzzle and a provider abstraction rather than binding the UI directly to one upstream service.
|
||||
|
||||
Implemented external providers:
|
||||
|
||||
- Last.fm
|
||||
- MusicBrainz
|
||||
- TIDAL
|
||||
|
||||
Provider logic is normalized behind `ExternalAlbumClientInterface`, with concrete clients for each provider. This means the rest of the application receives one consistent album payload structure regardless of the source.
|
||||
|
||||
The consumed API data is used to:
|
||||
|
||||
- search external album catalogs
|
||||
- populate album metadata
|
||||
- fetch artists, genres, release dates, and track lists
|
||||
- fetch cover art
|
||||
- ingest external albums into the local database
|
||||
- enrich album detail pages and search results in the UI
|
||||
|
||||
This is deep integration rather than a token demo call. External data directly affects the search experience, album display, save/ingest flow, and review context.
|
||||
|
||||
## External Integration Details
|
||||
|
||||
### Last.fm
|
||||
|
||||
Used for album search and metadata enrichment. Responses are cached and normalized into the project’s album format.
|
||||
|
||||
### MusicBrainz
|
||||
|
||||
Used as an alternative provider and for structured release metadata. The project supports optional authenticated requests and cover-art URL generation via normalized output.
|
||||
|
||||
### TIDAL
|
||||
|
||||
Used as another full external provider. The `TidalClient` implements OAuth 2.0 client credentials, caches access tokens, retries on expired auth, and converts JSON:API responses into the same normalized album contract used elsewhere.
|
||||
|
||||
This strengthens the project against the brief because it goes beyond a single trivial API call and includes a second non-trivial external integration path.
|
||||
|
||||
## Security Summary
|
||||
|
||||
Security is handled at multiple layers:
|
||||
|
||||
- JWT authentication for protected API routes
|
||||
- Symfony access control rules for route-level protection
|
||||
- role hierarchy: admin > moderator > user
|
||||
- `ReviewVoter` for ownership-based edit/delete permissions
|
||||
- normalized JSON authentication failures for API clients
|
||||
- exclusion of sensitive user fields from serialized API responses
|
||||
|
||||
This is stronger than basic coursework authentication because it combines framework security with resource-level authorization.
|
||||
|
||||
## Error Handling and Robustness
|
||||
|
||||
API error handling is centralized so clients receive JSON errors rather than HTML exception pages.
|
||||
|
||||
The project includes:
|
||||
|
||||
- a shared API response helper
|
||||
- exception subscribers that normalize failures
|
||||
- JWT failure subscribers for missing, invalid, or expired tokens
|
||||
- validation logic for malformed JSON and invalid field values
|
||||
- server-side handling for missing albums, forbidden edits, and unsupported methods
|
||||
|
||||
This improves client usability and supports a more production-like API design.
|
||||
|
||||
## Testing Summary
|
||||
|
||||
The project includes both functional and unit tests.
|
||||
|
||||
Functional API tests cover:
|
||||
|
||||
- missing JWT
|
||||
- invalid JWT
|
||||
- malformed JSON
|
||||
- validation failures
|
||||
- forbidden review updates
|
||||
- missing album resources
|
||||
- protection of sensitive author data
|
||||
- hypermedia links and action metadata on API index, album, and album-review responses
|
||||
|
||||
Unit tests cover provider-specific service behaviour, including TIDAL payload normalization and provider routing.
|
||||
|
||||
This supports the brief’s emphasis on working, demonstrable features rather than partially implemented code.
|
||||
|
||||
## How The Project Meets The Brief
|
||||
|
||||
### API Implementation
|
||||
|
||||
The brief requires a usable RESTful API capable of listing, reading, creating, editing, and deleting album reviews in JSON. This project meets that with:
|
||||
|
||||
- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` review endpoints
|
||||
- JWT-protected write routes
|
||||
- appropriate resource names
|
||||
- JSON request and response formats
|
||||
- HATEOAS-style `links` and `actions` to improve discoverability
|
||||
- status-code-based error handling
|
||||
- sub-resource support through album review listing
|
||||
- automatically generated OpenAPI documentation
|
||||
|
||||
### API Consumption
|
||||
|
||||
The brief requires at least two significantly different examples of API interaction and integration into the review website. This project meets that with:
|
||||
|
||||
- external album search
|
||||
- album metadata retrieval
|
||||
- track list retrieval
|
||||
- cover art integration
|
||||
- album ingestion into local storage
|
||||
- multiple provider implementations
|
||||
|
||||
The consumed data is visible in the website itself, especially on search results and album detail pages.
|
||||
|
||||
### Documentation and Report Potential
|
||||
|
||||
The project already has strong source material for the report because it includes:
|
||||
|
||||
- generated OpenAPI docs
|
||||
- explicit controller routes and OpenAPI attributes
|
||||
- centralized security configuration
|
||||
- reusable external service classes
|
||||
- tests that demonstrate expected API behaviour
|
||||
|
||||
## Honest Technical Caveats
|
||||
|
||||
- `PUT` and `PATCH` currently share the same update action, so `PUT` behaves more like a flexible update than a strict full replacement
|
||||
- local verification in this shell was limited because `php` is not installed here, so PHPUnit was not run live during this session
|
||||
- some observability headers in album search are still named with `Lastfm` even though the provider layer now supports multiple providers
|
||||
|
||||
These are not major blockers, but they are worth understanding if you want the final report to sound precise and credible.
|
||||
|
||||
## Strongest Points To Emphasize In A Final Report
|
||||
|
||||
- the API is versioned and resource-oriented
|
||||
- the API now includes lightweight HATEOAS support through response `links` and `actions`
|
||||
- write operations are protected with JWT rather than session auth
|
||||
- review ownership is enforced through a voter, not just a login check
|
||||
- external APIs are abstracted behind a provider interface
|
||||
- external data is deeply integrated into the UI and persisted catalog
|
||||
- OpenAPI documentation is generated automatically
|
||||
- the project includes automated tests for important API behaviour
|
||||
|
||||
## Short Project Summary
|
||||
|
||||
Tonehaus is a Symfony-based music review platform extended into a RESTful web application. It implements a versioned JSON API for review CRUD and album access, secures write routes with JWT, consumes multiple external music providers through Guzzle and a normalized service layer, and integrates that data directly into album discovery, metadata display, tracklists, and review workflows. The architecture is modular, test-backed, and aligned with the main goals of the Assignment 2 brief.
|
||||
@@ -0,0 +1,611 @@
|
||||
# Assignment 2 Report: Tonehaus REST API
|
||||
|
||||
## Part 1: API Documentation
|
||||
|
||||
### 1. API Overview
|
||||
|
||||
Tonehaus extends the album review website with a versioned JSON API under `/api/v1`. The API supports two main concerns:
|
||||
|
||||
- discovery and ingestion of album data
|
||||
- creation and management of user reviews
|
||||
|
||||
The implementation is resource-oriented. Public read endpoints are available without authentication, while write operations require JWT authentication. The API is documented manually in this report and automatically through Nelmio OpenAPI documentation at:
|
||||
|
||||
- `/api/docs`
|
||||
- `/api/v1/docs`
|
||||
- `/api/docs.json`
|
||||
- `/api/v1/docs.json`
|
||||
|
||||
### 2. Authentication
|
||||
|
||||
Protected endpoints use bearer-token authentication with JSON Web Tokens (JWT). Clients obtain a token by sending credentials to:
|
||||
|
||||
- `POST /api/v1/auth/login`
|
||||
|
||||
The token must then be included on protected requests:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <jwt-token>
|
||||
```
|
||||
|
||||
Protected routes:
|
||||
|
||||
- `POST /api/v1/albums/ingest`
|
||||
- `POST /api/v1/reviews`
|
||||
- `PUT /api/v1/reviews/{id}`
|
||||
- `PATCH /api/v1/reviews/{id}`
|
||||
- `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
### 3. Common Response Format
|
||||
|
||||
#### Success
|
||||
|
||||
Success payloads are JSON objects. Most collection/detail routes return a top-level `data` key. Some album endpoints also return `meta`, `tracks`, `stats`, or `saved_ids`.
|
||||
|
||||
#### Error
|
||||
|
||||
API failures are normalized into one JSON error envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Review payload failed validation.",
|
||||
"status": 422,
|
||||
"details": {
|
||||
"fields": {
|
||||
"content": "Content must be at least 20 characters."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes include:
|
||||
|
||||
- `authentication_required`
|
||||
- `invalid_credentials`
|
||||
- `invalid_token`
|
||||
- `token_expired`
|
||||
- `validation_error`
|
||||
- `album_not_found`
|
||||
- `external_album_not_found`
|
||||
- `forbidden`
|
||||
- `not_found`
|
||||
- `method_not_allowed`
|
||||
- `internal_server_error`
|
||||
|
||||
### 4. Resource Models
|
||||
|
||||
#### Review Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen...",
|
||||
"rating": 9,
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00",
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"album": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"source": "external"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Album Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"images": [
|
||||
{ "url": "https://cdn.example.test/640.jpg" }
|
||||
],
|
||||
"artists": [
|
||||
{ "name": "JAY-Z" }
|
||||
],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
}
|
||||
```
|
||||
|
||||
#### Track Resource
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc_number": 1,
|
||||
"track_number": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000,
|
||||
"preview_url": null
|
||||
}
|
||||
```
|
||||
|
||||
Album identifiers are public string IDs rather than numeric database IDs. Depending on provider and source, examples include:
|
||||
|
||||
- `tidal:251380836`
|
||||
- `mbid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
|
||||
- `u_ab12cd34ef56` for user-created albums
|
||||
|
||||
### 5. Endpoint Reference
|
||||
|
||||
### `GET /api/v1`
|
||||
|
||||
Purpose: API index and discovery endpoint.
|
||||
|
||||
Auth: None
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Tonehaus API",
|
||||
"version": "v1",
|
||||
"endpoints": {
|
||||
"auth": ["POST /api/v1/auth/login"],
|
||||
"albums": [
|
||||
"GET /api/v1/albums",
|
||||
"POST /api/v1/albums/ingest (JWT)",
|
||||
"GET /api/v1/albums/{id}",
|
||||
"GET /api/v1/albums/{id}/reviews"
|
||||
],
|
||||
"reviews": [
|
||||
"GET /api/v1/reviews",
|
||||
"GET /api/v1/reviews/{id}",
|
||||
"POST /api/v1/reviews (JWT)",
|
||||
"PUT /api/v1/reviews/{id} (JWT)",
|
||||
"PATCH /api/v1/reviews/{id} (JWT)",
|
||||
"DELETE /api/v1/reviews/{id} (JWT)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
|
||||
Purpose: authenticate a user and issue a JWT.
|
||||
|
||||
Auth: None
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK` token issued
|
||||
- `401 Unauthorized` invalid credentials
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums`
|
||||
|
||||
Purpose: search albums from the local catalog and the currently configured external provider.
|
||||
|
||||
Auth: None
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `query` free-text search
|
||||
- `album` album title filter
|
||||
- `artist` artist filter
|
||||
- `genre` genre filter
|
||||
- `year_from` inclusive lower year bound
|
||||
- `year_to` inclusive upper year bound
|
||||
- `source` `all`, `external`, or `user`
|
||||
- `limit` result limit, capped server-side
|
||||
- `mode` `full` or `fast`
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"source": "all",
|
||||
"limit": 5,
|
||||
"query": "4:44",
|
||||
"mode": "fast",
|
||||
"profile": {
|
||||
"external_provider": "tidal"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [{ "name": "JAY-Z" }],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"images": [{ "url": "https://cdn.example.test/320.jpg" }],
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"tidal:251380836": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
},
|
||||
"saved_ids": ["tidal:251380836"]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/albums/ingest`
|
||||
|
||||
Purpose: persist an external album into local storage.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Request body rules:
|
||||
|
||||
- either `external_id` must be supplied
|
||||
- or `query`
|
||||
- or a combination of `artist` and `album`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_id": "tidal:251380836"
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `201 Created`
|
||||
- `400 Bad Request` invalid JSON or missing identifier/query
|
||||
- `401 Unauthorized`
|
||||
- `404 Not Found` external album not found
|
||||
- `422 Unprocessable Entity` album missing metadata or tracks
|
||||
|
||||
Success response includes:
|
||||
|
||||
- `data` album payload
|
||||
- `tracks` normalized track list
|
||||
|
||||
### `GET /api/v1/albums/{id}`
|
||||
|
||||
Purpose: return one album by public ID.
|
||||
|
||||
Auth: None
|
||||
|
||||
Path parameter:
|
||||
|
||||
- `id` public album identifier string
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity` album payload exists upstream but is not ingestable
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "tidal:251380836",
|
||||
"name": "4:44",
|
||||
"artists": [{ "name": "JAY-Z" }],
|
||||
"genres": ["Hip Hop"],
|
||||
"release_date": "2017-06-30",
|
||||
"total_tracks": 10,
|
||||
"images": [{ "url": "https://cdn.example.test/640.jpg" }],
|
||||
"external_urls": {
|
||||
"external": "https://tidal.com/browse/album/251380836"
|
||||
},
|
||||
"source": "external"
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"id": "track-1",
|
||||
"disc_number": 1,
|
||||
"track_number": 1,
|
||||
"name": "Kill Jay Z",
|
||||
"duration_ms": 205000,
|
||||
"preview_url": null
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"count": 2,
|
||||
"avg": 8.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/albums/{id}/reviews`
|
||||
|
||||
Purpose: list reviews for one album.
|
||||
|
||||
Auth: None
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 12,
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen...",
|
||||
"rating": 9,
|
||||
"author": {
|
||||
"id": 4,
|
||||
"display_name": "Jake Andrews"
|
||||
},
|
||||
"created_at": "2026-04-02T11:40:21+00:00",
|
||||
"updated_at": "2026-04-02T11:40:21+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews`
|
||||
|
||||
Purpose: list recent reviews.
|
||||
|
||||
Auth: None
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `limit` integer, clamped to `1-100`, default `50`
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
GET /api/v1/reviews?limit=10
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
### `GET /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: fetch one review by numeric ID.
|
||||
|
||||
Auth: None
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
### `POST /api/v1/reviews`
|
||||
|
||||
Purpose: create a review for an existing album.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"album_id": "tidal:251380836",
|
||||
"title": "A dense and rewarding record",
|
||||
"content": "This album becomes stronger on every listen and the production is excellent.",
|
||||
"rating": 9
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `album_id` must resolve to an existing album with a track list
|
||||
- `title` is required
|
||||
- `content` is required and must be at least 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
|
||||
Responses:
|
||||
|
||||
- `201 Created`
|
||||
- `400 Bad Request` invalid JSON
|
||||
- `401 Unauthorized`
|
||||
- `404 Not Found` album not found
|
||||
- `422 Unprocessable Entity` validation failure
|
||||
|
||||
### `PUT /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: update an existing review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Current implementation note: `PUT` and `PATCH` are handled by the same update action. In practice, the API currently accepts partial payloads on both methods rather than enforcing full replacement semantics for `PUT`.
|
||||
|
||||
Accepted fields:
|
||||
|
||||
- `title`
|
||||
- `content`
|
||||
- `rating`
|
||||
|
||||
Validation:
|
||||
|
||||
- at least one updatable field is required
|
||||
- `title` cannot be empty
|
||||
- `content` cannot be empty and must be at least 20 characters
|
||||
- `rating` must be between 1 and 10
|
||||
- only the review author, moderator, or admin may modify the review
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `400 Bad Request`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Revisited after a few weeks",
|
||||
"content": "The sequencing is much better than I first thought, and the writing is sharper too.",
|
||||
"rating": 8
|
||||
}
|
||||
```
|
||||
|
||||
### `PATCH /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: partially update an existing review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"rating": 10
|
||||
}
|
||||
```
|
||||
|
||||
Responses:
|
||||
|
||||
- `200 OK`
|
||||
- `400 Bad Request`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
- `422 Unprocessable Entity`
|
||||
|
||||
### `DELETE /api/v1/reviews/{id}`
|
||||
|
||||
Purpose: delete a review.
|
||||
|
||||
Auth: JWT required
|
||||
|
||||
Authorization:
|
||||
|
||||
- author of the review
|
||||
- moderator
|
||||
- admin
|
||||
|
||||
Responses:
|
||||
|
||||
- `204 No Content`
|
||||
- `401 Unauthorized`
|
||||
- `403 Forbidden`
|
||||
- `404 Not Found`
|
||||
|
||||
### 6. Automatic Documentation and Testing Support
|
||||
|
||||
The API is additionally exposed through generated OpenAPI documentation with Nelmio. This gives the project a professional, interactive documentation layer on top of the written report. The application also includes automated tests for:
|
||||
|
||||
- JWT protection
|
||||
- normalized JSON error envelopes
|
||||
- validation failures
|
||||
- forbidden review updates
|
||||
- protection of sensitive user data in API responses
|
||||
- provider-specific payload normalization, including TIDAL
|
||||
|
||||
## Part 2: Code Report
|
||||
|
||||
### Introduction
|
||||
|
||||
The Tonehaus application was originally a server-rendered album review website, but for Assignment 2 it has been extended into a REST-oriented application with both API implementation and API consumption. The final system does not simply expose review data as JSON. It also uses external music services to build and enrich the album catalog that reviews depend on. As a result, the API and the web UI now share the same underlying album model, review model, and security rules.
|
||||
|
||||
### API Implementation
|
||||
|
||||
The implemented API is versioned under `/api/v1`, which is preferable to exposing unversioned endpoints because it leaves room for future iterations without breaking existing clients. The main resources are `reviews` and `albums`. Review routes cover list, read, create, update, and delete operations. Album routes cover search, detail retrieval, album-specific review retrieval, and explicit ingestion of external albums into local storage.
|
||||
|
||||
The review API is centered on `ReviewApiController`. Read operations are public, while write operations require `ROLE_USER`. Responses are serialized into a stable JSON shape that includes the review itself plus minimal nested album and author information. Sensitive fields such as email addresses, passwords, and role data are intentionally excluded. This is important because the assignment criteria explicitly reward avoiding unnecessary or sensitive output.
|
||||
|
||||
Shared response behaviour is centralized in `ApiController`, which provides helper methods for successful responses and consistent error envelopes. This reduces duplication across controllers and makes the API easier to maintain. Errors are further normalized by `ApiExceptionSubscriber` and `ApiJwtFailureSubscriber`, so authentication failures, invalid tokens, missing credentials, validation errors, and unexpected server errors are all returned as JSON instead of HTML exception pages. That is a significant usability improvement for third-party clients.
|
||||
|
||||
### Authentication and Authorization
|
||||
|
||||
Authentication is implemented with LexikJWTAuthenticationBundle. The login endpoint is handled through Symfony security `json_login` at `POST /api/v1/auth/login`. This design is stateless because the client submits credentials once, receives a token, and then supplies that token on later requests. The server does not need to keep session state for API consumers.
|
||||
|
||||
Authorization is layered on top of authentication. Access control rules in the security configuration protect the write routes, but review ownership is enforced more precisely by `ReviewVoter`. A normal user may edit or delete only their own review, while moderators and administrators may manage any review. This is stronger than basic route-level protection because it applies business rules at the resource level.
|
||||
|
||||
### RESTful Design Criteria
|
||||
|
||||
The API satisfies several core REST criteria. First, resources are named with nouns rather than action verbs: `/reviews`, `/reviews/{id}`, `/albums`, and `/albums/{id}/reviews`. Second, HTTP methods are used according to intent: `GET` for retrieval, `POST` for creation and ingestion, `DELETE` for removal, and `PUT`/`PATCH` for updates. Third, the API uses JSON consistently for request and response bodies, which keeps the contract simple for clients.
|
||||
|
||||
The service is also stateless for protected routes because every authenticated request carries its own bearer token. Status codes are generally used correctly: `200` for successful reads and updates, `201` for creation, `204` for deletion, `401` for authentication failures, `403` for authorization failures, `404` for missing resources, and `422` for validation problems.
|
||||
|
||||
One limitation is that `PUT` and `PATCH` are currently routed to the same update handler. In strict REST terms, `PUT` would usually imply full replacement of editable state, whereas `PATCH` implies partial modification. The current implementation is still practical and easy for clients to consume, but it is better described as REST-oriented than perfectly REST-pure in that one area.
|
||||
|
||||
### External API Consumption
|
||||
|
||||
External API consumption is implemented with Guzzle and a provider abstraction. Rather than hard-coding one service into controllers, the application uses `ExternalAlbumClientInterface` and an `ExternalAlbumClient` router that resolves the currently active provider. This allows the application to work with Last.fm, MusicBrainz, or TIDAL while keeping the rest of the code independent from provider-specific payload formats.
|
||||
|
||||
This abstraction is one of the strongest architectural parts of the project. `LastFmClient`, `MusicBrainzClient`, and `TidalClient` all normalize external responses into the same internal album contract: album ID, title, artists, genres, release date, cover image URLs, external URL, and track list. Because of this, the UI and the API can render albums without knowing which provider originally supplied the data.
|
||||
|
||||
The integration is also deep rather than superficial. Album search merges local and external results, attaches review aggregates, supports filtering by artist, genre, year range, and source, and can run in fast or full modes. Opening an unsaved external album detail page can automatically hydrate and persist it locally. The album page then displays cover art, release metadata, genres, tracklists, and user reviews in one place.
|
||||
|
||||
The latest provider addition is TIDAL. Its client uses OAuth 2.0 client credentials, caches access tokens, retries once on a `401`, and normalizes JSON:API documents into the same internal shape used by the other providers. This is a non-trivial second external integration and improves the project’s depth against the assignment rubric.
|
||||
|
||||
### Code Organization and Maintainability
|
||||
|
||||
The codebase is organized around separation of concerns. Controllers are thin orchestration layers. Services handle provider logic, search composition, uploads, registration toggling, and provider switching. Entities define the core data model. Event subscribers centralize error handling. Security rules and JWT behaviour are configured declaratively rather than embedded into controller code.
|
||||
|
||||
Maintainability is strengthened further by tests. Functional API tests cover authentication, malformed JSON, validation failures, forbidden updates, missing resources, and protection of personally identifiable information. Unit tests cover provider routing and TIDAL normalization behaviour. Although I was not able to execute PHPUnit in this shell because `php` is not installed locally, the presence of these tests is still relevant evidence of intended behaviour and regression control.
|
||||
|
||||
### Limitations and Further Work
|
||||
|
||||
The current API is solid for the project scope, but there are clear next steps. Pagination metadata could be formalized on list endpoints. `PUT` and `PATCH` semantics could be separated more strictly. JWT refresh token support could be added for longer-lived API clients. Rate-limiting and request-throttling at the API boundary would also strengthen production readiness. Finally, album search response headers still use Last.fm-oriented metric names even though multiple providers are now supported, so those observability labels could be generalized.
|
||||
|
||||
### Conclusion
|
||||
|
||||
Overall, Tonehaus meets the assignment brief with a realistic REST API, JWT-protected write operations, structured error handling, and meaningful external API consumption. The project goes beyond a minimal CRUD exercise because the consumed APIs actively shape the web application experience: they power album discovery, metadata enrichment, cover art, tracklists, and ingestion into the local review catalog. The result is a more maintainable and scalable application that treats the API as part of the system architecture rather than an isolated add-on.
|
||||
|
||||
## References
|
||||
|
||||
1. Module brief: *Assignment 2 - RESTful APIs*, Advanced Web Development, CRN 31802.
|
||||
2. Symfony Documentation.
|
||||
3. LexikJWTAuthenticationBundle documentation.
|
||||
4. FOSRestBundle documentation.
|
||||
5. NelmioApiDocBundle documentation.
|
||||
6. Guzzle documentation.
|
||||
7. Last.fm API documentation.
|
||||
8. MusicBrainz Web Service documentation and Cover Art Archive.
|
||||
9. TIDAL Open API documentation.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Authentication & Users
|
||||
|
||||
## Login & Registration (modal)
|
||||
- Login and sign‑up are handled in a Bootstrap modal.
|
||||
- AJAX submits keep users on the page; a successful login refreshes state.
|
||||
- Remember‑me cookie keeps users logged in across sessions.
|
||||
|
||||
## Roles & Permissions
|
||||
- `ROLE_USER` — default for registered users
|
||||
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
|
||||
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
|
||||
|
||||
Promotion (from your host):
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
|
||||
```
|
||||
|
||||
### Access flow
|
||||
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
|
||||
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
|
||||
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
|
||||
|
||||
## Public registration toggle
|
||||
- Toggle in UI: `/admin/settings` (stored in DB)
|
||||
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
|
||||
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
|
||||
|
||||
## User management (moderator+)
|
||||
- `/admin/users` lists accounts with album/review counts and actions:
|
||||
- Create accounts inline (does not affect the current session)
|
||||
- Delete users (guards prevent deleting self or administrators)
|
||||
- Admins can Promote/Demote Moderator on non‑admins
|
||||
|
||||
## Profiles & Passwords
|
||||
- `/account/profile`: update email and display name
|
||||
- `/account/password`: change password (requires current password)
|
||||
|
||||
## Demo accounts & avatars
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
|
||||
```
|
||||
|
||||
## Logout
|
||||
- Link in the user menu calls `/logout` (handled by Symfony security).
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Deployment
|
||||
|
||||
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
|
||||
|
||||
## Build (locally)
|
||||
```bash
|
||||
docker build \
|
||||
--target=prod \
|
||||
-t tonehaus-app:latest \
|
||||
-f docker/php/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
## Run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tonehaus \
|
||||
-p 8080:8080 \
|
||||
-e APP_ENV=prod \
|
||||
-e APP_SECRET=change_me \
|
||||
-e LASTFM_API_KEY=your_api_key \
|
||||
tonehaus-app:latest
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
|
||||
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
|
||||
- Cache warmup is executed on boot; `APP_SECRET` is required
|
||||
|
||||
## Persistence options
|
||||
### SQLite (default)
|
||||
- Data file at `var/data/database.sqlite`
|
||||
- Use a volume for durability:
|
||||
```bash
|
||||
docker run -d \
|
||||
-v tonehaus_sqlite:/var/www/html/var/data \
|
||||
...
|
||||
```
|
||||
|
||||
### Postgres
|
||||
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
|
||||
```
|
||||
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
|
||||
```
|
||||
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
|
||||
```bash
|
||||
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV` (`prod` recommended in production)
|
||||
- `APP_SECRET` (required; random string)
|
||||
- `LASTFM_API_KEY`
|
||||
- `APP_ALLOW_REGISTRATION` (env override for public registration)
|
||||
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
|
||||
- `DATABASE_URL` (when using Postgres)
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START` (default `1`)
|
||||
|
||||
## Reverse proxy / TLS
|
||||
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
|
||||
- Terminate TLS at the proxy and forward to the container’s port 8080
|
||||
- Ensure proxy sends `X-Forwarded-*` headers
|
||||
|
||||
## Zero‑downtime tips
|
||||
- Build then run a new container alongside the old one, switch traffic at the proxy
|
||||
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Features
|
||||
|
||||
## Albums & Reviews
|
||||
- Last.fm album search with advanced filters (album, artist, year range)
|
||||
- Album page: cover art, metadata, full tracklist (when available)
|
||||
- Reviews list (newest first) and inline new review form (logged-in)
|
||||
- Rating slider (1–10) with live badge
|
||||
- Per‑album aggregates: average rating and total review count
|
||||
|
||||
## Authentication & Users
|
||||
- Bootstrap auth modal for login/sign-up with AJAX submits
|
||||
- Remember‑me cookie keeps users signed in
|
||||
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
|
||||
- Profile: update email, display name, and password (requires current password)
|
||||
|
||||
## Administration
|
||||
- Dashboard: latest reviews/albums and key counts (moderator+)
|
||||
- Users: create/delete users, promote/demote moderators (admin constraints)
|
||||
- Settings: manage Last.fm credentials, toggle public registration (admin)
|
||||
|
||||
## Design & UX
|
||||
- Responsive Bootstrap UI
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- CSRF protection on forms
|
||||
- Access control via role hierarchy and security voters
|
||||
|
||||
## Screenshots (placeholders)
|
||||
- Search page — `docs/img/search.png` (optional)
|
||||
- Album page — `docs/img/album.png` (optional)
|
||||
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Last.fm Integration
|
||||
|
||||
## Credentials
|
||||
- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required).
|
||||
- Env fallback: `LASTFM_API_KEY`.
|
||||
|
||||
## API Client
|
||||
- `src/Service/LastFmClient.php` (uses Guzzle)
|
||||
- `searchAlbums(query, limit)`
|
||||
- `getAlbum(id)` / `getAlbums([ids])`
|
||||
- `getAlbumWithTracks(id)`
|
||||
- `getAlbumTracks(id)`
|
||||
|
||||
## Caching & Limits
|
||||
- Last.fm responses are cached in Symfony cache pools to reduce repeated calls.
|
||||
- Search/detail payloads are normalized to one internal album shape before persistence.
|
||||
|
||||
## Ingestion Flow
|
||||
1. Search Last.fm
|
||||
2. Resolve tags/genres via `LastFmTagResolver`
|
||||
3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()`
|
||||
4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()`
|
||||
|
||||
## Admin Settings
|
||||
- Set the Last.fm API key in `/admin/settings`.
|
||||
- Key changes apply immediately.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Reviews & Albums
|
||||
|
||||
## Album page
|
||||
- Artwork, metadata, average rating, and review count
|
||||
- Full Last.fm tracklist when available
|
||||
- Reviews list (newest first)
|
||||
- Inline new review form for logged‑in users
|
||||
|
||||
## Writing a review
|
||||
- Rating slider from 1–10
|
||||
- Title (max 160 chars) and body (20–5000 chars)
|
||||
- Server-side validation provides inline errors on failure
|
||||
- Successful submissions persist, flash a success message, and reload the album page
|
||||
|
||||
## Editing & deleting reviews
|
||||
- Authors can edit/delete their own reviews
|
||||
- Moderators/Admins can edit/delete any review
|
||||
- CSRF protection is required for deletion
|
||||
|
||||
## Aggregates
|
||||
- The album page computes:
|
||||
- Total number of reviews for the album
|
||||
- Average rating rounded to one decimal
|
||||
|
||||
## Demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
- Use `--only-empty` to focus on albums that currently have no reviews.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Setup
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose
|
||||
- Last.fm API key
|
||||
- A unique `APP_SECRET` value in your environment (for prod builds)
|
||||
|
||||
## 1) Start the stack
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
App: `http://localhost:8085`
|
||||
Health: `http://localhost:8085/healthz`
|
||||
|
||||
## 2) Create an admin
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## 3) Configure Last.fm
|
||||
- Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB)
|
||||
- Env fallback (in `.env` or your shell):
|
||||
```bash
|
||||
LASTFM_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
## 4) JWT auth keys
|
||||
- API login endpoint: `POST /api/v1/auth/login`
|
||||
- JWT keys are generated automatically on container start into `config/jwt/`.
|
||||
- Set `JWT_PASSPHRASE` in `.env` to your production secret.
|
||||
|
||||
## 5) (Optional) Seed demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
## Database drivers
|
||||
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
|
||||
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
|
||||
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV=dev|prod`
|
||||
- `APP_SECRET=<random_string>`
|
||||
- `LASTFM_API_KEY`
|
||||
- `JWT_PASSPHRASE=<random_string>`
|
||||
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
|
||||
- `DATABASE_DRIVER=sqlite|postgres`
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
|
||||
|
||||
## Useful commands
|
||||
```bash
|
||||
# Symfony cache
|
||||
docker compose exec tonehaus php bin/console cache:clear
|
||||
|
||||
# Inspect routes
|
||||
docker compose exec tonehaus php bin/console debug:router
|
||||
|
||||
# Promote moderator
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Cannot find template or routes
|
||||
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
|
||||
- List routes: `docker compose exec tonehaus php bin/console debug:router`
|
||||
|
||||
## Missing vendors
|
||||
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
|
||||
|
||||
## .env not read in container
|
||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||
|
||||
## Login modal shows blank
|
||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `admin-and-settings.md` to grant roles.
|
||||
|
||||
## SQLite file permissions
|
||||
- The default SQLite path is `var/data/database.sqlite`.
|
||||
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
|
||||
|
||||
## Postgres connection issues
|
||||
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
|
||||
- Example URL:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Last.fm errors
|
||||
- Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`.
|
||||
|
||||
## ARM64 Build
|
||||
|
||||
```bash
|
||||
sudo docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--target prod \
|
||||
-t tonehaus/tonehaus:dev-arm64 \
|
||||
-f docker/php/Dockerfile \
|
||||
. \
|
||||
--load
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Importmap configuration for the asset mapper.
|
||||
*
|
||||
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
|
||||
*/
|
||||
return [
|
||||
'app' => [
|
||||
'path' => './assets/app.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251205134500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add genres JSON column to albums for advanced search filters';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite cannot drop columns without rebuilding the table; leave as-is.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
|
||||
$this->addSql('ALTER TABLE albums DROP genres');
|
||||
}
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
|
||||
{
|
||||
if (!$schema->hasTable($tableName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$schema->getTable($tableName)->hasColumn($column);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227120500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->shouldAddColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'");
|
||||
if (!$this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column drops require table rebuilds; keep schema forward-only here.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('DROP INDEX uniq_users_oauth_identity');
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('DROP INDEX uniq_album_external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_provider');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'");
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'");
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName);
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227174500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy Spotify and unused album columns (created_by_id, cover_url, local_id, cover_image_path)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column drops/constraint rewrites are intentionally skipped in this migration.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
if (!$this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD spotify_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD created_by_id INT DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums ADD cover_url VARCHAR(1024) DEFAULT NULL');
|
||||
}
|
||||
if (!$this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474FA905FC5C')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
|
||||
}
|
||||
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474F5D5A2101')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
|
||||
}
|
||||
if (!$this->indexExists($schema, 'albums', 'IDX_F4E2474FB03A8386')) {
|
||||
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
if (!$this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks ADD spotify_track_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if (!$this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227181000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Apply SQLite-safe table rebuilds to remove legacy Spotify and unused album columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
$this->migrateSqlite();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums')) {
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
|
||||
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'local_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
|
||||
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Forward-only migration: reverting would require data-loss-prone table rewrites.
|
||||
}
|
||||
|
||||
private function migrateSqlite(): void
|
||||
{
|
||||
$this->addSql('PRAGMA foreign_keys = OFF');
|
||||
|
||||
$this->addSql(
|
||||
"CREATE TABLE albums_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
external_id VARCHAR(64) DEFAULT NULL,
|
||||
source VARCHAR(16) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
artists CLOB NOT NULL,
|
||||
release_date VARCHAR(20) DEFAULT NULL,
|
||||
total_tracks INTEGER NOT NULL,
|
||||
external_url VARCHAR(1024) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
genres CLOB NOT NULL DEFAULT '[]'
|
||||
)"
|
||||
);
|
||||
$this->addSql(
|
||||
"INSERT INTO albums_new (
|
||||
id, external_id, source, name, artists, release_date, total_tracks, external_url, created_at, updated_at, genres
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN external_id IS NOT NULL AND TRIM(external_id) <> '' THEN external_id
|
||||
WHEN local_id IS NOT NULL AND TRIM(local_id) <> '' THEN local_id
|
||||
ELSE 'legacy:' || id
|
||||
END AS external_id,
|
||||
CASE
|
||||
WHEN source = 'spotify' OR source IS NULL OR TRIM(source) = '' THEN 'external'
|
||||
ELSE source
|
||||
END AS source,
|
||||
name,
|
||||
artists,
|
||||
release_date,
|
||||
total_tracks,
|
||||
external_url,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(genres, '[]') AS genres
|
||||
FROM albums"
|
||||
);
|
||||
$this->addSql('DROP TABLE albums');
|
||||
$this->addSql('ALTER TABLE albums_new RENAME TO albums');
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_external_id ON albums (external_id)');
|
||||
|
||||
$this->addSql(
|
||||
"CREATE TABLE album_tracks_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
album_id INTEGER NOT NULL,
|
||||
external_track_id VARCHAR(64) DEFAULT NULL,
|
||||
disc_number INTEGER NOT NULL,
|
||||
track_number INTEGER NOT NULL,
|
||||
name VARCHAR(512) NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
preview_url VARCHAR(1024) DEFAULT NULL,
|
||||
CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)"
|
||||
);
|
||||
$this->addSql(
|
||||
"INSERT INTO album_tracks_new (
|
||||
id, album_id, external_track_id, disc_number, track_number, name, duration_ms, preview_url
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
album_id,
|
||||
CASE
|
||||
WHEN external_track_id IS NOT NULL AND TRIM(external_track_id) <> '' THEN external_track_id
|
||||
WHEN spotify_track_id IS NOT NULL AND TRIM(spotify_track_id) <> '' THEN spotify_track_id
|
||||
ELSE NULL
|
||||
END AS external_track_id,
|
||||
disc_number,
|
||||
track_number,
|
||||
name,
|
||||
duration_ms,
|
||||
preview_url
|
||||
FROM album_tracks"
|
||||
);
|
||||
$this->addSql('DROP TABLE album_tracks');
|
||||
$this->addSql('ALTER TABLE album_tracks_new RENAME TO album_tracks');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
|
||||
|
||||
$this->addSql('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260401101500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable album owner relation (albums.owner_id -> users.id)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql('ALTER TABLE albums ADD COLUMN owner_id INTEGER DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD owner_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_ALBUM_OWNER ON albums (owner_id)');
|
||||
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_ALBUM_OWNER FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite down-migration omitted to avoid lossy table rebuild.
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums DROP CONSTRAINT FK_ALBUM_OWNER');
|
||||
$this->addSql('DROP INDEX IDX_ALBUM_OWNER');
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN owner_id');
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260402120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add nullable cover_image_path to albums for user-uploaded album artwork';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if (!$this->hasTable($schema, 'albums') || $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql('ALTER TABLE albums ADD COLUMN cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column removal requires a table rebuild; keep this migration forward-only on SQLite.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasTable($schema, 'albums') && $this->hasColumn($schema, 'albums', 'cover_image_path')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Tonehaus Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<php>
|
||||
<server name="APP_ENV" value="test" force="true"/>
|
||||
<server name="APP_DEBUG" value="1" force="true"/>
|
||||
<server name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<server name="DATABASE_DRIVER" value="sqlite" force="true"/>
|
||||
<server name="DATABASE_SQLITE_PATH" value="/tmp/tonehaus_test.sqlite" force="true"/>
|
||||
<server name="JWT_PASSPHRASE" value="test" force="true"/>
|
||||
|
||||
<!-- ###+ symfony/framework-bundle ### -->
|
||||
<env name="APP_ENV" value="dev"/>
|
||||
<env name="APP_SECRET" value="test-secret"/>
|
||||
<!-- ###- symfony/framework-bundle ### -->
|
||||
|
||||
<!-- ###+ symfony/routing ### -->
|
||||
<!-- Configure how to generate URLs in non-HTTP contexts, such as CLI commands. -->
|
||||
<!-- See https://symfony.com/doc/current/routing.html#generating-urls-in-commands -->
|
||||
<env name="DEFAULT_URI" value="http://localhost"/>
|
||||
<!-- ###- symfony/routing ### -->
|
||||
|
||||
<!-- ###+ doctrine/doctrine-bundle ### -->
|
||||
<!-- Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url -->
|
||||
<!-- IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -->
|
||||
<!-- -->
|
||||
<!-- DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" -->
|
||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -->
|
||||
<!-- DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -->
|
||||
<env name="DATABASE_URL" value="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"/>
|
||||
<!-- ###- doctrine/doctrine-bundle ### -->
|
||||
|
||||
<!-- ###+ lexik/jwt-authentication-bundle ### -->
|
||||
<env name="JWT_SECRET_KEY" value="%kernel.project_dir%/config/jwt/private.pem"/>
|
||||
<env name="JWT_PUBLIC_KEY" value="%kernel.project_dir%/config/jwt/public.pem"/>
|
||||
<env name="JWT_PASSPHRASE" value="f5b6626c282780b036de5dad4b5aa9a42ef5ec4632ecfbefc840eb02a60fae0c"/>
|
||||
<!-- ###- lexik/jwt-authentication-bundle ### -->
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user