documentation and env changes
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s

This commit is contained in:
2025-11-28 08:14:13 +00:00
parent f77f3a9e40
commit d52eb6bd81
59 changed files with 932 additions and 565 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,17 +1,12 @@
SPOTIFY_CLIENT_ID= # Uncomment to override stored setting.
SPOTIFY_CLIENT_SECRET= #SPOTIFY_CLIENT_ID=
APP_ENV=dev #SPOTIFY_CLIENT_SECRET=
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.
# POSTGRES_DB= APP_ENV=prod
# POSTGRES_USER= APP_SECRET=changeme
# POSTGRES_PASSWORD= # 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 DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
PGADMIN_DEFAULT_PASSWORD=password # DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8

View File

@@ -12,6 +12,7 @@ on:
permissions: permissions:
contents: read contents: read
packages: write
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
@@ -70,6 +71,8 @@ jobs:
name: Build production image name: Build production image
needs: php-tests needs: php-tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
steps: steps:
- name: Checkout - name: Checkout
@@ -100,3 +103,19 @@ jobs:
- name: Smoke-test entrypoint & migrations - name: Smoke-test entrypoint & migrations
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true 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/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" /> <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-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" /> <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/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" /> <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/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" /> <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/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" /> <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-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/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" /> <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" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" /> <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/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" /> <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/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" /> <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/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
@@ -120,14 +112,12 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" /> <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-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" /> <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/web-profiler-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" /> <excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" /> <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/string-extra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

10
.idea/php.xml generated
View File

@@ -12,11 +12,9 @@
</component> </component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/twig/twig" /> <path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/string-extra" /> <path value="$PROJECT_DIR$/vendor/twig/string-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" /> <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/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" /> <path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" /> <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/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" /> <path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" /> <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/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" /> <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/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" /> <path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" /> <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-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" /> <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/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" /> <path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" /> <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/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" /> <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/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" /> <path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" /> <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/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" /> <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/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/console" /> <path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" /> <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/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/string" /> <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/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/composer" /> <path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" /> <path value="$PROJECT_DIR$/vendor/symfony/serializer" />
@@ -129,7 +120,6 @@
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" /> <path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/process" /> <path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" /> <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/clock" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" /> <path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/cache" /> <path value="$PROJECT_DIR$/vendor/psr/cache" />

139
README.md
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 1) Start the stack
@@ -10,109 +10,66 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
docker compose up -d --build 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 ```bash
docker compose exec php php bin/console doctrine:database:create --if-not-exists docker compose exec tonehaus php bin/console app:promote-admin you@example.com
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
``` ```
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 ```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) Notes:
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret. - To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
- 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
```
## Production container (immutable)
The repository ships with a single-container production target that bundles PHP-FPM, Nginx, your code, and a self-contained SQLite database. The build bakes in the `APP_ENV=prod` flag so production-only Symfony config is used automatically, and no bind mounts are required at runtime.
1. Build the image (uses `docker/php/Dockerfile`'s `prod` stage):
```bash
docker build \
--target=prod \
-t tonehaus-app:latest \
-f docker/php/Dockerfile \
.
```
2. Run the container (listens on port 8080 inside the container):
```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
```
- The runtime defaults to `DATABASE_DRIVER=sqlite` and stores the database file inside the image at `var/data/database.sqlite`. On each boot the entrypoint runs Doctrine migrations (safe to re-run) so the schema stays current while the container filesystem remains immutable from the host's perspective.
- To point at Postgres (or any external database), override `DATABASE_DRIVER` and `DATABASE_URL` at `docker run` time and optionally disable auto-migration with `RUN_MIGRATIONS_ON_START=0`.
- Health endpoint: `GET /healthz` on the published port (example: `curl http://localhost:8080/healthz`).
- The entrypoint now also performs Symfony cache clear/warmup on startup, which requires `APP_SECRET` to be set; the container exits with an error if it is missing so misconfigured deployments are caught immediately.
3. Rebuild/redeploy by re-running the `docker build` command; no manual steps or bind mounts are involved.
## Continuous integration
- `.github/workflows/ci.yml` runs on pushes and pull requests targeting `main` or `prod`.
- Job 1 installs Composer deps, prepares a SQLite database, runs Doctrine migrations, and executes the PHPUnit suite under PHP 8.2 so functional regressions are caught early.
- Job 2 builds the production Docker image (`docker/php/Dockerfile` prod stage), checks that key Symfony artifacts (e.g., `public/index.php`, `bin/console`) are present, ensures `APP_ENV=prod` is baked in, and smoke-tests the `/entrypoint.sh` startup path.
- The resulting artifact mirrors the immutable container described above, so a green CI run guarantees the repo can be deployed anywhere via `docker run`.
- Self-hosted runners can use `.gitea/workflows/ci.yml`, which mirrors the GitHub workflow but also supports optional registry pushes after the image passes the same verification steps.
## 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.
## Features ## Features
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count) - Spotify search with advanced filters (album, artist, year range) and peralbum aggregates (avg/count)
- Album page with details, reviews list, and inline new review (logged in) - Album page with details, tracklist, reviews list, and inline new review (logged-in)
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages - Auth modal (Login/Sign up) with rememberme; no separate pages
- Role-based access: authors manage their own reviews, admins can manage any - Role-based access: authors manage their own reviews; moderators/admins can moderate content
- Admin Site Settings to manage Spotify credentials in DB - Admin Site Settings: manage Spotify credentials and public registration toggle
- User Dashboard to update profile and change password (requires current password) - User Dashboard: profile updates and password change
- Light/Dark theme toggle in Settings (cookie-backed) - Light/Dark theme toggle (cookie-backed)
- Bootstrap UI
## 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: - `APP_ENV` (dev|prod), `APP_SECRET` (required)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- Setup and configuration: `docs/01-setup.md` - `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
- Features and UX: `docs/02-features.md` - `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
- Authentication and users: `docs/03-auth-and-users.md` - `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
- Spotify integration: `docs/04-spotify-integration.md` - `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
- Reviews and albums: `docs/05-reviews-and-albums.md`
- Admin & site settings: `docs/06-admin-and-settings.md`
- Troubleshooting: `docs/07-troubleshooting.md`
## License ## License

View File

@@ -1,5 +1,26 @@
<?php <?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); declare(strict_types=1);
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
@@ -7,6 +28,7 @@ use Symfony\Config\DoctrineConfig;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
return static function (DoctrineConfig $doctrine): void { 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')); $driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
$supportedDrivers = ['postgres', 'sqlite']; $supportedDrivers = ['postgres', 'sqlite'];
@@ -18,6 +40,7 @@ return static function (DoctrineConfig $doctrine): void {
)); ));
} }
// Configure the default DBAL connection.
$dbal = $doctrine->dbal(); $dbal = $doctrine->dbal();
$dbal->defaultConnection('default'); $dbal->defaultConnection('default');
@@ -26,12 +49,14 @@ return static function (DoctrineConfig $doctrine): void {
$connection->useSavepoints(true); $connection->useSavepoints(true);
if ('sqlite' === $driver) { if ('sqlite' === $driver) {
// SQLite: use a file-backed database by default.
$connection->driver('pdo_sqlite'); $connection->driver('pdo_sqlite');
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV) $hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER); || array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
if ($hasCustomPath) { if ($hasCustomPath) {
// Allow explicit database path via env overrides.
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%'); $connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
} else { } else {
$projectDir = dirname(__DIR__, 2); $projectDir = dirname(__DIR__, 2);
@@ -50,7 +75,9 @@ return static function (DoctrineConfig $doctrine): void {
$connection->path('%kernel.project_dir%/var/data/database.sqlite'); $connection->path('%kernel.project_dir%/var/data/database.sqlite');
} }
} else { } else {
// Postgres (or other server-based driver) via DATABASE_URL.
$connection->url('%env(resolve: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->serverVersion('16');
$connection->charset('utf8'); $connection->charset('utf8');
} }

View File

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

View File

@@ -1,56 +1,14 @@
## 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: 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
# 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
tonehaus: tonehaus:
image: tonehaus/tonehaus:dev-arm64 # image: tonehaus/tonehaus:dev-arm64
image: git.ntbx.io/boris/tonehaus:latest
container_name: tonehaus container_name: tonehaus
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./.env:/var/www/html/.env:ro - uploads:/var/www/html/public/uploads
- sqlite_data:/var/www/html/var/data - sqlite_data:/var/www/html/var/data
ports: ports:
- "8085:8080" - "8085:80"
env_file: env_file:
- .env - .env
healthcheck: healthcheck:
@@ -58,56 +16,7 @@ services:
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 5 retries: 5
# nginx:
# image: nginx:alpine
# container_name: nginx
# 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:
# - tonehaus
# 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: volumes:
sqlite_data: sqlite_data:
# composer_cache: uploads:
# pgadmin_data:

View File

@@ -1,5 +1,5 @@
server { server {
listen 8080; listen 80;
server_name _; server_name _;
root /var/www/html/public; root /var/www/html/public;

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. - `/settings` provides a dark/light mode toggle.
- Preference saved in a cookie; applied via `data-bs-theme`. - 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
```

View File

@@ -16,7 +16,10 @@ class PromoteAdminCommand extends Command
/** /**
* Stores injected dependencies for later use. * 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(); parent::__construct();
} }
@@ -35,7 +38,7 @@ class PromoteAdminCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$email = (string) $input->getArgument('email'); $email = (string) $input->getArgument('email');
$user = $this->users->findOneByEmail($email); $user = $this->userRepository->findOneByEmail($email);
if (!$user) { if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>'); $output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE; return Command::FAILURE;
@@ -45,7 +48,7 @@ class PromoteAdminCommand extends Command
if (!in_array('ROLE_ADMIN', $roles, true)) { if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN'; $roles[] = 'ROLE_ADMIN';
$user->setRoles($roles); $user->setRoles($roles);
$this->em->flush(); $this->entityManager->flush();
} }
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>'); $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. * 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(); parent::__construct();
} }
@@ -35,7 +38,7 @@ class PromoteModeratorCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$email = (string) $input->getArgument('email'); $email = (string) $input->getArgument('email');
$user = $this->users->findOneByEmail($email); $user = $this->userRepository->findOneByEmail($email);
if (!$user) { if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>'); $output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE; return Command::FAILURE;
@@ -45,7 +48,7 @@ class PromoteModeratorCommand extends Command
if (!in_array('ROLE_MODERATOR', $roles, true)) { if (!in_array('ROLE_MODERATOR', $roles, true)) {
$roles[] = 'ROLE_MODERATOR'; $roles[] = 'ROLE_MODERATOR';
$user->setRoles($roles); $user->setRoles($roles);
$this->em->flush(); $this->entityManager->flush();
} }
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>'); $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', name: 'app:seed-demo-albums',
description: 'Create demo albums with randomized metadata for local development.' 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 class SeedDemoAlbumsCommand extends Command
{ {
private const GENRES = [ private const GENRES = [
@@ -59,11 +66,14 @@ class SeedDemoAlbumsCommand extends Command
$users = $attachUsers ? $this->userRepository->findAll() : []; $users = $attachUsers ? $this->userRepository->findAll() : [];
$created = 0; $created = 0;
// Track generated localIds so we never attempt to persist obvious duplicates.
$seenLocalIds = []; $seenLocalIds = [];
while ($created < $count) { while ($created < $count) {
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
$localId = $this->generateLocalId(); $localId = $this->generateLocalId();
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) { 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; continue;
} }

View File

@@ -21,6 +21,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
name: 'app:seed-demo-reviews', name: 'app:seed-demo-reviews',
description: 'Generate demo reviews across existing albums.' 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 class SeedDemoReviewsCommand extends Command
{ {
private const SUBJECTS = [ private const SUBJECTS = [
@@ -59,6 +71,7 @@ class SeedDemoReviewsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
// Pull all albums/users once up front so downstream helpers filter as needed.
$albums = $this->albumRepository->findAll(); $albums = $this->albumRepository->findAll();
$users = $this->userRepository->findAll(); $users = $this->userRepository->findAll();
@@ -67,14 +80,17 @@ class SeedDemoReviewsCommand extends Command
return Command::FAILURE; 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')); $minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album')); $maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent'))); $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); $selectedAlbums = $this->selectAlbums($albums, $coverPercent);
$onlyEmpty = (bool) $input->getOption('only-empty'); $onlyEmpty = (bool) $input->getOption('only-empty');
$created = 0; $created = 0;
// Count how many albums actually received new reviews for clearer operator feedback. (count)
$processedAlbums = 0; $processedAlbums = 0;
foreach ($selectedAlbums as $album) { foreach ($selectedAlbums as $album) {
if ($onlyEmpty && $this->albumHasReviews($album)) { if ($onlyEmpty && $this->albumHasReviews($album)) {
@@ -107,6 +123,7 @@ class SeedDemoReviewsCommand extends Command
return $albums; return $albums;
} }
// Randomly sample albums until the requested coverage threshold is met.
$selected = []; $selected = [];
foreach ($albums as $album) { foreach ($albums as $album) {
if (random_int(1, 100) <= $coverPercent) { 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; return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
} }
@@ -124,17 +142,22 @@ class SeedDemoReviewsCommand extends Command
{ {
$created = 0; $created = 0;
$existingAuthors = $this->fetchExistingAuthors($album); $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])); $availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
if ($availableUsers === []) { if ($availableUsers === []) {
return 0; 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)); $targetReviews = min($targetReviews, count($availableUsers));
shuffle($availableUsers); shuffle($availableUsers);
$selectedUsers = array_slice($availableUsers, 0, $targetReviews); $selectedUsers = array_slice($availableUsers, 0, $targetReviews);
foreach ($selectedUsers as $user) { foreach ($selectedUsers as $user) {
// Prevent duplicate reviews per author by only iterating over filtered unique users.
$review = new Review(); $review = new Review();
$review->setAlbum($album); $review->setAlbum($album);
$review->setAuthor($user); $review->setAuthor($user);
@@ -154,6 +177,8 @@ class SeedDemoReviewsCommand extends Command
*/ */
private function fetchExistingAuthors(Album $album): array 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() $qb = $this->entityManager->createQueryBuilder()
->select('IDENTITY(r.author) AS authorId') ->select('IDENTITY(r.author) AS authorId')
->from(Review::class, 'r') ->from(Review::class, 'r')

View File

@@ -19,6 +19,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
name: 'app:seed-demo-users', name: 'app:seed-demo-users',
description: 'Create demo users with random emails and display names.' 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 class SeedDemoUsersCommand extends Command
{ {
private const FIRST_NAMES = [ private const FIRST_NAMES = [
@@ -54,11 +61,15 @@ class SeedDemoUsersCommand extends Command
$plainPassword = (string) $input->getOption('password'); $plainPassword = (string) $input->getOption('password');
$created = 0; $created = 0;
// Track generated emails so we never attempt to persist obvious duplicates.
$seenEmails = []; $seenEmails = [];
while ($created < $count) { 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(); $email = $this->generateEmail();
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) { 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; continue;
} }

View File

@@ -18,6 +18,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
name: 'app:seed-user-avatars', name: 'app:seed-user-avatars',
description: 'Assign generated profile images to existing users.' 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 class SeedUserAvatarsCommand extends Command
{ {
public function __construct( public function __construct(
@@ -54,6 +61,7 @@ class SeedUserAvatarsCommand extends Command
continue; continue;
} }
if (!$overwrite && $user->getProfileImagePath()) { if (!$overwrite && $user->getProfileImagePath()) {
// Respect existing uploads unless the operator explicitly allows clobbering them.
continue; continue;
} }
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix)); $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 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())); $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); $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); return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);

View File

@@ -6,7 +6,7 @@ use App\Entity\User;
use App\Form\ProfileFormType; use App\Form\ProfileFormType;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Service\ImageStorage; use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -76,7 +76,7 @@ class AccountController extends AbstractController
* Allows users to update profile details and avatar. * Allows users to update profile details and avatar.
*/ */
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])] #[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 */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
@@ -90,6 +90,7 @@ class AccountController extends AbstractController
if ($current === '' || !$hasher->isPasswordValid($user, $current)) { if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); $form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
} else { } else {
// Allow password updates inside the same form submission instead of forcing a separate flow.
$user->setPassword($hasher->hashPassword($user, $newPassword)); $user->setPassword($hasher->hashPassword($user, $newPassword));
} }
} }
@@ -97,8 +98,8 @@ class AccountController extends AbstractController
if ($form->isValid()) { if ($form->isValid()) {
$upload = $form->get('profileImage')->getData(); $upload = $form->get('profileImage')->getData();
if ($upload instanceof UploadedFile) { if ($upload instanceof UploadedFile) {
$images->remove($user->getProfileImagePath()); $uploadStorage->remove($user->getProfileImagePath());
$user->setProfileImagePath($images->storeProfileImage($upload)); $user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
} }
$em->flush(); $em->flush();

View File

@@ -24,6 +24,7 @@ class DashboardController extends AbstractController
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])] #[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response 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') $totalReviews = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)') ->select('COUNT(r.id)')
->getQuery()->getSingleScalarResult(); ->getQuery()->getSingleScalarResult();
@@ -36,6 +37,7 @@ class DashboardController extends AbstractController
->select('COUNT(u.id)') ->select('COUNT(u.id)')
->getQuery()->getSingleScalarResult(); ->getQuery()->getSingleScalarResult();
// Latest rows are pulled separately so the dashboard can show concrete activity.
$recentReviews = $reviews->findLatest(50); $recentReviews = $reviews->findLatest(50);
$recentAlbums = $albums->createQueryBuilder('a') $recentAlbums = $albums->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC') ->orderBy('a.createdAt', 'DESC')
@@ -61,6 +63,7 @@ class DashboardController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.'); throw $this->createAccessDeniedException('Invalid CSRF token.');
} }
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
$updated = $refresher->refreshAllSpotifyAlbums(); $updated = $refresher->refreshAllSpotifyAlbums();
if ($updated === 0) { if ($updated === 0) {
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.'); $this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');

View File

@@ -5,7 +5,7 @@ namespace App\Controller\Admin;
use App\Form\SiteSettingsType; use App\Form\SiteSettingsType;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
use App\Service\CatalogResetService; use App\Service\CatalogResetService;
use App\Service\ConsoleCommandRunner; use App\Service\CommandRunner;
use App\Service\RegistrationToggle; use App\Service\RegistrationToggle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -19,6 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController class SettingsController extends AbstractController
{ {
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
private const DEMO_COMMANDS = [ private const DEMO_COMMANDS = [
'users' => [ 'users' => [
'command' => 'app:seed-demo-users', 'command' => 'app:seed-demo-users',
@@ -69,7 +70,7 @@ class SettingsController extends AbstractController
$form = $this->createForm(SiteSettingsType::class); $form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID')); $form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET')); $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->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
$form->handleRequest($request); $form->handleRequest($request);
@@ -77,6 +78,7 @@ class SettingsController extends AbstractController
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData()); $settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData()); $settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
if ($registrationOverride === null) { if ($registrationOverride === null) {
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData()); $registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
} else { } else {
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.'); $this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
@@ -101,7 +103,7 @@ class SettingsController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.'); throw $this->createAccessDeniedException('Invalid CSRF token.');
} }
$result = $resetService->reset(); $result = $resetService->resetCatalog();
$this->addFlash('success', sprintf( $this->addFlash('success', sprintf(
'Reset catalog: deleted %d reviews and %d albums.', 'Reset catalog: deleted %d reviews and %d albums.',
$result['reviews'], $result['reviews'],
@@ -115,7 +117,7 @@ class SettingsController extends AbstractController
public function generateDemo( public function generateDemo(
string $type, string $type,
Request $request, Request $request,
ConsoleCommandRunner $runner CommandRunner $runner
): Response { ): Response {
$config = self::DEMO_COMMANDS[$type] ?? null; $config = self::DEMO_COMMANDS[$type] ?? null;
if ($config === null) { if ($config === null) {
@@ -128,7 +130,7 @@ class SettingsController extends AbstractController
try { try {
$options = $this->buildCommandOptions($config, $request); $options = $this->buildCommandOptions($config, $request);
$runner->run($config['command'], $options); $runner->runConsoleCommand($config['command'], $options);
$this->addFlash('success', sprintf('%s generation complete.', $config['label'])); $this->addFlash('success', sprintf('%s generation complete.', $config['label']));
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->addFlash('danger', sprintf( $this->addFlash('danger', sprintf(
@@ -154,6 +156,7 @@ class SettingsController extends AbstractController
$value = $request->request->get($name); $value = $request->request->get($name);
if ($type === 'checkbox') { if ($type === 'checkbox') {
if ($value) { if ($value) {
// Symfony console options expect "--flag" style boolean toggles.
$options['--' . $name] = true; $options['--' . $name] = true;
} }
continue; continue;

View File

@@ -21,8 +21,8 @@ use Symfony\Component\Routing\Attribute\Route;
class UserController extends AbstractController class UserController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $hasher, private readonly UserPasswordHasherInterface $passwordHasher,
) { ) {
} }
@@ -37,13 +37,14 @@ class UserController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
// Form collects only high-level metadata; everything else is defaulted here.
$plainPassword = (string) $form->get('plainPassword')->getData(); $plainPassword = (string) $form->get('plainPassword')->getData();
$newUser = new User(); $newUser = new User();
$newUser->setEmail($formData->email); $newUser->setEmail($formData->email);
$newUser->setDisplayName($formData->displayName); $newUser->setDisplayName($formData->displayName);
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword)); $newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
$this->em->persist($newUser); $this->entityManager->persist($newUser);
$this->em->flush(); $this->entityManager->flush();
$this->addFlash('success', 'User account created.'); $this->addFlash('success', 'User account created.');
return $this->redirectToRoute('admin_users'); return $this->redirectToRoute('admin_users');
} }
@@ -67,6 +68,7 @@ class UserController extends AbstractController
/** @var User|null $current */ /** @var User|null $current */
$current = $this->getUser(); $current = $this->getUser();
if ($current && $target->getId() === $current->getId()) { if ($current && $target->getId() === $current->getId()) {
// Protect against accidental lockouts by blocking self-deletes.
$this->addFlash('danger', 'You cannot delete your own account.'); $this->addFlash('danger', 'You cannot delete your own account.');
return $this->redirectToRoute('admin_users'); return $this->redirectToRoute('admin_users');
} }
@@ -76,8 +78,8 @@ class UserController extends AbstractController
return $this->redirectToRoute('admin_users'); return $this->redirectToRoute('admin_users');
} }
$this->em->remove($target); $this->entityManager->remove($target);
$this->em->flush(); $this->entityManager->flush();
$this->addFlash('success', 'User deleted.'); $this->addFlash('success', 'User deleted.');
return $this->redirectToRoute('admin_users'); return $this->redirectToRoute('admin_users');
@@ -102,14 +104,15 @@ class UserController extends AbstractController
$isModerator = in_array('ROLE_MODERATOR', $roles, true); $isModerator = in_array('ROLE_MODERATOR', $roles, true);
if ($isModerator) { 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')); $filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
$target->setRoles($filtered); $target->setRoles($filtered);
$this->em->flush(); $this->entityManager->flush();
$this->addFlash('success', 'Moderator privileges removed.'); $this->addFlash('success', 'Moderator privileges removed.');
} else { } else {
$roles[] = 'ROLE_MODERATOR'; $roles[] = 'ROLE_MODERATOR';
$target->setRoles(array_values(array_unique($roles))); $target->setRoles(array_values(array_unique($roles)));
$this->em->flush(); $this->entityManager->flush();
$this->addFlash('success', 'User promoted to moderator.'); $this->addFlash('success', 'User promoted to moderator.');
} }

View File

@@ -12,7 +12,7 @@ use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository; use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService; use App\Service\AlbumSearchService;
use App\Service\ImageStorage; use App\Service\UploadStorage;
use App\Service\SpotifyClient; use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver; use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -30,7 +30,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AlbumController extends AbstractController class AlbumController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ImageStorage $imageStorage, private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch, private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver, private readonly SpotifyGenreResolver $genreResolver,
private readonly int $searchLimit = 20 private readonly int $searchLimit = 20
@@ -50,13 +50,14 @@ class AlbumController extends AbstractController
'query' => $criteria->query, 'query' => $criteria->query,
'album' => $criteria->albumName, 'album' => $criteria->albumName,
'artist' => $criteria->artist, 'artist' => $criteria->artist,
'genre' => $criteria->getGenre(), 'genre' => $criteria->genre,
'year_from' => $criteria->yearFrom ?? '', 'year_from' => $criteria->yearFrom ?? '',
'year_to' => $criteria->yearTo ?? '', 'year_to' => $criteria->yearTo ?? '',
'albums' => $result->albums, 'albums' => $result->albums,
'stats' => $result->stats, 'stats' => $result->stats,
'savedIds' => $result->savedIds, 'savedIds' => $result->savedIds,
'source' => $criteria->source, 'source' => $criteria->source,
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
]); ]);
} }
@@ -98,6 +99,7 @@ class AlbumController extends AbstractController
$albumEntity = $this->findAlbum($id, $albumRepo); $albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null; $isSaved = $albumEntity !== null;
if (!$albumEntity) { if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via Spotify before rendering.
$spotifyAlbum = $spotify->getAlbumWithTracks($id); $spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) { if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found'); throw $this->createNotFoundException('Album not found');
@@ -106,6 +108,7 @@ class AlbumController extends AbstractController
$em->flush(); $em->flush();
} else { } else {
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) { if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
// Track sync mutated the entity: persist before we build template arrays.
$em->flush(); $em->flush();
} }
} }
@@ -193,7 +196,7 @@ class AlbumController extends AbstractController
if ($album) { if ($album) {
$this->ensureCanManageAlbum($album); $this->ensureCanManageAlbum($album);
if ($album->getSource() === 'user') { if ($album->getSource() === 'user') {
$this->imageStorage->remove($album->getCoverImagePath()); $this->uploadStorage->remove($album->getCoverImagePath());
} }
$em->remove($album); $em->remove($album);
$em->flush(); $em->flush();
@@ -238,6 +241,7 @@ class AlbumController extends AbstractController
} }
// Fallback: attempt to parse // Fallback: attempt to parse
try { try {
// Trust PHP's parser only as a last resort (it accepts many human formats).
$dt = new \DateTimeImmutable($s); $dt = new \DateTimeImmutable($s);
return $dt->format('Y-m-d'); return $dt->format('Y-m-d');
} catch (\Throwable) { } catch (\Throwable) {
@@ -328,8 +332,8 @@ class AlbumController extends AbstractController
} }
$file = $form->get('coverUpload')->getData(); $file = $form->get('coverUpload')->getData();
if ($file instanceof UploadedFile) { if ($file instanceof UploadedFile) {
$this->imageStorage->remove($album->getCoverImagePath()); $this->uploadStorage->remove($album->getCoverImagePath());
$album->setCoverImagePath($this->imageStorage->storeAlbumCover($file)); $album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
} }
} }
@@ -365,6 +369,7 @@ class AlbumController extends AbstractController
$storedCount = $album->getTracks()->count(); $storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0; $needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) { 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; $needsSync = true;
} }
if (!$needsSync) { if (!$needsSync) {

View File

@@ -5,17 +5,24 @@ namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* AdminUserData captures the fields used when an admin creates a user manually. * AdminUserData carries the lightweight fields needed when admins create or edit
* Using a DTO keeps validation separate from the User entity and avoids side effects. * 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. * Used to allow user creation in the user management panel without invalidating active token.
* (This took too long to figure out) * (This took too long to figure out)
*/ */
class AdminUserData class AdminUserData
{ {
/**
* Email address for the managed user.
*/
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Email] #[Assert\Email]
public string $email = ''; public string $email = '';
/**
* Optional public display name.
*/
#[Assert\Length(max: 120)] #[Assert\Length(max: 120)]
public ?string $displayName = null; public ?string $displayName = null;
} }

View File

@@ -9,13 +9,28 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
final class AlbumSearchCriteria final class AlbumSearchCriteria
{ {
/** Free-form query that mixes album, artist, and keyword matches. */
public readonly string $query; public readonly string $query;
/** Explicit album title filter supplied via the advanced panel. */
public readonly string $albumName; public readonly string $albumName;
/** Explicit artist filter supplied via the advanced panel. */
public readonly string $artist; public readonly string $artist;
/** Genre substring to match within stored Spotify/user genres. */
public readonly string $genre; public readonly string $genre;
/** Lower bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearFrom; public readonly ?int $yearFrom;
/** Upper bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearTo; public readonly ?int $yearTo;
/** Requested source scope: `all`, `spotify`, or `user`. */
public readonly string $source; public readonly string $source;
/** Maximum number of results the search should return. */
public readonly int $limit; public readonly int $limit;
public function __construct( public function __construct(
@@ -55,21 +70,22 @@ 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'; 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'; return $this->source === 'all' || $this->source === 'user';
} }
public function getGenre(): string
{
return $this->genre;
}
private static function normalizeYear(mixed $value): ?int private static function normalizeYear(mixed $value): ?int
{ {
if ($value === null) { if ($value === null) {

View File

@@ -15,9 +15,13 @@ final class AlbumSearchResult
* @param array<int,string> $savedIds * @param array<int,string> $savedIds
*/ */
public function __construct( public function __construct(
/** Filters that produced this result set. */
public readonly AlbumSearchCriteria $criteria, public readonly AlbumSearchCriteria $criteria,
/** Album payloads ready for Twig rendering. */
public readonly array $albums, public readonly array $albums,
/** Per-album review aggregates keyed by album ID. */
public readonly array $stats, public readonly array $stats,
/** List of Spotify IDs saved locally for quick lookup. */
public readonly array $savedIds public readonly array $savedIds
) { ) {
} }

View File

@@ -17,6 +17,9 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class AdminUserType extends AbstractType class AdminUserType extends AbstractType
{ {
/**
* Declares the admin-only account fields plus password confirmation.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
@@ -46,6 +49,9 @@ class AdminUserType extends AbstractType
]); ]);
} }
/**
* Uses the AdminUserData DTO as the underlying data object.
*/
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; 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 class AlbumType extends AbstractType
{ {
/** /**
@@ -54,6 +57,7 @@ class AlbumType extends AbstractType
'label' => 'External link', 'label' => 'External link',
]); ]);
// Seed the CSV helper fields with existing entity values before rendering.
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$album = $event->getData(); $album = $event->getData();
if (!$album instanceof Album) { if (!$album instanceof Album) {
@@ -68,6 +72,7 @@ class AlbumType extends AbstractType
} }
}); });
// Convert the CSV helper fields back into normalized arrays when saving.
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$album = $event->getData(); $album = $event->getData();
if (!$album instanceof Album) { if (!$album instanceof Album) {

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* ProfileFormType lets authenticated users edit their account details and password.
*/
class ProfileFormType extends AbstractType class ProfileFormType extends AbstractType
{ {
/** /**

View File

@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* RegistrationFormType defines the public signup form and its validation rules.
*/
class RegistrationFormType extends AbstractType class RegistrationFormType extends AbstractType
{ {
/** /**

View File

@@ -12,6 +12,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* ReviewType captures the fields needed to author or edit a review.
*/
class ReviewType extends AbstractType 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\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
*/
class SiteSettingsType extends AbstractType class SiteSettingsType extends AbstractType
{ {
/** /**

View File

@@ -16,6 +16,9 @@ class AlbumRepository extends ServiceEntityRepository
/** /**
* Wires the repository to Doctrine's registry. * Wires the repository to Doctrine's registry.
*/ */
/**
* Provides the Doctrine registry so we can build query builders on demand.
*/
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Album::class); parent::__construct($registry, Album::class);
@@ -63,6 +66,8 @@ class AlbumRepository extends ServiceEntityRepository
} }
/** /**
* Returns all stored Spotify album IDs so background jobs can iterate over them.
*
* @return list<string> * @return list<string>
*/ */
public function findAllSpotifyIds(): array public function findAllSpotifyIds(): array
@@ -153,10 +158,8 @@ class AlbumRepository extends ServiceEntityRepository
/** /**
* Upserts data from a Spotify album payload and keeps DB entities in sync. * 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).
/**
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups).
*/ */
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album
{ {
@@ -249,6 +252,9 @@ class AlbumRepository extends ServiceEntityRepository
return $filtered; return $filtered;
} }
/**
* Normalizes a filter needle to lowercase so comparisons stay consistent.
*/
private function normalizeNeedle(?string $needle): ?string private function normalizeNeedle(?string $needle): ?string
{ {
if ($needle === null) { if ($needle === null) {

View File

@@ -14,6 +14,9 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class AlbumTrackRepository extends ServiceEntityRepository class AlbumTrackRepository extends ServiceEntityRepository
{ {
/**
* Registers the repository with Doctrine's manager registry.
*/
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, AlbumTrack::class); parent::__construct($registry, AlbumTrack::class);
@@ -61,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 private function stringOrNull(mixed $value): ?string
{ {
if ($value === null) { if ($value === null) {
@@ -70,12 +76,20 @@ class AlbumTrackRepository extends ServiceEntityRepository
return $string === '' ? null : $string; return $string === '' ? null : $string;
} }
/**
* Ensures a positive integer (defaults to 1) for disc/track numbers.
*/
private function normalizePositiveInt(mixed $value): int private function normalizePositiveInt(mixed $value): int
{ {
$int = (int) $value; $int = (int) $value;
return $int > 0 ? $int : 1; 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 private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
{ {
// Track which disc/track slots have already been claimed in this upsert run. // Track which disc/track slots have already been claimed in this upsert run.

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 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 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; 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 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 protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{ {
@@ -40,6 +41,7 @@ class ReviewVoter extends Voter
/** @var Review $review */ /** @var Review $review */
$review = $subject; $review = $subject;
// Only the author may edit/delete their own review.
return $review->getAuthor()?->getId() === $user->getId(); return $review->getAuthor()?->getId() === $user->getId();
} }
} }

View File

@@ -18,10 +18,10 @@ use Psr\Log\LoggerInterface;
class AlbumSearchService class AlbumSearchService
{ {
public function __construct( public function __construct(
private readonly SpotifyClient $spotify, private readonly SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository, private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository, private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver, private readonly SpotifyGenreResolver $genreResolver,
) { ) {
@@ -33,12 +33,12 @@ class AlbumSearchService
*/ */
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{ {
$spotifyQuery = $this->buildSpotifyQuery($criteria); $spotifyQuery = $this->buildSpotifySearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery); $hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
// Spotify only gets pinged when callers explicitly enable it and we actually have // 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). // something to ask for (bare "all" requests would otherwise waste API calls).
$shouldQuerySpotify = $criteria->useSpotify() $shouldQuerySpotify = $criteria->shouldUseSpotify()
&& ($spotifyQuery !== '' || $criteria->getGenre() !== '' || $criteria->source === 'spotify'); && ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
$stats = []; $stats = [];
$savedIds = []; $savedIds = [];
@@ -53,7 +53,7 @@ class AlbumSearchService
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']); $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. // Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria); $userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads']; $userPayloads = $userData['payloads'];
@@ -65,10 +65,18 @@ class AlbumSearchService
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds); return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
} }
/**
* Indicates whether Spotify credentials are configured.
*/
public function isSpotifyConfigured(): bool
{
return $this->spotifyClient->isConfigured();
}
/** /**
* Turns structured filters into Spotify's free-form query syntax. * Turns structured filters into Spotify's free-form query syntax.
*/ */
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
{ {
$parts = []; $parts = [];
if ($criteria->albumName !== '') { if ($criteria->albumName !== '') {
@@ -105,7 +113,7 @@ class AlbumSearchService
return $spotifyQuery !== '' return $spotifyQuery !== ''
|| $criteria->albumName !== '' || $criteria->albumName !== ''
|| $criteria->artist !== '' || $criteria->artist !== ''
|| $criteria->getGenre() !== '' || $criteria->genre !== ''
|| $criteria->yearFrom !== null || $criteria->yearFrom !== null
|| $criteria->yearTo !== null; || $criteria->yearTo !== null;
} }
@@ -117,22 +125,22 @@ class AlbumSearchService
*/ */
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
{ {
$stored = $this->albumRepository->searchSpotifyAlbums( $storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums(
$criteria->query, $criteria->query,
$criteria->albumName, $criteria->albumName,
$criteria->artist, $criteria->artist,
$criteria->getGenre(), $criteria->genre,
$criteria->yearFrom ?? 0, $criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $criteria->limit
); );
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored); $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedSpotifyAlbums);
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->getGenre()); $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
$storedIds = $this->collectSpotifyIds($stored); $storedIds = $this->collectSpotifyIds($storedSpotifyAlbums);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : []; $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds; $savedIds = $storedIds;
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit; $shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit;
if (!$shouldFetchFromSpotify) { if (!$shouldFetchFromSpotify) {
return [ return [
@@ -143,8 +151,9 @@ class AlbumSearchService
} }
// Mix cached payloads with just enough fresh API data to satisfy the limit. // 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); $apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre()); $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); $payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']); $stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']); $savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
@@ -160,7 +169,7 @@ class AlbumSearchService
*/ */
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array 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'] ?? []; $searchItems = $result['albums']['items'] ?? [];
$this->logger->info('Album search results received', [ $this->logger->info('Album search results received', [
'query' => $spotifyQuery, 'query' => $spotifyQuery,
@@ -176,7 +185,7 @@ class AlbumSearchService
return ['payloads' => [], 'stats' => [], 'savedIds' => []]; return ['payloads' => [], 'stats' => [], 'savedIds' => []];
} }
$full = $this->spotify->getAlbums($ids); $full = $this->spotifyClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : []; $albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) { if ($albumsPayload === [] && $searchItems !== []) {
$albumsPayload = $searchItems; $albumsPayload = $searchItems;
@@ -196,7 +205,7 @@ class AlbumSearchService
); );
$upserted++; $upserted++;
} }
$this->em->flush(); $this->entityManager->flush();
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]); $this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids); $existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
@@ -227,7 +236,7 @@ class AlbumSearchService
$criteria->query, $criteria->query,
$criteria->albumName, $criteria->albumName,
$criteria->artist, $criteria->artist,
$criteria->getGenre(), $criteria->genre,
$criteria->yearFrom ?? 0, $criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $criteria->limit
@@ -260,6 +269,7 @@ class AlbumSearchService
$entityId = (int) $album->getId(); $entityId = (int) $album->getId();
$localId = (string) $album->getLocalId(); $localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) { 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]; $mapped[$localId] = $userStats[$entityId];
} }
} }
@@ -373,7 +383,7 @@ class AlbumSearchService
if ($id !== null && isset($seen[$id])) { if ($id !== null && isset($seen[$id])) {
continue; continue;
} }
// Fill the remainder of the list with secondary payloads that have not already been emitted. // Secondary payloads often duplicate the primary list; skip anything we've already emitted.
$merged[] = $payload; $merged[] = $payload;
if ($id !== null) { if ($id !== null) {
$seen[$id] = true; $seen[$id] = true;

View File

@@ -14,15 +14,18 @@ class CatalogResetService
} }
/** /**
* Deletes all reviews and albums from the catalog and returns summary counts.
*
* @return array{albums:int,reviews:int} * @return array{albums:int,reviews:int}
*/ */
public function reset(): array public function resetCatalog(): array
{ {
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute(); $deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
$albums = $this->albumRepository->findAll(); $albums = $this->albumRepository->findAll();
$albumCount = count($albums); $albumCount = count($albums);
foreach ($albums as $album) { foreach ($albums as $album) {
// Remove entities one-by-one so Doctrine cascades delete related tracks/reviews as configured.
$this->entityManager->remove($album); $this->entityManager->remove($album);
} }
$this->entityManager->flush(); $this->entityManager->flush();

View File

@@ -9,18 +9,21 @@ use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\KernelInterface;
/** /**
* ConsoleCommandRunner executes Symfony console commands from HTTP contexts. * CommandRunner executes Symfony console commands from non-CLI contexts
* and returns their buffered output.
*/ */
class ConsoleCommandRunner class CommandRunner
{ {
public function __construct(private readonly KernelInterface $kernel) public function __construct(private readonly KernelInterface $kernel)
{ {
} }
/** /**
* Executes a Symfony console command and returns its buffered output.
*
* @param array<string,mixed> $options * @param array<string,mixed> $options
*/ */
public function run(string $commandName, array $options = []): string public function runConsoleCommand(string $commandName, array $options = []): string
{ {
$application = new Application($this->kernel); $application = new Application($this->kernel);
$application->setAutoExit(false); $application->setAutoExit(false);
@@ -30,6 +33,7 @@ class ConsoleCommandRunner
$exitCode = $application->run($input, $output); $exitCode = $application->run($input, $output);
if ($exitCode !== Command::SUCCESS) { 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)); throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
} }

View File

@@ -1,78 +0,0 @@
<?php
namespace App\Service;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
/**
* ImageStorage handles moving uploaded images under /public/uploads,
* making sure directories exist and returning web-ready paths.
*/
class ImageStorage
{
private Filesystem $fs;
public function __construct(
private readonly string $projectDir,
private readonly SluggerInterface $slugger
) {
$this->fs = new Filesystem();
}
/**
* Saves a profile avatar and returns the path the front end can render.
*/
public function storeProfileImage(UploadedFile $file): string
{
return $this->store($file, 'avatars');
}
/**
* Saves an album cover and returns the path the front end can render.
*/
public function storeAlbumCover(UploadedFile $file): string
{
return $this->store($file, 'album_covers');
}
/**
* Removes a stored image when the provided web path points to a file.
*/
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);
}
}
/**
* Moves the uploaded file into the requested uploads directory and returns its web path.
*
* @param UploadedFile $file Uploaded Symfony file object.
* @param string $subDirectory Subdirectory under /public/uploads.
*/
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';
// 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);
return '/uploads/' . $subDirectory . '/' . $filename;
}
}

View File

@@ -19,7 +19,7 @@ final class RegistrationToggle
/** /**
* Returns the environment override when present, otherwise null. * Returns the environment override when present, otherwise null.
*/ */
public function envOverride(): ?bool public function getEnvOverride(): ?bool
{ {
return $this->envOverride; return $this->envOverride;
} }
@@ -49,6 +49,7 @@ final class RegistrationToggle
*/ */
private function detectEnvOverride(): ?bool 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; $raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
if ($raw === null) { if ($raw === null) {
return null; return null;

View File

@@ -24,8 +24,8 @@ class SpotifyClient
public function __construct( public function __construct(
HttpClientInterface $httpClient, HttpClientInterface $httpClient,
CacheInterface $cache, CacheInterface $cache,
string $clientId, ?string $clientId,
string $clientSecret, ?string $clientSecret,
SettingRepository $settings SettingRepository $settings
) { ) {
$this->httpClient = $httpClient; $this->httpClient = $httpClient;
@@ -129,6 +129,8 @@ class SpotifyClient
$offset += $limit; $offset += $limit;
$total = isset($page['total']) ? (int) $page['total'] : null; $total = isset($page['total']) ? (int) $page['total'] : null;
$hasNext = isset($page['next']) && $page['next'] !== 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)); } while ($hasNext && ($total === null || $offset < $total));
return $items; return $items;
@@ -197,6 +199,7 @@ class SpotifyClient
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET'; $shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
if ($shouldCache) { if ($shouldCache) {
// Cache fingerprint mixes URL and query only; headers are static (Bearer token).
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? [])); $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
$item->expiresAfter($cacheTtlSeconds); $item->expiresAfter($cacheTtlSeconds);
@@ -268,12 +271,22 @@ class SpotifyClient
}); });
if ($token === null) { 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); $this->cache->delete($cacheKey);
} }
return $token; 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

@@ -7,7 +7,7 @@ namespace App\Service;
*/ */
class SpotifyGenreResolver class SpotifyGenreResolver
{ {
public function __construct(private readonly SpotifyClient $spotify) public function __construct(private readonly SpotifyClient $spotifyClient)
{ {
} }
@@ -80,9 +80,9 @@ class SpotifyGenreResolver
private function fetchArtistsGenres(array $artistIds): array private function fetchArtistsGenres(array $artistIds): array
{ {
$genres = []; $genres = [];
foreach (array_chunk($artistIds, 50) as $chunk) { foreach (array_chunk($artistIds, 50) as $artistIdChunk) {
// Spotify allows up to 50 artist IDs per request; batching keeps calls minimal. // Spotify allows up to 50 artist IDs per request; batching keeps calls minimal.
$payload = $this->spotify->getArtists($chunk); $payload = $this->spotifyClient->getArtists($artistIdChunk);
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : []; $artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
foreach ($artists as $artist) { foreach ($artists as $artist) {
$id = (string) ($artist['id'] ?? ''); $id = (string) ($artist['id'] ?? '');

View File

@@ -15,10 +15,10 @@ class SpotifyMetadataRefresher
private const BATCH_SIZE = 20; private const BATCH_SIZE = 20;
public function __construct( public function __construct(
private readonly SpotifyClient $spotify, private readonly SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository, private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository, private readonly AlbumTrackRepository $trackRepository,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver, private readonly SpotifyGenreResolver $genreResolver,
) { ) {
@@ -34,45 +34,46 @@ class SpotifyMetadataRefresher
return 0; return 0;
} }
$updated = 0; $updatedAlbumCount = 0;
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $chunk) { foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) {
$payload = $this->spotify->getAlbums($chunk); $payload = $this->spotifyClient->getAlbums($albumIdChunk);
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : []; $albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
if ($albums === []) { if ($albums === []) {
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($chunk)]); $this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
continue; continue;
} }
// Share the same genre resolution logic used during search so existing rows gain genre data too. // Share the same genre resolution logic used during search so existing rows gain genre data too.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums); $genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
foreach ($albums as $albumData) { foreach ($albums as $albumPayload) {
try { try {
$albumId = (string) ($albumData['id'] ?? ''); $albumId = (string) ($albumPayload['id'] ?? '');
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum( $albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
(array) $albumData, (array) $albumPayload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
); );
if ($albumId !== '' && $albumEntity !== null) { if ($albumId !== '' && $albumEntity !== null) {
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumData); $tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
if ($tracks !== []) { if ($tracks !== []) {
// Replace tracks wholesale to simplify diffs (instead of diffing rows).
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks); $this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
$albumEntity->setTotalTracks(count($tracks)); $albumEntity->setTotalTracks(count($tracks));
} }
} }
$updated++; $updatedAlbumCount++;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('Failed to upsert Spotify album', [ $this->logger->error('Failed to upsert Spotify album', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'album' => $albumData['id'] ?? null, 'album' => $albumPayload['id'] ?? null,
]); ]);
} }
} }
$this->em->flush(); $this->entityManager->flush();
} }
return $updated; return $updatedAlbumCount;
} }
/** /**
@@ -84,9 +85,9 @@ class SpotifyMetadataRefresher
$total = (int) ($albumPayload['tracks']['total'] ?? 0); $total = (int) ($albumPayload['tracks']['total'] ?? 0);
if ($total > count($tracks)) { if ($total > count($tracks)) {
$full = $this->spotify->getAlbumTracks($albumId); $fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId);
if ($full !== []) { if ($fullTrackPayloads !== []) {
return $full; return $fullTrackPayloads;
} }
} }

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

@@ -28,6 +28,14 @@
{% endif %} {% endif %}
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1> <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"> <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 %} {% if landing_view %}
<div> <div>