Compare commits

12 Commits
main ... prod

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

BIN
.DS_Store vendored

Binary file not shown.

19
.dockerignore Normal file
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/

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.
#SPOTIFY_CLIENT_ID=
#SPOTIFY_CLIENT_SECRET=
# 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 Spotify.
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

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
.github/workflows/ci.yml vendored Normal file
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
.idea/musicratings.iml generated
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" />

10
.idea/php.xml generated
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" />

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 Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
## Quick start
## Quick start (Docker Compose)
1) Start the stack
@@ -10,67 +10,66 @@ 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 Spotify
- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or
- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
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
- Spotify search with advanced filters (album, artist, year range) and peralbum aggregates (avg/count)
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
- Auth modal (Login/Sign up) with rememberme; no separate pages
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
- Admin Site Settings: manage Spotify credentials 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`
- Spotify integration: `docs/spotify-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)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- `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

5
assets/app.js Normal file
View File

@@ -0,0 +1,5 @@
// Placeholder front-end entrypoint to satisfy importmap & asset mapper.
// You can add real JS behavior here; for now we only register a noop.
console.debug('Tonehaus assets initialized');

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",

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');
}

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

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

View File

@@ -22,12 +22,13 @@ services:
App\Service\SpotifyClient:
arguments:
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
$clientId: '%env(default::SPOTIFY_CLIENT_ID)%'
$clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
App\Service\ImageStorage:
App\Service\UploadStorage:
arguments:
$projectDir: '%kernel.project_dir%'
$storageRoot: '%kernel.project_dir%/public/uploads'
$publicPrefix: '/uploads'
App\Controller\AlbumController:
arguments:

View File

@@ -1,96 +1,21 @@
## 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
build:
context: .
dockerfile: docker/php/Dockerfile
target: dev
args:
- APP_ENV=dev
container_name: php
tonehaus:
image: git.ntbx.io/boris/tonehaus:latest-arm64
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:

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;

View File

@@ -1,48 +1,43 @@
# FROM php:8.2-fpm
# # Install dependencies
# RUN apt-get update && apt-get install -y \
# git \
# unzip \
# libzip-dev \
# libpng-dev \
# libjpeg-dev \
# libonig-dev \
# libxml2-dev \
# zip \
# && docker-php-ext-install pdo pdo_mysql zip gd mbstring exif pcntl bcmath intl opcache
# # Install Composer
# COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# # Copy PHP config
# COPY docker/php/php.ini /usr/local/etc/php/
# WORKDIR /var/www/html
# -----------------------------------------------------------------------------
# Base PHP-FPM with Composer + Symfony-friendly extensions
# -----------------------------------------------------------------------------
FROM php:8.2-fpm-alpine AS base
ARG APP_ENV=dev
ENV APP_ENV=${APP_ENV}
WORKDIR /var/www/html
# System dependencies
# System dependencies shared across images
RUN apk add --no-cache \
bash git unzip icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev \
libzip-dev oniguruma-dev libxml2-dev postgresql-dev zlib-dev
bash \
git \
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
# PHP extensions commonly used by Symfony (plus both Postgres + SQLite)
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
&& docker-php-ext-install -j"$(nproc)" \
intl \
gd \
pdo_pgsql \
pdo_sqlite \
opcache \
mbstring \
zip \
xml
# Composer available in the running container (dev + prod)
# Composer available in every stage (dev + prod)
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# Recommended PHP settings (tweak as needed)
@@ -63,20 +58,22 @@
echo "opcache.jit_buffer_size=128M"; \
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
# Small healthcheck file for Nginx
# Small healthcheck file for HTTP probes
RUN mkdir -p public && printf "OK" > public/healthz
# Ensure correct user
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app
# php-fpm uses www-data; keep both available
RUN chown -R www-data:www-data /var/www
# Ensure unprivileged app user exists
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app \
&& chown -R www-data:www-data /var/www
# -----------------------------------------------------------------------------
# Development image (mount your code via docker-compose volumes)
# -----------------------------------------------------------------------------
FROM base AS dev
ENV APP_ENV=dev
# Optional: enable Xdebug (uncomment to use)
ARG APP_ENV=dev
ENV APP_ENV=${APP_ENV}
ENV APP_DEBUG=1
# Optional: enable Xdebug by uncommenting below
# RUN apk add --no-cache $PHPIZE_DEPS \
# && pecl install xdebug \
# && docker-php-ext-enable xdebug \
@@ -84,7 +81,7 @@
# echo "xdebug.mode=debug,develop"; \
# echo "xdebug.client_host=host.docker.internal"; \
# } > /usr/local/etc/php/conf.d/xdebug.ini
# Composer cache directory (faster installs inside container)
ENV COMPOSER_CACHE_DIR=/tmp/composer
CMD ["php-fpm"]
@@ -92,10 +89,17 @@
# Production image (copies your app + installs deps + warms cache)
# -----------------------------------------------------------------------------
FROM base AS prod
ENV APP_ENV=prod
# Copy only manifests first (better layer caching); ignore if missing
ARG APP_ENV=prod
ENV APP_ENV=${APP_ENV}
ENV APP_DEBUG=0 \
DATABASE_DRIVER=sqlite \
DATABASE_SQLITE_PATH=/var/www/html/var/data/database.sqlite \
RUN_MIGRATIONS_ON_START=1
# Copy only composer manifests for layer caching
COPY composer.json composer.lock* symfony.lock* ./
# Install vendors (no scripts here; run later with console if needed)
# Install vendors (cached)
RUN --mount=type=cache,target=/tmp/composer \
if [ -f composer.json ]; then \
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
@@ -104,15 +108,29 @@
# Copy the rest of the app
COPY . /var/www/html
# If Symfony console exists, finalize install & warm cache
# Finalize install & warm cache
RUN if [ -f bin/console ]; then \
set -ex; \
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; \
mkdir -p var var/data public/uploads; \
chown -R www-data:www-data var public/uploads; \
fi
USER www-data
CMD ["php-fpm"]
# Runtime web stack (nginx + supervisor) for a single immutable container
RUN apk add --no-cache nginx supervisor curl
COPY docker/prod/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY docker/prod/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /run/nginx /var/log/supervisor \
&& chown -R www-data:www-data /run/nginx /var/log/supervisor /var/www/html
RUN mkdir -p /var/lib/nginx /var/log/nginx && chown -R www-data:www-data /var/lib/nginx /var/log/nginx
RUN sed -i 's|^error_log =.*|error_log = /proc/self/fd/2|' /usr/local/etc/php-fpm.conf
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]

51
docker/prod/entrypoint.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/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
}
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
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 "$@"

29
docker/prod/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
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";
}
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
}

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

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
```

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 (110) 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

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).

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

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 (110) 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.

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.

View File

@@ -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 Spotify credentials in settings is effective immediately; no restart is required.
- Admin UI actions are CSRFprotected and rolechecked; if a button appears disabled, hover for a tooltip explanation.

90
docs/architecture.md Normal file
View File

@@ -0,0 +1,90 @@
# 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 HTTPoriented actions with verbbased method names (`search`, `show`, `edit`, `delete`).
- **Services** are named by capability, not by caller, using nouns or nounphrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a thirdparty, the integration appears in the name (e.g. `SpotifyClient`, `SpotifyMetadataRefresher`).
- **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 **verbbased, intentionrevealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllSpotifyAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isSpotifyConfigured()`).
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
- **Variables & parameters**
- Use **descriptive, domainlevel names** (e.g. `$albumRepository`, `$reviewCount`, `$spotifyAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
- Booleans read naturally (`$isEnabled`, `$shouldQuerySpotify`, `$needsSync`).
- Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`).
- **Files & namespaces**
- File names match their primary class name and follow PSR4 (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 selfdocumenting.
## High-level flow
1. Visitors search for albums (Spotify) and view an album page
2. Loggedin users can write, edit, and delete reviews
3. Moderators and admins can moderate content and manage users
4. Admins configure site settings (Spotify 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` — signup and login/logout routes
### Entities (`src/Entity/*`)
- `User` — authentication principal and roles
- `Album`, `AlbumTrack` — normalized album metadata and track list
- `Review` — userauthored review with rating and timestamps
- `Setting` — key/value store for site configuration (e.g., Spotify 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 serverside validation
### Services (`src/Service/*`)
- `SpotifyClient` — Client Credentials token management (cached) and API calls
- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data
- `CatalogResetService` — admin action to reset/sync catalog state safely
- `ImageStorage` — avatar uploads and related image handling
- `RegistrationToggle` — DBbacked 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 roleaware 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
docs/auth-and-users.md Normal file
View File

@@ -0,0 +1,48 @@
# Authentication & Users
## Login & Registration (modal)
- Login and signup are handled in a Bootstrap modal.
- AJAX submits keep users on the page; a successful login refreshes state.
- Rememberme 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 adminonly).
## 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 nonadmins
## 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).

70
docs/deployment.md Normal file
View File

@@ -0,0 +1,70 @@
# Deployment
This application ships with an immutable, singlecontainer image that includes PHPFPM, Nginx, and your code. By default it uses SQLite and autoruns 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 SPOTIFY_CLIENT_ID=your_client_id \
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
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 rerun)
- 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)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- `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 containers port 8080
- Ensure proxy sends `X-Forwarded-*` headers
## Zerodowntime 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
docs/features.md Normal file
View File

@@ -0,0 +1,31 @@
# Features
## Albums & Reviews
- Spotify 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 (110) with live badge
- Peralbum aggregates: average rating and total review count
## Authentication & Users
- Bootstrap auth modal for login/sign-up with AJAX submits
- Rememberme 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 Spotify 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)

View File

@@ -0,0 +1,31 @@
# Reviews & Albums
## Album page
- Artwork, metadata, average rating, and review count
- Full Spotify tracklist when available
- Reviews list (newest first)
- Inline new review form for loggedin users
## Writing a review
- Rating slider from 110
- Title (max 160 chars) and body (205000 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.

63
docs/setup.md Normal file
View File

@@ -0,0 +1,63 @@
# Setup
## Prerequisites
- Docker + Docker Compose
- Spotify Developer account (Client ID/Secret)
- 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 Spotify
- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB)
- Env fallback (in `.env` or your shell):
```bash
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
```
## 4) (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>`
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- `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
```

View File

@@ -0,0 +1,30 @@
# Spotify Integration
## Credentials
- Preferred: Manage in `/admin/settings` (persisted in DB; no restart required)
- Env fallback: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
## API Client
- `src/Service/SpotifyClient.php`
- Client Credentials token fetch with caching
- `searchAlbums(q, limit)` — album search endpoint
- `getAlbum(id)` / `getAlbums([ids])` — metadata fetch
- `getAlbumWithTracks(id)` — metadata + hydrated tracklist
- `getAlbumTracks(id)` — raw paginated tracks (when needed)
### Caching & Rate Limits
- Access tokens are cached until expiry to avoid unnecessary auth calls.
- Downstream requests should be mindful of Spotify rate limits; user actions are debounced in the UI and server calls are focused on album/track data needed by the current page.
## Advanced search syntax
- Fielded queries are composed as:
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
- Optional free text is appended to the query
- Examples:
- `album:"in rainbows" artist:"radiohead"`
- `year:1999-2004 post rock`
## Admin settings
- Update credentials in `/admin/settings`
- Settings are stored in the database; `APP_ENV` reload or container restart is not required

46
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,46 @@
# 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
```
## Spotify errors
- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`.
- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container.
## ARM64 Build
```bash
sudo docker buildx build \
--platform linux/arm64 \
--target prod \
-t tonehaus/tonehaus:dev-arm64 \
-f docker/php/Dockerfile \
. \
--load
```

14
importmap.php Normal file
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,
],
];

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);
}
}

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>');

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>');

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);

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')

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;
}

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);

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,37 +76,37 @@ 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));
}
}
if ($form->isValid()) {
$upload = $form->get('profileImage')->getData();
if ($upload instanceof UploadedFile) {
$images->remove($user->getProfileImagePath());
$user->setProfileImagePath($images->storeProfileImage($upload));
$uploadStorage->remove($user->getProfileImagePath());
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
}
$em->flush();
$this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_profile');
}
}
return $this->render('account/profile.html.twig', [
'form' => $form->createView(),

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\SpotifyMetadataRefresher;
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-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])]
public function refreshSpotify(
Request $request,
SpotifyMetadataRefresher $refresher
): Response {
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
$updated = $refresher->refreshAllSpotifyAlbums();
if ($updated === 0) {
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
} else {
$this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated));
}
return $this->redirectToRoute('admin_dashboard');
}
}

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,6 +19,48 @@ 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.
*/
@@ -26,7 +70,7 @@ class SettingsController extends AbstractController
$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();
$registrationOverride = $registrationToggle->getEnvOverride();
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
$form->handleRequest($request);
@@ -34,6 +78,7 @@ class SettingsController extends AbstractController
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->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 +91,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;
}
}

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.');
}

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\UploadStorage;
use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver;
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,8 +30,9 @@ 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 SpotifyGenreResolver $genreResolver,
private readonly int $searchLimit = 20
) {
}
@@ -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,
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
]);
}
@@ -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);
@@ -95,6 +99,7 @@ class AlbumController extends AbstractController
$albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null;
if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via Spotify before rendering.
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
@@ -103,6 +108,7 @@ class AlbumController extends AbstractController
$em->flush();
} else {
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
// Track sync mutated the entity: persist before we build template arrays.
$em->flush();
}
}
@@ -190,7 +196,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 +241,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 +263,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.');
@@ -314,26 +320,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,8 +332,8 @@ 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));
}
}
@@ -351,7 +342,13 @@ class AlbumController extends AbstractController
*/
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
{
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
// Bring genres along when we persist Spotify albums so templates can display them immediately.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumId = (string) ($spotifyAlbum['id'] ?? '');
$album = $albumRepo->upsertFromSpotifyAlbum(
$spotifyAlbum,
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) {
$trackRepo->replaceAlbumTracks($album, $tracks);
@@ -372,6 +369,7 @@ class AlbumController extends AbstractController
$storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
// Spotify track counts do not match what we have stored; re-sync to avoid stale data.
$needsSync = true;
}
if (!$needsSync) {
@@ -381,7 +379,10 @@ class AlbumController extends AbstractController
if ($spotifyAlbum === null) {
return false;
}
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
// Rehydrate genres during syncs as well, in case Spotify has updated the metadata.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumGenres = $genresMap[$spotifyId] ?? [];
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (!is_array($tracks) || $tracks === []) {
return false;

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')]

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;
}

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 Spotify/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`, `spotify`, 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,6 +46,7 @@ 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';
@@ -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 Spotify-sourced albums.
*/
public function shouldUseSpotify(): bool
{
return $this->source === 'all' || $this->source === 'spotify';
}
public function useUser(): bool
/**
* Determines whether the search should include user-created albums.
*/
public function shouldUseUserCatalog(): bool
{
return $this->source === 'all' || $this->source === 'user';
}

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 Spotify IDs stored locally.
*/
final class AlbumSearchResult
{
@@ -13,9 +15,13 @@ 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 Spotify IDs saved locally for quick lookup. */
public readonly array $savedIds
) {
}

View File

@@ -49,6 +49,12 @@ class Album
#[ORM\Column(type: 'json')]
private array $artists = [];
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $genres = [];
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null;
@@ -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.
*/
@@ -324,12 +351,14 @@ class Album
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
}
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
$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 ],

View File

@@ -133,6 +133,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

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([

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));
}
}

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
{
/**

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
{
/**

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
{
/**

View File

@@ -8,6 +8,9 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
*/
class SiteSettingsType extends AbstractType
{
/**

View File

@@ -16,6 +16,9 @@ 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);
@@ -62,12 +65,38 @@ class AlbumRepository extends ServiceEntityRepository
return $out;
}
/**
* Returns all stored Spotify album IDs so background jobs can iterate over them.
*
* @return list<string>
*/
public function findAllSpotifyIds(): array
{
$rows = $this->createQueryBuilder('a')
->select('a.spotifyId')
->where('a.source = :src')
->andWhere('a.spotifyId IS NOT NULL')
->setParameter('src', 'spotify')
->getQuery()
->getScalarResult();
$ids = [];
foreach ($rows as $row) {
$sid = (string) ($row['spotifyId'] ?? '');
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,7 +120,7 @@ class AlbumRepository extends ServiceEntityRepository
}
$results = $qb->getQuery()->getResult();
$artistNeedle = $artist ?? $freeText;
return $this->filterByArtistAndLimit($results, $artistNeedle, $limit);
return $this->filterByArtistAndGenreAndLimit($results, $artistNeedle, $genre, $limit);
}
/**
@@ -99,7 +128,7 @@ class AlbumRepository extends ServiceEntityRepository
*
* @return list<Album>
*/
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
{
$qb = $this->createQueryBuilder('a')
->where('a.source = :src')
@@ -123,19 +152,22 @@ 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.
*
* @param array<string,mixed> $spotifyAlbum
* @param array<string,mixed> $spotifyAlbum Raw Spotify album payload.
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups).
*/
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album
public function upsertFromSpotifyAlbum(array $spotifyAlbum, 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'] ?? [])));
$rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($spotifyAlbum['genres'] ?? []);
$genres = array_values(array_filter(array_map(static fn($g) => trim((string) $g), $rawGenres), static fn($g) => $g !== ''));
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
@@ -157,6 +189,7 @@ class AlbumRepository extends ServiceEntityRepository
$album->setSpotifyId($spotifyId);
$album->setName($name);
$album->setArtists($artists);
$album->setGenres($genres);
$album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks);
$album->setCoverUrl($coverUrl);
@@ -166,19 +199,48 @@ class AlbumRepository extends ServiceEntityRepository
}
/**
* 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 +249,23 @@ 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);
}
}

View File

@@ -14,6 +14,9 @@ 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);
@@ -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 === '') {
@@ -44,8 +48,12 @@ 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));
$disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1);
$trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position);
// Some Spotify 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/Spotify 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;
}
}

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
{

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();
}
}

View File

@@ -7,41 +7,54 @@ use App\Dto\AlbumSearchResult;
use App\Entity\Album;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* AlbumSearchService composes Spotify and user albums into reusable payloads.
* AlbumSearchService pulls albums from Spotify 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 SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $em,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) {
}
/**
* Runs the end-to-end search flow: figures out which sources to query, merges the payloads,
* and returns review stats plus saved IDs so templates get everything in one package.
*/
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{
$spotifyQuery = $this->buildSpotifyQuery($criteria);
$spotifyQuery = $this->buildSpotifySearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
// Spotify only gets pinged when callers explicitly enable it and we actually have
// something to ask for (bare "all" requests would otherwise waste API calls).
$shouldQuerySpotify = $criteria->shouldUseSpotify()
&& ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
$stats = [];
$savedIds = [];
$spotifyPayloads = [];
$userPayloads = [];
if ($criteria->useSpotify() && $spotifyQuery !== '') {
if ($shouldQuerySpotify) {
// Try to reuse cached Spotify albums and only hit the API if we still need more.
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
$spotifyPayloads = $spotifyData['payloads'];
$stats = $this->mergeStats($stats, $spotifyData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
}
if ($criteria->useUser() && $hasUserFilters) {
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
// Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads'];
$stats = $this->mergeStats($stats, $userData['stats']);
@@ -52,7 +65,18 @@ class AlbumSearchService
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
}
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
/**
* Indicates whether Spotify credentials are configured.
*/
public function isSpotifyConfigured(): bool
{
return $this->spotifyClient->isConfigured();
}
/**
* Turns structured filters into Spotify's free-form query syntax.
*/
private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
{
$parts = [];
if ($criteria->albumName !== '') {
@@ -77,34 +101,48 @@ class AlbumSearchService
return implode(' ', $parts);
}
/**
* Quick gate to tell if it's worth running the user catalog query.
*/
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
{
if ($criteria->source === 'user') {
return true;
}
return $spotifyQuery !== ''
|| $criteria->albumName !== ''
|| $criteria->artist !== ''
|| $criteria->genre !== ''
|| $criteria->yearFrom !== null
|| $criteria->yearTo !== null;
}
/**
* Looks up cached Spotify albums and, if needed, tops them off with fresh API data.
*
* @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
{
$stored = $this->albumRepository->searchSpotifyAlbums(
$spotifyQuery,
$storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums(
$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);
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedSpotifyAlbums);
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
$storedIds = $this->collectSpotifyIds($storedSpotifyAlbums);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
if (count($stored) >= $criteria->limit) {
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit;
if (!$shouldFetchFromSpotify) {
return [
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
'stats' => $stats,
@@ -112,8 +150,11 @@ class AlbumSearchService
];
}
// Mix cached payloads with just enough fresh API data to satisfy the limit.
// This has the consequence of preferring cached data, but reduces API calls.
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit);
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
@@ -121,12 +162,14 @@ class AlbumSearchService
}
/**
* Calls Spotify search/albums endpoints, syncs what we find, and returns normalized payloads, stats, and IDs.
*
* @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
{
$result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
$result = $this->spotifyClient->searchAlbums($spotifyQuery, $criteria->limit);
$searchItems = $result['albums']['items'] ?? [];
$this->logger->info('Album search results received', [
'query' => $spotifyQuery,
@@ -142,19 +185,27 @@ class AlbumSearchService
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$full = $this->spotify->getAlbums($ids);
$full = $this->spotifyClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
$albumsPayload = $searchItems;
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
}
// Resolve genres up-front (either from album payloads or artist fallbacks) so we store richer data.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload);
$upserted = 0;
foreach ($albumsPayload as $payload) {
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload);
// Persist each album so the next search can reuse it without another API call.
$albumId = (string) ($payload['id'] ?? '');
$this->albumRepository->upsertFromSpotifyAlbum(
(array) $payload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
$upserted++;
}
$this->em->flush();
$this->entityManager->flush();
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
@@ -175,6 +226,8 @@ class AlbumSearchService
}
/**
* Searches user-created albums and attaches review aggregates keyed to each payload.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
@@ -183,6 +236,7 @@ class AlbumSearchService
$criteria->query,
$criteria->albumName,
$criteria->artist,
$criteria->genre,
$criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0,
$criteria->limit
@@ -202,6 +256,8 @@ class AlbumSearchService
}
/**
* Re-keys entity-based stats to the local IDs that templates actually use.
*
* @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}>
@@ -213,12 +269,20 @@ class AlbumSearchService
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
// Templates never see entity IDs, so stats must be re-keyed to the user-facing local IDs.
$mapped[$localId] = $userStats[$entityId];
}
}
return $mapped;
}
/**
* Chooses which payload list to return based on the requested source preference.
*
* @param array<int,array<mixed>> $userPayloads
* @param array<int,array<mixed>> $spotifyPayloads
* @return array<int,array<mixed>>
*/
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
{
if ($source === 'user') {
@@ -231,6 +295,8 @@ class AlbumSearchService
}
/**
* Collects Spotify IDs from hydrated album entities while skipping blanks.
*
* @param list<Album> $albums
* @return list<string>
*/
@@ -247,6 +313,8 @@ class AlbumSearchService
}
/**
* Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls.
*
* @param array<int,mixed> $searchItems
* @return list<string>
*/
@@ -262,6 +330,13 @@ class AlbumSearchService
return array_values(array_unique($ids));
}
/**
* Overwrites stats with the newest aggregates from the update set.
*
* @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) {
@@ -270,6 +345,13 @@ class AlbumSearchService
return $current;
}
/**
* Adds newly saved Spotify IDs while tossing blanks and duplicates.
*
* @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 !== ''));
@@ -277,6 +359,8 @@ class AlbumSearchService
}
/**
* Combines payload lists without duplicates, preferring primary items and honoring the limit.
*
* @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>>
@@ -299,6 +383,7 @@ class AlbumSearchService
if ($id !== null && isset($seen[$id])) {
continue;
}
// Secondary payloads often duplicate the primary list; skip anything we've already emitted.
$merged[] = $payload;
if ($id !== null) {
$seen[$id] = true;
@@ -309,5 +394,30 @@ class AlbumSearchService
}
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
{
$genreNeedle = trim($genreNeedle);
if ($genreNeedle === '') {
return $payloads;
}
$needle = mb_strtolower($genreNeedle);
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;
}));
}
}

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,
];
}
}

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());
}
}

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;
}
}

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;

View File

@@ -24,8 +24,8 @@ class SpotifyClient
public function __construct(
HttpClientInterface $httpClient,
CacheInterface $cache,
string $clientId,
string $clientSecret,
?string $clientId,
?string $clientSecret,
SettingRepository $settings
) {
$this->httpClient = $httpClient;
@@ -119,6 +119,7 @@ class SpotifyClient
$limit = 50;
$offset = 0;
do {
// Spotify returns tracks in pages of 50, so iterate until there are no further pages.
$page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset);
if ($page === null) {
break;
@@ -128,6 +129,8 @@ class SpotifyClient
$offset += $limit;
$total = isset($page['total']) ? (int) $page['total'] : null;
$hasNext = isset($page['next']) && $page['next'] !== null;
// Guard against Spotify omitting total by relying on the "next" cursor.
// Ensures album requests stop when Spotify has no more pages.
} while ($hasNext && ($total === null || $offset < $total));
return $items;
@@ -156,6 +159,31 @@ class SpotifyClient
}
}
/**
* Fetch multiple artists to gather genre information.
*
* @param list<string> $artistIds
* @return array<mixed>|null
*/
public function getArtists(array $artistIds): ?array
{
if ($artistIds === []) { return []; }
$accessToken = $this->getAccessToken();
if ($accessToken === null) { return null; }
$url = 'https://api.spotify.com/v1/artists';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'ids' => implode(',', $artistIds) ],
];
try {
return $this->sendRequest('GET', $url, $options, 1800);
} catch (\Throwable) {
return null;
}
}
/**
* Centralized request helper with lightweight caching.
*
@@ -164,31 +192,27 @@ class SpotifyClient
*/
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array
{
$cacheKey = null;
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
$request = function () use ($method, $url, $options): array {
$response = $this->httpClient->request($method, $url, $options);
return $response->toArray(false);
};
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
if ($shouldCache) {
// Cache fingerprint mixes URL and query only; headers are static (Bearer token).
$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;
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
$item->expiresAfter($cacheTtlSeconds);
return $request();
});
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 $request();
}
/**
* Requests one paginated track list page for an album using the provided OAuth token.
*
* @return array<mixed>|null
*/
private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array
@@ -247,12 +271,22 @@ class SpotifyClient
});
if ($token === null) {
// Remove failed entries so the next request retries instead of serving cached nulls.
// Nuke cached nulls so the next request retries instead of reusing the failure sentinel.
$this->cache->delete($cacheKey);
}
return $token;
}
/**
* Returns true when credentials are available from DB or environment.
*/
public function isConfigured(): bool
{
$clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
$clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
return $clientId !== '' && $clientSecret !== '';
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Service;
/**
* SpotifyGenreResolver fills in album genres using the Spotify artists endpoint as a fallback.
*/
class SpotifyGenreResolver
{
public function __construct(private readonly SpotifyClient $spotifyClient)
{
}
/**
* @param list<array<string,mixed>> $albums
* @return array<string,list<string>> Map of album ID => genres list
*/
public function resolveGenresForAlbums(array $albums): array
{
$albumGenres = [];
$artistIds = [];
foreach ($albums as $album) {
$albumId = (string) ($album['id'] ?? '');
if ($albumId === '') {
continue;
}
$genres = $this->normalizeGenres((array) ($album['genres'] ?? []));
if ($genres !== []) {
$albumGenres[$albumId] = $genres;
continue;
}
// Collect artist IDs so we can fetch their genres in one pass rather than per album.
foreach ((array) ($album['artists'] ?? []) as $artist) {
$artistId = (string) ($artist['id'] ?? '');
if ($artistId !== '') {
$artistIds[$artistId] = true;
}
}
}
if ($artistIds === []) {
return $albumGenres;
}
$artistGenres = $this->fetchArtistsGenres(array_keys($artistIds));
if ($artistGenres === []) {
return $albumGenres;
}
foreach ($albums as $album) {
$albumId = (string) ($album['id'] ?? '');
if ($albumId === '' || isset($albumGenres[$albumId])) {
continue;
}
$combined = [];
foreach ((array) ($album['artists'] ?? []) as $artist) {
$artistId = (string) ($artist['id'] ?? '');
if ($artistId !== '' && isset($artistGenres[$artistId])) {
// Merge artist genres; duplicates removed later.
$combined = array_merge($combined, $artistGenres[$artistId]);
}
}
if ($combined !== []) {
$albumGenres[$albumId] = array_values(array_unique($combined));
}
}
return $albumGenres;
}
/**
* @param list<string> $artistIds
* @return array<string,list<string>>
*/
private function fetchArtistsGenres(array $artistIds): array
{
$genres = [];
foreach (array_chunk($artistIds, 50) as $artistIdChunk) {
// Spotify allows up to 50 artist IDs per request; batching keeps calls minimal.
$payload = $this->spotifyClient->getArtists($artistIdChunk);
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
foreach ($artists as $artist) {
$id = (string) ($artist['id'] ?? '');
if ($id === '') {
continue;
}
$genres[$id] = $this->normalizeGenres((array) ($artist['genres'] ?? []));
}
}
return $genres;
}
/**
* @param array<int,mixed> $values
* @return list<string>
*/
private function normalizeGenres(array $values): array
{
return array_values(array_filter(array_map(
static fn($genre) => trim((string) $genre),
$values
), static fn($genre) => $genre !== ''));
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Service;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* SpotifyMetadataRefresher bulk-updates stored Spotify albums using batched API calls.
*/
class SpotifyMetadataRefresher
{
private const BATCH_SIZE = 20;
public function __construct(
private readonly SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) {
}
/**
* Refreshes all saved Spotify albums and returns the number of payloads that were re-synced.
*/
public function refreshAllSpotifyAlbums(): int
{
$spotifyIds = $this->albumRepository->findAllSpotifyIds();
if ($spotifyIds === []) {
return 0;
}
$updatedAlbumCount = 0;
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) {
$payload = $this->spotifyClient->getAlbums($albumIdChunk);
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
if ($albums === []) {
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
continue;
}
// Share the same genre resolution logic used during search so existing rows gain genre data too.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
foreach ($albums as $albumPayload) {
try {
$albumId = (string) ($albumPayload['id'] ?? '');
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
(array) $albumPayload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
if ($albumId !== '' && $albumEntity !== null) {
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
if ($tracks !== []) {
// Replace tracks wholesale to simplify diffs (instead of diffing rows).
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
$albumEntity->setTotalTracks(count($tracks));
}
}
$updatedAlbumCount++;
} catch (\Throwable $e) {
$this->logger->error('Failed to upsert Spotify 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->spotifyClient->getAlbumTracks($albumId);
if ($fullTrackPayloads !== []) {
return $fullTrackPayloads;
}
}
return $tracks;
}
}

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;
}
}

View File

@@ -5,7 +5,7 @@
{% 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>
@@ -34,6 +34,62 @@
{{ 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 %}

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_spotify') }}" class="ms-auto">
<input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_spotify') }}">
<button class="btn btn-outline-success" type="submit">
Refresh Spotify metadata
</button>
</form>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-4">

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>

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>

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 spotifyConfigured is defined and not spotifyConfigured %}
<div class="alert alert-info">
Spotify 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 Spotify credentials</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>
@@ -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>
@@ -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>
@@ -109,7 +124,12 @@
<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">
@@ -133,7 +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 %}

View File

@@ -24,6 +24,9 @@
</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>

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>

BIN
var/.DS_Store vendored

Binary file not shown.