Compare commits

..
13 Commits
Author SHA1 Message Date
boris 56dea097d6 Migration to last.fm, removed old spotify api client. FOSRestBundle API serving data driven front end (JWT). 2026-02-27 16:52:57 +00:00
boris 391ecf1732 Update Monolog action level to info, adjust Dockerfile permissions and logging configuration, and set supervisord user to root
CI (Gitea) / php-tests (push) Successful in 10m4s
CI (Gitea) / docker-image (push) Successful in 2m23s
2025-11-28 10:08:36 +00:00
boris 4ae7a44881 Update trusted proxies configuration to use default environment variable and add importmap block in base template
CI (Gitea) / php-tests (push) Successful in 10m16s
CI (Gitea) / docker-image (push) Successful in 2m29s
2025-11-28 08:59:28 +00:00
boris fa54cb4167 Enhance CI workflow by adding PHP setup step and caching for vendor directory
CI (Gitea) / php-tests (push) Failing after 6m22s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 08:49:59 +00:00
boris f109c933c1 add trusted proxies configuration
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 08:47:17 +00:00
boris d52eb6bd81 documentation and env changes
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s
2025-11-28 08:14:13 +00:00
boris f77f3a9e40 its 7am i havent slept i have no idea
CI (Gitea) / php-tests (push) Successful in 10m5s
CI (Gitea) / docker-image (push) Successful in 2m22s
2025-11-28 06:40:10 +00:00
boris 336dcc4d3a erm
CI (Gitea) / php-tests (push) Successful in 10m23s
CI (Gitea) / docker-image (push) Successful in 3m3s
2025-11-28 03:23:52 +00:00
boris 54b1908793 Add APP_SECRET to Dockerfile
CI (Gitea) / php-tests (push) Successful in 10m11s
CI (Gitea) / docker-image (push) Failing after 2m17s
2025-11-28 02:59:01 +00:00
boris dda9ff06b5 add temp build secret
CI (Gitea) / php-tests (push) Successful in 10m7s
CI (Gitea) / docker-image (push) Failing after 1m38s
2025-11-28 02:37:54 +00:00
boris 796acaa9c0 changed project name
CI (Gitea) / php-tests (push) Successful in 10m6s
CI (Gitea) / docker-image (push) Failing after 2m34s
2025-11-28 02:16:00 +00:00
boris 3879c6c312 update composer.json
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 02:14:49 +00:00
boris da9af888c0 Attempt to be prod ready
CI (Gitea) / php-tests (push) Failing after 1m30s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 02:11:23 +00:00
95 changed files with 3722 additions and 1171 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
.DS_Store
.cursor
.env.local
.git
.gitea
.github
.idea
.public
backup_manifests/
docs/
tests/
var/
# Symfony cache/logs generated inside the container should not come from the host
var/cache/
var/log/
# Uploaded files stay on the host, not baked into images
public/uploads/
+10 -15
View File
@@ -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
View File
@@ -1,20 +1,74 @@
name: CI - Build Tonehaus Docker image
name: CI (Gitea)
on:
push:
branches: [ main ]
branches:
- main
- prod
pull_request:
branches:
- main
- prod
workflow_dispatch:
env:
IMAGE_NAME: tonehaus
APP_ENV: test
APP_SECRET: ci-secret
DATABASE_DRIVER: sqlite
DATABASE_SQLITE_PATH: ${{ gitea.workspace }}/var/data/database.test.sqlite
DOCKERFILE: docker/php/Dockerfile
BUILD_TARGET: prod
PLATFORMS: linux/amd64
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 }}
+134
View File
@@ -0,0 +1,134 @@
name: CI
on:
push:
branches:
- main
- prod
pull_request:
branches:
- main
- prod
permissions:
contents: read
packages: write
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
APP_ENV: test
APP_SECRET: ci-secret
DATABASE_DRIVER: sqlite
DATABASE_SQLITE_PATH: ${{ github.workspace }}/var/data/database.test.sqlite
jobs:
php-tests:
name: PHPUnit + migrations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
id: setup-php
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
coverage: none
ini-values: memory_limit=512M
tools: composer:v2
- name: Validate Composer manifest
run: composer validate --strict
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: |
~/.cache/composer/files
~/.cache/composer/vcs
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
- name: Cache vendor directory
id: cache-vendor
uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
env:
COMPOSER_NO_INTERACTION: 1
COMPOSER_MEMORY_LIMIT: -1
- name: Prepare SQLite database
run: |
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
touch "$DATABASE_SQLITE_PATH"
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
- name: Run PHPUnit
run: vendor/bin/phpunit --colors=always
docker-image:
name: Build production image
needs: php-tests
runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build prod image
id: build-prod
uses: docker/build-push-action@v6
with:
context: .
file: docker/php/Dockerfile
target: prod
load: true
push: false
tags: tonehaus-app:ci
- name: Verify baked APP_ENV
run: docker run --rm --entrypoint sh tonehaus-app:ci -c 'test "$APP_ENV" = "prod"'
- name: Verify Symfony artifacts exist
run: |
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/public/index.php'
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/bin/console'
- name: Smoke-test entrypoint & migrations
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag latest image
if: github.event_name == 'push'
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
- name: Push latest image
if: github.event_name == 'push'
run: docker push $IMAGE_NAME:latest
-10
View File
@@ -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
View File
@@ -12,11 +12,9 @@
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
@@ -31,11 +29,8 @@
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
@@ -43,7 +38,6 @@
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
@@ -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" />
+52 -50
View File
@@ -1,8 +1,8 @@
# Tonehaus — Music Ratings
# Tonehaus — Music Ratings (Symfony 7)
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
Discover albums via Last.fm, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
## Quick start
## Quick start (Docker Compose)
1) Start the stack
@@ -10,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
+1
View File
@@ -0,0 +1 @@
// Intentionally empty importmap entrypoint.
+5
View File
@@ -1,4 +1,6 @@
{
"name": "tonehaus/tonehaus",
"description": "Tonehaus — discover albums, manage reviews, and administer site settings with a Symfony 7 stack.",
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
@@ -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
View File
@@ -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],
+29 -1
View File
@@ -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');
}
+10
View File
@@ -0,0 +1,10 @@
fos_rest:
format_listener:
rules:
- { path: ^/api, prefer_extension: false, fallback_format: json, priorities: [json] }
- { path: ^/, prefer_extension: false, fallback_format: html, priorities: [html, '*/*'] }
body_listener: true
view:
view_response_listener: force
formats:
json: true
+2
View File
@@ -1,6 +1,8 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
# Note that the session will be started ONLY if you read or write from it.
session: true
@@ -0,0 +1,5 @@
lexik_jwt_authentication:
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
public_key: '%kernel.project_dir%/config/jwt/public.pem'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
+1 -1
View File
@@ -42,7 +42,7 @@ when@prod:
handlers:
main:
type: fingers_crossed
action_level: error
action_level: info
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+2 -2
View File
@@ -1,6 +1,6 @@
server {
listen 80;
server_name localhost; # host header (not used locally)
server_name _; # host header (not used locally)
root /var/www/html/public; # Symfony's public/ dir (front controller)
location / {
@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
# Forward PHP requests to php-fpm service
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_pass tonehaus:9000;
# Use resolved path to avoid path traversal issues
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
+133 -114
View File
@@ -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"]
+84
View File
@@ -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 "$@"
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "OK";
}
location ^/api/v1 {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
}
+22
View File
@@ -0,0 +1,22 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
user=root
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
-47
View File
@@ -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
```
-16
View File
@@ -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
-38
View File
@@ -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).
-20
View File
@@ -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
-22
View File
@@ -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.
-20
View File
@@ -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.
+88
View File
@@ -0,0 +1,88 @@
# Architecture
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
## Naming & reusability standards (PHP)
- **Classes**
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `LastFmClient`, `LastFmMetadataRefresher`).
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
- **Methods**
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllExternalAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isExternalConfigured()`).
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
- **Variables & parameters**
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$lastfmAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
- Booleans read naturally (`$isEnabled`, `$shouldQueryLast.fm`, `$needsSync`).
- Collections are pluralized (`$albums`, `$userReviews`, `$lastfmIds`).
- **Files & namespaces**
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
## High-level flow
1. Visitors search for albums (Last.fm) and view an album page
2. Logged‑in users can write, edit, and delete reviews
3. Moderators and admins can moderate content and manage users
4. Admins configure site settings (Last.fm credentials, registration toggle)
## Layers & components
### Controllers (`src/Controller/*`)
- `AlbumController` — search, album detail, inline review creation
- `ReviewController` — view, edit, and delete reviews
- `AccountController` — profile, password, and user settings pages
- `Admin/*` — site dashboard, user management, and settings
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
### Entities (`src/Entity/*`)
- `User` — authentication principal and roles
- `Album`, `AlbumTrack` — normalized album metadata and track list
- `Review` — user‑authored review with rating and timestamps
- `Setting` — key/value store for site configuration (e.g., Last.fm credentials)
### Repositories (`src/Repository/*`)
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
### Forms (`src/Form/*`)
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
- Leverage Symfony validation constraints for robust server‑side validation
### Services (`src/Service/*`)
- `LastFmClient` — Last.fm API integration with caching
- `LastFmMetadataRefresher`, `LastFmTagResolver` — helpers for richer album data
- `CatalogResetService` — admin action to reset/sync catalog state safely
- `UploadStorage` — avatar/album image uploads and related file handling
- `RegistrationToggle` — DB‑backed registration flag with env override
### Security (`config/packages/security.yaml`, `src/Security/*`)
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
- Access control for `/admin/*` enforced via routes and controllers
### Views (`templates/*`)
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
- Auth modal in `templates/_partials/auth_modal.html.twig`
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
### DTOs (`src/Dto/*`)
- Simple data transfer objects for admin tables and search results
## Data & persistence
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
## Error handling & UX
- 404 for missing albums
- Flash messages for success/error on actions
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
## Testing & tooling
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
- Web Profiler enabled in dev
+48
View File
@@ -0,0 +1,48 @@
# Authentication & Users
## Login & Registration (modal)
- Login and sign‑up are handled in a Bootstrap modal.
- AJAX submits keep users on the page; a successful login refreshes state.
- Remember‑me cookie keeps users logged in across sessions.
## Roles & Permissions
- `ROLE_USER` — default for registered users
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
Promotion (from your host):
```bash
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
```
### Access flow
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
## Public registration toggle
- Toggle in UI: `/admin/settings` (stored in DB)
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
## User management (moderator+)
- `/admin/users` lists accounts with album/review counts and actions:
- Create accounts inline (does not affect the current session)
- Delete users (guards prevent deleting self or administrators)
- Admins can Promote/Demote Moderator on non‑admins
## Profiles & Passwords
- `/account/profile`: update email and display name
- `/account/password`: change password (requires current password)
## Demo accounts & avatars
```bash
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
```
## Logout
- Link in the user menu calls `/logout` (handled by Symfony security).
+68
View File
@@ -0,0 +1,68 @@
# Deployment
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
## Build (locally)
```bash
docker build \
--target=prod \
-t tonehaus-app:latest \
-f docker/php/Dockerfile \
.
```
## Run
```bash
docker run -d \
--name tonehaus \
-p 8080:8080 \
-e APP_ENV=prod \
-e APP_SECRET=change_me \
-e LASTFM_API_KEY=your_api_key \
tonehaus-app:latest
```
### Notes
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
- Cache warmup is executed on boot; `APP_SECRET` is required
## Persistence options
### SQLite (default)
- Data file at `var/data/database.sqlite`
- Use a volume for durability:
```bash
docker run -d \
-v tonehaus_sqlite:/var/www/html/var/data \
...
```
### Postgres
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
```
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
```
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
```bash
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
```
## Environment variables
- `APP_ENV` (`prod` recommended in production)
- `APP_SECRET` (required; random string)
- `LASTFM_API_KEY`
- `APP_ALLOW_REGISTRATION` (env override for public registration)
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
- `DATABASE_URL` (when using Postgres)
- `DATABASE_SQLITE_PATH` (optional)
- `RUN_MIGRATIONS_ON_START` (default `1`)
## Reverse proxy / TLS
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
- Terminate TLS at the proxy and forward to the container’s port 8080
- Ensure proxy sends `X-Forwarded-*` headers
## Zero‑downtime tips
- Build then run a new container alongside the old one, switch traffic at the proxy
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
+31
View File
@@ -0,0 +1,31 @@
# Features
## Albums & Reviews
- Last.fm album search with advanced filters (album, artist, year range)
- Album page: cover art, metadata, full tracklist (when available)
- Reviews list (newest first) and inline new review form (logged-in)
- Rating slider (1–10) with live badge
- Per‑album aggregates: average rating and total review count
## Authentication & Users
- Bootstrap auth modal for login/sign-up with AJAX submits
- Remember‑me cookie keeps users signed in
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
- Profile: update email, display name, and password (requires current password)
## Administration
- Dashboard: latest reviews/albums and key counts (moderator+)
- Users: create/delete users, promote/demote moderators (admin constraints)
- Settings: manage Last.fm credentials, toggle public registration (admin)
## Design & UX
- Responsive Bootstrap UI
- Light/Dark theme toggle (cookie-backed)
- CSRF protection on forms
- Access control via role hierarchy and security voters
## Screenshots (placeholders)
- Search page — `docs/img/search.png` (optional)
- Album page — `docs/img/album.png` (optional)
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
+26
View File
@@ -0,0 +1,26 @@
# Last.fm Integration
## Credentials
- Preferred: manage the Last.fm API key in `/admin/settings` (persisted in DB; no restart required).
- Env fallback: `LASTFM_API_KEY`.
## API Client
- `src/Service/LastFmClient.php` (uses Guzzle)
- `searchAlbums(query, limit)`
- `getAlbum(id)` / `getAlbums([ids])`
- `getAlbumWithTracks(id)`
- `getAlbumTracks(id)`
## Caching & Limits
- Last.fm responses are cached in Symfony cache pools to reduce repeated calls.
- Search/detail payloads are normalized to one internal album shape before persistence.
## Ingestion Flow
1. Search Last.fm
2. Resolve tags/genres via `LastFmTagResolver`
3. Persist or update album with `AlbumRepository::upsertFromExternalAlbum()`
4. Persist tracks with `AlbumTrackRepository::replaceAlbumTracks()`
## Admin Settings
- Set the Last.fm API key in `/admin/settings`.
- Key changes apply immediately.
+31
View File
@@ -0,0 +1,31 @@
# Reviews & Albums
## Album page
- Artwork, metadata, average rating, and review count
- Full Last.fm tracklist when available
- Reviews list (newest first)
- Inline new review form for logged‑in users
## Writing a review
- Rating slider from 1–10
- Title (max 160 chars) and body (20–5000 chars)
- Server-side validation provides inline errors on failure
- Successful submissions persist, flash a success message, and reload the album page
## Editing & deleting reviews
- Authors can edit/delete their own reviews
- Moderators/Admins can edit/delete any review
- CSRF protection is required for deletion
## Aggregates
- The album page computes:
- Total number of reviews for the album
- Average rating rounded to one decimal
## Demo data
```bash
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
- Use `--only-empty` to focus on albums that currently have no reviews.
+68
View File
@@ -0,0 +1,68 @@
# Setup
## Prerequisites
- Docker + Docker Compose
- Last.fm API key
- A unique `APP_SECRET` value in your environment (for prod builds)
## 1) Start the stack
```bash
docker compose up -d --build
```
App: `http://localhost:8085`
Health: `http://localhost:8085/healthz`
## 2) Create an admin
```bash
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
```
## 3) Configure Last.fm
- Preferred: open `/admin/settings` and enter your Last.fm API key (stored in DB)
- Env fallback (in `.env` or your shell):
```bash
LASTFM_API_KEY=your_api_key
```
## 4) JWT auth keys
- API login endpoint: `POST /api/v1/auth/login`
- JWT keys are generated automatically on container start into `config/jwt/`.
- Set `JWT_PASSPHRASE` in `.env` to your production secret.
## 5) (Optional) Seed demo data
```bash
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
## Database drivers
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
```
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
```
## Environment variables
- `APP_ENV=dev|prod`
- `APP_SECRET=<random_string>`
- `LASTFM_API_KEY`
- `JWT_PASSPHRASE=<random_string>`
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
- `DATABASE_DRIVER=sqlite|postgres`
- `DATABASE_SQLITE_PATH` (optional)
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
## Useful commands
```bash
# Symfony cache
docker compose exec tonehaus php bin/console cache:clear
# Inspect routes
docker compose exec tonehaus php bin/console debug:router
# Promote moderator
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
```
+45
View File
@@ -0,0 +1,45 @@
# Troubleshooting
## Cannot find template or routes
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
- List routes: `docker compose exec tonehaus php bin/console debug:router`
## Missing vendors
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
## .env not read in container
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
## Login modal shows blank
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
## Hitting admin routes redirects to home
- Expected when not logged in or lacking the required role.
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
- Use the console commands in `admin-and-settings.md` to grant roles.
## SQLite file permissions
- The default SQLite path is `var/data/database.sqlite`.
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
## Postgres connection issues
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
- Example URL:
```
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
```
## Last.fm errors
- Verify credentials in `/admin/settings` or env var `LASTFM_API_KEY`.
## ARM64 Build
```bash
sudo docker buildx build \
--platform linux/arm64 \
--target prod \
-t tonehaus/tonehaus:dev-arm64 \
-f docker/php/Dockerfile \
. \
--load
```
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Importmap configuration for the asset mapper.
*
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
];
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205134500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add genres JSON column to albums for advanced search filters';
}
public function up(Schema $schema): void
{
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
return;
}
if ($this->isSqlite()) {
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
return;
}
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite cannot drop columns without rebuilding the table; leave as-is.
return;
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
$this->addSql('ALTER TABLE albums DROP genres');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
{
if (!$schema->hasTable($tableName)) {
return false;
}
return !$schema->getTable($tableName)->hasColumn($column);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add external source columns (external_id, external_track_id) and OAuth identity fields on users';
}
public function up(Schema $schema): void
{
if ($this->shouldAddColumn($schema, 'albums', 'external_id')) {
$this->addSql('ALTER TABLE albums ADD external_id VARCHAR(64) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('ALTER TABLE album_tracks ADD external_track_id VARCHAR(64) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'users', 'oauth_provider')) {
$this->addSql('ALTER TABLE users ADD oauth_provider VARCHAR(50) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'users', 'oauth_id')) {
$this->addSql('ALTER TABLE users ADD oauth_id VARCHAR(190) DEFAULT NULL');
}
if ($this->hasColumn($schema, 'albums', 'spotify_id') && $this->hasColumn($schema, 'albums', 'external_id')) {
$this->addSql('UPDATE albums SET external_id = spotify_id WHERE external_id IS NULL AND spotify_id IS NOT NULL');
}
if ($this->hasColumn($schema, 'albums', 'source')) {
$this->addSql("UPDATE albums SET source = 'external' WHERE source = 'spotify'");
if (!$this->isSqlite()) {
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'external'");
}
}
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id') && $this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('UPDATE album_tracks SET external_track_id = spotify_track_id WHERE external_track_id IS NULL AND spotify_track_id IS NOT NULL');
}
if (!$this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
$this->addSql('CREATE UNIQUE INDEX uniq_album_external_id ON albums (external_id)');
}
if (!$this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
$this->addSql('CREATE UNIQUE INDEX uniq_users_oauth_identity ON users (oauth_provider, oauth_id)');
}
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite column drops require table rebuilds; keep schema forward-only here.
return;
}
if ($this->indexExists($schema, 'users', 'uniq_users_oauth_identity')) {
$this->addSql('DROP INDEX uniq_users_oauth_identity');
}
if ($this->indexExists($schema, 'albums', 'uniq_album_external_id')) {
$this->addSql('DROP INDEX uniq_album_external_id');
}
if ($this->hasColumn($schema, 'users', 'oauth_id')) {
$this->addSql('ALTER TABLE users DROP COLUMN oauth_id');
}
if ($this->hasColumn($schema, 'users', 'oauth_provider')) {
$this->addSql('ALTER TABLE users DROP COLUMN oauth_provider');
}
if ($this->hasColumn($schema, 'album_tracks', 'external_track_id')) {
$this->addSql('ALTER TABLE album_tracks DROP COLUMN external_track_id');
}
if ($this->hasColumn($schema, 'albums', 'external_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN external_id');
}
if ($this->hasColumn($schema, 'albums', 'source')) {
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source = 'external'");
$this->addSql("ALTER TABLE albums ALTER source SET DEFAULT 'spotify'");
}
}
private function shouldAddColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && !$this->hasColumn($schema, $tableName, $columnName);
}
private function hasTable(Schema $schema, string $tableName): bool
{
return $schema->hasTable($tableName);
}
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
}
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+6 -3
View File
@@ -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>');
+6 -3
View File
@@ -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>');
+24
View File
@@ -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);
+25
View File
@@ -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')
+11
View File
@@ -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;
}
+12
View File
@@ -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);
+17 -17
View File
@@ -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', [
+26 -1
View File
@@ -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');
}
}
+126 -8
View File
@@ -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;
}
}
+13 -10
View File
@@ -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.');
}
+82 -54
View File
@@ -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 -6
View File
@@ -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')]
+9 -2
View File
@@ -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;
}
+30 -6
View File
@@ -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';
}
}
+7 -2
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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
View File
@@ -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);
}
}
+6
View File
@@ -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
View File
@@ -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));
}
}
+3
View File
@@ -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
{
/**
+3
View File
@@ -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
{
/**
+3
View File
@@ -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
{
/**
+6 -9
View File
@@ -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
}
}
+132 -49
View File
@@ -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 [];
}
}
+40 -9
View File
@@ -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;
}
}
+4 -4
View File
@@ -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 = [];
+2 -2
View File
@@ -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
{
+14 -1
View File
@@ -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
}
}
+4 -2
View File
@@ -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();
}
}
+241 -89
View File
@@ -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;
}
}
+40
View File
@@ -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,
];
}
}
+44
View File
@@ -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());
}
}
-58
View File
@@ -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;
}
}
+443
View File
@@ -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');
}
}
+94
View File
@@ -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;
}
}
+72
View File
@@ -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');
}
}
+9 -5
View File
@@ -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;
-258
View File
@@ -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;
}
}
+117
View File
@@ -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;
}
}
+59 -8
View File
@@ -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 %}
+11 -4
View File
@@ -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 %}
+1
View File
@@ -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>
+1
View File
@@ -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>
+29 -11
View File
@@ -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 %}
+6 -5
View File
@@ -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 %}
+3
View File
@@ -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>
+1 -1
View File
@@ -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>
BIN
View File
Binary file not shown.