Compare commits
13
Commits
main
...
56dea097d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56dea097d6 | ||
|
|
391ecf1732 | ||
|
|
4ae7a44881 | ||
|
|
fa54cb4167 | ||
|
|
f109c933c1 | ||
|
|
d52eb6bd81 | ||
|
|
f77f3a9e40 | ||
|
|
336dcc4d3a | ||
|
|
54b1908793 | ||
|
|
dda9ff06b5 | ||
|
|
796acaa9c0 | ||
|
|
3879c6c312 | ||
|
|
da9af888c0 |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
.DS_Store
|
||||
.cursor
|
||||
.env.local
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.idea
|
||||
.public
|
||||
backup_manifests/
|
||||
docs/
|
||||
tests/
|
||||
var/
|
||||
# Symfony cache/logs generated inside the container should not come from the host
|
||||
var/cache/
|
||||
var/log/
|
||||
|
||||
# Uploaded files stay on the host, not baked into images
|
||||
public/uploads/
|
||||
|
||||
+10
-15
@@ -1,17 +1,12 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changeme # Arbitrary secret. Ideally a long random string.
|
||||
APP_ALLOW_REGISTRATION=1 #
|
||||
DEFAULT_URI=http://localhost:8000 # Should match external URI of application.
|
||||
DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||
#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite
|
||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits.
|
||||
# Uncomment to override stored setting.
|
||||
#LASTFM_API_KEY=
|
||||
|
||||
# POSTGRES_DB=
|
||||
# POSTGRES_USER=
|
||||
# POSTGRES_PASSWORD=
|
||||
APP_ENV=prod
|
||||
APP_SECRET=changeme
|
||||
# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
|
||||
DEFAULT_URI=http://localhost:8085
|
||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by external APIs.
|
||||
JWT_PASSPHRASE=changeme
|
||||
|
||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD=password
|
||||
DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
|
||||
# DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||
+90
-58
@@ -1,20 +1,74 @@
|
||||
name: CI - Build Tonehaus Docker image
|
||||
name: CI (Gitea)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: tonehaus
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret
|
||||
DATABASE_DRIVER: sqlite
|
||||
DATABASE_SQLITE_PATH: ${{ gitea.workspace }}/var/data/database.test.sqlite
|
||||
DOCKERFILE: docker/php/Dockerfile
|
||||
BUILD_TARGET: prod
|
||||
PLATFORMS: linux/amd64
|
||||
IMAGE_NAME: tonehaus-app
|
||||
|
||||
jobs:
|
||||
tonehaus-ci-build:
|
||||
php-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||
coverage: none
|
||||
ini-values: memory_limit=512M
|
||||
tools: composer:v2
|
||||
|
||||
- name: Validate Composer manifest
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/composer/files
|
||||
~/.cache/composer/vcs
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
|
||||
- name: Prepare SQLite database
|
||||
run: |
|
||||
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||
touch "$DATABASE_SQLITE_PATH"
|
||||
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --colors=always
|
||||
|
||||
docker-image:
|
||||
needs: php-tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -22,62 +76,40 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
run: |
|
||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-unknown}}"
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Build prod image (local)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
target: ${{ env.BUILD_TARGET }}
|
||||
tags: ${{ env.IMAGE_NAME }}:ci
|
||||
load: true
|
||||
|
||||
- name: Optional registry login
|
||||
- name: Verify baked APP_ENV
|
||||
run: docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test "$APP_ENV" = "prod"'
|
||||
|
||||
- name: Verify Symfony artifacts exist
|
||||
run: |
|
||||
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/public/index.php'
|
||||
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/bin/console'
|
||||
|
||||
- name: Smoke-test entrypoint & migrations
|
||||
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh ${{ env.IMAGE_NAME }}:ci true
|
||||
|
||||
- name: Login to registry
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Docker Build
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' }}
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
|
||||
run: |
|
||||
TAG_SHA=${{ steps.meta.outputs.short_sha }}
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--file "$DOCKERFILE" \
|
||||
--target "$BUILD_TARGET" \
|
||||
--build-arg APP_ENV=prod \
|
||||
--tag "$REGISTRY/$REGISTRY_IMAGE:$TAG_SHA" \
|
||||
--tag "$REGISTRY/$REGISTRY_IMAGE:ci" \
|
||||
--push \
|
||||
.
|
||||
|
||||
# - name: Build single-arch images for artifacts (no registry)
|
||||
# if: ${{ env.REGISTRY == '' }}
|
||||
# run: |
|
||||
# TAG_SHA=${{ steps.meta.outputs.short_sha }}
|
||||
# for P in $PLATFORMS; do \
|
||||
# ARCH=${P#linux/}; \
|
||||
# docker buildx build \
|
||||
# --platform "$P" \
|
||||
# --file "$DOCKERFILE" \
|
||||
# --target "$BUILD_TARGET" \
|
||||
# --build-arg APP_ENV=prod \
|
||||
# --output type=docker \
|
||||
# --tag "$IMAGE_NAME:$TAG_SHA-$ARCH" \
|
||||
# . ; \
|
||||
# docker save "$IMAGE_NAME:$TAG_SHA-$ARCH" -o "tonehaus-image-$ARCH.tar" ; \
|
||||
# done
|
||||
|
||||
## Artifacts not configured yet..
|
||||
# - name: Upload artifacts
|
||||
# if: ${{ env.REGISTRY == '' }}
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: tonehaus-images
|
||||
# path: |
|
||||
# tonehaus-image-amd64.tar
|
||||
- name: Push prod image
|
||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
target: ${{ env.BUILD_TARGET }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:ci
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret
|
||||
DATABASE_DRIVER: sqlite
|
||||
DATABASE_SQLITE_PATH: ${{ github.workspace }}/var/data/database.test.sqlite
|
||||
|
||||
jobs:
|
||||
php-tests:
|
||||
name: PHPUnit + migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
id: setup-php
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||
coverage: none
|
||||
ini-values: memory_limit=512M
|
||||
tools: composer:v2
|
||||
|
||||
- name: Validate Composer manifest
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/composer/files
|
||||
~/.cache/composer/vcs
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Cache vendor directory
|
||||
id: cache-vendor
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --no-progress
|
||||
env:
|
||||
COMPOSER_NO_INTERACTION: 1
|
||||
COMPOSER_MEMORY_LIMIT: -1
|
||||
|
||||
- name: Prepare SQLite database
|
||||
run: |
|
||||
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||
touch "$DATABASE_SQLITE_PATH"
|
||||
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --colors=always
|
||||
|
||||
docker-image:
|
||||
name: Build production image
|
||||
needs: php-tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build prod image
|
||||
id: build-prod
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/php/Dockerfile
|
||||
target: prod
|
||||
load: true
|
||||
push: false
|
||||
tags: tonehaus-app:ci
|
||||
|
||||
- name: Verify baked APP_ENV
|
||||
run: docker run --rm --entrypoint sh tonehaus-app:ci -c 'test "$APP_ENV" = "prod"'
|
||||
|
||||
- name: Verify Symfony artifacts exist
|
||||
run: |
|
||||
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/public/index.php'
|
||||
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/bin/console'
|
||||
|
||||
- name: Smoke-test entrypoint & migrations
|
||||
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Tag latest image
|
||||
if: github.event_name == 'push'
|
||||
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
|
||||
|
||||
- name: Push latest image
|
||||
if: github.event_name == 'push'
|
||||
run: docker push $IMAGE_NAME:latest
|
||||
|
||||
Generated
-10
@@ -24,10 +24,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
||||
@@ -38,7 +34,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
||||
@@ -69,13 +64,11 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
||||
@@ -87,7 +80,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||
@@ -120,14 +112,12 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
-10
@@ -12,11 +12,9 @@
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
@@ -31,11 +29,8 @@
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
@@ -43,7 +38,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
@@ -88,7 +82,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
||||
@@ -112,7 +105,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
@@ -120,7 +112,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
@@ -129,7 +120,6 @@
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Tonehaus — Music Ratings
|
||||
# Tonehaus — Music Ratings (Symfony 7)
|
||||
|
||||
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||
Discover albums via Last.fm, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||
|
||||
## Quick start
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
1) Start the stack
|
||||
|
||||
@@ -10,69 +10,71 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
2) Create the database schema
|
||||
2) Open the app
|
||||
|
||||
- App URL: `http://localhost:8085`
|
||||
- Health: `http://localhost:8085/healthz`
|
||||
|
||||
3) Create your first admin
|
||||
|
||||
- Sign Up through Tonehaus
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
3) Promote an admin (to access Site Settings)
|
||||
4) Configure Last.fm
|
||||
|
||||
- Go to `http://localhost:8085/admin/settings` and enter your Last.fm API key, or
|
||||
- Set env var in `.env`: `LASTFM_API_KEY`
|
||||
|
||||
5) (Optional) Seed demo data
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
4) Configure Spotify API credentials (admin only)
|
||||
|
||||
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
|
||||
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
|
||||
|
||||
5) Visit `http://localhost:8000` to search for albums.
|
||||
|
||||
6) (Optional) Seed demo data
|
||||
|
||||
```bash
|
||||
docker compose exec php php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
## Database driver
|
||||
|
||||
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
||||
- Set `DATABASE_DRIVER=sqlite` to run against a self-contained SQLite file stored at `var/data/database.sqlite`.
|
||||
- When `DATABASE_DRIVER=sqlite`, the `DATABASE_URL` env var is ignored. Doctrine will automatically create and use the SQLite file; override the default location with `DATABASE_SQLITE_PATH` if needed.
|
||||
Notes:
|
||||
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
|
||||
- To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
|
||||
|
||||
## Features
|
||||
|
||||
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
|
||||
- Album page with details, reviews list, and inline new review (logged in)
|
||||
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
|
||||
- Role-based access: authors manage their own reviews, admins can manage any
|
||||
- Admin Site Settings to manage Spotify credentials in DB
|
||||
- User Dashboard to update profile and change password (requires current password)
|
||||
- Light/Dark theme toggle in Settings (cookie-backed)
|
||||
- Bootstrap UI
|
||||
- Last.fm-backed album search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count)
|
||||
- REST API under `/api/v1` for albums/reviews with JSON responses and proper HTTP verbs
|
||||
- JWT authentication for protected API endpoints (`/api/v1/auth/login`) via LexikJWTAuthenticationBundle
|
||||
- REST response handling via FOSRestBundle
|
||||
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
|
||||
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
||||
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
||||
- Admin Site Settings: manage Last.fm API key and public registration toggle
|
||||
- User Dashboard: profile updates and password change
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
|
||||
## Rate limiting & caching
|
||||
## Documentation
|
||||
|
||||
- Server-side Client Credentials; access tokens are cached.
|
||||
- Setup and configuration: `docs/setup.md`
|
||||
- Feature overview: `docs/features.md`
|
||||
- Authentication and users: `docs/auth-and-users.md`
|
||||
- Last.fm integration: `docs/lastfm-integration.md`
|
||||
- Reviews and albums: `docs/reviews-and-albums.md`
|
||||
- Admin & site settings: `docs/admin-and-settings.md`
|
||||
- Troubleshooting: `docs/troubleshooting.md`
|
||||
- Architecture: `docs/architecture.md`
|
||||
- Deployment: `docs/deployment.md`
|
||||
|
||||
## Docs
|
||||
## Environment overview
|
||||
|
||||
See `/docs` for how-tos and deeper notes:
|
||||
|
||||
- Setup and configuration: `docs/01-setup.md`
|
||||
- Features and UX: `docs/02-features.md`
|
||||
- Authentication and users: `docs/03-auth-and-users.md`
|
||||
- Spotify integration: `docs/04-spotify-integration.md`
|
||||
- Reviews and albums: `docs/05-reviews-and-albums.md`
|
||||
- Admin & site settings: `docs/06-admin-and-settings.md`
|
||||
- Troubleshooting: `docs/07-troubleshooting.md`
|
||||
- `APP_ENV` (dev|prod), `APP_SECRET` (required)
|
||||
- `LASTFM_API_KEY`
|
||||
- `JWT_PASSPHRASE` (used for JWT private key encryption)
|
||||
- `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
|
||||
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
|
||||
- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
|
||||
- `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Intentionally empty importmap entrypoint.
|
||||
@@ -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",
|
||||
@@ -7,6 +9,9 @@
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"friendsofsymfony/rest-bundle": "^3.8",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"lexik/jwt-authentication-bundle": "^3.1",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.18",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.5",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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],
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dynamic Doctrine DBAL configuration.
|
||||
*
|
||||
* This file complements `config/packages/doctrine.yaml`, not replacing it!:
|
||||
* - YAML handles ORM mappings, naming strategy, caches, and env-specific tweaks.
|
||||
* - This PHP config focuses on DBAL and runtime driver selection.
|
||||
*
|
||||
* Behavior:
|
||||
* - Chooses the database driver from `DATABASE_DRIVER` (`postgres` or `sqlite`).
|
||||
* - For Postgres:
|
||||
* - Uses `DATABASE_URL` (e.g. `postgresql://user:pass@host:5432/dbname`).
|
||||
* - Pins `serverVersion` (currently `16`) to avoid auto-detection issues.
|
||||
* - For SQLite:
|
||||
* - Uses `DATABASE_SQLITE_PATH` when provided.
|
||||
* - Otherwise, defaults to `<projectDir>/var/data/database.sqlite`, creating the
|
||||
* directory and file if they do not already exist. (Recommended)
|
||||
*
|
||||
* This split keeps the mapping/caching config in YAML while allowing
|
||||
* DBAL to adapt between Docker/postgres and local sqlite setups.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Config\DoctrineConfig;
|
||||
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
|
||||
|
||||
return static function (DoctrineConfig $doctrine): void {
|
||||
// Normalize DATABASE_DRIVER and validate allowed values up front.
|
||||
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
|
||||
$supportedDrivers = ['postgres', 'sqlite'];
|
||||
|
||||
@@ -17,20 +40,23 @@ return static function (DoctrineConfig $doctrine): void {
|
||||
));
|
||||
}
|
||||
|
||||
// Configure the default DBAL connection.
|
||||
$dbal = $doctrine->dbal();
|
||||
$dbal->defaultConnection('default');
|
||||
|
||||
$connection = $dbal->connection('default');
|
||||
$connection->profilingCollectBacktrace('%kernel.debug%');
|
||||
$connection->profilingCollectBacktrace(param('kernel.debug'));
|
||||
$connection->useSavepoints(true);
|
||||
|
||||
if ('sqlite' === $driver) {
|
||||
// SQLite: use a file-backed database by default.
|
||||
$connection->driver('pdo_sqlite');
|
||||
|
||||
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
||||
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
||||
|
||||
if ($hasCustomPath) {
|
||||
// Allow explicit database path via env overrides.
|
||||
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||
} else {
|
||||
$projectDir = dirname(__DIR__, 2);
|
||||
@@ -49,7 +75,9 @@ return static function (DoctrineConfig $doctrine): void {
|
||||
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||
}
|
||||
} else {
|
||||
// Postgres (or other server-based driver) via DATABASE_URL.
|
||||
$connection->url('%env(resolve:DATABASE_URL)%');
|
||||
// Keep the server version explicit so Doctrine does not need network calls to detect it.
|
||||
$connection->serverVersion('16');
|
||||
$connection->charset('utf8');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
fos_rest:
|
||||
format_listener:
|
||||
rules:
|
||||
- { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] }
|
||||
- { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] }
|
||||
body_listener: true
|
||||
view:
|
||||
view_response_listener: force
|
||||
formats:
|
||||
json: true
|
||||
@@ -1,6 +1,8 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
|
||||
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
|
||||
public_key: '%kernel.project_dir%/config/jwt/public.pem'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 3600
|
||||
@@ -42,7 +42,7 @@ when@prod:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
action_level: info
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
|
||||
@@ -15,6 +15,21 @@ security:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api_login:
|
||||
pattern: ^/api/v1/auth/login$
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
json_login:
|
||||
check_path: /api/v1/auth/login
|
||||
username_path: email
|
||||
password_path: password
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
jwt: ~
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
@@ -48,6 +63,11 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/reviews$, roles: ROLE_USER, methods: [POST] }
|
||||
- { path: ^/api/v1/reviews/[0-9]+$, roles: ROLE_USER, methods: [PUT, PATCH, DELETE] }
|
||||
- { path: ^/api/v1/albums/ingest$, roles: ROLE_USER, methods: [POST] }
|
||||
- { path: ^/api, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin/settings, roles: ROLE_ADMIN }
|
||||
- { path: ^/admin/users, roles: ROLE_MODERATOR }
|
||||
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
api_login_check:
|
||||
path: /api/v1/auth/login
|
||||
+14
-5
@@ -20,15 +20,24 @@ 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: 10.0, connect_timeout: 5.0, http_errors: false }
|
||||
|
||||
App\Service\ImageStorage:
|
||||
App\Service\LastFmClient:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$httpClient: '@GuzzleHttp\Client'
|
||||
$apiKey: '%env(default::LASTFM_API_KEY)%'
|
||||
|
||||
App\Service\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%'
|
||||
+12
-81
@@ -1,96 +1,27 @@
|
||||
## Docker Compose stack for Symfony app
|
||||
## - php: PHP-FPM container that runs Symfony and Composer
|
||||
## - nginx: Frontend web server proxying PHP to php-fpm
|
||||
## - db: Postgres database for Doctrine
|
||||
## - pgadmin: Optional UI to inspect the database
|
||||
services:
|
||||
php:
|
||||
# Build multi-stage image defined in docker/php/Dockerfile
|
||||
tonehaus:
|
||||
image: tonehaus:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
target: dev
|
||||
target: prod
|
||||
args:
|
||||
- APP_ENV=dev
|
||||
container_name: php
|
||||
APP_ENV: prod
|
||||
container_name: tonehaus
|
||||
restart: unless-stopped
|
||||
#environment:
|
||||
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
|
||||
#DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
|
||||
volumes:
|
||||
# Mount only source and config; vendors are installed in-container
|
||||
- ./bin:/var/www/html/bin
|
||||
- ./config:/var/www/html/config
|
||||
- ./migrations:/var/www/html/migrations
|
||||
- ./public:/var/www/html/public
|
||||
- ./templates:/var/www/html/templates
|
||||
- ./src:/var/www/html/src
|
||||
- ./var:/var/www/html/var
|
||||
- ./.env:/var/www/html/.env:ro
|
||||
- ./vendor:/var/www/html/vendor
|
||||
# Keep composer manifests on host for version control
|
||||
- ./composer.json:/var/www/html/composer.json
|
||||
- ./composer.lock:/var/www/html/composer.lock
|
||||
- ./symfony.lock:/var/www/html/symfony.lock
|
||||
# Speed up composer installs by caching download artifacts
|
||||
- composer_cache:/tmp/composer
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "php -v || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- db
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
- uploads:/var/www/html/public/uploads
|
||||
- sqlite_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;
|
||||
|
||||
+133
-114
@@ -1,118 +1,137 @@
|
||||
# FROM php:8.2-fpm
|
||||
|
||||
# # Install dependencies
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# git \
|
||||
# unzip \
|
||||
# libzip-dev \
|
||||
# libpng-dev \
|
||||
# libjpeg-dev \
|
||||
# libonig-dev \
|
||||
# libxml2-dev \
|
||||
# zip \
|
||||
# && docker-php-ext-install pdo pdo_mysql zip gd mbstring exif pcntl bcmath intl opcache
|
||||
|
||||
# # Install Composer
|
||||
# COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# # Copy PHP config
|
||||
# COPY docker/php/php.ini /usr/local/etc/php/
|
||||
|
||||
# WORKDIR /var/www/html
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Base PHP-FPM with Composer + Symfony-friendly extensions
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM php:8.2-fpm-alpine AS base
|
||||
WORKDIR /var/www/html
|
||||
FROM php:8.2-fpm-alpine AS base
|
||||
|
||||
ARG APP_ENV=dev
|
||||
ENV APP_ENV=${APP_ENV}
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# System dependencies shared across images
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
git \
|
||||
openssl \
|
||||
unzip \
|
||||
icu-dev \
|
||||
libpng-dev \
|
||||
libjpeg-turbo-dev \
|
||||
libwebp-dev \
|
||||
libzip-dev \
|
||||
oniguruma-dev \
|
||||
libxml2-dev \
|
||||
postgresql-dev \
|
||||
sqlite-dev \
|
||||
zlib-dev \
|
||||
su-exec
|
||||
|
||||
# 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 every stage (dev + prod)
|
||||
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Recommended PHP settings (tweak as needed)
|
||||
RUN { \
|
||||
echo "memory_limit=512M"; \
|
||||
echo "upload_max_filesize=50M"; \
|
||||
echo "post_max_size=50M"; \
|
||||
echo "date.timezone=UTC"; \
|
||||
} > /usr/local/etc/php/conf.d/php-recommended.ini \
|
||||
&& { \
|
||||
echo "opcache.enable=1"; \
|
||||
echo "opcache.enable_cli=1"; \
|
||||
echo "opcache.memory_consumption=256"; \
|
||||
echo "opcache.interned_strings_buffer=16"; \
|
||||
echo "opcache.max_accelerated_files=20000"; \
|
||||
echo "opcache.validate_timestamps=0"; \
|
||||
echo "opcache.jit=tracing"; \
|
||||
echo "opcache.jit_buffer_size=128M"; \
|
||||
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||
|
||||
# Small healthcheck file for HTTP probes
|
||||
RUN mkdir -p public && printf "OK" > public/healthz
|
||||
|
||||
# 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
|
||||
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 \
|
||||
# && { \
|
||||
# echo "xdebug.mode=debug,develop"; \
|
||||
# echo "xdebug.client_host=host.docker.internal"; \
|
||||
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
||||
|
||||
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
||||
CMD ["php-fpm"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production image (copies your app + installs deps + warms cache)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS prod
|
||||
ARG APP_ENV=prod
|
||||
ENV APP_ENV=${APP_ENV}
|
||||
ENV APP_DEBUG=0 \
|
||||
DATABASE_DRIVER=sqlite \
|
||||
DATABASE_SQLITE_PATH=/var/www/html/var/data/database.sqlite \
|
||||
RUN_MIGRATIONS_ON_START=1
|
||||
|
||||
# Copy only composer manifests for layer caching
|
||||
COPY composer.json composer.lock* symfony.lock* ./
|
||||
|
||||
# Install vendors (cached)
|
||||
RUN --mount=type=cache,target=/tmp/composer \
|
||||
if [ -f composer.json ]; then \
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||
fi
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . /var/www/html
|
||||
|
||||
# Finalize install & warm cache
|
||||
RUN if [ -f bin/console ]; then \
|
||||
set -ex; \
|
||||
composer dump-autoload --no-dev --optimize; \
|
||||
mkdir -p var var/data public/uploads; \
|
||||
chown -R www-data:www-data var public/uploads; \
|
||||
fi
|
||||
|
||||
# Runtime web stack (nginx + supervisor) for a single immutable container
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
|
||||
COPY docker/prod/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY docker/prod/entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh \
|
||||
&& mkdir -p /run/nginx /var/log/supervisor \
|
||||
&& chown -R www-data:www-data /run/nginx /var/log/supervisor /var/www/html
|
||||
RUN mkdir -p /var/lib/nginx /var/log/nginx && chown -R www-data:www-data /var/lib/nginx /var/log/nginx
|
||||
RUN sed -i 's|^error_log =.*|error_log = /proc/self/fd/2|' /usr/local/etc/php-fpm.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||
|
||||
# System dependencies
|
||||
RUN apk add --no-cache \
|
||||
bash git unzip icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev \
|
||||
libzip-dev oniguruma-dev libxml2-dev postgresql-dev zlib-dev
|
||||
|
||||
# PHP extensions commonly used by Symfony
|
||||
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
intl \
|
||||
gd \
|
||||
pdo_pgsql \
|
||||
opcache \
|
||||
mbstring \
|
||||
zip \
|
||||
xml
|
||||
|
||||
# Composer available in the running container (dev + prod)
|
||||
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Recommended PHP settings (tweak as needed)
|
||||
RUN { \
|
||||
echo "memory_limit=512M"; \
|
||||
echo "upload_max_filesize=50M"; \
|
||||
echo "post_max_size=50M"; \
|
||||
echo "date.timezone=UTC"; \
|
||||
} > /usr/local/etc/php/conf.d/php-recommended.ini \
|
||||
&& { \
|
||||
echo "opcache.enable=1"; \
|
||||
echo "opcache.enable_cli=1"; \
|
||||
echo "opcache.memory_consumption=256"; \
|
||||
echo "opcache.interned_strings_buffer=16"; \
|
||||
echo "opcache.max_accelerated_files=20000"; \
|
||||
echo "opcache.validate_timestamps=0"; \
|
||||
echo "opcache.jit=tracing"; \
|
||||
echo "opcache.jit_buffer_size=128M"; \
|
||||
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||
|
||||
# Small healthcheck file for Nginx
|
||||
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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Development image (mount your code via docker-compose volumes)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS dev
|
||||
ENV APP_ENV=dev
|
||||
# Optional: enable Xdebug (uncomment to use)
|
||||
# RUN apk add --no-cache $PHPIZE_DEPS \
|
||||
# && pecl install xdebug \
|
||||
# && docker-php-ext-enable xdebug \
|
||||
# && { \
|
||||
# echo "xdebug.mode=debug,develop"; \
|
||||
# echo "xdebug.client_host=host.docker.internal"; \
|
||||
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
||||
# Composer cache directory (faster installs inside container)
|
||||
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
||||
CMD ["php-fpm"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production image (copies your app + installs deps + warms cache)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS prod
|
||||
ENV APP_ENV=prod
|
||||
# Copy only manifests first (better layer caching); ignore if missing
|
||||
COPY composer.json composer.lock* symfony.lock* ./
|
||||
# Install vendors (no scripts here; run later with console if needed)
|
||||
RUN --mount=type=cache,target=/tmp/composer \
|
||||
if [ -f composer.json ]; then \
|
||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||
fi
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . /var/www/html
|
||||
|
||||
# If Symfony console exists, finalize install & warm cache
|
||||
RUN if [ -f bin/console ]; then \
|
||||
set -ex; \
|
||||
composer dump-autoload --no-dev --optimize; \
|
||||
php bin/console cache:clear --no-warmup; \
|
||||
php bin/console cache:warmup; \
|
||||
mkdir -p var && chown -R www-data:www-data var; \
|
||||
fi
|
||||
|
||||
USER www-data
|
||||
CMD ["php-fpm"]
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
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..."
|
||||
su-exec www-data composer install \
|
||||
--no-dev \
|
||||
--prefer-dist \
|
||||
--no-interaction \
|
||||
--no-progress
|
||||
}
|
||||
|
||||
install_runtime
|
||||
|
||||
if [ -f bin/console ]; then
|
||||
require_app_secret
|
||||
ensure_jwt_keys
|
||||
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 cache:clear --no-warmup
|
||||
su-exec www-data php bin/console cache:warmup
|
||||
chown -R www-data:www-data var
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "OK";
|
||||
}
|
||||
|
||||
location ^/api/v1 {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
internal;
|
||||
}
|
||||
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
user=root
|
||||
|
||||
[program:php-fpm]
|
||||
command=/usr/local/sbin/php-fpm --nodaemonize
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Setup
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose
|
||||
- Spotify Developer account (for a Client ID/Secret)
|
||||
|
||||
## Start services
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Database
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
### Switching database drivers
|
||||
- `DATABASE_DRIVER=postgres` (default) continues to use the Postgres 16 service from `docker-compose.yml` and reads credentials from `DATABASE_URL`.
|
||||
- `DATABASE_DRIVER=sqlite` runs Doctrine against a local SQLite file at `var/data/database.sqlite`. `DATABASE_URL` is ignored; override the SQLite file path with `DATABASE_SQLITE_PATH` if desired.
|
||||
|
||||
## Admin user
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## Moderator (optional)
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
|
||||
## Spotify credentials
|
||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. (Stored in DB)
|
||||
- Fallback to env vars:
|
||||
```bash
|
||||
export SPOTIFY_CLIENT_ID=your_client_id
|
||||
export SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Optional feature flags
|
||||
- Disable public registration by setting an env variable before starting Symfony:
|
||||
```bash
|
||||
export APP_ALLOW_REGISTRATION=0 # set to 1 (default) to re-enable
|
||||
```
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Features
|
||||
|
||||
- Spotify album search with Advanced filters (album, artist, year range)
|
||||
- Album page with details, list of reviews, and inline new review
|
||||
- Review rating slider (1–10) with live badge
|
||||
- Per-album aggregates: average rating and total review count
|
||||
- Auth modal (Login/Sign up) with remember-me cookie
|
||||
- Role-based access (user, moderator, admin) with protected admin routes
|
||||
- Admin Site Settings to manage Spotify credentials
|
||||
- Moderator/Admin dashboard with latest activity snapshots
|
||||
- User management table (create/delete accounts, promote/demote moderators)
|
||||
- User Dashboard for profile changes (email, display name, password)
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- Bootstrap UI
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Authentication & Users
|
||||
|
||||
## Modal auth
|
||||
- Login and registration happen in a Bootstrap modal.
|
||||
- AJAX submits keep users on the same page; state updates after reload.
|
||||
- Remember-me cookie keeps users logged in across sessions.
|
||||
|
||||
## Roles
|
||||
- `ROLE_USER`: default for registered users.
|
||||
- `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings.
|
||||
- `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access.
|
||||
|
||||
### Demo accounts
|
||||
- Generate placeholder accounts locally with `php bin/console app:seed-demo-users --count=50` (default password: `password`).
|
||||
- Emails use the pattern `demo+<token>@example.com`, making them easy to spot in the admin UI.
|
||||
- Give existing accounts avatars with `php bin/console app:seed-user-avatars`; pass `--overwrite` to refresh everyone or tweak `--style` to try other DiceBear sets.
|
||||
|
||||
### Access flow
|
||||
- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically.
|
||||
- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy.
|
||||
- Admin-only actions (site settings, moderator toggling, deleting other admins) are additionally guarded in controllers/templates to avoid accidental misuse.
|
||||
|
||||
### User management UI
|
||||
- `/admin/users` (moderator+) lists every account along with album/review counts.
|
||||
- Moderators can create new accounts (without affecting their own login session.. ).
|
||||
- Delete buttons are disabled (with tooltip hints) for protected rows such as the current user or any admin.
|
||||
- Admins see a Promote/Demote toggle: promoting grants `ROLE_MODERATOR`; demoting removes that role unless the target is an admin (admins always outrank moderators).
|
||||
- Admins can disable public registration from `/admin/settings`; when disabled, the “Sign up” button in the auth modal is replaced with a tooltip explaining that registration is closed, but `/admin/users` remains fully functional.
|
||||
- Registration can also be enforced via `APP_ALLOW_REGISTRATION=0/1` in the environment; the DB setting syncs on each Symfony boot, so flips take effect after the next restart.
|
||||
|
||||
## Password changes
|
||||
- On `/profile`, users can change email/display name.
|
||||
- To set a new password, the current password must be provided.
|
||||
|
||||
## Logout
|
||||
- `/logout` (link in user menu).
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Spotify Integration
|
||||
|
||||
## Credentials
|
||||
- Prefer configuring via `/admin/settings` (stored in DB).
|
||||
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
|
||||
|
||||
## API client
|
||||
- `src/Service/SpotifyClient.php`
|
||||
- Client Credentials token fetch (cached)
|
||||
- `searchAlbums(q, limit)`
|
||||
- `getAlbum(id)` / `getAlbums([ids])`
|
||||
- `getAlbumWithTracks(id)` fetches metadata plus a hydrated tracklist
|
||||
- `getAlbumTracks(id)` provides the raw paginated track payload when needed
|
||||
|
||||
## Advanced search
|
||||
- The search page builds Spotify fielded queries:
|
||||
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
||||
- Optional free-text added to the query
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Reviews & Albums
|
||||
|
||||
## Album page
|
||||
- Shows album artwork, metadata, average rating and review count.
|
||||
- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
|
||||
- Lists reviews newest-first.
|
||||
- Logged-in users can submit a review inline.
|
||||
|
||||
## Permissions
|
||||
- Anyone can view.
|
||||
- Authors can edit/delete their own reviews.
|
||||
- Moderators and admins can edit/delete any review or user-created album.
|
||||
|
||||
## UI
|
||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
||||
|
||||
## Demo data
|
||||
- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately.
|
||||
- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity.
|
||||
- Use `--only-empty` when you want to focus on albums that currently have no reviews.
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Cannot find template or routes
|
||||
- Clear cache: `docker compose exec php php bin/console cache:clear`
|
||||
- List routes: `docker compose exec php php bin/console debug:router`
|
||||
|
||||
## Missing vendors
|
||||
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
|
||||
|
||||
## .env not read in container
|
||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||
|
||||
## Login modal shows blank
|
||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `06-admin-and-settings.md` to grant roles.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## Site settings (ROLE_ADMIN)
|
||||
- URL: `/admin/settings`
|
||||
- Form persists Spotify Client ID/Secret in the DB (no restart needed).
|
||||
- Form persists Last.fm API key in the DB (no restart needed).
|
||||
- Toggle “Allow self-service registration” to pause public sign-ups while keeping `/admin/users` creation available to staff.
|
||||
- The setting syncs with the `APP_ALLOW_REGISTRATION` environment variable each time Symfony boots (change the env value and restart to enforce). UI changes persist while the process runs.
|
||||
- CSRF + role guards prevent unauthorized updates.
|
||||
@@ -42,4 +42,7 @@ docker compose exec php php bin/console app:promote-moderator user@example.com
|
||||
- `/settings` provides a dark/light mode toggle.
|
||||
- Preference saved in a cookie; applied via `data-bs-theme`.
|
||||
|
||||
|
||||
## Useful tips
|
||||
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
|
||||
- Changing Last.fm credentials in settings is effective immediately; no restart is required.
|
||||
- Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Architecture
|
||||
|
||||
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
|
||||
|
||||
## Naming & reusability standards (PHP)
|
||||
|
||||
- **Classes**
|
||||
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
|
||||
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `LastFmClient`, `LastFmMetadataRefresher`).
|
||||
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
|
||||
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
|
||||
|
||||
- **Methods**
|
||||
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllExternalAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
|
||||
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isExternalConfigured()`).
|
||||
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
|
||||
|
||||
- **Variables & parameters**
|
||||
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$lastfmAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
|
||||
- Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`).
|
||||
- Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`).
|
||||
|
||||
- **Files & namespaces**
|
||||
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
|
||||
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
|
||||
|
||||
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
|
||||
|
||||
## High-level flow
|
||||
1. Visitors search for albums (Last.fm) and view an album page
|
||||
2. Logged‑in users can write, edit, and delete reviews
|
||||
3. Moderators and admins can moderate content and manage users
|
||||
4. Admins configure site settings (Last.fm credentials, registration toggle)
|
||||
|
||||
## Layers & components
|
||||
|
||||
### Controllers (`src/Controller/*`)
|
||||
- `AlbumController` — search, album detail, inline review creation
|
||||
- `ReviewController` — view, edit, and delete reviews
|
||||
- `AccountController` — profile, password, and user settings pages
|
||||
- `Admin/*` — site dashboard, user management, and settings
|
||||
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
|
||||
|
||||
### Entities (`src/Entity/*`)
|
||||
- `User` — authentication principal and roles
|
||||
- `Album`, `AlbumTrack` — normalized album metadata and track list
|
||||
- `Review` — user‑authored review with rating and timestamps
|
||||
- `Setting` — key/value store for site configuration (e.g., Last.fm credentials)
|
||||
|
||||
### Repositories (`src/Repository/*`)
|
||||
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
|
||||
|
||||
### Forms (`src/Form/*`)
|
||||
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
|
||||
- Leverage Symfony validation constraints for robust server‑side validation
|
||||
|
||||
### Services (`src/Service/*`)
|
||||
- `LastFmClient` — Last.fm API integration with caching
|
||||
- `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data
|
||||
- `CatalogResetService` — admin action to reset/sync catalog state safely
|
||||
- `UploadStorage` — avatar/album image uploads and related file handling
|
||||
- `RegistrationToggle` — DB‑backed registration flag with env override
|
||||
|
||||
### Security (`config/packages/security.yaml`, `src/Security/*`)
|
||||
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
|
||||
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
|
||||
- Access control for `/admin/*` enforced via routes and controllers
|
||||
|
||||
### Views (`templates/*`)
|
||||
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
|
||||
- Auth modal in `templates/_partials/auth_modal.html.twig`
|
||||
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
|
||||
|
||||
### DTOs (`src/Dto/*`)
|
||||
- Simple data transfer objects for admin tables and search results
|
||||
|
||||
## Data & persistence
|
||||
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
|
||||
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
|
||||
|
||||
## Error handling & UX
|
||||
- 404 for missing albums
|
||||
- Flash messages for success/error on actions
|
||||
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
|
||||
|
||||
## Testing & tooling
|
||||
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
|
||||
- Web Profiler enabled in dev
|
||||
@@ -0,0 +1,48 @@
|
||||
# Authentication & Users
|
||||
|
||||
## Login & Registration (modal)
|
||||
- Login and sign‑up are handled in a Bootstrap modal.
|
||||
- AJAX submits keep users on the page; a successful login refreshes state.
|
||||
- Remember‑me cookie keeps users logged in across sessions.
|
||||
|
||||
## Roles & Permissions
|
||||
- `ROLE_USER` — default for registered users
|
||||
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
|
||||
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
|
||||
|
||||
Promotion (from your host):
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
|
||||
```
|
||||
|
||||
### Access flow
|
||||
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
|
||||
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
|
||||
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
|
||||
|
||||
## Public registration toggle
|
||||
- Toggle in UI: `/admin/settings` (stored in DB)
|
||||
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
|
||||
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
|
||||
|
||||
## User management (moderator+)
|
||||
- `/admin/users` lists accounts with album/review counts and actions:
|
||||
- Create accounts inline (does not affect the current session)
|
||||
- Delete users (guards prevent deleting self or administrators)
|
||||
- Admins can Promote/Demote Moderator on non‑admins
|
||||
|
||||
## Profiles & Passwords
|
||||
- `/account/profile`: update email and display name
|
||||
- `/account/password`: change password (requires current password)
|
||||
|
||||
## Demo accounts & avatars
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
|
||||
```
|
||||
|
||||
## Logout
|
||||
- Link in the user menu calls `/logout` (handled by Symfony security).
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Deployment
|
||||
|
||||
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
|
||||
|
||||
## Build (locally)
|
||||
```bash
|
||||
docker build \
|
||||
--target=prod \
|
||||
-t tonehaus-app:latest \
|
||||
-f docker/php/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
## Run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tonehaus \
|
||||
-p 8080:8080 \
|
||||
-e APP_ENV=prod \
|
||||
-e APP_SECRET=change_me \
|
||||
-e LASTFM_API_KEY=your_api_key \
|
||||
tonehaus-app:latest
|
||||
```
|
||||
|
||||
### Notes
|
||||
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
|
||||
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
|
||||
- Cache warmup is executed on boot; `APP_SECRET` is required
|
||||
|
||||
## Persistence options
|
||||
### SQLite (default)
|
||||
- Data file at `var/data/database.sqlite`
|
||||
- Use a volume for durability:
|
||||
```bash
|
||||
docker run -d \
|
||||
-v tonehaus_sqlite:/var/www/html/var/data \
|
||||
...
|
||||
```
|
||||
|
||||
### Postgres
|
||||
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
|
||||
```
|
||||
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
|
||||
```
|
||||
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
|
||||
```bash
|
||||
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV` (`prod` recommended in production)
|
||||
- `APP_SECRET` (required; random string)
|
||||
- `LASTFM_API_KEY`
|
||||
- `APP_ALLOW_REGISTRATION` (env override for public registration)
|
||||
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
|
||||
- `DATABASE_URL` (when using Postgres)
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START` (default `1`)
|
||||
|
||||
## Reverse proxy / TLS
|
||||
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
|
||||
- Terminate TLS at the proxy and forward to the container’s port 8080
|
||||
- Ensure proxy sends `X-Forwarded-*` headers
|
||||
|
||||
## Zero‑downtime tips
|
||||
- Build then run a new container alongside the old one, switch traffic at the proxy
|
||||
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Features
|
||||
|
||||
## Albums & Reviews
|
||||
- Last.fm album search with advanced filters (album, artist, year range)
|
||||
- Album page: cover art, metadata, full tracklist (when available)
|
||||
- Reviews list (newest first) and inline new review form (logged-in)
|
||||
- Rating slider (1–10) with live badge
|
||||
- Per‑album aggregates: average rating and total review count
|
||||
|
||||
## Authentication & Users
|
||||
- Bootstrap auth modal for login/sign-up with AJAX submits
|
||||
- Remember‑me cookie keeps users signed in
|
||||
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
|
||||
- Profile: update email, display name, and password (requires current password)
|
||||
|
||||
## Administration
|
||||
- Dashboard: latest reviews/albums and key counts (moderator+)
|
||||
- Users: create/delete users, promote/demote moderators (admin constraints)
|
||||
- Settings: manage Last.fm credentials, toggle public registration (admin)
|
||||
|
||||
## Design & UX
|
||||
- Responsive Bootstrap UI
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- CSRF protection on forms
|
||||
- Access control via role hierarchy and security voters
|
||||
|
||||
## Screenshots (placeholders)
|
||||
- Search page — `docs/img/search.png` (optional)
|
||||
- Album page — `docs/img/album.png` (optional)
|
||||
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Last.fm Integration
|
||||
|
||||
## Credentials
|
||||
- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required).
|
||||
- Env fallback: `LASTFM_API_KEY`.
|
||||
|
||||
## API Client
|
||||
- `src/Service/LastFmClient.php` (uses Guzzle)
|
||||
- `searchAlbums(query, limit)`
|
||||
- `getAlbum(id)` / `getAlbums([ids])`
|
||||
- `getAlbumWithTracks(id)`
|
||||
- `getAlbumTracks(id)`
|
||||
|
||||
## Caching & Limits
|
||||
- Last.fm responses are cached in Symfony cache pools to reduce repeated calls.
|
||||
- Search/detail payloads are normalized to one internal album shape before persistence.
|
||||
|
||||
## Ingestion Flow
|
||||
1. Search Last.fm
|
||||
2. Resolve tags/genres via `LastFmTagResolver`
|
||||
3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()`
|
||||
4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()`
|
||||
|
||||
## Admin Settings
|
||||
- Set the Last.fm API key in `/admin/settings`.
|
||||
- Key changes apply immediately.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Reviews & Albums
|
||||
|
||||
## Album page
|
||||
- Artwork, metadata, average rating, and review count
|
||||
- Full Last.fm tracklist when available
|
||||
- Reviews list (newest first)
|
||||
- Inline new review form for logged‑in users
|
||||
|
||||
## Writing a review
|
||||
- Rating slider from 1–10
|
||||
- Title (max 160 chars) and body (20–5000 chars)
|
||||
- Server-side validation provides inline errors on failure
|
||||
- Successful submissions persist, flash a success message, and reload the album page
|
||||
|
||||
## Editing & deleting reviews
|
||||
- Authors can edit/delete their own reviews
|
||||
- Moderators/Admins can edit/delete any review
|
||||
- CSRF protection is required for deletion
|
||||
|
||||
## Aggregates
|
||||
- The album page computes:
|
||||
- Total number of reviews for the album
|
||||
- Average rating rounded to one decimal
|
||||
|
||||
## Demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
- Use `--only-empty` to focus on albums that currently have no reviews.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Setup
|
||||
|
||||
## Prerequisites
|
||||
- Docker + Docker Compose
|
||||
- Last.fm API key
|
||||
- A unique `APP_SECRET` value in your environment (for prod builds)
|
||||
|
||||
## 1) Start the stack
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
App: `http://localhost:8085`
|
||||
Health: `http://localhost:8085/healthz`
|
||||
|
||||
## 2) Create an admin
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## 3) Configure Last.fm
|
||||
- Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB)
|
||||
- Env fallback (in `.env` or your shell):
|
||||
```bash
|
||||
LASTFM_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
## 4) JWT auth keys
|
||||
- API login endpoint: `POST /api/v1/auth/login`
|
||||
- JWT keys are generated automatically on container start into `config/jwt/`.
|
||||
- Set `JWT_PASSPHRASE` in `.env` to your production secret.
|
||||
|
||||
## 5) (Optional) Seed demo data
|
||||
```bash
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||
```
|
||||
|
||||
## Database drivers
|
||||
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
|
||||
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
|
||||
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
- `APP_ENV=dev|prod`
|
||||
- `APP_SECRET=<random_string>`
|
||||
- `LASTFM_API_KEY`
|
||||
- `JWT_PASSPHRASE=<random_string>`
|
||||
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
|
||||
- `DATABASE_DRIVER=sqlite|postgres`
|
||||
- `DATABASE_SQLITE_PATH` (optional)
|
||||
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
|
||||
|
||||
## Useful commands
|
||||
```bash
|
||||
# Symfony cache
|
||||
docker compose exec tonehaus php bin/console cache:clear
|
||||
|
||||
# Inspect routes
|
||||
docker compose exec tonehaus php bin/console debug:router
|
||||
|
||||
# Promote moderator
|
||||
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Cannot find template or routes
|
||||
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
|
||||
- List routes: `docker compose exec tonehaus php bin/console debug:router`
|
||||
|
||||
## Missing vendors
|
||||
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
|
||||
|
||||
## .env not read in container
|
||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||
|
||||
## Login modal shows blank
|
||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `admin-and-settings.md` to grant roles.
|
||||
|
||||
## SQLite file permissions
|
||||
- The default SQLite path is `var/data/database.sqlite`.
|
||||
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
|
||||
|
||||
## Postgres connection issues
|
||||
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
|
||||
- Example URL:
|
||||
```
|
||||
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||
```
|
||||
|
||||
## Last.fm errors
|
||||
- Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`.
|
||||
|
||||
## ARM64 Build
|
||||
|
||||
```bash
|
||||
sudo docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--target prod \
|
||||
-t tonehaus/tonehaus:dev-arm64 \
|
||||
-f docker/php/Dockerfile \
|
||||
. \
|
||||
--load
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Importmap configuration for the asset mapper.
|
||||
*
|
||||
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
|
||||
*/
|
||||
return [
|
||||
'app' => [
|
||||
'path' => './assets/app.js',
|
||||
'entrypoint' => true,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251205134500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add genres JSON column to albums for advanced search filters';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite cannot drop columns without rebuilding the table; leave as-is.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
|
||||
$this->addSql('ALTER TABLE albums DROP genres');
|
||||
}
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
|
||||
{
|
||||
if (!$schema->hasTable($tableName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$schema->getTable($tableName)->hasColumn($column);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260227120500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
if ($this->shouldAddColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'");
|
||||
if (!$this->isSqlite()) {
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)');
|
||||
}
|
||||
|
||||
if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
if ($this->isSqlite()) {
|
||||
// SQLite column drops require table rebuilds; keep schema forward-only here.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
|
||||
$this->addSql('DROP INDEX uniq_users_oauth_identity');
|
||||
}
|
||||
|
||||
if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
|
||||
$this->addSql('DROP INDEX uniq_album_external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_id')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'users', 'oauth_provider')) {
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN oauth_provider');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
|
||||
$this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'external_id')) {
|
||||
$this->addSql('ALTER TABLE albums DROP COLUMN external_id');
|
||||
}
|
||||
|
||||
if ($this->hasColumn($schema, 'albums', 'source')) {
|
||||
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'");
|
||||
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'");
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName);
|
||||
}
|
||||
|
||||
private function hasTable(Schema $schema, string $tableName): bool
|
||||
{
|
||||
return $schema->hasTable($tableName);
|
||||
}
|
||||
|
||||
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
|
||||
}
|
||||
|
||||
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
|
||||
{
|
||||
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ class PromoteAdminCommand extends Command
|
||||
/**
|
||||
* Stores injected dependencies for later use.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -35,7 +38,7 @@ class PromoteAdminCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
$user = $this->userRepository->findOneByEmail($email);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||
return Command::FAILURE;
|
||||
@@ -45,7 +48,7 @@ class PromoteAdminCommand extends Command
|
||||
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
||||
$roles[] = 'ROLE_ADMIN';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
||||
|
||||
@@ -16,7 +16,10 @@ class PromoteModeratorCommand extends Command
|
||||
/**
|
||||
* Stores dependencies for the console handler.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -35,7 +38,7 @@ class PromoteModeratorCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
$user = $this->userRepository->findOneByEmail($email);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||
return Command::FAILURE;
|
||||
@@ -45,7 +48,7 @@ class PromoteModeratorCommand extends Command
|
||||
if (!in_array('ROLE_MODERATOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
||||
|
||||
@@ -20,6 +20,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-demo-albums',
|
||||
description: 'Create demo albums with randomized metadata for local development.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with synthetic user-sourced albums.
|
||||
*
|
||||
* - Always marked as "user" source with a unique localId.
|
||||
* - Include randomized names, artists, genres, release dates, and cover URLs.
|
||||
* - Optionally link to existing users as creators when --attach-users is set.
|
||||
*/
|
||||
class SeedDemoAlbumsCommand extends Command
|
||||
{
|
||||
private const GENRES = [
|
||||
@@ -59,11 +66,14 @@ class SeedDemoAlbumsCommand extends Command
|
||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
||||
|
||||
$created = 0;
|
||||
// Track generated localIds so we never attempt to persist obvious duplicates.
|
||||
$seenLocalIds = [];
|
||||
|
||||
while ($created < $count) {
|
||||
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
|
||||
$localId = $this->generateLocalId();
|
||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
||||
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -72,6 +82,7 @@ class SeedDemoAlbumsCommand extends Command
|
||||
$album->setLocalId($localId);
|
||||
$album->setName($this->generateAlbumName());
|
||||
$album->setArtists($this->generateArtists());
|
||||
$album->setGenres($this->generateGenres());
|
||||
$album->setReleaseDate($this->generateReleaseDate());
|
||||
$album->setTotalTracks(random_int(6, 16));
|
||||
$album->setCoverUrl($this->generateCoverUrl($localId));
|
||||
@@ -136,6 +147,19 @@ class SeedDemoAlbumsCommand extends Command
|
||||
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 generateCoverUrl(string $seed): string
|
||||
{
|
||||
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
||||
|
||||
@@ -21,6 +21,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-demo-reviews',
|
||||
description: 'Generate demo reviews across existing albums.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with demo reviews attached to existing albums and users.
|
||||
*
|
||||
* Controls:
|
||||
* - --cover-percent: roughly what percentage of albums receive reviews.
|
||||
* - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
|
||||
* - --only-empty: restricts seeding to albums that currently have no reviews.
|
||||
*
|
||||
* The command avoids:
|
||||
* - Creating multiple reviews from the same user on a single album.
|
||||
* - Touching albums/users when there is no suitable data to seed.
|
||||
*/
|
||||
class SeedDemoReviewsCommand extends Command
|
||||
{
|
||||
private const SUBJECTS = [
|
||||
@@ -59,6 +71,7 @@ class SeedDemoReviewsCommand extends Command
|
||||
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 = $this->albumRepository->findAll();
|
||||
$users = $this->userRepository->findAll();
|
||||
|
||||
@@ -67,14 +80,17 @@ class SeedDemoReviewsCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
|
||||
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
||||
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
||||
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
||||
|
||||
// Apply coverage and "only empty" filters before creating any Review entities. (filter)
|
||||
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
||||
$onlyEmpty = (bool) $input->getOption('only-empty');
|
||||
|
||||
$created = 0;
|
||||
// Count how many albums actually received new reviews for clearer operator feedback. (count)
|
||||
$processedAlbums = 0;
|
||||
foreach ($selectedAlbums as $album) {
|
||||
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
||||
@@ -107,6 +123,7 @@ class SeedDemoReviewsCommand extends Command
|
||||
return $albums;
|
||||
}
|
||||
|
||||
// Randomly sample albums until the requested coverage threshold is met.
|
||||
$selected = [];
|
||||
foreach ($albums as $album) {
|
||||
if (random_int(1, 100) <= $coverPercent) {
|
||||
@@ -114,6 +131,7 @@ class SeedDemoReviewsCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we always seed at least one album when any albums exist.
|
||||
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
||||
}
|
||||
|
||||
@@ -124,17 +142,22 @@ class SeedDemoReviewsCommand extends Command
|
||||
{
|
||||
$created = 0;
|
||||
$existingAuthors = $this->fetchExistingAuthors($album);
|
||||
// Filter out users who have already reviewed this album so we only ever
|
||||
// create one review per (album, author) pair.
|
||||
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
||||
|
||||
if ($availableUsers === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Limit requested reviews to the number of eligible authors, then randomly
|
||||
// choose a stable subset for this run.
|
||||
$targetReviews = min($targetReviews, count($availableUsers));
|
||||
shuffle($availableUsers);
|
||||
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
||||
|
||||
foreach ($selectedUsers as $user) {
|
||||
// Prevent duplicate reviews per author by only iterating over filtered unique users.
|
||||
$review = new Review();
|
||||
$review->setAlbum($album);
|
||||
$review->setAuthor($user);
|
||||
@@ -154,6 +177,8 @@ class SeedDemoReviewsCommand extends Command
|
||||
*/
|
||||
private function fetchExistingAuthors(Album $album): array
|
||||
{
|
||||
// Fetch all distinct author IDs that have already reviewed this album so we
|
||||
// can cheaply check for duplicates in PHP without loading full Review objects.
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(r.author) AS authorId')
|
||||
->from(Review::class, 'r')
|
||||
|
||||
@@ -19,6 +19,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
name: 'app:seed-demo-users',
|
||||
description: 'Create demo users with random emails and display names.'
|
||||
)]
|
||||
/**
|
||||
* Seeds the database with demo users for local development and testing.
|
||||
*
|
||||
* - Generates unique, non-conflicting demo email addresses.
|
||||
* - Assigns a predictable default password (overridable via --password).
|
||||
* - Creates users with a single ROLE_USER role.
|
||||
*/
|
||||
class SeedDemoUsersCommand extends Command
|
||||
{
|
||||
private const FIRST_NAMES = [
|
||||
@@ -54,11 +61,15 @@ class SeedDemoUsersCommand extends Command
|
||||
$plainPassword = (string) $input->getOption('password');
|
||||
|
||||
$created = 0;
|
||||
// Track generated emails so we never attempt to persist obvious duplicates.
|
||||
$seenEmails = [];
|
||||
|
||||
while ($created < $count) {
|
||||
// Keep generating new tokens until we find an email that is unique
|
||||
// for both this run and the existing database.
|
||||
$email = $this->generateEmail();
|
||||
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
||||
// Collisions are rare but possible because we only randomize 8 hex chars; try again.
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
name: 'app:seed-user-avatars',
|
||||
description: 'Assign generated profile images to existing users.'
|
||||
)]
|
||||
/**
|
||||
* Seeds or refreshes user profile images using the DiceBear avatar API.
|
||||
*
|
||||
* - Skips users that already have an image unless --overwrite is provided.
|
||||
* - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
|
||||
* - Does not download or cache the avatars locally; URLs are stored directly.
|
||||
*/
|
||||
class SeedUserAvatarsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
@@ -54,6 +61,7 @@ class SeedUserAvatarsCommand extends Command
|
||||
continue;
|
||||
}
|
||||
if (!$overwrite && $user->getProfileImagePath()) {
|
||||
// Respect existing uploads unless the operator explicitly allows clobbering them.
|
||||
continue;
|
||||
}
|
||||
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
||||
@@ -73,7 +81,11 @@ class SeedUserAvatarsCommand extends Command
|
||||
|
||||
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
||||
{
|
||||
// Use a stable identifier (display name when present, email as fallback)
|
||||
// so the same user is always mapped to the same avatar for a given prefix.
|
||||
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
||||
// Combine prefix, identifier, and primary key into a deterministic hash
|
||||
// and trim it to a shorter seed value accepted by DiceBear.
|
||||
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
||||
|
||||
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
||||
|
||||
@@ -6,10 +6,10 @@ use App\Entity\User;
|
||||
use App\Form\ProfileFormType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Service\ImageStorage;
|
||||
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;
|
||||
@@ -76,36 +76,36 @@ class AccountController extends AbstractController
|
||||
* Allows users to update profile details and avatar.
|
||||
*/
|
||||
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ImageStorage $images): Response
|
||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$form = $this->createForm(ProfileFormType::class, $user);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if ($form->isSubmitted()) {
|
||||
$newPassword = (string) $form->get('newPassword')->getData();
|
||||
if ($newPassword !== '') {
|
||||
$current = (string) $form->get('currentPassword')->getData();
|
||||
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
||||
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
||||
return $this->render('account/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'profileImage' => $user->getProfileImagePath(),
|
||||
]);
|
||||
} else {
|
||||
// Allow password updates inside the same form submission instead of forcing a separate flow.
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
|
||||
$upload = $form->get('profileImage')->getData();
|
||||
if ($upload instanceof UploadedFile) {
|
||||
$images->remove($user->getProfileImagePath());
|
||||
$user->setProfileImagePath($images->storeProfileImage($upload));
|
||||
}
|
||||
if ($form->isValid()) {
|
||||
$upload = $form->get('profileImage')->getData();
|
||||
if ($upload instanceof UploadedFile) {
|
||||
$uploadStorage->remove($user->getProfileImagePath());
|
||||
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_profile');
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_profile');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('account/profile.html.twig', [
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Controller\Admin;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\LastFmMetadataRefresher;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -22,6 +24,7 @@ class DashboardController extends AbstractController
|
||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
||||
{
|
||||
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
|
||||
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->getQuery()->getSingleScalarResult();
|
||||
@@ -34,6 +37,7 @@ class DashboardController extends AbstractController
|
||||
->select('COUNT(u.id)')
|
||||
->getQuery()->getSingleScalarResult();
|
||||
|
||||
// Latest rows are pulled separately so the dashboard can show concrete activity.
|
||||
$recentReviews = $reviews->findLatest(50);
|
||||
$recentAlbums = $albums->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
@@ -48,4 +52,25 @@ class DashboardController extends AbstractController
|
||||
'recentAlbums' => $recentAlbums,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/dashboard/refresh-external', name: 'admin_dashboard_refresh_external', methods: ['POST'])]
|
||||
public function refreshExternal(
|
||||
Request $request,
|
||||
LastFmMetadataRefresher $refresher
|
||||
): 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();
|
||||
if ($updated === 0) {
|
||||
$this->addFlash('info', 'No external albums needed refresh or none are saved.');
|
||||
} else {
|
||||
$this->addFlash('success', sprintf('Refreshed %d external albums from Last.fm.', $updated));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\SiteSettingsType;
|
||||
use App\Repository\SettingRepository;
|
||||
use App\Service\CatalogResetService;
|
||||
use App\Service\CommandRunner;
|
||||
use App\Service\RegistrationToggle;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* SettingsController lets admins adjust key integration settings.
|
||||
@@ -17,23 +19,64 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
|
||||
private const DEMO_COMMANDS = [
|
||||
'users' => [
|
||||
'command' => 'app:seed-demo-users',
|
||||
'label' => 'Demo users',
|
||||
'description' => 'Creates demo accounts with randomized emails.',
|
||||
'fields' => [
|
||||
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||
['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'],
|
||||
],
|
||||
],
|
||||
'albums' => [
|
||||
'command' => 'app:seed-demo-albums',
|
||||
'label' => 'Demo albums',
|
||||
'description' => 'Creates user albums with randomized metadata.',
|
||||
'fields' => [
|
||||
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40],
|
||||
['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true],
|
||||
],
|
||||
],
|
||||
'reviews' => [
|
||||
'command' => 'app:seed-demo-reviews',
|
||||
'label' => 'Demo reviews',
|
||||
'description' => 'Adds sample reviews for existing albums.',
|
||||
'fields' => [
|
||||
['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||
['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1],
|
||||
['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3],
|
||||
['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
'avatars' => [
|
||||
'command' => 'app:seed-user-avatars',
|
||||
'label' => 'Profile pictures',
|
||||
'description' => 'Assigns generated avatars to users (skips existing).',
|
||||
'fields' => [
|
||||
['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'],
|
||||
['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Displays and persists Spotify credential settings.
|
||||
* Displays and persists Last.fm credential settings.
|
||||
*/
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||
public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): 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'));
|
||||
$registrationOverride = $registrationToggle->envOverride();
|
||||
$form->get('LASTFM_API_KEY')->setData($settings->getValue('LASTFM_API_KEY'));
|
||||
$registrationOverride = $registrationToggle->getEnvOverride();
|
||||
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
||||
$settings->setValue('LASTFM_API_KEY', (string) $form->get('LASTFM_API_KEY')->getData());
|
||||
if ($registrationOverride === null) {
|
||||
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
|
||||
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
|
||||
} else {
|
||||
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
|
||||
@@ -46,6 +89,81 @@ class SettingsController extends AbstractController
|
||||
'form' => $form->createView(),
|
||||
'registrationImmutable' => $registrationOverride !== null,
|
||||
'registrationOverrideValue' => $registrationOverride,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use App\Form\AdminUserType;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -21,8 +21,8 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -37,13 +37,14 @@ class UserController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Form collects only high-level metadata; everything else is defaulted here.
|
||||
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||
$newUser = new User();
|
||||
$newUser->setEmail($formData->email);
|
||||
$newUser->setDisplayName($formData->displayName);
|
||||
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
|
||||
$this->em->persist($newUser);
|
||||
$this->em->flush();
|
||||
$newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
|
||||
$this->entityManager->persist($newUser);
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User account created.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
@@ -67,6 +68,7 @@ class UserController extends AbstractController
|
||||
/** @var User|null $current */
|
||||
$current = $this->getUser();
|
||||
if ($current && $target->getId() === $current->getId()) {
|
||||
// Protect against accidental lockouts by blocking self-deletes.
|
||||
$this->addFlash('danger', 'You cannot delete your own account.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
@@ -76,8 +78,8 @@ class UserController extends AbstractController
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
$this->em->remove($target);
|
||||
$this->em->flush();
|
||||
$this->entityManager->remove($target);
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User deleted.');
|
||||
|
||||
return $this->redirectToRoute('admin_users');
|
||||
@@ -102,14 +104,15 @@ class UserController extends AbstractController
|
||||
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
|
||||
|
||||
if ($isModerator) {
|
||||
// Toggle-style UX: hitting the endpoint again demotes the moderator.
|
||||
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
||||
$target->setRoles($filtered);
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'Moderator privileges removed.');
|
||||
} else {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$target->setRoles(array_values(array_unique($roles)));
|
||||
$this->em->flush();
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'User promoted to moderator.');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Service\SpotifyClient;
|
||||
use App\Service\UploadStorage;
|
||||
use App\Service\LastFmClient;
|
||||
use App\Service\LastFmTagResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
@@ -21,7 +22,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* AlbumController orchestrates search, CRUD, and review entry on albums.
|
||||
@@ -29,14 +30,15 @@ use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
class AlbumController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImageStorage $imageStorage,
|
||||
private readonly UploadStorage $uploadStorage,
|
||||
private readonly AlbumSearchService $albumSearch,
|
||||
private readonly LastFmTagResolver $genreResolver,
|
||||
private readonly int $searchLimit = 20
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches Spotify plus local albums and decorates results with review stats.
|
||||
* Searches external plus local albums and decorates results with review stats.
|
||||
*/
|
||||
#[Route('/', name: 'album_search', methods: ['GET'])]
|
||||
public function search(Request $request): Response
|
||||
@@ -48,12 +50,14 @@ class AlbumController extends AbstractController
|
||||
'query' => $criteria->query,
|
||||
'album' => $criteria->albumName,
|
||||
'artist' => $criteria->artist,
|
||||
'genre' => $criteria->genre,
|
||||
'year_from' => $criteria->yearFrom ?? '',
|
||||
'year_to' => $criteria->yearTo ?? '',
|
||||
'albums' => $result->albums,
|
||||
'stats' => $result->stats,
|
||||
'savedIds' => $result->savedIds,
|
||||
'source' => $criteria->source,
|
||||
'externalConfigured' => $this->albumSearch->isExternalConfigured(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,7 +73,7 @@ class AlbumController extends AbstractController
|
||||
$form = $this->createForm(AlbumType::class, $album);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$this->normalizeAlbumFormData($album);
|
||||
$user = $this->getUser();
|
||||
if ($user instanceof User) {
|
||||
$album->setCreatedBy($user);
|
||||
@@ -90,23 +94,26 @@ class AlbumController extends AbstractController
|
||||
* Renders a detailed album view plus inline review form.
|
||||
*/
|
||||
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
|
||||
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
public function show(string $id, Request $request, LastFmClient $lastFmClient, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||
$isSaved = $albumEntity !== null;
|
||||
if (!$albumEntity) {
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
// Album has never been saved locally, so hydrate it via Last.fm before rendering.
|
||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
||||
if ($externalAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
} else {
|
||||
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||
if ($this->syncExternalTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $lastFmClient)) {
|
||||
// Track sync mutated the entity: persist before we build template arrays.
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
$albumCard = $albumEntity->toTemplateArray();
|
||||
$albumCard = $this->hydrateExternalAlbumImages($albumCard, $albumEntity, $lastFmClient);
|
||||
$canManage = $this->canManageAlbum($albumEntity);
|
||||
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
|
||||
|
||||
@@ -150,23 +157,23 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a Spotify album locally for quicker access.
|
||||
* Saves an external album locally for quicker access.
|
||||
*/
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
|
||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
public function save(string $id, Request $request, LastFmClient $lastFmClient, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$token = (string) $request->request->get('_token');
|
||||
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||
$existing = $albumRepo->findOneByExternalId($id);
|
||||
if (!$existing) {
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
||||
if ($externalAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album saved.');
|
||||
} else {
|
||||
@@ -190,7 +197,7 @@ class AlbumController extends AbstractController
|
||||
if ($album) {
|
||||
$this->ensureCanManageAlbum($album);
|
||||
if ($album->getSource() === 'user') {
|
||||
$this->imageStorage->remove($album->getCoverImagePath());
|
||||
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||
}
|
||||
$em->remove($album);
|
||||
$em->flush();
|
||||
@@ -235,6 +242,7 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
// Fallback: attempt to parse
|
||||
try {
|
||||
// Trust PHP's parser only as a last resort (it accepts many human formats).
|
||||
$dt = new \DateTimeImmutable($s);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
@@ -256,10 +264,9 @@ class AlbumController extends AbstractController
|
||||
$this->ensureCanManageAlbum($album);
|
||||
|
||||
$form = $this->createForm(AlbumType::class, $album);
|
||||
$form->get('artistsCsv')->setData(implode(', ', $album->getArtists()));
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$this->normalizeAlbumFormData($album);
|
||||
$this->handleAlbumCoverUpload($album, $form);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album updated.');
|
||||
@@ -272,7 +279,7 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an album by either local or Spotify identifier.
|
||||
* Looks up an album by either local or external identifier.
|
||||
*/
|
||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||
{
|
||||
@@ -281,7 +288,7 @@ class AlbumController extends AbstractController
|
||||
return $local;
|
||||
}
|
||||
|
||||
return $albumRepo->findOneBySpotifyId($id);
|
||||
return $albumRepo->findOneByExternalId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,26 +321,11 @@ class AlbumController extends AbstractController
|
||||
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies normalized metadata from the album form.
|
||||
*/
|
||||
private function applyAlbumFormData(Album $album, FormInterface $form): void
|
||||
private function normalizeAlbumFormData(Album $album): void
|
||||
{
|
||||
$album->setArtists($this->parseArtistsCsv((string) $form->get('artistsCsv')->getData()));
|
||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the artists CSV input into a normalized list.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseArtistsCsv(string $csv): array
|
||||
{
|
||||
$parts = array_map(static fn($s) => trim((string) $s), explode(',', $csv));
|
||||
return array_values(array_filter($parts, static fn($s) => $s !== ''));
|
||||
}
|
||||
|
||||
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
|
||||
{
|
||||
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
|
||||
@@ -341,18 +333,24 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
$file = $form->get('coverUpload')->getData();
|
||||
if ($file instanceof UploadedFile) {
|
||||
$this->imageStorage->remove($album->getCoverImagePath());
|
||||
$album->setCoverImagePath($this->imageStorage->storeAlbumCover($file));
|
||||
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
* @param array<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||
private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||
{
|
||||
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
// 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 = $externalAlbum['tracks']['items'] ?? [];
|
||||
if (is_array($tracks) && $tracks !== []) {
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
@@ -360,29 +358,33 @@ class AlbumController extends AbstractController
|
||||
return $album;
|
||||
}
|
||||
|
||||
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
|
||||
private function syncExternalTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, LastFmClient $lastFmClient): bool
|
||||
{
|
||||
if ($album->getSource() !== 'spotify') {
|
||||
if ($album->getSource() !== 'external') {
|
||||
return false;
|
||||
}
|
||||
$spotifyId = $album->getSpotifyId();
|
||||
if ($spotifyId === null) {
|
||||
$externalId = $album->getExternalId();
|
||||
if ($externalId === null) {
|
||||
return false;
|
||||
}
|
||||
$storedCount = $album->getTracks()->count();
|
||||
$needsSync = $storedCount === 0;
|
||||
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
||||
// external track counts do not match what we have stored; re-sync to avoid stale data.
|
||||
$needsSync = true;
|
||||
}
|
||||
if (!$needsSync) {
|
||||
return false;
|
||||
}
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
|
||||
if ($spotifyAlbum === null) {
|
||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($externalId);
|
||||
if ($externalAlbum === null) {
|
||||
return false;
|
||||
}
|
||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
// Rehydrate genres during syncs as well, in case source metadata has updated.
|
||||
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||
$albumGenres = $genresMap[$externalId] ?? [];
|
||||
$albumRepo->upsertFromExternalAlbum($externalAlbum, $albumGenres);
|
||||
$tracks = $externalAlbum['tracks']['items'] ?? [];
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
@@ -391,6 +393,32 @@ class AlbumController extends AbstractController
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function hydrateExternalAlbumImages(array $payload, Album $album, LastFmClient $lastFmClient): array
|
||||
{
|
||||
if ($album->getSource() !== 'external') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$externalId = trim((string) $album->getExternalId());
|
||||
if ($externalId === '') {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$fresh = $lastFmClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($fresh)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$images = $fresh['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$payload['images'] = $images;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use App\Dto\AlbumSearchCriteria;
|
||||
use App\Entity\Album;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\LastFmClient;
|
||||
use App\Service\LastFmTagResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||
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 AbstractFOSRestController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AlbumSearchService $albumSearchService,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly AlbumTrackRepository $trackRepository,
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly LastFmClient $lastFmClient,
|
||||
private readonly LastFmTagResolver $tagResolver,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly int $searchLimit = 20,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||
$result = $this->albumSearchService->search($criteria);
|
||||
|
||||
return $this->handleView($this->view([
|
||||
'meta' => [
|
||||
'source' => $criteria->source,
|
||||
'limit' => $criteria->limit,
|
||||
'query' => $criteria->query,
|
||||
],
|
||||
'data' => $result->albums,
|
||||
'stats' => $result->stats,
|
||||
'saved_ids' => $result->savedIds,
|
||||
], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function ingest(Request $request): Response
|
||||
{
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$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->lastFmClient->searchAlbums($query, 1);
|
||||
$externalId = trim((string) ($search['albums']['items'][0]['id'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
if ($externalId === '') {
|
||||
return $this->handleView($this->view(['error' => 'external_id_or_query_required'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$externalAlbum = $this->lastFmClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($externalAlbum)) {
|
||||
return $this->handleView($this->view(['error' => 'external_album_not_found'], Response::HTTP_NOT_FOUND));
|
||||
}
|
||||
|
||||
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||
$this->entityManager->flush();
|
||||
$payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album, $externalAlbum);
|
||||
|
||||
return $this->handleView($this->view([
|
||||
'data' => $payload,
|
||||
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||
], Response::HTTP_CREATED));
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])]
|
||||
public function show(string $id): Response
|
||||
{
|
||||
$album = $this->findAlbum($id);
|
||||
if (!$album instanceof Album) {
|
||||
$externalAlbum = $this->lastFmClient->getAlbumWithTracks($id);
|
||||
if (!is_array($externalAlbum)) {
|
||||
return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND));
|
||||
}
|
||||
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$albumId = $album->getSource() === 'user' ? (string) $album->getLocalId() : (string) $album->getExternalId();
|
||||
$stats = $album->getSource() === 'user'
|
||||
? $this->reviewRepository->getAggregatesForAlbumEntityIds([(int) $album->getId()])
|
||||
: $this->reviewRepository->getAggregatesForAlbumIds([$albumId]);
|
||||
$payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album);
|
||||
|
||||
return $this->handleView($this->view([
|
||||
'data' => $payload,
|
||||
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||
'stats' => $album->getSource() === 'user'
|
||||
? ($stats[(int) $album->getId()] ?? ['count' => 0, 'avg' => 0.0])
|
||||
: ($stats[$albumId] ?? ['count' => 0, 'avg' => 0.0]),
|
||||
], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])]
|
||||
public function reviews(string $id): Response
|
||||
{
|
||||
$album = $this->findAlbum($id);
|
||||
if (!$album instanceof Album) {
|
||||
return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND));
|
||||
}
|
||||
|
||||
$reviews = $this->reviewRepository->findBy(['album' => $album], ['createdAt' => 'DESC']);
|
||||
return $this->handleView($this->view([
|
||||
'data' => array_map(static fn($review) => [
|
||||
'id' => $review->getId(),
|
||||
'title' => $review->getTitle(),
|
||||
'content' => $review->getContent(),
|
||||
'rating' => $review->getRating(),
|
||||
'author' => [
|
||||
'id' => $review->getAuthor()?->getId(),
|
||||
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||
],
|
||||
'created_at' => $review->getCreatedAt()?->format(DATE_ATOM),
|
||||
'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM),
|
||||
], $reviews),
|
||||
], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
private function findAlbum(string $id): ?Album
|
||||
{
|
||||
$local = $this->albumRepository->findOneByLocalId($id);
|
||||
if ($local instanceof Album) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
return $this->albumRepository->findOneByExternalId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $externalAlbum
|
||||
*/
|
||||
private function persistExternalAlbumPayload(array $externalAlbum): Album
|
||||
{
|
||||
$genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||
|
||||
$album = $this->albumRepository->upsertFromExternalAlbum(
|
||||
$externalAlbum,
|
||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||
);
|
||||
|
||||
$tracks = $externalAlbum['tracks']['items'] ?? [];
|
||||
if (is_array($tracks) && $tracks !== []) {
|
||||
$this->trackRepository->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
}
|
||||
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function readJsonPayload(Request $request): ?array
|
||||
{
|
||||
$content = trim($request->getContent());
|
||||
if ($content === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $request->toArray();
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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->lastFmClient->getAlbumWithTracks($externalId);
|
||||
if (!is_array($resolved)) {
|
||||
return $payload;
|
||||
}
|
||||
$fresh = $resolved;
|
||||
}
|
||||
|
||||
$images = $fresh['images'] ?? null;
|
||||
if (is_array($images) && $images !== []) {
|
||||
$payload['images'] = $images;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/v1')]
|
||||
class ApiIndexController extends AbstractFOSRestController
|
||||
{
|
||||
#[Route('', name: 'api_v1_index', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->handleView($this->view([
|
||||
'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)',
|
||||
],
|
||||
],
|
||||
], Response::HTTP_OK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\V1;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/v1/reviews')]
|
||||
class ReviewApiController extends AbstractFOSRestController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$limit = max(1, min(100, (int) $request->query->get('limit', 50)));
|
||||
$reviews = $this->reviewRepository->findLatest($limit);
|
||||
|
||||
return $this->handleView($this->view([
|
||||
'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews),
|
||||
], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
public function show(Review $review): Response
|
||||
{
|
||||
return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('', name: 'api_v1_review_create', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$albumId = trim((string) ($payload['album_id'] ?? ''));
|
||||
$album = $this->resolveAlbum($albumId);
|
||||
if (!$album instanceof Album) {
|
||||
return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND));
|
||||
}
|
||||
|
||||
$title = trim((string) ($payload['title'] ?? ''));
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$rating = (int) ($payload['rating'] ?? 0);
|
||||
|
||||
if ($title === '' || $content === '' || $rating < 1 || $rating > 10) {
|
||||
return $this->handleView($this->view(['error' => 'title_content_rating_required'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
if (mb_strlen($content) < 20) {
|
||||
return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
$author = $this->getUser();
|
||||
if (!$author instanceof User) {
|
||||
return $this->handleView($this->view(['error' => 'authentication_required'], Response::HTTP_UNAUTHORIZED));
|
||||
}
|
||||
|
||||
$review = new Review();
|
||||
$review->setAlbum($album);
|
||||
$review->setAuthor($author);
|
||||
$review->setTitle($title);
|
||||
$review->setContent($content);
|
||||
$review->setRating($rating);
|
||||
|
||||
$this->entityManager->persist($review);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_CREATED));
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function update(Review $review, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
|
||||
|
||||
$payload = $this->readJsonPayload($request);
|
||||
if ($payload === null) {
|
||||
return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
|
||||
if (array_key_exists('title', $payload)) {
|
||||
$title = trim((string) $payload['title']);
|
||||
if ($title === '') {
|
||||
return $this->handleView($this->view(['error' => 'title_cannot_be_empty'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
$review->setTitle($title);
|
||||
}
|
||||
|
||||
if (array_key_exists('content', $payload)) {
|
||||
$content = trim((string) $payload['content']);
|
||||
if ($content === '') {
|
||||
return $this->handleView($this->view(['error' => 'content_cannot_be_empty'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
if (mb_strlen($content) < 20) {
|
||||
return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
$review->setContent($content);
|
||||
}
|
||||
|
||||
if (array_key_exists('rating', $payload)) {
|
||||
$rating = (int) $payload['rating'];
|
||||
if ($rating < 1 || $rating > 10) {
|
||||
return $this->handleView($this->view(['error' => 'rating_out_of_range'], Response::HTTP_BAD_REQUEST));
|
||||
}
|
||||
$review->setRating($rating);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function delete(Review $review): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
||||
|
||||
$this->entityManager->remove($review);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->handleView($this->view(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function serializeReview(Review $review): array
|
||||
{
|
||||
$album = $review->getAlbum();
|
||||
|
||||
return [
|
||||
'id' => $review->getId(),
|
||||
'title' => $review->getTitle(),
|
||||
'content' => $review->getContent(),
|
||||
'rating' => $review->getRating(),
|
||||
'created_at' => $review->getCreatedAt()?->format(DATE_ATOM),
|
||||
'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM),
|
||||
'author' => [
|
||||
'id' => $review->getAuthor()?->getId(),
|
||||
'email' => $review->getAuthor()?->getEmail(),
|
||||
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||
],
|
||||
'album' => [
|
||||
'id' => $album?->getSource() === 'user' ? $album?->getLocalId() : $album?->getExternalId(),
|
||||
'name' => $album?->getName(),
|
||||
'source' => $album?->getSource(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveAlbum(string $albumId): ?Album
|
||||
{
|
||||
if ($albumId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$local = $this->albumRepository->findOneByLocalId($albumId);
|
||||
if ($local instanceof Album) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
return $this->albumRepository->findOneByExternalId($albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function readJsonPayload(Request $request): ?array
|
||||
{
|
||||
$content = trim($request->getContent());
|
||||
if ($content === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $request->toArray();
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ use App\Entity\Review;
|
||||
use App\Form\ReviewType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* ReviewController funnels CRUD flows through album pages and simple routes.
|
||||
* ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow.
|
||||
*/
|
||||
#[Route('/reviews')]
|
||||
class ReviewController extends AbstractController
|
||||
@@ -28,7 +28,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects users to the album flow when starting a review.
|
||||
* Nudges users back to the album flow so new reviews always start from a specific album.
|
||||
*/
|
||||
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
@@ -43,7 +43,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a standalone review page.
|
||||
* Shows a read-only page for a single review.
|
||||
*/
|
||||
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
public function show(Review $review): Response
|
||||
@@ -54,7 +54,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles review form edits with authorization.
|
||||
* Handles review edits by running the voter, binding the form, and saving valid updates.
|
||||
*/
|
||||
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
@@ -78,7 +78,7 @@ class ReviewController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a review when the CSRF token and permission check pass.
|
||||
* Deletes a review after both the CSRF token and voter checks pass.
|
||||
*/
|
||||
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
|
||||
@@ -5,17 +5,24 @@ namespace App\Dto;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AdminUserData transports user creation input from the admin form.
|
||||
* This is a Data Transfer Object to avoid direct entity manipulation.
|
||||
* AdminUserData carries the lightweight fields needed when admins create or edit
|
||||
* users from the back office without touching the `User` entity directly.
|
||||
* Used to allow user creation in the user management panel without invalidating active token.
|
||||
* (This took too long to figure out)
|
||||
|
||||
*/
|
||||
class AdminUserData
|
||||
{
|
||||
/**
|
||||
* Email address for the managed user.
|
||||
*/
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Optional public display name.
|
||||
*/
|
||||
#[Assert\Length(max: 120)]
|
||||
public ?string $displayName = null;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,35 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
*/
|
||||
final class AlbumSearchCriteria
|
||||
{
|
||||
/** Free-form query that mixes album, artist, and keyword matches. */
|
||||
public readonly string $query;
|
||||
|
||||
/** Explicit album title filter supplied via the advanced panel. */
|
||||
public readonly string $albumName;
|
||||
|
||||
/** Explicit artist filter supplied via the advanced panel. */
|
||||
public readonly string $artist;
|
||||
|
||||
/** Genre substring to match within stored external/user genres. */
|
||||
public readonly string $genre;
|
||||
|
||||
/** Lower bound (inclusive) of the release year filter, if any. */
|
||||
public readonly ?int $yearFrom;
|
||||
|
||||
/** Upper bound (inclusive) of the release year filter, if any. */
|
||||
public readonly ?int $yearTo;
|
||||
|
||||
/** Requested source scope: `all`, `external`, or `user`. */
|
||||
public readonly string $source;
|
||||
|
||||
/** Maximum number of results the search should return. */
|
||||
public readonly int $limit;
|
||||
|
||||
public function __construct(
|
||||
string $query,
|
||||
string $albumName,
|
||||
string $artist,
|
||||
string $genre,
|
||||
?int $yearFrom,
|
||||
?int $yearTo,
|
||||
string $source,
|
||||
@@ -29,9 +46,10 @@ final class AlbumSearchCriteria
|
||||
$this->query = $query;
|
||||
$this->albumName = $albumName;
|
||||
$this->artist = $artist;
|
||||
$this->genre = $genre;
|
||||
$this->yearFrom = $yearFrom;
|
||||
$this->yearTo = $yearTo;
|
||||
$this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
$this->source = in_array($source, ['all', 'external', 'user'], true) ? $source : 'all';
|
||||
$this->limit = max(1, $limit);
|
||||
}
|
||||
|
||||
@@ -44,6 +62,7 @@ final class AlbumSearchCriteria
|
||||
query: trim((string) $request->query->get('q', '')),
|
||||
albumName: trim($request->query->getString('album', '')),
|
||||
artist: trim($request->query->getString('artist', '')),
|
||||
genre: trim($request->query->getString('genre', '')),
|
||||
yearFrom: self::normalizeYear($request->query->get('year_from')),
|
||||
yearTo: self::normalizeYear($request->query->get('year_to')),
|
||||
source: self::normalizeSource($request->query->getString('source', 'all')),
|
||||
@@ -51,12 +70,18 @@ final class AlbumSearchCriteria
|
||||
);
|
||||
}
|
||||
|
||||
public function useSpotify(): bool
|
||||
/**
|
||||
* Determines whether the search should include externally sourced albums.
|
||||
*/
|
||||
public function shouldUseExternal(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'spotify';
|
||||
return $this->source === 'all' || $this->source === 'external';
|
||||
}
|
||||
|
||||
public function useUser(): bool
|
||||
/**
|
||||
* Determines whether the search should include user-created albums.
|
||||
*/
|
||||
public function shouldUseUserCatalog(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'user';
|
||||
}
|
||||
@@ -76,7 +101,6 @@ final class AlbumSearchCriteria
|
||||
private static function normalizeSource(string $source): string
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
return in_array($source, ['all', 'external', 'user'], true) ? $source : 'all';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Dto;
|
||||
|
||||
/**
|
||||
* AlbumSearchResult encapsulates merged payloads for presentation layers.
|
||||
* AlbumSearchResult is the value object returned by AlbumSearchService.
|
||||
* It keeps the original criteria alongside the merged album payloads,
|
||||
* per-album review stats, and a list of externally sourced IDs stored locally.
|
||||
*/
|
||||
final class AlbumSearchResult
|
||||
{
|
||||
@@ -13,11 +15,14 @@ final class AlbumSearchResult
|
||||
* @param array<int,string> $savedIds
|
||||
*/
|
||||
public function __construct(
|
||||
/** Filters that produced this result set. */
|
||||
public readonly AlbumSearchCriteria $criteria,
|
||||
/** Album payloads ready for Twig rendering. */
|
||||
public readonly array $albums,
|
||||
/** Per-album review aggregates keyed by album ID. */
|
||||
public readonly array $stats,
|
||||
/** List of externally sourced IDs saved locally for quick lookup. */
|
||||
public readonly array $savedIds
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+68
-24
@@ -11,7 +11,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Album aggregates Spotify or user-submitted metadata persisted in the catalog.
|
||||
* Album aggregates external or user-submitted metadata persisted in the catalog.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
|
||||
#[ORM\Table(name: 'albums')]
|
||||
@@ -27,17 +27,17 @@ class Album
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
// For Spotify-sourced albums; null for user-created
|
||||
// For externally sourced albums; null for user-created
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||
private ?string $spotifyId = null;
|
||||
private ?string $externalId = null;
|
||||
|
||||
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
|
||||
// Public identifier for user-created albums (e.g., "u_abc123"); null for external
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||
private ?string $localId = null;
|
||||
|
||||
// 'spotify' or 'user'
|
||||
// 'external' or 'user'
|
||||
#[ORM\Column(type: 'string', length: 16)]
|
||||
private string $source = 'spotify';
|
||||
private string $source = 'external';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
@@ -49,7 +49,13 @@ class Album
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $artists = [];
|
||||
|
||||
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $genres = [];
|
||||
|
||||
// Stored as provided by the source: YYYY or YYYY-MM or YYYY-MM-DD
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $releaseDate = null;
|
||||
|
||||
@@ -109,19 +115,19 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Spotify album identifier when sourced from Spotify.
|
||||
* Gets the external album identifier when sourced externally.
|
||||
*/
|
||||
public function getSpotifyId(): ?string
|
||||
public function getExternalId(): ?string
|
||||
{
|
||||
return $this->spotifyId;
|
||||
return $this->externalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the Spotify album identifier.
|
||||
* Stores the external album identifier.
|
||||
*/
|
||||
public function setSpotifyId(?string $spotifyId): void
|
||||
public function setExternalId(?string $externalId): void
|
||||
{
|
||||
$this->spotifyId = $spotifyId;
|
||||
$this->externalId = $externalId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,7 +147,7 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the album source flag ("spotify" or "user").
|
||||
* Returns the album source flag ("external" or "user").
|
||||
*/
|
||||
public function getSource(): string
|
||||
{
|
||||
@@ -149,7 +155,7 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the album source flag ("spotify" or "user").
|
||||
* Sets the album source flag ("external" or "user").
|
||||
*/
|
||||
public function setSource(string $source): void
|
||||
{
|
||||
@@ -188,6 +194,27 @@ class Album
|
||||
$this->artists = array_values($artists);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getGenres(): array
|
||||
{
|
||||
return $this->genres;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $genres
|
||||
*/
|
||||
public function setGenres(array $genres): void
|
||||
{
|
||||
$normalized = array_map(
|
||||
static fn($genre) => trim((string) $genre),
|
||||
$genres
|
||||
);
|
||||
$filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== ''));
|
||||
$this->genres = $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored release date string.
|
||||
*/
|
||||
@@ -237,7 +264,7 @@ class Album
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an external link (defaults to Spotify).
|
||||
* Returns an external link.
|
||||
*/
|
||||
public function getExternalUrl(): ?string
|
||||
{
|
||||
@@ -308,9 +335,11 @@ class Album
|
||||
public function toTemplateArray(): array
|
||||
{
|
||||
$images = [];
|
||||
$imageUrl = $this->coverUrl;
|
||||
$imageUrl = null;
|
||||
if ($this->source === 'user' && $this->coverImagePath) {
|
||||
$imageUrl = $this->coverImagePath;
|
||||
} elseif ($this->source !== 'external') {
|
||||
$imageUrl = $this->coverUrl;
|
||||
}
|
||||
if ($imageUrl) {
|
||||
$images = [
|
||||
@@ -320,23 +349,40 @@ class Album
|
||||
}
|
||||
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
|
||||
$external = $this->externalUrl;
|
||||
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
|
||||
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
|
||||
}
|
||||
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
|
||||
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->externalId;
|
||||
$genres = $this->source === 'external'
|
||||
? $this->normalizeTopGenreForOutput($this->genres)
|
||||
: array_slice($this->genres, 0, 5);
|
||||
|
||||
return [
|
||||
'id' => $publicId,
|
||||
'name' => $this->name,
|
||||
'images' => $images,
|
||||
'artists' => $artists,
|
||||
'genres' => $genres,
|
||||
'release_date' => $this->releaseDate,
|
||||
'total_tracks' => $this->totalTracks,
|
||||
'external_urls' => [ 'spotify' => $external ],
|
||||
'external_urls' => ['external' => $external],
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $genres
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeTopGenreForOutput(array $genres): array
|
||||
{
|
||||
foreach ($genres as $genre) {
|
||||
$value = trim((string) $genre);
|
||||
if ($value !== '') {
|
||||
return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AlbumTrack>
|
||||
*/
|
||||
@@ -360,5 +406,3 @@ class Album
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Repository\AlbumTrackRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* AlbumTrack persists individual tracks fetched from Spotify.
|
||||
* AlbumTrack persists individual tracks fetched from External.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
|
||||
#[ORM\Table(name: 'album_tracks')]
|
||||
@@ -23,7 +23,7 @@ class AlbumTrack
|
||||
private ?Album $album = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $spotifyTrackId = null;
|
||||
private ?string $externalTrackId = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $discNumber = 1;
|
||||
@@ -55,14 +55,14 @@ class AlbumTrack
|
||||
$this->album = $album;
|
||||
}
|
||||
|
||||
public function getSpotifyTrackId(): ?string
|
||||
public function getExternalTrackId(): ?string
|
||||
{
|
||||
return $this->spotifyTrackId;
|
||||
return $this->externalTrackId;
|
||||
}
|
||||
|
||||
public function setSpotifyTrackId(?string $spotifyTrackId): void
|
||||
public function setExternalTrackId(?string $externalTrackId): void
|
||||
{
|
||||
$this->spotifyTrackId = $spotifyTrackId;
|
||||
$this->externalTrackId = $externalTrackId;
|
||||
}
|
||||
|
||||
public function getDiscNumber(): int
|
||||
|
||||
+27
-1
@@ -46,6 +46,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $profileImagePath = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50, nullable: true)]
|
||||
private ?string $oauthProvider = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 190, nullable: true)]
|
||||
private ?string $oauthId = null;
|
||||
|
||||
/**
|
||||
* Returns the database identifier.
|
||||
*/
|
||||
@@ -133,6 +139,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
/**
|
||||
* Removes any sensitive transient data (no-op here).
|
||||
*/
|
||||
#[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// no-op
|
||||
@@ -163,6 +170,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
$this->profileImagePath = $profileImagePath;
|
||||
}
|
||||
|
||||
public function getOauthProvider(): ?string
|
||||
{
|
||||
return $this->oauthProvider;
|
||||
}
|
||||
|
||||
public function setOauthProvider(?string $oauthProvider): void
|
||||
{
|
||||
$this->oauthProvider = $oauthProvider === null ? null : strtolower(trim($oauthProvider));
|
||||
}
|
||||
|
||||
public function getOauthId(): ?string
|
||||
{
|
||||
return $this->oauthId;
|
||||
}
|
||||
|
||||
public function setOauthId(?string $oauthId): void
|
||||
{
|
||||
$this->oauthId = $oauthId === null ? null : trim($oauthId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
class AdminUserType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Declares the admin-only account fields plus password confirmation.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -46,6 +49,9 @@ class AdminUserType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the AdminUserData DTO as the underlying data object.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
+72
-1
@@ -6,12 +6,16 @@ use App\Entity\Album;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
|
||||
*/
|
||||
class AlbumType extends AbstractType
|
||||
{
|
||||
/**
|
||||
@@ -28,6 +32,12 @@ class AlbumType extends AbstractType
|
||||
'label' => 'Artists (comma-separated)',
|
||||
'constraints' => [new Assert\NotBlank()],
|
||||
])
|
||||
->add('genresCsv', TextType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'Genres (comma-separated)',
|
||||
'help' => 'Optional: e.g. Dream pop, Shoegaze',
|
||||
])
|
||||
->add('releaseDate', TextType::class, [
|
||||
'required' => false,
|
||||
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
|
||||
@@ -46,6 +56,36 @@ class AlbumType extends AbstractType
|
||||
'required' => false,
|
||||
'label' => 'External link',
|
||||
]);
|
||||
|
||||
// Seed the CSV helper fields with existing entity values before rendering.
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
$album = $event->getData();
|
||||
if (!$album instanceof Album) {
|
||||
return;
|
||||
}
|
||||
$form = $event->getForm();
|
||||
if ($form->has('artistsCsv')) {
|
||||
$form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists()));
|
||||
}
|
||||
if ($form->has('genresCsv')) {
|
||||
$form->get('genresCsv')->setData($this->implodeCsv($album->getGenres()));
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the CSV helper fields back into normalized arrays when saving.
|
||||
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
||||
$album = $event->getData();
|
||||
if (!$album instanceof Album) {
|
||||
return;
|
||||
}
|
||||
$form = $event->getForm();
|
||||
if ($form->has('artistsCsv')) {
|
||||
$album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData()));
|
||||
}
|
||||
if ($form->has('genresCsv')) {
|
||||
$album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +97,37 @@ class AlbumType extends AbstractType
|
||||
'data_class' => Album::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a comma-separated string into a normalized, de-duplicated list.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitCsv(string $input): array
|
||||
{
|
||||
if ($input === '') {
|
||||
return [];
|
||||
}
|
||||
$normalized = str_replace(["\n", "\r", ';'], ',', $input);
|
||||
$parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized));
|
||||
$filtered = array_values(array_filter($parts, static fn(string $value) => $value !== ''));
|
||||
|
||||
return array_values(array_unique($filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a list back into a user-friendly CSV string for display.
|
||||
*
|
||||
* @param list<string> $items
|
||||
*/
|
||||
private function implodeCsv(array $items): string
|
||||
{
|
||||
if ($items === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* ProfileFormType lets authenticated users edit their account details and password.
|
||||
*/
|
||||
class ProfileFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* RegistrationFormType defines the public signup form and its validation rules.
|
||||
*/
|
||||
class RegistrationFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* ReviewType captures the fields needed to author or edit a review.
|
||||
*/
|
||||
class ReviewType extends AbstractType
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -8,22 +8,20 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* SiteSettingsType exposes toggles for operations staff (Last.fm key, registration).
|
||||
*/
|
||||
class SiteSettingsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Exposes Spotify credential inputs for administrators.
|
||||
* Exposes Last.fm credential inputs for administrators.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('SPOTIFY_CLIENT_ID', TextType::class, [
|
||||
->add('LASTFM_API_KEY', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client ID',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client Secret',
|
||||
'label' => 'Last.fm API key',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
||||
@@ -42,4 +40,3 @@ class SiteSettingsType extends AbstractType
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,17 +16,20 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
/**
|
||||
* Wires the repository to Doctrine's registry.
|
||||
*/
|
||||
/**
|
||||
* Provides the Doctrine registry so we can build query builders on demand.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Album::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one album by Spotify identifier.
|
||||
* Finds one album by External identifier.
|
||||
*/
|
||||
public function findOneBySpotifyId(string $spotifyId): ?Album
|
||||
public function findOneByExternalId(string $externalId): ?Album
|
||||
{
|
||||
return $this->findOneBy(['spotifyId' => $spotifyId]);
|
||||
return $this->findOneBy(['externalId' => $externalId]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,36 +41,62 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns albums keyed by Spotify identifiers for quick lookup.
|
||||
* Returns albums keyed by External identifiers for quick lookup.
|
||||
*
|
||||
* @param list<string> $spotifyIds
|
||||
* @param list<string> $externalIds
|
||||
* @return array<string,Album>
|
||||
*/
|
||||
public function findBySpotifyIdsKeyed(array $spotifyIds): array
|
||||
public function findByExternalIdsKeyed(array $externalIds): array
|
||||
{
|
||||
if ($spotifyIds === []) {
|
||||
if ($externalIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = $this->createQueryBuilder('a')
|
||||
->where('a.spotifyId IN (:ids)')
|
||||
->setParameter('ids', $spotifyIds)
|
||||
->where('a.externalId IN (:ids)')
|
||||
->setParameter('ids', $externalIds)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row instanceof Album && $row->getSpotifyId() !== null) {
|
||||
$out[$row->getSpotifyId()] = $row;
|
||||
if ($row instanceof Album && $row->getExternalId() !== null) {
|
||||
$out[$row->getExternalId()] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all stored External album IDs so background jobs can iterate over them.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function findAllExternalIds(): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('a')
|
||||
->select('a.externalId')
|
||||
->where('a.source = :src')
|
||||
->andWhere('a.externalId IS NOT NULL')
|
||||
->setParameter('src', 'external')
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (string) ($row['externalId'] ?? '');
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters user albums by optional metadata.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.source = :src')
|
||||
@@ -91,19 +120,19 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
$results = $qb->getQuery()->getResult();
|
||||
$artistNeedle = $artist ?? $freeText;
|
||||
return $this->filterByArtistAndLimit($results, $artistNeedle, $limit);
|
||||
return $this->filterByArtistAndGenreAndLimit($results, $artistNeedle, $genre, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters persisted Spotify albums before falling back to the API.
|
||||
* Filters persisted External albums before falling back to the API.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
public function searchExternalAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.source = :src')
|
||||
->setParameter('src', 'spotify')
|
||||
->setParameter('src', 'external')
|
||||
->setMaxResults($limit * 2);
|
||||
if ($freeText !== null && $freeText !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :qName')
|
||||
@@ -123,62 +152,88 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
}
|
||||
$results = $qb->getQuery()->getResult();
|
||||
return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit);
|
||||
return $this->filterByArtistAndGenreAndLimit($results, $artist ?? $freeText, $genre, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts data from a Spotify album payload and keeps DB entities in sync.
|
||||
* Upserts data from an external album payload and keeps DB entities in sync.
|
||||
*
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
* @param array<string,mixed> $externalAlbum Raw external album payload.
|
||||
* @param list<string> $resolvedGenres Optional, precomputed genres.
|
||||
*/
|
||||
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album
|
||||
public function upsertFromExternalAlbum(array $externalAlbum, array $resolvedGenres = []): Album
|
||||
{
|
||||
$spotifyId = (string) ($spotifyAlbum['id'] ?? '');
|
||||
$name = (string) ($spotifyAlbum['name'] ?? '');
|
||||
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? [])));
|
||||
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
|
||||
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
|
||||
|
||||
$images = (array) ($spotifyAlbum['images'] ?? []);
|
||||
$coverUrl = null;
|
||||
if (isset($images[1]['url'])) {
|
||||
$coverUrl = (string) $images[1]['url'];
|
||||
} elseif (isset($images[0]['url'])) {
|
||||
$coverUrl = (string) $images[0]['url'];
|
||||
}
|
||||
$externalId = (string) ($externalAlbum['id'] ?? '');
|
||||
$name = (string) ($externalAlbum['name'] ?? '');
|
||||
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($externalAlbum['artists'] ?? [])));
|
||||
$rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($externalAlbum['genres'] ?? []);
|
||||
$genres = $this->normalizeTopGenre($rawGenres);
|
||||
$releaseDate = isset($externalAlbum['release_date']) ? (string) $externalAlbum['release_date'] : null;
|
||||
$totalTracks = (int) ($externalAlbum['total_tracks'] ?? 0);
|
||||
$external = null;
|
||||
if (isset($spotifyAlbum['external_urls']['spotify'])) {
|
||||
$external = (string) $spotifyAlbum['external_urls']['spotify'];
|
||||
if (isset($externalAlbum['external_urls']['external'])) {
|
||||
$external = (string) $externalAlbum['external_urls']['external'];
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$album = $this->findOneBy(['spotifyId' => $spotifyId]) ?? new Album();
|
||||
$album->setSource('spotify');
|
||||
$album->setSpotifyId($spotifyId);
|
||||
$album = $this->findOneBy(['externalId' => $externalId]) ?? new Album();
|
||||
$album->setSource('external');
|
||||
$album->setExternalId($externalId);
|
||||
$album->setName($name);
|
||||
$album->setArtists($artists);
|
||||
$album->setGenres($genres);
|
||||
$album->setReleaseDate($releaseDate);
|
||||
$album->setTotalTracks($totalTracks);
|
||||
$album->setCoverUrl($coverUrl);
|
||||
// External cover URLs should be fetched live from Last.fm at response time.
|
||||
$album->setCoverUrl(null);
|
||||
$album->setExternalUrl($external);
|
||||
$em->persist($album);
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies artist/genre substring filters and the requested limit in the environment.
|
||||
*
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array
|
||||
private function filterByArtistAndGenreAndLimit(array $albums, ?string $artistNeedle, ?string $genreNeedle, int $limit): array
|
||||
{
|
||||
if ($needle === null || trim($needle) === '') {
|
||||
$filtered = $albums;
|
||||
$appliedFilter = false;
|
||||
$normalizedArtist = $this->normalizeNeedle($artistNeedle);
|
||||
$normalizedGenre = $this->normalizeNeedle($genreNeedle);
|
||||
|
||||
if ($normalizedArtist !== null) {
|
||||
$filtered = $this->filterByNeedle($filtered, $normalizedArtist, static fn(Album $album) => $album->getArtists(), $limit);
|
||||
$appliedFilter = true;
|
||||
}
|
||||
if ($normalizedGenre !== null) {
|
||||
$filtered = $this->filterByNeedle($filtered, $normalizedGenre, static fn(Album $album) => $album->getGenres(), $limit);
|
||||
$appliedFilter = true;
|
||||
}
|
||||
|
||||
if (!$appliedFilter) {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
$needle = mb_strtolower(trim($needle));
|
||||
|
||||
return array_slice($filtered, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters albums by matching a lowercase needle against extracted values.
|
||||
*
|
||||
* @param callable(Album):list<string> $valueExtractor
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function filterByNeedle(array $albums, string $needle, callable $valueExtractor, int $limit): array
|
||||
{
|
||||
$filtered = [];
|
||||
foreach ($albums as $album) {
|
||||
foreach ($album->getArtists() as $artist) {
|
||||
if (str_contains(mb_strtolower($artist), $needle)) {
|
||||
$haystack = $valueExtractor($album);
|
||||
foreach ($haystack as $value) {
|
||||
if (str_contains(mb_strtolower($value), $needle)) {
|
||||
$filtered[] = $album;
|
||||
break;
|
||||
}
|
||||
@@ -187,11 +242,39 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($filtered === []) {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a filter needle to lowercase so comparisons stay consistent.
|
||||
*/
|
||||
private function normalizeNeedle(?string $needle): ?string
|
||||
{
|
||||
if ($needle === null) {
|
||||
return null;
|
||||
}
|
||||
$trimmed = trim($needle);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
return mb_strtolower($trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $rawGenres
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeTopGenre(array $rawGenres): array
|
||||
{
|
||||
foreach ($rawGenres as $genre) {
|
||||
$value = trim((string) $genre);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8')];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
class AlbumTrackRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Registers the repository with Doctrine's manager registry.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AlbumTrack::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an album's stored tracklist with the provided Spotify payload.
|
||||
* Replaces an album's stored tracklist with the provided External payload.
|
||||
*
|
||||
* @param list<array<string,mixed>> $trackPayloads
|
||||
*/
|
||||
@@ -28,13 +31,14 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
foreach ($album->getTracks()->toArray() as $existing) {
|
||||
if ($existing instanceof AlbumTrack) {
|
||||
$album->removeTrack($existing);
|
||||
}
|
||||
}
|
||||
// Remove existing rows with a single query so unique constraints don't conflict during reinsert.
|
||||
$em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE t.album = :album')
|
||||
->setParameter('album', $album)
|
||||
->execute();
|
||||
$album->getTracks()->clear();
|
||||
|
||||
$position = 1;
|
||||
$occupied = [];
|
||||
foreach ($trackPayloads as $payload) {
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
@@ -43,9 +47,13 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
|
||||
$track = new AlbumTrack();
|
||||
$track->setAlbum($album);
|
||||
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null));
|
||||
$track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1));
|
||||
$track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position));
|
||||
$track->setExternalTrackId($this->stringOrNull($payload['id'] ?? null));
|
||||
$disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1);
|
||||
$trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position);
|
||||
// Some External payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique.
|
||||
$trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber);
|
||||
$track->setDiscNumber($disc);
|
||||
$track->setTrackNumber($trackNumber);
|
||||
$track->setName($name);
|
||||
$track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0)));
|
||||
$track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null));
|
||||
@@ -56,6 +64,9 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims user/External data to a nullable string, collapsing empty values to null.
|
||||
*/
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -65,11 +76,31 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a positive integer (defaults to 1) for disc/track numbers.
|
||||
*/
|
||||
private function normalizePositiveInt(mixed $value): int
|
||||
{
|
||||
$int = (int) $value;
|
||||
return $int > 0 ? $int : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps disc/track combinations unique within the upsert operation.
|
||||
*
|
||||
* @param array<int,array<int,bool>> $occupied
|
||||
*/
|
||||
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
|
||||
{
|
||||
// Track which disc/track slots have already been claimed in this upsert run.
|
||||
$discMap = $occupied[$disc] ?? [];
|
||||
while (isset($discMap[$track])) {
|
||||
$track++;
|
||||
}
|
||||
$discMap[$track] = true;
|
||||
$occupied[$disc] = $discMap;
|
||||
return $track;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates review counts and averages for Spotify IDs.
|
||||
* Aggregates review counts and averages for External IDs.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
@@ -49,10 +49,10 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->innerJoin('r.album', 'a')
|
||||
->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
|
||||
->where('a.spotifyId IN (:ids)')
|
||||
->select('a.externalId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
|
||||
->where('a.externalId IN (:ids)')
|
||||
->setParameter('ids', $albumIds)
|
||||
->groupBy('a.spotifyId');
|
||||
->groupBy('a.externalId');
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
$out = [];
|
||||
|
||||
@@ -20,7 +20,7 @@ class SettingRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a setting value falling back to the supplied default.
|
||||
* Returns a setting value, falling back to the caller's default when missing.
|
||||
*/
|
||||
public function getValue(string $name, ?string $default = null): ?string
|
||||
{
|
||||
@@ -29,7 +29,7 @@ class SettingRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the supplied configuration value.
|
||||
* Persists or updates the supplied configuration value.
|
||||
*/
|
||||
public function setValue(string $name, ?string $value): void
|
||||
{
|
||||
|
||||
@@ -35,6 +35,20 @@ class UserRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a user by OAuth provider and provider user ID.
|
||||
*/
|
||||
public function findOneByOauthIdentity(string $provider, string $oauthId): ?User
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->andWhere('u.oauthProvider = :provider')
|
||||
->andWhere('u.oauthId = :oauthId')
|
||||
->setParameter('provider', strtolower($provider))
|
||||
->setParameter('oauthId', $oauthId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every user with aggregated album/review counts.
|
||||
*
|
||||
@@ -66,4 +80,3 @@ class UserRepository extends ServiceEntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* ReviewVoter grants edit/delete access to review owners or admins.
|
||||
* ReviewVoter determines whether the authenticated user may edit or delete a review.
|
||||
* Moderators/admins always pass; otherwise the review author must match the current user.
|
||||
*/
|
||||
class ReviewVoter extends Voter
|
||||
{
|
||||
@@ -24,7 +25,7 @@ class ReviewVoter extends Voter
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants access to admins or the review author.
|
||||
* Evaluates the permission for the given attribute/subject pair.
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
@@ -40,6 +41,7 @@ class ReviewVoter extends Voter
|
||||
|
||||
/** @var Review $review */
|
||||
$review = $subject;
|
||||
// Only the author may edit/delete their own review.
|
||||
return $review->getAuthor()?->getId() === $user->getId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,77 +11,89 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* AlbumSearchService composes Spotify and user albums into reusable payloads.
|
||||
* AlbumSearchService pulls albums from Last.fm and the local catalog,
|
||||
* adds review stats, and hands the UI one uniform structure to render.
|
||||
*/
|
||||
class AlbumSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SpotifyClient $spotify,
|
||||
private readonly LastFmClient $lastFmClient,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly LastFmTagResolver $tagResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the end-to-end search flow and returns review stats plus saved IDs.
|
||||
*/
|
||||
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
||||
{
|
||||
$spotifyQuery = $this->buildSpotifyQuery($criteria);
|
||||
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
|
||||
$externalQuery = $this->buildExternalSearchQuery($criteria);
|
||||
$hasUserFilters = $this->hasUserFilters($criteria, $externalQuery);
|
||||
|
||||
$shouldQueryExternal = $criteria->shouldUseExternal()
|
||||
&& ($externalQuery !== '' || $criteria->genre !== '' || $criteria->source === 'external');
|
||||
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
$spotifyPayloads = [];
|
||||
$externalPayloads = [];
|
||||
$userPayloads = [];
|
||||
|
||||
if ($criteria->useSpotify() && $spotifyQuery !== '') {
|
||||
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
|
||||
$spotifyPayloads = $spotifyData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $spotifyData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
||||
if ($shouldQueryExternal) {
|
||||
$externalData = $this->resolveExternalAlbums($criteria, $externalQuery);
|
||||
$externalPayloads = $externalData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $externalData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']);
|
||||
}
|
||||
|
||||
if ($criteria->useUser() && $hasUserFilters) {
|
||||
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
|
||||
$userData = $this->resolveUserAlbums($criteria);
|
||||
$userPayloads = $userData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $userData['stats']);
|
||||
}
|
||||
|
||||
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $spotifyPayloads, $criteria->limit);
|
||||
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $externalPayloads, $criteria->limit);
|
||||
|
||||
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
||||
}
|
||||
|
||||
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
|
||||
/**
|
||||
* Indicates whether Last.fm credentials are configured.
|
||||
*/
|
||||
public function isExternalConfigured(): bool
|
||||
{
|
||||
return $this->lastFmClient->isConfigured();
|
||||
}
|
||||
|
||||
private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($criteria->albumName !== '') {
|
||||
$parts[] = 'album:' . $criteria->albumName;
|
||||
}
|
||||
if ($criteria->artist !== '') {
|
||||
$parts[] = 'artist:' . $criteria->artist;
|
||||
}
|
||||
if ($criteria->yearFrom !== null || $criteria->yearTo !== null) {
|
||||
if ($criteria->yearFrom !== null && $criteria->yearTo !== null && $criteria->yearTo >= $criteria->yearFrom) {
|
||||
$parts[] = 'year:' . $criteria->yearFrom . '-' . $criteria->yearTo;
|
||||
} else {
|
||||
$year = $criteria->yearFrom ?? $criteria->yearTo;
|
||||
if ($year !== null) {
|
||||
$parts[] = 'year:' . $year;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($criteria->query !== '') {
|
||||
$parts[] = $criteria->query;
|
||||
}
|
||||
return implode(' ', $parts);
|
||||
if ($criteria->albumName !== '') {
|
||||
$parts[] = $criteria->albumName;
|
||||
}
|
||||
if ($criteria->artist !== '') {
|
||||
$parts[] = $criteria->artist;
|
||||
}
|
||||
|
||||
return trim(implode(' ', $parts));
|
||||
}
|
||||
|
||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
|
||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $externalQuery): bool
|
||||
{
|
||||
return $spotifyQuery !== ''
|
||||
if ($criteria->source === 'user') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $externalQuery !== ''
|
||||
|| $criteria->albumName !== ''
|
||||
|| $criteria->artist !== ''
|
||||
|| $criteria->genre !== ''
|
||||
|| $criteria->yearFrom !== null
|
||||
|| $criteria->yearTo !== null;
|
||||
}
|
||||
@@ -89,87 +101,98 @@ class AlbumSearchService
|
||||
/**
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
|
||||
private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
{
|
||||
$stored = $this->albumRepository->searchSpotifyAlbums(
|
||||
$spotifyQuery,
|
||||
$storedExternalAlbums = $this->albumRepository->searchExternalAlbums(
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->genre,
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
|
||||
$storedIds = $this->collectSpotifyIds($stored);
|
||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||
$savedIds = $storedIds;
|
||||
|
||||
if (count($stored) >= $criteria->limit) {
|
||||
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums);
|
||||
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
|
||||
$storedPayloads = $this->filterPayloadsByYear($storedPayloads, $criteria->yearFrom, $criteria->yearTo);
|
||||
$storedPayloads = $this->hydrateExternalImages($storedPayloads);
|
||||
|
||||
$storedIds = $this->collectExternalIds($storedExternalAlbums);
|
||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||
|
||||
$shouldFetchFromExternal = $externalQuery !== '' && count($storedPayloads) < $criteria->limit;
|
||||
if (!$shouldFetchFromExternal) {
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
'savedIds' => $storedIds,
|
||||
];
|
||||
}
|
||||
|
||||
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
|
||||
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit);
|
||||
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
|
||||
$apiPayloads = $this->fetchExternalPayloads($criteria, $externalQuery);
|
||||
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
|
||||
$filteredApiPayloads = $this->filterPayloadsByYear($filteredApiPayloads, $criteria->yearFrom, $criteria->yearTo);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
|
||||
return [
|
||||
'payloads' => $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit),
|
||||
'stats' => $this->mergeStats($stats, $apiPayloads['stats']),
|
||||
'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $storedPayloads
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
|
||||
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||
{
|
||||
$result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
|
||||
$searchItems = $result['albums']['items'] ?? [];
|
||||
$result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit);
|
||||
$searchItems = (array) ($result['albums']['items'] ?? []);
|
||||
|
||||
$this->logger->info('Album search results received', [
|
||||
'query' => $spotifyQuery,
|
||||
'items' => is_countable($searchItems) ? count($searchItems) : 0,
|
||||
'query' => $externalQuery,
|
||||
'items' => count($searchItems),
|
||||
]);
|
||||
|
||||
if (!$searchItems) {
|
||||
if ($searchItems === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$ids = $this->extractSpotifyIds($searchItems);
|
||||
$ids = $this->extractExternalIds($searchItems);
|
||||
if ($ids === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$full = $this->spotify->getAlbums($ids);
|
||||
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
||||
if ($albumsPayload === [] && $searchItems !== []) {
|
||||
$full = $this->lastFmClient->getAlbums($ids);
|
||||
$albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
|
||||
if ($albumsPayload === []) {
|
||||
$albumsPayload = $searchItems;
|
||||
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
|
||||
}
|
||||
|
||||
$upserted = 0;
|
||||
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload);
|
||||
|
||||
foreach ($albumsPayload as $payload) {
|
||||
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload);
|
||||
$upserted++;
|
||||
$albumId = (string) ($payload['id'] ?? '');
|
||||
$this->albumRepository->upsertFromExternalAlbum(
|
||||
(array) $payload,
|
||||
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
||||
);
|
||||
}
|
||||
$this->em->flush();
|
||||
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
||||
|
||||
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$existing = $this->albumRepository->findByExternalIdsKeyed($ids);
|
||||
$payloads = [];
|
||||
foreach ($ids as $sid) {
|
||||
if (isset($existing[$sid])) {
|
||||
$payloads[] = $existing[$sid]->toTemplateArray();
|
||||
foreach ($ids as $externalId) {
|
||||
if (isset($existing[$externalId])) {
|
||||
$payloads[] = $existing[$externalId]->toTemplateArray();
|
||||
}
|
||||
}
|
||||
|
||||
$stats = $this->reviewRepository->getAggregatesForAlbumIds($ids);
|
||||
$payloads = $this->hydrateExternalImages($payloads);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'stats' => $stats,
|
||||
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids),
|
||||
'savedIds' => array_keys($existing),
|
||||
];
|
||||
}
|
||||
@@ -183,20 +206,21 @@ class AlbumSearchService
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->genre,
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
|
||||
if ($userAlbums === []) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums));
|
||||
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
|
||||
$payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
|
||||
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
|
||||
];
|
||||
}
|
||||
@@ -216,33 +240,41 @@ class AlbumSearchService
|
||||
$mapped[$localId] = $userStats[$entityId];
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
|
||||
/**
|
||||
* @param array<int,array<mixed>> $userPayloads
|
||||
* @param array<int,array<mixed>> $externalPayloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $externalPayloads, int $limit): array
|
||||
{
|
||||
if ($source === 'user') {
|
||||
return array_slice($userPayloads, 0, $limit);
|
||||
}
|
||||
if ($source === 'spotify') {
|
||||
return array_slice($spotifyPayloads, 0, $limit);
|
||||
if ($source === 'external') {
|
||||
return array_slice($externalPayloads, 0, $limit);
|
||||
}
|
||||
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
|
||||
|
||||
return array_slice(array_merge($userPayloads, $externalPayloads), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectSpotifyIds(array $albums): array
|
||||
private function collectExternalIds(array $albums): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($albums as $album) {
|
||||
$sid = (string) $album->getSpotifyId();
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
$externalId = (string) $album->getExternalId();
|
||||
if ($externalId !== '') {
|
||||
$ids[] = $externalId;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
@@ -250,7 +282,7 @@ class AlbumSearchService
|
||||
* @param array<int,mixed> $searchItems
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractSpotifyIds(array $searchItems): array
|
||||
private function extractExternalIds(array $searchItems): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($searchItems as $item) {
|
||||
@@ -259,17 +291,29 @@ class AlbumSearchService
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{count:int,avg:float}> $current
|
||||
* @param array<string,array{count:int,avg:float}> $updates
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
*/
|
||||
private function mergeStats(array $current, array $updates): array
|
||||
{
|
||||
foreach ($updates as $key => $value) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $current
|
||||
* @param list<string> $updates
|
||||
* @return list<string>
|
||||
*/
|
||||
private function mergeSavedIds(array $current, array $updates): array
|
||||
{
|
||||
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
|
||||
@@ -285,29 +329,137 @@ class AlbumSearchService
|
||||
{
|
||||
$seen = [];
|
||||
$merged = [];
|
||||
|
||||
foreach ($primary as $payload) {
|
||||
$merged[] = $payload;
|
||||
if (isset($payload['id'])) {
|
||||
$seen[$payload['id']] = true;
|
||||
$id = (string) ($payload['id'] ?? '');
|
||||
if ($id !== '') {
|
||||
$seen[$id] = true;
|
||||
}
|
||||
$merged[] = $payload;
|
||||
if (count($merged) >= $limit) {
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($secondary as $payload) {
|
||||
$id = $payload['id'] ?? null;
|
||||
if ($id !== null && isset($seen[$id])) {
|
||||
$id = (string) ($payload['id'] ?? '');
|
||||
if ($id !== '' && isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$merged[] = $payload;
|
||||
if ($id !== null) {
|
||||
if ($id !== '') {
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array
|
||||
{
|
||||
$needle = trim(mb_strtolower($genreNeedle));
|
||||
if ($needle === '') {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool {
|
||||
$genres = $payload['genres'] ?? [];
|
||||
if (!is_array($genres)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($genres as $genre) {
|
||||
if (str_contains(mb_strtolower((string) $genre), $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function filterPayloadsByYear(array $payloads, ?int $yearFrom, ?int $yearTo): array
|
||||
{
|
||||
if ($yearFrom === null && $yearTo === null) {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
return array_values(array_filter($payloads, static function (array $payload) use ($yearFrom, $yearTo): bool {
|
||||
$releaseDate = trim((string) ($payload['release_date'] ?? ''));
|
||||
if (!preg_match('/^\d{4}/', $releaseDate, $matches)) {
|
||||
return false;
|
||||
}
|
||||
$year = (int) $matches[0];
|
||||
if ($yearFrom !== null && $year < $yearFrom) {
|
||||
return false;
|
||||
}
|
||||
if ($yearTo !== null && $year > $yearTo) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $payloads
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function hydrateExternalImages(array $payloads): array
|
||||
{
|
||||
$externalIds = [];
|
||||
foreach ($payloads as $payload) {
|
||||
if ((string) ($payload['source'] ?? '') !== 'external') {
|
||||
continue;
|
||||
}
|
||||
$id = trim((string) ($payload['id'] ?? ''));
|
||||
if ($id !== '') {
|
||||
$externalIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($externalIds === []) {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
$full = $this->lastFmClient->getAlbums(array_values(array_unique($externalIds)));
|
||||
$albums = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
|
||||
if ($albums === []) {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
$imagesByAlbum = [];
|
||||
foreach ($albums as $album) {
|
||||
if (!is_array($album)) {
|
||||
continue;
|
||||
}
|
||||
$id = trim((string) ($album['id'] ?? ''));
|
||||
$images = $album['images'] ?? null;
|
||||
if ($id === '' || !is_array($images) || $images === []) {
|
||||
continue;
|
||||
}
|
||||
$imagesByAlbum[$id] = $images;
|
||||
}
|
||||
|
||||
if ($imagesByAlbum === []) {
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
foreach ($payloads as $index => $payload) {
|
||||
$id = trim((string) ($payload['id'] ?? ''));
|
||||
if ($id !== '' && isset($imagesByAlbum[$id])) {
|
||||
$payloads[$index]['images'] = $imagesByAlbum[$id];
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\AlbumRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class CatalogResetService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all reviews and albums from the catalog and returns summary counts.
|
||||
*
|
||||
* @return array{albums:int,reviews:int}
|
||||
*/
|
||||
public function resetCatalog(): array
|
||||
{
|
||||
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
|
||||
|
||||
$albums = $this->albumRepository->findAll();
|
||||
$albumCount = count($albums);
|
||||
foreach ($albums as $album) {
|
||||
// Remove entities one-by-one so Doctrine cascades delete related tracks/reviews as configured.
|
||||
$this->entityManager->remove($album);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
return [
|
||||
'albums' => $albumCount,
|
||||
'reviews' => $deletedReviews,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* CommandRunner executes Symfony console commands from non-CLI contexts
|
||||
* and returns their buffered output.
|
||||
*/
|
||||
class CommandRunner
|
||||
{
|
||||
public function __construct(private readonly KernelInterface $kernel)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Symfony console command and returns its buffered output.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
public function runConsoleCommand(string $commandName, array $options = []): string
|
||||
{
|
||||
$application = new Application($this->kernel);
|
||||
$application->setAutoExit(false);
|
||||
|
||||
$input = new ArrayInput(array_merge(['command' => $commandName], $options));
|
||||
$output = new BufferedOutput();
|
||||
|
||||
$exitCode = $application->run($input, $output);
|
||||
if ($exitCode !== Command::SUCCESS) {
|
||||
// Surface the underlying command failure so the caller can surface the message in UI.
|
||||
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
|
||||
}
|
||||
|
||||
return trim($output->fetch());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class ImageStorage
|
||||
{
|
||||
private Filesystem $fs;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly SluggerInterface $slugger
|
||||
) {
|
||||
$this->fs = new Filesystem();
|
||||
}
|
||||
|
||||
public function storeProfileImage(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'avatars');
|
||||
}
|
||||
|
||||
public function storeAlbumCover(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'album_covers');
|
||||
}
|
||||
|
||||
public function remove(?string $webPath): void
|
||||
{
|
||||
if ($webPath === null || $webPath === '') {
|
||||
return;
|
||||
}
|
||||
$path = $this->projectDir . '/public' . $webPath;
|
||||
if ($this->fs->exists($path)) {
|
||||
$this->fs->remove($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function store(UploadedFile $file, string $subDirectory): string
|
||||
{
|
||||
$targetDir = $this->projectDir . '/public/uploads/' . $subDirectory;
|
||||
if (!$this->fs->exists($targetDir)) {
|
||||
$this->fs->mkdir($targetDir);
|
||||
}
|
||||
|
||||
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$safeName = $this->slugger->slug($originalName ?: 'image');
|
||||
$extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
|
||||
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension);
|
||||
|
||||
$file->move($targetDir, $filename);
|
||||
|
||||
return '/uploads/' . $subDirectory . '/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* LastFmClient wraps Last.fm API calls with caching and payload normalization.
|
||||
*/
|
||||
class LastFmClient
|
||||
{
|
||||
private const API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||
private const IMAGE_SIZE_RANK = [
|
||||
'mega' => 5,
|
||||
'extralarge' => 4,
|
||||
'large' => 3,
|
||||
'medium' => 2,
|
||||
'small' => 1,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ClientInterface $httpClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly ?string $apiKey,
|
||||
private readonly SettingRepository $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Last.fm albums by query string.
|
||||
*
|
||||
* @return array{albums:array{items:list<array<string,mixed>>}}
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12): array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null || trim($query) === '') {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->sendRequest([
|
||||
'method' => 'album.search',
|
||||
'album' => $query,
|
||||
'limit' => max(1, $limit),
|
||||
], 600);
|
||||
} catch (\Throwable) {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
$rawAlbums = $payload['results']['albummatches']['album'] ?? [];
|
||||
$items = [];
|
||||
foreach ($this->normalizeList($rawAlbums) as $album) {
|
||||
$normalized = $this->normalizeSearchAlbum((array) $album);
|
||||
if ($normalized !== null) {
|
||||
$items[] = $normalized;
|
||||
}
|
||||
if (count($items) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['albums' => ['items' => $items]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches album metadata plus tracklist from Last.fm.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId): ?array
|
||||
{
|
||||
return $this->getAlbum($albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single album by normalized external identifier.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function getAlbum(string $albumId): ?array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lookup = $this->parseAlbumIdentifier($albumId);
|
||||
if ($lookup === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = ['method' => 'album.getinfo'];
|
||||
if (isset($lookup['mbid'])) {
|
||||
$query['mbid'] = (string) $lookup['mbid'];
|
||||
} else {
|
||||
$artist = trim((string) ($lookup['artist'] ?? ''));
|
||||
$name = trim((string) ($lookup['album'] ?? ''));
|
||||
if ($artist === '' || $name === '') {
|
||||
return null;
|
||||
}
|
||||
$query['artist'] = $artist;
|
||||
$query['album'] = $name;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->sendRequest($query, 1800);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$album = $payload['album'] ?? null;
|
||||
if (!is_array($album)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeDetailedAlbum($album, $albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple albums in sequence.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array{albums:list<array<string,mixed>>}|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds): ?array
|
||||
{
|
||||
if ($albumIds === []) {
|
||||
return ['albums' => []];
|
||||
}
|
||||
if ($this->getApiKey() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$albums = [];
|
||||
foreach ($albumIds as $albumId) {
|
||||
$album = $this->getAlbumWithTracks((string) $albumId);
|
||||
if ($album !== null) {
|
||||
$albums[] = $album;
|
||||
}
|
||||
}
|
||||
|
||||
return ['albums' => $albums];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array
|
||||
{
|
||||
$album = $this->getAlbumWithTracks($albumId);
|
||||
if ($album === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = $album['tracks']['items'] ?? [];
|
||||
return is_array($items) ? array_values($items) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when Last.fm API key is available from DB or environment.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->getApiKey() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $query
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function sendRequest(array $query, int $cacheTtlSeconds = 0): array
|
||||
{
|
||||
$apiKey = $this->getApiKey();
|
||||
if ($apiKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query['api_key'] = $apiKey;
|
||||
$query['format'] = 'json';
|
||||
$query['autocorrect'] = 1;
|
||||
|
||||
$request = function () use ($query): array {
|
||||
$response = $this->httpClient->request('GET', self::API_BASE_URL, ['query' => $query]);
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
};
|
||||
|
||||
if ($cacheTtlSeconds > 0) {
|
||||
$cacheKey = 'lastfm_resp_' . sha1(json_encode($query));
|
||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $request();
|
||||
});
|
||||
}
|
||||
|
||||
return $request();
|
||||
}
|
||||
|
||||
private function getApiKey(): ?string
|
||||
{
|
||||
$key = trim((string) $this->settings->getValue('LASTFM_API_KEY', $this->apiKey ?? ''));
|
||||
return $key === '' ? null : $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function normalizeList(mixed $value): array
|
||||
{
|
||||
if (is_array($value) && array_is_list($value)) {
|
||||
return array_values(array_filter($value, static fn($row) => is_array($row)));
|
||||
}
|
||||
|
||||
if (is_array($value) && $value !== []) {
|
||||
return [array_filter($value, static fn($row) => true)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rawAlbum
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function normalizeSearchAlbum(array $rawAlbum): ?array
|
||||
{
|
||||
$name = trim((string) ($rawAlbum['name'] ?? ''));
|
||||
$artist = trim((string) ($rawAlbum['artist'] ?? ''));
|
||||
if ($name === '' || $artist === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mbid = trim((string) ($rawAlbum['mbid'] ?? ''));
|
||||
$id = $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'artists' => [['name' => $artist]],
|
||||
'genres' => [],
|
||||
'release_date' => null,
|
||||
'total_tracks' => 0,
|
||||
'images' => $this->normalizeImages($rawAlbum['image'] ?? []),
|
||||
'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)],
|
||||
'source' => 'external',
|
||||
'tracks' => ['items' => [], 'total' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $rawAlbum
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalizeDetailedAlbum(array $rawAlbum, ?string $preferredId = null): array
|
||||
{
|
||||
$name = trim((string) ($rawAlbum['name'] ?? ''));
|
||||
$artistField = $rawAlbum['artist'] ?? '';
|
||||
$artist = '';
|
||||
if (is_array($artistField)) {
|
||||
$artist = trim((string) ($artistField['name'] ?? ''));
|
||||
} else {
|
||||
$artist = trim((string) $artistField);
|
||||
}
|
||||
|
||||
$mbid = trim((string) ($rawAlbum['mbid'] ?? ''));
|
||||
$id = $preferredId ?? $this->buildAlbumIdentifier($mbid !== '' ? $mbid : null, $artist, $name);
|
||||
|
||||
$tracks = $this->normalizeTracks($rawAlbum['tracks']['track'] ?? [], $id);
|
||||
$genres = [];
|
||||
foreach ($this->normalizeList($rawAlbum['tags']['tag'] ?? []) as $tag) {
|
||||
$label = $this->normalizeGenreLabel((string) ($tag['name'] ?? ''));
|
||||
if ($label !== '') {
|
||||
$genres = [$label];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$releaseDate = $this->normalizePublishedDate((string) ($rawAlbum['wiki']['published'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'artists' => [['name' => $artist]],
|
||||
'genres' => $genres,
|
||||
'release_date' => $releaseDate,
|
||||
'total_tracks' => count($tracks),
|
||||
'images' => $this->normalizeImages($rawAlbum['image'] ?? []),
|
||||
'external_urls' => ['external' => $this->stringOrNull($rawAlbum['url'] ?? null)],
|
||||
'source' => 'external',
|
||||
'tracks' => [
|
||||
'items' => $tracks,
|
||||
'total' => count($tracks),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rawTracks
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function normalizeTracks(mixed $rawTracks, string $albumId): array
|
||||
{
|
||||
$tracks = [];
|
||||
$rows = $this->normalizeList($rawTracks);
|
||||
$index = 1;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rank = (int) ($row['@attr']['rank'] ?? $row['rank'] ?? $index);
|
||||
$seconds = (int) ($row['duration'] ?? 0);
|
||||
$tracks[] = [
|
||||
'id' => sha1($albumId . '|' . $rank . '|' . $name),
|
||||
'disc_number' => 1,
|
||||
'track_number' => max(1, $rank),
|
||||
'name' => $name,
|
||||
'duration_ms' => max(0, $seconds) * 1000,
|
||||
'preview_url' => null,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rawImages
|
||||
* @return list<array{url:string}>
|
||||
*/
|
||||
private function normalizeImages(mixed $rawImages): array
|
||||
{
|
||||
$images = [];
|
||||
foreach ($this->normalizeList($rawImages) as $image) {
|
||||
$url = trim((string) ($image['#text'] ?? $image['url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
$size = mb_strtolower(trim((string) ($image['size'] ?? '')));
|
||||
$images[] = [
|
||||
'url' => $url,
|
||||
'rank' => self::IMAGE_SIZE_RANK[$size] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($images === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usort($images, static fn(array $a, array $b) => $b['rank'] <=> $a['rank']);
|
||||
|
||||
$unique = [];
|
||||
$result = [];
|
||||
foreach ($images as $image) {
|
||||
$url = (string) ($image['url'] ?? '');
|
||||
if ($url === '' || isset($unique[$url])) {
|
||||
continue;
|
||||
}
|
||||
$unique[$url] = true;
|
||||
$result[] = ['url' => $url];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function normalizePublishedDate(string $published): ?string
|
||||
{
|
||||
$published = trim($published);
|
||||
if ($published === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($published))->format('Y-m-d');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAlbumIdentifier(?string $mbid, string $artist, string $album): string
|
||||
{
|
||||
if ($mbid !== null && trim($mbid) !== '') {
|
||||
return 'mbid:' . trim($mbid);
|
||||
}
|
||||
|
||||
$slugSource = mb_strtolower(trim($artist) . '|' . trim($album));
|
||||
$encoded = rtrim(strtr(base64_encode($slugSource), '+/', '-_'), '=');
|
||||
return 'slug:' . $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mbid:string}|array{artist:string,album:string}|null
|
||||
*/
|
||||
private function parseAlbumIdentifier(string $identifier): ?array
|
||||
{
|
||||
if (str_starts_with($identifier, 'mbid:')) {
|
||||
$mbid = trim(substr($identifier, 5));
|
||||
return $mbid === '' ? null : ['mbid' => $mbid];
|
||||
}
|
||||
|
||||
if (str_starts_with($identifier, 'slug:')) {
|
||||
$payload = substr($identifier, 5);
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
if ($decoded === false || !str_contains($decoded, '|')) {
|
||||
return null;
|
||||
}
|
||||
[$artist, $album] = explode('|', $decoded, 2);
|
||||
$artist = trim($artist);
|
||||
$album = trim($album);
|
||||
if ($artist === '' || $album === '') {
|
||||
return null;
|
||||
}
|
||||
return ['artist' => $artist, 'album' => $album];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
private function normalizeGenreLabel(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* LastFmMetadataRefresher re-syncs locally stored external albums from Last.fm.
|
||||
*/
|
||||
class LastFmMetadataRefresher
|
||||
{
|
||||
private const BATCH_SIZE = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly LastFmClient $lastFmClient,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly AlbumTrackRepository $trackRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly LastFmTagResolver $tagResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes all saved external albums and returns the number of payloads that were re-synced.
|
||||
*/
|
||||
public function refreshAllExternalAlbums(): int
|
||||
{
|
||||
$externalIds = $this->albumRepository->findAllExternalIds();
|
||||
if ($externalIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$updatedAlbumCount = 0;
|
||||
foreach (array_chunk($externalIds, self::BATCH_SIZE) as $albumIdChunk) {
|
||||
$payload = $this->lastFmClient->getAlbums($albumIdChunk);
|
||||
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
|
||||
if ($albums === []) {
|
||||
$this->logger->warning('External getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albums);
|
||||
|
||||
foreach ($albums as $albumPayload) {
|
||||
try {
|
||||
$albumId = (string) ($albumPayload['id'] ?? '');
|
||||
$albumEntity = $this->albumRepository->upsertFromExternalAlbum(
|
||||
(array) $albumPayload,
|
||||
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
||||
);
|
||||
if ($albumId !== '' && $albumEntity !== null) {
|
||||
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
|
||||
if ($tracks !== []) {
|
||||
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
|
||||
$albumEntity->setTotalTracks(count($tracks));
|
||||
}
|
||||
}
|
||||
$updatedAlbumCount++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to upsert external album', [
|
||||
'error' => $e->getMessage(),
|
||||
'album' => $albumPayload['id'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
return $updatedAlbumCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function resolveTrackPayloads(string $albumId, array $albumPayload): array
|
||||
{
|
||||
$tracks = (array) ($albumPayload['tracks']['items'] ?? []);
|
||||
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
|
||||
|
||||
if ($total > count($tracks)) {
|
||||
$fullTrackPayloads = $this->lastFmClient->getAlbumTracks($albumId);
|
||||
if ($fullTrackPayloads !== []) {
|
||||
return $fullTrackPayloads;
|
||||
}
|
||||
}
|
||||
|
||||
return $tracks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* LastFmTagResolver enriches album payloads with normalized Last.fm tags.
|
||||
*/
|
||||
class LastFmTagResolver
|
||||
{
|
||||
public function __construct(private readonly LastFmClient $lastFmClient)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $albums
|
||||
* @return array<string,list<string>> Map of album ID => tag list
|
||||
*/
|
||||
public function resolveGenresForAlbums(array $albums): array
|
||||
{
|
||||
$albumTags = [];
|
||||
|
||||
foreach ($albums as $album) {
|
||||
$albumId = trim((string) ($album['id'] ?? ''));
|
||||
if ($albumId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$genres = $this->extractGenres((array) ($album['genres'] ?? []));
|
||||
if ($genres !== []) {
|
||||
$albumTags[$albumId] = $genres;
|
||||
continue;
|
||||
}
|
||||
|
||||
$hydrated = $this->lastFmClient->getAlbumWithTracks($albumId);
|
||||
if ($hydrated === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved = $this->extractGenres((array) ($hydrated['genres'] ?? []));
|
||||
if ($resolved !== []) {
|
||||
$albumTags[$albumId] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return $albumTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $values
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractGenres(array $values): array
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
$genre = $this->toTitleCase(trim((string) $value));
|
||||
if ($genre !== '') {
|
||||
return [$genre];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function toTitleCase(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return mb_convert_case(mb_strtolower($value), MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace App\Service;
|
||||
use App\Repository\SettingRepository;
|
||||
|
||||
/**
|
||||
* RegistrationToggle centralizes the logic around the registration switch.
|
||||
* RegistrationToggle decides whether sign-ups are allowed, respecting env overrides and DB settings.
|
||||
*/
|
||||
final class RegistrationToggle
|
||||
{
|
||||
@@ -17,15 +17,15 @@ final class RegistrationToggle
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the environment-provided override, or null when unset.
|
||||
* Returns the environment override when present, otherwise null.
|
||||
*/
|
||||
public function envOverride(): ?bool
|
||||
public function getEnvOverride(): ?bool
|
||||
{
|
||||
return $this->envOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether registration should currently be enabled.
|
||||
* Tells callers whether registration is currently enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
@@ -37,15 +37,19 @@ final class RegistrationToggle
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a new database-backed toggle value.
|
||||
* Saves the given toggle value to the settings store.
|
||||
*/
|
||||
public function persist(bool $enabled): void
|
||||
{
|
||||
$this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes APP_ALLOW_REGISTRATION from the environment into a bool or null.
|
||||
*/
|
||||
private function detectEnvOverride(): ?bool
|
||||
{
|
||||
// Symfony loads env vars into both $_ENV and $_SERVER; prefer $_ENV for consistency.
|
||||
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* SpotifyClient wraps API calls with caching and error handling.
|
||||
*/
|
||||
class SpotifyClient
|
||||
{
|
||||
private HttpClientInterface $httpClient;
|
||||
private CacheInterface $cache;
|
||||
private ?string $clientId;
|
||||
private ?string $clientSecret;
|
||||
private SettingRepository $settings;
|
||||
|
||||
/**
|
||||
* Builds the client with HTTP, cache, and configuration dependencies.
|
||||
*/
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
CacheInterface $cache,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
SettingRepository $settings
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->cache = $cache;
|
||||
$this->clientId = $clientId;
|
||||
$this->clientSecret = $clientSecret;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Spotify albums by query string.
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
if ($accessToken === null) {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
$url = 'https://api.spotify.com/v1/search';
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ],
|
||||
];
|
||||
return $this->sendRequest('GET', $url, $options, 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single album by Spotify ID.
|
||||
*
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbum(string $albumId): ?array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId);
|
||||
$options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches album metadata plus the full tracklist.
|
||||
*
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId): ?array
|
||||
{
|
||||
$album = $this->getAlbum($albumId);
|
||||
if ($album === null) {
|
||||
return null;
|
||||
}
|
||||
$tracks = $this->getAlbumTracks($albumId);
|
||||
if ($tracks !== []) {
|
||||
$album['tracks'] = $album['tracks'] ?? [];
|
||||
$album['tracks']['items'] = $tracks;
|
||||
$album['tracks']['total'] = count($tracks);
|
||||
$album['tracks']['limit'] = count($tracks);
|
||||
$album['tracks']['offset'] = 0;
|
||||
$album['tracks']['next'] = null;
|
||||
$album['tracks']['previous'] = null;
|
||||
}
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the complete tracklist for an album.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$limit = 50;
|
||||
$offset = 0;
|
||||
do {
|
||||
$page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset);
|
||||
if ($page === null) {
|
||||
break;
|
||||
}
|
||||
$batch = (array) ($page['items'] ?? []);
|
||||
$items = array_merge($items, $batch);
|
||||
$offset += $limit;
|
||||
$total = isset($page['total']) ? (int) $page['total'] : null;
|
||||
$hasNext = isset($page['next']) && $page['next'] !== null;
|
||||
} while ($hasNext && ($total === null || $offset < $total));
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple albums with one call.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds): ?array
|
||||
{
|
||||
if ($albumIds === []) { return []; }
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) { return null; }
|
||||
$url = 'https://api.spotify.com/v1/albums';
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'ids' => implode(',', $albumIds) ],
|
||||
];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized request helper with lightweight caching.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array
|
||||
{
|
||||
$cacheKey = null;
|
||||
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
|
||||
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
|
||||
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
|
||||
// placeholder; we'll set item value explicitly below on miss
|
||||
$item->expiresAfter(1);
|
||||
return null;
|
||||
});
|
||||
if (is_array($cached) && !empty($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$data = $response->toArray(false);
|
||||
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
|
||||
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array
|
||||
{
|
||||
$url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId));
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'limit' => $limit, 'offset' => $offset ],
|
||||
];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 1200);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached access token or refreshes credentials when missing.
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
$cacheKey = 'spotify_client_credentials_token';
|
||||
$token = $this->cache->get($cacheKey, function (ItemInterface $item) {
|
||||
// Default to ~1 hour, adjusted after Spotify response
|
||||
$item->expiresAfter(3500);
|
||||
|
||||
$clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
|
||||
$clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
// surface the miss quickly so the cache can be recomputed on the next request
|
||||
$item->expiresAfter(60);
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret),
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => 'grant_type=client_credentials',
|
||||
]);
|
||||
|
||||
$data = $response->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
$item->expiresAfter(60);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($data['expires_in']) && is_int($data['expires_in'])) {
|
||||
$ttl = max(60, $data['expires_in'] - 60);
|
||||
$item->expiresAfter($ttl);
|
||||
}
|
||||
|
||||
return $data['access_token'];
|
||||
});
|
||||
|
||||
if ($token === null) {
|
||||
// Remove failed entries so the next request retries instead of serving cached nulls.
|
||||
$this->cache->delete($cacheKey);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
/**
|
||||
* UploadStorage handles moving uploaded files into a stable storage root
|
||||
* and returns web-ready paths for use in templates.
|
||||
*
|
||||
* By default this stores under "<projectDir>/public/uploads" inside the
|
||||
* container (i.e. "/var/www/html/var/data/uploads").
|
||||
*/
|
||||
class UploadStorage
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $storageRoot,
|
||||
private readonly string $publicPrefix,
|
||||
private readonly SluggerInterface $slugger,
|
||||
) {
|
||||
$this->filesystem = new Filesystem();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a profile avatar and returns the path the front end can render.
|
||||
*/
|
||||
public function storeProfileImage(UploadedFile $file): string
|
||||
{
|
||||
return $this->storeInNamespace($file, 'avatars');
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an album cover and returns the path the front end can render.
|
||||
*/
|
||||
public function storeAlbumCover(UploadedFile $file): string
|
||||
{
|
||||
return $this->storeInNamespace($file, 'album_covers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a stored file when the provided web path points to a file
|
||||
* managed under the configured storage root.
|
||||
*/
|
||||
public function remove(?string $webPath): void
|
||||
{
|
||||
if ($webPath === null || $webPath === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$absolutePath = $this->resolveAbsolutePathFromWebPath($webPath);
|
||||
if ($absolutePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->filesystem->exists($absolutePath)) {
|
||||
$this->filesystem->remove($absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the uploaded file into the requested uploads namespace and returns its web path.
|
||||
*
|
||||
* @param UploadedFile $file Uploaded Symfony file object.
|
||||
* @param string $namespace Logical namespace under the storage root (e.g. "avatars").
|
||||
*/
|
||||
private function storeInNamespace(UploadedFile $file, string $namespace): string
|
||||
{
|
||||
$namespace = trim($namespace, '/');
|
||||
|
||||
$targetDir = rtrim($this->storageRoot, '/') . '/' . $namespace;
|
||||
if (!$this->filesystem->exists($targetDir)) {
|
||||
$this->filesystem->mkdir($targetDir);
|
||||
}
|
||||
|
||||
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$safeName = $this->slugger->slug($originalName ?: 'file');
|
||||
$extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
|
||||
// The uniqid suffix avoids collisions when users upload files with identical names.
|
||||
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension);
|
||||
|
||||
$file->move($targetDir, $filename);
|
||||
|
||||
$publicPrefix = '/' . ltrim($this->publicPrefix, '/');
|
||||
|
||||
return sprintf('%s/%s/%s', rtrim($publicPrefix, '/'), $namespace, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a stored web path back into an absolute filesystem path
|
||||
* under the storage root, or null when it is outside the managed prefix.
|
||||
*/
|
||||
private function resolveAbsolutePathFromWebPath(string $webPath): ?string
|
||||
{
|
||||
$normalizedPath = '/' . ltrim($webPath, '/');
|
||||
$normalizedPrefix = '/' . ltrim($this->publicPrefix, '/');
|
||||
|
||||
// Only strip the prefix when the path starts with our configured public prefix.
|
||||
if (str_starts_with($normalizedPath, $normalizedPrefix)) {
|
||||
$relative = ltrim(substr($normalizedPath, strlen($normalizedPrefix)), '/');
|
||||
} else {
|
||||
// Fallback: treat the incoming path as already relative to the storage root.
|
||||
$relative = ltrim($webPath, '/');
|
||||
}
|
||||
|
||||
if ($relative === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim($this->storageRoot, '/') . '/' . $relative;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,12 @@
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>
|
||||
{{ form_label(form.SPOTIFY_CLIENT_ID) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_label(form.LASTFM_API_KEY) }}
|
||||
{{ form_widget(form.LASTFM_API_KEY, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
{% set registrationToggleAttrs = {class: 'form-check-input'} %}
|
||||
@@ -34,6 +30,61 @@
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Maintenance</h2>
|
||||
<form method="post" action="{{ path('admin_settings_reset_catalog') }}" onsubmit="return confirm('Delete all albums, tracks, and reviews? Users remain untouched.');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('admin_settings_reset_catalog') }}">
|
||||
<button class="btn btn-outline-danger" type="submit">Reset album & review data</button>
|
||||
<div class="form-text mt-1">Deletes all albums (and tracks) plus reviews. Users and settings stay intact.</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Generate demo data</h2>
|
||||
<div class="vstack gap-4">
|
||||
{% for key, command in demoCommands %}
|
||||
<form class="p-3 border rounded" method="post" action="{{ path('admin_settings_generate_demo', {type: key}) }}">
|
||||
<div class="row gy-3 align-items-center">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="fw-semibold">{{ command.label }}</div>
|
||||
<div class="text-secondary small">{{ command.description }}</div>
|
||||
<div class="text-secondary small">Command: <code>php bin/console {{ command.command }}</code></div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="row g-3 align-items-end">
|
||||
{% for field in command.fields %}
|
||||
{% if field.type != 'checkbox' %}
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="{{ key }}_{{ field.name }}">{{ field.label }}</label>
|
||||
<input class="form-control form-control-sm" id="{{ key }}_{{ field.name }}" type="{{ field.type }}" name="{{ field.name }}" placeholder="{{ field.placeholder|default('') }}" value="{{ field.default|default('') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 mt-3">
|
||||
{% for field in command.fields %}
|
||||
{% if field.type == 'checkbox' %}
|
||||
<div class="form-check form-switch" style="min-width: 220px;">
|
||||
<input class="form-check-input" type="checkbox" name="{{ field.name }}" id="{{ key }}_{{ field.name }}" {% if field.default is defined and field.default %}checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ key }}_{{ field.name }}">{{ field.label }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end mt-3">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('admin_settings_generate_' ~ key) }}">
|
||||
<button class="btn btn-outline-primary btn-sm" type="submit">Run</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Site Dashboard{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Site dashboard</h1>
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||
<h1 class="h4 mb-0">Site dashboard</h1>
|
||||
<form method="post" action="{{ path('admin_dashboard_refresh_external') }}" class="ms-auto">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_external') }}">
|
||||
<button class="btn btn-outline-success" type="submit">
|
||||
Refresh Last.fm metadata
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
@@ -60,7 +68,7 @@
|
||||
<h2 class="h6 mb-3">Latest albums (50)</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for a in recentAlbums %}
|
||||
{% set publicId = a.source == 'user' ? a.localId : a.spotifyId %}
|
||||
{% set publicId = a.source == 'user' ? a.localId : a.externalId %}
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-2">
|
||||
<div>
|
||||
@@ -69,7 +77,7 @@
|
||||
{% else %}
|
||||
<span>{{ a.name }}</span>
|
||||
{% endif %}
|
||||
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Spotify</span>{% endif %}
|
||||
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Last.fm</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
@@ -90,4 +98,3 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
{% set artist_value = artist|default('') %}
|
||||
{% set year_from_value = year_from|default('') %}
|
||||
{% set year_to_value = year_to|default('') %}
|
||||
{% set genre_value = genre|default('') %}
|
||||
{% set source_value = source|default('all') %}
|
||||
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set landing_view = not has_search %}
|
||||
|
||||
{% if landing_view %}
|
||||
@@ -27,6 +28,14 @@
|
||||
{% endif %}
|
||||
|
||||
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
||||
{% if source_value != 'user' and externalConfigured is defined and not externalConfigured %}
|
||||
<div class="alert alert-info">
|
||||
Last.fm is not configured yet. Results will only include user-created albums.
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a class="alert-link" href="{{ path('admin_settings') }}">Enter Last.fm API key</a>.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
||||
{% if landing_view %}
|
||||
<div>
|
||||
@@ -42,7 +51,7 @@
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -52,6 +61,9 @@
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
</div>
|
||||
@@ -74,7 +86,7 @@
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="external" {{ source_value == 'external' ? 'selected' : '' }}>Last.fm</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -84,6 +96,9 @@
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
|
||||
</div>
|
||||
@@ -100,7 +115,7 @@
|
||||
{% for album in albums %}
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
|
||||
{% set image = (album.images[0] ?? album.images[1] ?? null) %}
|
||||
{% if image %}
|
||||
<a href="{{ path('album_show', {id: album.id}) }}">
|
||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
||||
@@ -109,12 +124,17 @@
|
||||
<div class="card-body d-flex flex-column">
|
||||
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
|
||||
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
|
||||
<p class="card-text text-secondary">Released {{ album.release_date }} • {{ album.total_tracks }} tracks</p>
|
||||
<p class="card-text text-secondary">
|
||||
Released {{ album.release_date }} • {{ album.total_tracks }} tracks
|
||||
{% if album.genres is defined and album.genres is not empty %}
|
||||
<br><small>Genre: {{ album.genres|join(', ') }}</small>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
|
||||
<div class="mt-auto">
|
||||
{% if album.external_urls.spotify is defined and album.external_urls.spotify %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% if album.external_urls.external is defined and album.external_urls.external %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||
@@ -133,9 +153,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif query or album or artist or year_from or year_to %}
|
||||
{% elseif query or album or artist or genre or year_from or year_to %}
|
||||
<p>No albums found.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
|
||||
{% set image = (album.images[0] ?? album.images[1] ?? null) %}
|
||||
{% if image %}
|
||||
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
|
||||
{% endif %}
|
||||
@@ -24,9 +24,12 @@
|
||||
</small>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if album.genres is defined and album.genres is not empty %}
|
||||
<p class="text-secondary mb-2">Genres: {{ album.genres|join(', ') }}</p>
|
||||
{% endif %}
|
||||
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
|
||||
{% if album.external_urls.spotify %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% if album.external_urls.external %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.external }}" target="_blank" rel="noopener">Open Source</a>
|
||||
{% endif %}
|
||||
{% if album.source is defined and album.source == 'user' %}
|
||||
<span class="badge text-bg-primary ms-2">User album</span>
|
||||
@@ -150,5 +153,3 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
{% block javascripts %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
{% endblock %}
|
||||
{% include '_partials/auth_modal.html.twig' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<p class="text-secondary">
|
||||
{{ review.album.name }} — {{ review.album.artists|join(', ') }}
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId ?? review.album.localId}) }}">View album</a>
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.externalId ?? review.album.localId}) }}">View album</a>
|
||||
</p>
|
||||
<article class="mb-3">
|
||||
<p>{{ review.content|nl2br }}</p>
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user