diff --git a/.DS_Store b/.DS_Store
index 6d4ad6f..13d7095 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.env.example b/.env.example
index 2479b3f..2fb02e8 100644
--- a/.env.example
+++ b/.env.example
@@ -1,17 +1,12 @@
-SPOTIFY_CLIENT_ID=
-SPOTIFY_CLIENT_SECRET=
-APP_ENV=dev
-APP_SECRET=changeme # Arbitrary secret. Ideally a long random string.
-APP_ALLOW_REGISTRATION=1 #
-DEFAULT_URI=http://localhost:8000 # Should match external URI of application.
-DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite
-DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
-#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite
-ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits.
+# Uncomment to override stored setting.
+#SPOTIFY_CLIENT_ID=
+#SPOTIFY_CLIENT_SECRET=
-# POSTGRES_DB=
-# POSTGRES_USER=
-# POSTGRES_PASSWORD=
+APP_ENV=prod
+APP_SECRET=changeme
+# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
+DEFAULT_URI=http://localhost:8085
+ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by Spotify.
-PGADMIN_DEFAULT_EMAIL=admin@example.com
-PGADMIN_DEFAULT_PASSWORD=password
+DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
+# DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8908d77..8d52b89 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,6 +12,7 @@ on:
permissions:
contents: read
+ packages: write
concurrency:
group: ci-${{ github.ref }}
@@ -70,6 +71,8 @@ jobs:
name: Build production image
needs: php-tests
runs-on: ubuntu-latest
+ env:
+ IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
steps:
- name: Checkout
@@ -100,3 +103,19 @@ jobs:
- name: Smoke-test entrypoint & migrations
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
+ - name: Log in to GHCR
+ if: github.event_name == 'push'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Tag latest image
+ if: github.event_name == 'push'
+ run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
+
+ - name: Push latest image
+ if: github.event_name == 'push'
+ run: docker push $IMAGE_NAME:latest
+
diff --git a/.idea/musicratings.iml b/.idea/musicratings.iml
index f17edce..78d987e 100644
--- a/.idea/musicratings.iml
+++ b/.idea/musicratings.iml
@@ -24,10 +24,6 @@
-
-
-
-
@@ -38,7 +34,6 @@
-
@@ -69,13 +64,11 @@
-
-
@@ -87,7 +80,6 @@
-
@@ -120,14 +112,12 @@
-
-
diff --git a/.idea/php.xml b/.idea/php.xml
index 299ff24..d1995bc 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -12,11 +12,9 @@
-
-
@@ -31,11 +29,8 @@
-
-
-
@@ -43,7 +38,6 @@
-
@@ -88,7 +82,6 @@
-
@@ -112,7 +105,6 @@
-
@@ -120,7 +112,6 @@
-
@@ -129,7 +120,6 @@
-
diff --git a/README.md b/README.md
index d90ad14..09f7ed8 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# Tonehaus — Music Ratings
+# Tonehaus — Music Ratings (Symfony 7)
-Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
+Discover albums via Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
-## Quick start
+## Quick start (Docker Compose)
1) Start the stack
@@ -10,109 +10,66 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
docker compose up -d --build
```
-2) Create the database schema
+2) Open the app
+
+- App URL: `http://localhost:8085`
+- Health: `http://localhost:8085/healthz`
+
+3) Create your first admin
+
+- Sign Up through Tonehaus
```bash
-docker compose exec php php bin/console doctrine:database:create --if-not-exists
-docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
-docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
+docker compose exec tonehaus php bin/console app:promote-admin you@example.com
```
-3) Promote an admin (to access Site Settings)
+4) Configure Spotify
+
+- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or
+- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
+
+5) (Optional) Seed demo data
```bash
-docker compose exec php php bin/console app:promote-admin you@example.com
+docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
+docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
+docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
-4) Configure Spotify API credentials (admin only)
-
-- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
-- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
-
-5) Visit `http://localhost:8000` to search for albums.
-
-6) (Optional) Seed demo data
-
-```bash
-docker compose exec php php bin/console app:seed-demo-users --count=50
-docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users
-docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
-```
-
-## 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.
+Notes:
+- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
+- To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
## Features
-- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
-- Album page with details, reviews list, and inline new review (logged in)
-- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
-- Role-based access: authors manage their own reviews, admins can manage any
-- Admin Site Settings to manage Spotify credentials in DB
-- User Dashboard to update profile and change password (requires current password)
-- Light/Dark theme toggle in Settings (cookie-backed)
-- Bootstrap UI
+- Spotify search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count)
+- Album page with details, tracklist, reviews list, and inline new review (logged-in)
+- Auth modal (Login/Sign up) with remember‑me; no separate pages
+- Role-based access: authors manage their own reviews; moderators/admins can moderate content
+- Admin Site Settings: manage Spotify credentials and public registration toggle
+- User Dashboard: profile updates and password change
+- Light/Dark theme toggle (cookie-backed)
-## Rate limiting & caching
+## Documentation
-- Server-side Client Credentials; access tokens are cached.
+- Setup and configuration: `docs/setup.md`
+- Feature overview: `docs/features.md`
+- Authentication and users: `docs/auth-and-users.md`
+- Spotify integration: `docs/spotify-integration.md`
+- Reviews and albums: `docs/reviews-and-albums.md`
+- Admin & site settings: `docs/admin-and-settings.md`
+- Troubleshooting: `docs/troubleshooting.md`
+- Architecture: `docs/architecture.md`
+- Deployment: `docs/deployment.md`
-## Docs
+## Environment overview
-See `/docs` for how-tos and deeper notes:
-
-- Setup and configuration: `docs/01-setup.md`
-- Features and UX: `docs/02-features.md`
-- Authentication and users: `docs/03-auth-and-users.md`
-- Spotify integration: `docs/04-spotify-integration.md`
-- Reviews and albums: `docs/05-reviews-and-albums.md`
-- Admin & site settings: `docs/06-admin-and-settings.md`
-- Troubleshooting: `docs/07-troubleshooting.md`
+- `APP_ENV` (dev|prod), `APP_SECRET` (required)
+- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
+- `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
+- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
+- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
+- `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
## License
diff --git a/config/packages/doctrine.php b/config/packages/doctrine.php
index 02ec6e7..2d0db59 100644
--- a/config/packages/doctrine.php
+++ b/config/packages/doctrine.php
@@ -1,5 +1,26 @@
/var/data/database.sqlite`, creating the
+ * directory and file if they do not already exist. (Recommended)
+ *
+ * This split keeps the mapping/caching config in YAML while allowing
+ * DBAL to adapt between Docker/postgres and local sqlite setups.
+ */
+
declare(strict_types=1);
use Symfony\Component\Filesystem\Filesystem;
@@ -7,6 +28,7 @@ use Symfony\Config\DoctrineConfig;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
return static function (DoctrineConfig $doctrine): void {
+ // Normalize DATABASE_DRIVER and validate allowed values up front.
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
$supportedDrivers = ['postgres', 'sqlite'];
@@ -18,6 +40,7 @@ return static function (DoctrineConfig $doctrine): void {
));
}
+ // Configure the default DBAL connection.
$dbal = $doctrine->dbal();
$dbal->defaultConnection('default');
@@ -26,12 +49,14 @@ return static function (DoctrineConfig $doctrine): void {
$connection->useSavepoints(true);
if ('sqlite' === $driver) {
+ // SQLite: use a file-backed database by default.
$connection->driver('pdo_sqlite');
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
if ($hasCustomPath) {
+ // Allow explicit database path via env overrides.
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
} else {
$projectDir = dirname(__DIR__, 2);
@@ -50,7 +75,9 @@ return static function (DoctrineConfig $doctrine): void {
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
}
} else {
+ // Postgres (or other server-based driver) via DATABASE_URL.
$connection->url('%env(resolve:DATABASE_URL)%');
+ // Keep the server version explicit so Doctrine does not need network calls to detect it.
$connection->serverVersion('16');
$connection->charset('utf8');
}
diff --git a/config/services.yaml b/config/services.yaml
index 333d521..0ed7f49 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -22,12 +22,13 @@ services:
App\Service\SpotifyClient:
arguments:
- $clientId: '%env(SPOTIFY_CLIENT_ID)%'
- $clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
+ $clientId: '%env(default::SPOTIFY_CLIENT_ID)%'
+ $clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
- App\Service\ImageStorage:
+ App\Service\UploadStorage:
arguments:
- $projectDir: '%kernel.project_dir%'
+ $storageRoot: '%kernel.project_dir%/public/uploads'
+ $publicPrefix: '/uploads'
App\Controller\AlbumController:
arguments:
diff --git a/docker-compose.yml b/docker-compose.yml
index ff72901..de3ff24 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
-# 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:
- image: tonehaus/tonehaus:dev-arm64
+# image: tonehaus/tonehaus:dev-arm64
+ image: git.ntbx.io/boris/tonehaus:latest
container_name: tonehaus
restart: unless-stopped
volumes:
- - ./.env:/var/www/html/.env:ro
+ - uploads:/var/www/html/public/uploads
- sqlite_data:/var/www/html/var/data
ports:
- - "8085:8080"
+ - "8085:80"
env_file:
- .env
healthcheck:
@@ -58,56 +16,7 @@ services:
interval: 10s
timeout: 3s
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:
sqlite_data:
-# composer_cache:
-# pgadmin_data:
\ No newline at end of file
+ uploads:
\ No newline at end of file
diff --git a/docker/prod/nginx.conf b/docker/prod/nginx.conf
index f607d29..806c2be 100644
--- a/docker/prod/nginx.conf
+++ b/docker/prod/nginx.conf
@@ -1,5 +1,5 @@
server {
- listen 8080;
+ listen 80;
server_name _;
root /var/www/html/public;
diff --git a/docs/01-setup.md b/docs/01-setup.md
deleted file mode 100644
index c6db1dc..0000000
--- a/docs/01-setup.md
+++ /dev/null
@@ -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
-```
-
-
diff --git a/docs/02-features.md b/docs/02-features.md
deleted file mode 100644
index e77683e..0000000
--- a/docs/02-features.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Features
-
-- Spotify album search with Advanced filters (album, artist, year range)
-- Album page with details, list of reviews, and inline new review
-- Review rating slider (1–10) with live badge
-- Per-album aggregates: average rating and total review count
-- Auth modal (Login/Sign up) with remember-me cookie
-- Role-based access (user, moderator, admin) with protected admin routes
-- Admin Site Settings to manage Spotify credentials
-- Moderator/Admin dashboard with latest activity snapshots
-- User management table (create/delete accounts, promote/demote moderators)
-- User Dashboard for profile changes (email, display name, password)
-- Light/Dark theme toggle (cookie-backed)
-- Bootstrap UI
-
-
diff --git a/docs/03-auth-and-users.md b/docs/03-auth-and-users.md
deleted file mode 100644
index 857ffaf..0000000
--- a/docs/03-auth-and-users.md
+++ /dev/null
@@ -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+@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).
-
-
diff --git a/docs/04-spotify-integration.md b/docs/04-spotify-integration.md
deleted file mode 100644
index d0264cb..0000000
--- a/docs/04-spotify-integration.md
+++ /dev/null
@@ -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
-
-
diff --git a/docs/05-reviews-and-albums.md b/docs/05-reviews-and-albums.md
deleted file mode 100644
index 622fd7f..0000000
--- a/docs/05-reviews-and-albums.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Reviews & Albums
-
-## Album page
-- Shows album artwork, metadata, average rating and review count.
-- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
-- Lists reviews newest-first.
-- Logged-in users can submit a review inline.
-
-## Permissions
-- Anyone can view.
-- Authors can edit/delete their own reviews.
-- Moderators and admins can edit/delete any review or user-created album.
-
-## UI
-- Rating uses a slider (1–10) with ticks; badge shows current value.
-
-## Demo data
-- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately.
-- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity.
- - Use `--only-empty` when you want to focus on albums that currently have no reviews.
-
-
diff --git a/docs/07-troubleshooting.md b/docs/07-troubleshooting.md
deleted file mode 100644
index 604baa0..0000000
--- a/docs/07-troubleshooting.md
+++ /dev/null
@@ -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.
-
diff --git a/docs/06-admin-and-settings.md b/docs/admin-and-settings.md
similarity index 83%
rename from docs/06-admin-and-settings.md
rename to docs/admin-and-settings.md
index a892a0b..6ba43b4 100644
--- a/docs/06-admin-and-settings.md
+++ b/docs/admin-and-settings.md
@@ -42,4 +42,7 @@ docker compose exec php php bin/console app:promote-moderator user@example.com
- `/settings` provides a dark/light mode toggle.
- Preference saved in a cookie; applied via `data-bs-theme`.
-
+## Useful tips
+- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
+- Changing Spotify credentials in settings is effective immediately; no restart is required.
+- Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..0af0c4a
--- /dev/null
+++ b/docs/architecture.md
@@ -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 HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
+ - **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `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 **verb‑based, intention‑revealing 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, domain‑level 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 PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
+ - Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
+
+These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
+
+## High-level flow
+1. Visitors search for albums (Spotify) and view an album page
+2. Logged‑in users can write, edit, and delete reviews
+3. Moderators and admins can moderate content and manage users
+4. Admins configure site settings (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` — sign‑up and login/logout routes
+
+### Entities (`src/Entity/*`)
+- `User` — authentication principal and roles
+- `Album`, `AlbumTrack` — normalized album metadata and track list
+- `Review` — user‑authored review with rating and timestamps
+- `Setting` — key/value store for site configuration (e.g., 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 server‑side 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` — DB‑backed registration flag with env override
+
+### Security (`config/packages/security.yaml`, `src/Security/*`)
+- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
+- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
+- Access control for `/admin/*` enforced via routes and controllers
+
+### Views (`templates/*`)
+- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
+- Auth modal in `templates/_partials/auth_modal.html.twig`
+- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
+
+### DTOs (`src/Dto/*`)
+- Simple data transfer objects for admin tables and search results
+
+## Data & persistence
+- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
+- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
+
+## Error handling & UX
+- 404 for missing albums
+- Flash messages for success/error on actions
+- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
+
+## Testing & tooling
+- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
+- Web Profiler enabled in dev
+
+
diff --git a/docs/auth-and-users.md b/docs/auth-and-users.md
new file mode 100644
index 0000000..0b44605
--- /dev/null
+++ b/docs/auth-and-users.md
@@ -0,0 +1,48 @@
+# Authentication & Users
+
+## Login & Registration (modal)
+- Login and sign‑up are handled in a Bootstrap modal.
+- AJAX submits keep users on the page; a successful login refreshes state.
+- Remember‑me cookie keeps users logged in across sessions.
+
+## Roles & Permissions
+- `ROLE_USER` — default for registered users
+- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
+- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
+
+Promotion (from your host):
+```bash
+docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
+docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
+```
+
+### Access flow
+- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
+- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
+- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
+
+## Public registration toggle
+- Toggle in UI: `/admin/settings` (stored in DB)
+- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
+- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
+
+## User management (moderator+)
+- `/admin/users` lists accounts with album/review counts and actions:
+ - Create accounts inline (does not affect the current session)
+ - Delete users (guards prevent deleting self or administrators)
+ - Admins can Promote/Demote Moderator on non‑admins
+
+## Profiles & Passwords
+- `/account/profile`: update email and display name
+- `/account/password`: change password (requires current password)
+
+## Demo accounts & avatars
+```bash
+docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
+docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
+```
+
+## Logout
+- Link in the user menu calls `/logout` (handled by Symfony security).
+
+
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..2f18b5d
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,70 @@
+# Deployment
+
+This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
+
+## Build (locally)
+```bash
+docker build \
+ --target=prod \
+ -t tonehaus-app:latest \
+ -f docker/php/Dockerfile \
+ .
+```
+
+## Run
+```bash
+docker run -d \
+ --name tonehaus \
+ -p 8080:8080 \
+ -e APP_ENV=prod \
+ -e APP_SECRET=change_me \
+ -e 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 re‑run)
+- Cache warmup is executed on boot; `APP_SECRET` is required
+
+## Persistence options
+### SQLite (default)
+- Data file at `var/data/database.sqlite`
+- Use a volume for durability:
+```bash
+docker run -d \
+ -v tonehaus_sqlite:/var/www/html/var/data \
+ ...
+```
+
+### Postgres
+Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
+```
+postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
+```
+You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
+```bash
+docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
+```
+
+## Environment variables
+- `APP_ENV` (`prod` recommended in production)
+- `APP_SECRET` (required; random string)
+- `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 container’s port 8080
+- Ensure proxy sends `X-Forwarded-*` headers
+
+## Zero‑downtime tips
+- Build then run a new container alongside the old one, switch traffic at the proxy
+- Keep SQLite on a named volume, or use Postgres for shared state across replicas
+
+
diff --git a/docs/features.md b/docs/features.md
new file mode 100644
index 0000000..40398a7
--- /dev/null
+++ b/docs/features.md
@@ -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 (1–10) with live badge
+- Per‑album aggregates: average rating and total review count
+
+## Authentication & Users
+- Bootstrap auth modal for login/sign-up with AJAX submits
+- Remember‑me cookie keeps users signed in
+- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
+- Profile: update email, display name, and password (requires current password)
+
+## Administration
+- Dashboard: latest reviews/albums and key counts (moderator+)
+- Users: create/delete users, promote/demote moderators (admin constraints)
+- Settings: manage 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)
+
diff --git a/docs/reviews-and-albums.md b/docs/reviews-and-albums.md
new file mode 100644
index 0000000..21776e4
--- /dev/null
+++ b/docs/reviews-and-albums.md
@@ -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 logged‑in users
+
+## Writing a review
+- Rating slider from 1–10
+- Title (max 160 chars) and body (20–5000 chars)
+- Server-side validation provides inline errors on failure
+- Successful submissions persist, flash a success message, and reload the album page
+
+## Editing & deleting reviews
+- Authors can edit/delete their own reviews
+- Moderators/Admins can edit/delete any review
+- CSRF protection is required for deletion
+
+## Aggregates
+- The album page computes:
+ - Total number of reviews for the album
+ - Average rating rounded to one decimal
+
+## Demo data
+```bash
+docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
+docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
+```
+- Use `--only-empty` to focus on albums that currently have no reviews.
+
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 0000000..2d749d1
--- /dev/null
+++ b/docs/setup.md
@@ -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=`
+- `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
+```
diff --git a/docs/spotify-integration.md b/docs/spotify-integration.md
new file mode 100644
index 0000000..68d3bcd
--- /dev/null
+++ b/docs/spotify-integration.md
@@ -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
+
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..0ec2dc7
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -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
+```
\ No newline at end of file
diff --git a/src/Command/PromoteAdminCommand.php b/src/Command/PromoteAdminCommand.php
index 08f8168..aa5c7d7 100644
--- a/src/Command/PromoteAdminCommand.php
+++ b/src/Command/PromoteAdminCommand.php
@@ -16,7 +16,10 @@ class PromoteAdminCommand extends Command
/**
* Stores injected dependencies for later use.
*/
- public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
+ public function __construct(
+ private readonly UserRepository $userRepository,
+ private readonly EntityManagerInterface $entityManager,
+ )
{
parent::__construct();
}
@@ -35,7 +38,7 @@ class PromoteAdminCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
- $user = $this->users->findOneByEmail($email);
+ $user = $this->userRepository->findOneByEmail($email);
if (!$user) {
$output->writeln('User not found: ' . $email . '');
return Command::FAILURE;
@@ -45,7 +48,7 @@ class PromoteAdminCommand extends Command
if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles($roles);
- $this->em->flush();
+ $this->entityManager->flush();
}
$output->writeln('Granted ROLE_ADMIN to ' . $email . '');
diff --git a/src/Command/PromoteModeratorCommand.php b/src/Command/PromoteModeratorCommand.php
index 505f7d3..1a4394c 100644
--- a/src/Command/PromoteModeratorCommand.php
+++ b/src/Command/PromoteModeratorCommand.php
@@ -16,7 +16,10 @@ class PromoteModeratorCommand extends Command
/**
* Stores dependencies for the console handler.
*/
- public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
+ public function __construct(
+ private readonly UserRepository $userRepository,
+ private readonly EntityManagerInterface $entityManager,
+ )
{
parent::__construct();
}
@@ -35,7 +38,7 @@ class PromoteModeratorCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
- $user = $this->users->findOneByEmail($email);
+ $user = $this->userRepository->findOneByEmail($email);
if (!$user) {
$output->writeln('User not found: ' . $email . '');
return Command::FAILURE;
@@ -45,7 +48,7 @@ class PromoteModeratorCommand extends Command
if (!in_array('ROLE_MODERATOR', $roles, true)) {
$roles[] = 'ROLE_MODERATOR';
$user->setRoles($roles);
- $this->em->flush();
+ $this->entityManager->flush();
}
$output->writeln('Granted ROLE_MODERATOR to ' . $email . '');
diff --git a/src/Command/SeedDemoAlbumsCommand.php b/src/Command/SeedDemoAlbumsCommand.php
index 9a14c6e..e9140d3 100644
--- a/src/Command/SeedDemoAlbumsCommand.php
+++ b/src/Command/SeedDemoAlbumsCommand.php
@@ -20,6 +20,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
name: 'app:seed-demo-albums',
description: 'Create demo albums with randomized metadata for local development.'
)]
+/**
+ * Seeds the database with synthetic user-sourced albums.
+ *
+ * - Always marked as "user" source with a unique localId.
+ * - Include randomized names, artists, genres, release dates, and cover URLs.
+ * - Optionally link to existing users as creators when --attach-users is set.
+ */
class SeedDemoAlbumsCommand extends Command
{
private const GENRES = [
@@ -59,11 +66,14 @@ class SeedDemoAlbumsCommand extends Command
$users = $attachUsers ? $this->userRepository->findAll() : [];
$created = 0;
+ // Track generated localIds so we never attempt to persist obvious duplicates.
$seenLocalIds = [];
while ($created < $count) {
+ // Generate a localId that is unique in-memory and in the database to avoid constraint violations.
$localId = $this->generateLocalId();
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
+ // Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
continue;
}
diff --git a/src/Command/SeedDemoReviewsCommand.php b/src/Command/SeedDemoReviewsCommand.php
index 5093e13..cf4cbb2 100644
--- a/src/Command/SeedDemoReviewsCommand.php
+++ b/src/Command/SeedDemoReviewsCommand.php
@@ -21,6 +21,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
name: 'app:seed-demo-reviews',
description: 'Generate demo reviews across existing albums.'
)]
+/**
+ * Seeds the database with demo reviews attached to existing albums and users.
+ *
+ * Controls:
+ * - --cover-percent: roughly what percentage of albums receive reviews.
+ * - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
+ * - --only-empty: restricts seeding to albums that currently have no reviews.
+ *
+ * The command avoids:
+ * - Creating multiple reviews from the same user on a single album.
+ * - Touching albums/users when there is no suitable data to seed.
+ */
class SeedDemoReviewsCommand extends Command
{
private const SUBJECTS = [
@@ -59,6 +71,7 @@ class SeedDemoReviewsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
+ // Pull all albums/users once up front so downstream helpers filter as needed.
$albums = $this->albumRepository->findAll();
$users = $this->userRepository->findAll();
@@ -67,14 +80,17 @@ class SeedDemoReviewsCommand extends Command
return Command::FAILURE;
}
+ // Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
+ // Apply coverage and "only empty" filters before creating any Review entities. (filter)
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
$onlyEmpty = (bool) $input->getOption('only-empty');
$created = 0;
+ // Count how many albums actually received new reviews for clearer operator feedback. (count)
$processedAlbums = 0;
foreach ($selectedAlbums as $album) {
if ($onlyEmpty && $this->albumHasReviews($album)) {
@@ -107,6 +123,7 @@ class SeedDemoReviewsCommand extends Command
return $albums;
}
+ // Randomly sample albums until the requested coverage threshold is met.
$selected = [];
foreach ($albums as $album) {
if (random_int(1, 100) <= $coverPercent) {
@@ -114,6 +131,7 @@ class SeedDemoReviewsCommand extends Command
}
}
+ // Ensure we always seed at least one album when any albums exist.
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
}
@@ -124,17 +142,22 @@ class SeedDemoReviewsCommand extends Command
{
$created = 0;
$existingAuthors = $this->fetchExistingAuthors($album);
+ // Filter out users who have already reviewed this album so we only ever
+ // create one review per (album, author) pair.
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
if ($availableUsers === []) {
return 0;
}
+ // Limit requested reviews to the number of eligible authors, then randomly
+ // choose a stable subset for this run.
$targetReviews = min($targetReviews, count($availableUsers));
shuffle($availableUsers);
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
foreach ($selectedUsers as $user) {
+ // Prevent duplicate reviews per author by only iterating over filtered unique users.
$review = new Review();
$review->setAlbum($album);
$review->setAuthor($user);
@@ -154,6 +177,8 @@ class SeedDemoReviewsCommand extends Command
*/
private function fetchExistingAuthors(Album $album): array
{
+ // Fetch all distinct author IDs that have already reviewed this album so we
+ // can cheaply check for duplicates in PHP without loading full Review objects.
$qb = $this->entityManager->createQueryBuilder()
->select('IDENTITY(r.author) AS authorId')
->from(Review::class, 'r')
diff --git a/src/Command/SeedDemoUsersCommand.php b/src/Command/SeedDemoUsersCommand.php
index b6255b9..46f2026 100644
--- a/src/Command/SeedDemoUsersCommand.php
+++ b/src/Command/SeedDemoUsersCommand.php
@@ -19,6 +19,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
name: 'app:seed-demo-users',
description: 'Create demo users with random emails and display names.'
)]
+/**
+ * Seeds the database with demo users for local development and testing.
+ *
+ * - Generates unique, non-conflicting demo email addresses.
+ * - Assigns a predictable default password (overridable via --password).
+ * - Creates users with a single ROLE_USER role.
+ */
class SeedDemoUsersCommand extends Command
{
private const FIRST_NAMES = [
@@ -54,11 +61,15 @@ class SeedDemoUsersCommand extends Command
$plainPassword = (string) $input->getOption('password');
$created = 0;
+ // Track generated emails so we never attempt to persist obvious duplicates.
$seenEmails = [];
while ($created < $count) {
+ // Keep generating new tokens until we find an email that is unique
+ // for both this run and the existing database.
$email = $this->generateEmail();
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
+ // Collisions are rare but possible because we only randomize 8 hex chars; try again.
continue;
}
diff --git a/src/Command/SeedUserAvatarsCommand.php b/src/Command/SeedUserAvatarsCommand.php
index d3e6e6f..3f4c4e3 100644
--- a/src/Command/SeedUserAvatarsCommand.php
+++ b/src/Command/SeedUserAvatarsCommand.php
@@ -18,6 +18,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
name: 'app:seed-user-avatars',
description: 'Assign generated profile images to existing users.'
)]
+/**
+ * Seeds or refreshes user profile images using the DiceBear avatar API.
+ *
+ * - Skips users that already have an image unless --overwrite is provided.
+ * - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
+ * - Does not download or cache the avatars locally; URLs are stored directly.
+ */
class SeedUserAvatarsCommand extends Command
{
public function __construct(
@@ -54,6 +61,7 @@ class SeedUserAvatarsCommand extends Command
continue;
}
if (!$overwrite && $user->getProfileImagePath()) {
+ // Respect existing uploads unless the operator explicitly allows clobbering them.
continue;
}
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
@@ -73,7 +81,11 @@ class SeedUserAvatarsCommand extends Command
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
{
+ // Use a stable identifier (display name when present, email as fallback)
+ // so the same user is always mapped to the same avatar for a given prefix.
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
+ // Combine prefix, identifier, and primary key into a deterministic hash
+ // and trim it to a shorter seed value accepted by DiceBear.
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php
index 35a52b5..fdc69c7 100644
--- a/src/Controller/AccountController.php
+++ b/src/Controller/AccountController.php
@@ -6,7 +6,7 @@ use App\Entity\User;
use App\Form\ProfileFormType;
use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository;
-use App\Service\ImageStorage;
+use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -76,7 +76,7 @@ class AccountController extends AbstractController
* Allows users to update profile details and avatar.
*/
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
- public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ImageStorage $images): Response
+ public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -90,6 +90,7 @@ class AccountController extends AbstractController
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
} else {
+ // Allow password updates inside the same form submission instead of forcing a separate flow.
$user->setPassword($hasher->hashPassword($user, $newPassword));
}
}
@@ -97,8 +98,8 @@ class AccountController extends AbstractController
if ($form->isValid()) {
$upload = $form->get('profileImage')->getData();
if ($upload instanceof UploadedFile) {
- $images->remove($user->getProfileImagePath());
- $user->setProfileImagePath($images->storeProfileImage($upload));
+ $uploadStorage->remove($user->getProfileImagePath());
+ $user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
}
$em->flush();
diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php
index 751ba92..33e3bdc 100644
--- a/src/Controller/Admin/DashboardController.php
+++ b/src/Controller/Admin/DashboardController.php
@@ -24,6 +24,7 @@ class DashboardController extends AbstractController
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
{
+ // Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
$totalReviews = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->getQuery()->getSingleScalarResult();
@@ -36,6 +37,7 @@ class DashboardController extends AbstractController
->select('COUNT(u.id)')
->getQuery()->getSingleScalarResult();
+ // Latest rows are pulled separately so the dashboard can show concrete activity.
$recentReviews = $reviews->findLatest(50);
$recentAlbums = $albums->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
@@ -61,6 +63,7 @@ class DashboardController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
+ // Refresh runs synchronously; keep user feedback short so the POST remains snappy.
$updated = $refresher->refreshAllSpotifyAlbums();
if ($updated === 0) {
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php
index 70ecc50..39814af 100644
--- a/src/Controller/Admin/SettingsController.php
+++ b/src/Controller/Admin/SettingsController.php
@@ -5,7 +5,7 @@ namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
use App\Service\CatalogResetService;
-use App\Service\ConsoleCommandRunner;
+use App\Service\CommandRunner;
use App\Service\RegistrationToggle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -19,6 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController
{
+ // Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
private const DEMO_COMMANDS = [
'users' => [
'command' => 'app:seed-demo-users',
@@ -69,7 +70,7 @@ class SettingsController extends AbstractController
$form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
- $registrationOverride = $registrationToggle->envOverride();
+ $registrationOverride = $registrationToggle->getEnvOverride();
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
$form->handleRequest($request);
@@ -77,6 +78,7 @@ class SettingsController extends AbstractController
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
if ($registrationOverride === null) {
+ // Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
} else {
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
@@ -101,7 +103,7 @@ class SettingsController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
- $result = $resetService->reset();
+ $result = $resetService->resetCatalog();
$this->addFlash('success', sprintf(
'Reset catalog: deleted %d reviews and %d albums.',
$result['reviews'],
@@ -115,7 +117,7 @@ class SettingsController extends AbstractController
public function generateDemo(
string $type,
Request $request,
- ConsoleCommandRunner $runner
+ CommandRunner $runner
): Response {
$config = self::DEMO_COMMANDS[$type] ?? null;
if ($config === null) {
@@ -128,7 +130,7 @@ class SettingsController extends AbstractController
try {
$options = $this->buildCommandOptions($config, $request);
- $runner->run($config['command'], $options);
+ $runner->runConsoleCommand($config['command'], $options);
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
} catch (\Throwable $e) {
$this->addFlash('danger', sprintf(
@@ -154,6 +156,7 @@ class SettingsController extends AbstractController
$value = $request->request->get($name);
if ($type === 'checkbox') {
if ($value) {
+ // Symfony console options expect "--flag" style boolean toggles.
$options['--' . $name] = true;
}
continue;
diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php
index 8eac474..e8ff9af 100644
--- a/src/Controller/Admin/UserController.php
+++ b/src/Controller/Admin/UserController.php
@@ -21,8 +21,8 @@ use Symfony\Component\Routing\Attribute\Route;
class UserController extends AbstractController
{
public function __construct(
- private readonly EntityManagerInterface $em,
- private readonly UserPasswordHasherInterface $hasher,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly UserPasswordHasherInterface $passwordHasher,
) {
}
@@ -37,13 +37,14 @@ class UserController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
+ // Form collects only high-level metadata; everything else is defaulted here.
$plainPassword = (string) $form->get('plainPassword')->getData();
$newUser = new User();
$newUser->setEmail($formData->email);
$newUser->setDisplayName($formData->displayName);
- $newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
- $this->em->persist($newUser);
- $this->em->flush();
+ $newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
+ $this->entityManager->persist($newUser);
+ $this->entityManager->flush();
$this->addFlash('success', 'User account created.');
return $this->redirectToRoute('admin_users');
}
@@ -67,6 +68,7 @@ class UserController extends AbstractController
/** @var User|null $current */
$current = $this->getUser();
if ($current && $target->getId() === $current->getId()) {
+ // Protect against accidental lockouts by blocking self-deletes.
$this->addFlash('danger', 'You cannot delete your own account.');
return $this->redirectToRoute('admin_users');
}
@@ -76,8 +78,8 @@ class UserController extends AbstractController
return $this->redirectToRoute('admin_users');
}
- $this->em->remove($target);
- $this->em->flush();
+ $this->entityManager->remove($target);
+ $this->entityManager->flush();
$this->addFlash('success', 'User deleted.');
return $this->redirectToRoute('admin_users');
@@ -102,14 +104,15 @@ class UserController extends AbstractController
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
if ($isModerator) {
+ // Toggle-style UX: hitting the endpoint again demotes the moderator.
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
$target->setRoles($filtered);
- $this->em->flush();
+ $this->entityManager->flush();
$this->addFlash('success', 'Moderator privileges removed.');
} else {
$roles[] = 'ROLE_MODERATOR';
$target->setRoles(array_values(array_unique($roles)));
- $this->em->flush();
+ $this->entityManager->flush();
$this->addFlash('success', 'User promoted to moderator.');
}
diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php
index 7c458e5..9b001a0 100644
--- a/src/Controller/AlbumController.php
+++ b/src/Controller/AlbumController.php
@@ -12,7 +12,7 @@ use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
-use App\Service\ImageStorage;
+use App\Service\UploadStorage;
use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface;
@@ -30,7 +30,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AlbumController extends AbstractController
{
public function __construct(
- private readonly ImageStorage $imageStorage,
+ private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver,
private readonly int $searchLimit = 20
@@ -50,13 +50,14 @@ class AlbumController extends AbstractController
'query' => $criteria->query,
'album' => $criteria->albumName,
'artist' => $criteria->artist,
- 'genre' => $criteria->getGenre(),
+ 'genre' => $criteria->genre,
'year_from' => $criteria->yearFrom ?? '',
'year_to' => $criteria->yearTo ?? '',
'albums' => $result->albums,
'stats' => $result->stats,
'savedIds' => $result->savedIds,
'source' => $criteria->source,
+ 'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
]);
}
@@ -98,6 +99,7 @@ class AlbumController extends AbstractController
$albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null;
if (!$albumEntity) {
+ // Album has never been saved locally, so hydrate it via Spotify before rendering.
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
@@ -106,6 +108,7 @@ class AlbumController extends AbstractController
$em->flush();
} else {
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
+ // Track sync mutated the entity: persist before we build template arrays.
$em->flush();
}
}
@@ -193,7 +196,7 @@ class AlbumController extends AbstractController
if ($album) {
$this->ensureCanManageAlbum($album);
if ($album->getSource() === 'user') {
- $this->imageStorage->remove($album->getCoverImagePath());
+ $this->uploadStorage->remove($album->getCoverImagePath());
}
$em->remove($album);
$em->flush();
@@ -238,6 +241,7 @@ class AlbumController extends AbstractController
}
// Fallback: attempt to parse
try {
+ // Trust PHP's parser only as a last resort (it accepts many human formats).
$dt = new \DateTimeImmutable($s);
return $dt->format('Y-m-d');
} catch (\Throwable) {
@@ -328,8 +332,8 @@ class AlbumController extends AbstractController
}
$file = $form->get('coverUpload')->getData();
if ($file instanceof UploadedFile) {
- $this->imageStorage->remove($album->getCoverImagePath());
- $album->setCoverImagePath($this->imageStorage->storeAlbumCover($file));
+ $this->uploadStorage->remove($album->getCoverImagePath());
+ $album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
}
}
@@ -365,6 +369,7 @@ class AlbumController extends AbstractController
$storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
+ // Spotify track counts do not match what we have stored; re-sync to avoid stale data.
$needsSync = true;
}
if (!$needsSync) {
diff --git a/src/Dto/AdminUserData.php b/src/Dto/AdminUserData.php
index a4cecb7..213cc09 100644
--- a/src/Dto/AdminUserData.php
+++ b/src/Dto/AdminUserData.php
@@ -5,17 +5,24 @@ namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * AdminUserData captures the fields used when an admin creates a user manually.
- * Using a DTO keeps validation separate from the User entity and avoids side effects.
+ * AdminUserData carries the lightweight fields needed when admins create or edit
+ * users from the back office without touching the `User` entity directly.
* Used to allow user creation in the user management panel without invalidating active token.
* (This took too long to figure out)
+
*/
class AdminUserData
{
+ /**
+ * Email address for the managed user.
+ */
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '';
+ /**
+ * Optional public display name.
+ */
#[Assert\Length(max: 120)]
public ?string $displayName = null;
}
diff --git a/src/Dto/AlbumSearchCriteria.php b/src/Dto/AlbumSearchCriteria.php
index 3927d4b..5616273 100644
--- a/src/Dto/AlbumSearchCriteria.php
+++ b/src/Dto/AlbumSearchCriteria.php
@@ -9,13 +9,28 @@ use Symfony\Component\HttpFoundation\Request;
*/
final class AlbumSearchCriteria
{
+ /** Free-form query that mixes album, artist, and keyword matches. */
public readonly string $query;
+
+ /** Explicit album title filter supplied via the advanced panel. */
public readonly string $albumName;
+
+ /** Explicit artist filter supplied via the advanced panel. */
public readonly string $artist;
+
+ /** Genre substring to match within stored Spotify/user genres. */
public readonly string $genre;
+
+ /** Lower bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearFrom;
+
+ /** Upper bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearTo;
+
+ /** Requested source scope: `all`, `spotify`, or `user`. */
public readonly string $source;
+
+ /** Maximum number of results the search should return. */
public readonly int $limit;
public function __construct(
@@ -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';
}
- public function useUser(): bool
+ /**
+ * Determines whether the search should include user-created albums.
+ */
+ public function shouldUseUserCatalog(): bool
{
return $this->source === 'all' || $this->source === 'user';
}
- public function getGenre(): string
- {
- return $this->genre;
- }
-
private static function normalizeYear(mixed $value): ?int
{
if ($value === null) {
diff --git a/src/Dto/AlbumSearchResult.php b/src/Dto/AlbumSearchResult.php
index edb8f56..6cba2db 100644
--- a/src/Dto/AlbumSearchResult.php
+++ b/src/Dto/AlbumSearchResult.php
@@ -15,9 +15,13 @@ final class AlbumSearchResult
* @param array $savedIds
*/
public function __construct(
+ /** Filters that produced this result set. */
public readonly AlbumSearchCriteria $criteria,
+ /** Album payloads ready for Twig rendering. */
public readonly array $albums,
+ /** Per-album review aggregates keyed by album ID. */
public readonly array $stats,
+ /** List of Spotify IDs saved locally for quick lookup. */
public readonly array $savedIds
) {
}
diff --git a/src/Form/AdminUserType.php b/src/Form/AdminUserType.php
index e48ad91..5f2a680 100644
--- a/src/Form/AdminUserType.php
+++ b/src/Form/AdminUserType.php
@@ -17,6 +17,9 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class AdminUserType extends AbstractType
{
+ /**
+ * Declares the admin-only account fields plus password confirmation.
+ */
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
@@ -46,6 +49,9 @@ class AdminUserType extends AbstractType
]);
}
+ /**
+ * Uses the AdminUserData DTO as the underlying data object.
+ */
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
diff --git a/src/Form/AlbumType.php b/src/Form/AlbumType.php
index 8784a8c..e6497a7 100644
--- a/src/Form/AlbumType.php
+++ b/src/Form/AlbumType.php
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
+/**
+ * AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
+ */
class AlbumType extends AbstractType
{
/**
@@ -54,6 +57,7 @@ class AlbumType extends AbstractType
'label' => 'External link',
]);
+ // Seed the CSV helper fields with existing entity values before rendering.
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$album = $event->getData();
if (!$album instanceof Album) {
@@ -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 {
$album = $event->getData();
if (!$album instanceof Album) {
diff --git a/src/Form/ProfileFormType.php b/src/Form/ProfileFormType.php
index 389fe9f..8b2b057 100644
--- a/src/Form/ProfileFormType.php
+++ b/src/Form/ProfileFormType.php
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
+/**
+ * ProfileFormType lets authenticated users edit their account details and password.
+ */
class ProfileFormType extends AbstractType
{
/**
diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php
index 6bdcdf8..5b13412 100644
--- a/src/Form/RegistrationFormType.php
+++ b/src/Form/RegistrationFormType.php
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
+/**
+ * RegistrationFormType defines the public signup form and its validation rules.
+ */
class RegistrationFormType extends AbstractType
{
/**
diff --git a/src/Form/ReviewType.php b/src/Form/ReviewType.php
index aa6302c..21a0f72 100644
--- a/src/Form/ReviewType.php
+++ b/src/Form/ReviewType.php
@@ -12,6 +12,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
+/**
+ * ReviewType captures the fields needed to author or edit a review.
+ */
class ReviewType extends AbstractType
{
/**
diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php
index 26b39fc..936068e 100644
--- a/src/Form/SiteSettingsType.php
+++ b/src/Form/SiteSettingsType.php
@@ -8,6 +8,9 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
+/**
+ * SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
+ */
class SiteSettingsType extends AbstractType
{
/**
diff --git a/src/Repository/AlbumRepository.php b/src/Repository/AlbumRepository.php
index 79dbd5d..dfff745 100644
--- a/src/Repository/AlbumRepository.php
+++ b/src/Repository/AlbumRepository.php
@@ -16,6 +16,9 @@ class AlbumRepository extends ServiceEntityRepository
/**
* Wires the repository to Doctrine's registry.
*/
+ /**
+ * Provides the Doctrine registry so we can build query builders on demand.
+ */
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Album::class);
@@ -63,6 +66,8 @@ class AlbumRepository extends ServiceEntityRepository
}
/**
+ * Returns all stored Spotify album IDs so background jobs can iterate over them.
+ *
* @return list
*/
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.
*
- * @param array $spotifyAlbum
- */
- /**
- * @param list $resolvedGenres Optional, precomputed genres (typically from artist lookups).
+ * @param array $spotifyAlbum Raw Spotify album payload.
+ * @param list $resolvedGenres Optional, precomputed genres (typically from artist lookups).
*/
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album
{
@@ -249,6 +252,9 @@ class AlbumRepository extends ServiceEntityRepository
return $filtered;
}
+ /**
+ * Normalizes a filter needle to lowercase so comparisons stay consistent.
+ */
private function normalizeNeedle(?string $needle): ?string
{
if ($needle === null) {
diff --git a/src/Repository/AlbumTrackRepository.php b/src/Repository/AlbumTrackRepository.php
index d7ce6db..a3d553a 100644
--- a/src/Repository/AlbumTrackRepository.php
+++ b/src/Repository/AlbumTrackRepository.php
@@ -14,6 +14,9 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class AlbumTrackRepository extends ServiceEntityRepository
{
+ /**
+ * Registers the repository with Doctrine's manager registry.
+ */
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AlbumTrack::class);
@@ -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
{
if ($value === null) {
@@ -70,12 +76,20 @@ class AlbumTrackRepository extends ServiceEntityRepository
return $string === '' ? null : $string;
}
+ /**
+ * Ensures a positive integer (defaults to 1) for disc/track numbers.
+ */
private function normalizePositiveInt(mixed $value): int
{
$int = (int) $value;
return $int > 0 ? $int : 1;
}
+ /**
+ * Keeps disc/track combinations unique within the upsert operation.
+ *
+ * @param array> $occupied
+ */
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
{
// Track which disc/track slots have already been claimed in this upsert run.
diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php
index 97bc452..bd4a3d6 100644
--- a/src/Repository/SettingRepository.php
+++ b/src/Repository/SettingRepository.php
@@ -20,7 +20,7 @@ class SettingRepository extends ServiceEntityRepository
}
/**
- * Returns a setting value falling back to the supplied default.
+ * Returns a setting value, falling back to the caller's default when missing.
*/
public function getValue(string $name, ?string $default = null): ?string
{
@@ -29,7 +29,7 @@ class SettingRepository extends ServiceEntityRepository
}
/**
- * Persists the supplied configuration value.
+ * Persists or updates the supplied configuration value.
*/
public function setValue(string $name, ?string $value): void
{
diff --git a/src/Security/ReviewVoter.php b/src/Security/ReviewVoter.php
index 6b194a9..ea2b210 100644
--- a/src/Security/ReviewVoter.php
+++ b/src/Security/ReviewVoter.php
@@ -8,7 +8,8 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
- * ReviewVoter grants edit/delete access to review owners or admins.
+ * ReviewVoter determines whether the authenticated user may edit or delete a review.
+ * Moderators/admins always pass; otherwise the review author must match the current user.
*/
class ReviewVoter extends Voter
{
@@ -24,7 +25,7 @@ class ReviewVoter extends Voter
}
/**
- * Grants access to admins or the review author.
+ * Evaluates the permission for the given attribute/subject pair.
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
@@ -40,6 +41,7 @@ class ReviewVoter extends Voter
/** @var Review $review */
$review = $subject;
+ // Only the author may edit/delete their own review.
return $review->getAuthor()?->getId() === $user->getId();
}
}
diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php
index b45806d..24111e1 100644
--- a/src/Service/AlbumSearchService.php
+++ b/src/Service/AlbumSearchService.php
@@ -18,10 +18,10 @@ use Psr\Log\LoggerInterface;
class AlbumSearchService
{
public function __construct(
- private readonly SpotifyClient $spotify,
+ private readonly SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository,
- private readonly EntityManagerInterface $em,
+ private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) {
@@ -33,12 +33,12 @@ class AlbumSearchService
*/
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{
- $spotifyQuery = $this->buildSpotifyQuery($criteria);
+ $spotifyQuery = $this->buildSpotifySearchQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
// Spotify only gets pinged when callers explicitly enable it and we actually have
// something to ask for (bare "all" requests would otherwise waste API calls).
- $shouldQuerySpotify = $criteria->useSpotify()
- && ($spotifyQuery !== '' || $criteria->getGenre() !== '' || $criteria->source === 'spotify');
+ $shouldQuerySpotify = $criteria->shouldUseSpotify()
+ && ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
$stats = [];
$savedIds = [];
@@ -53,7 +53,7 @@ class AlbumSearchService
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
}
- if ($criteria->useUser() && $hasUserFilters) {
+ if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
// Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads'];
@@ -64,11 +64,19 @@ class AlbumSearchService
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.
*/
- private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
+ private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
{
$parts = [];
if ($criteria->albumName !== '') {
@@ -105,7 +113,7 @@ class AlbumSearchService
return $spotifyQuery !== ''
|| $criteria->albumName !== ''
|| $criteria->artist !== ''
- || $criteria->getGenre() !== ''
+ || $criteria->genre !== ''
|| $criteria->yearFrom !== null
|| $criteria->yearTo !== null;
}
@@ -117,22 +125,22 @@ class AlbumSearchService
*/
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
{
- $stored = $this->albumRepository->searchSpotifyAlbums(
+ $storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums(
$criteria->query,
$criteria->albumName,
$criteria->artist,
- $criteria->getGenre(),
+ $criteria->genre,
$criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0,
$criteria->limit
);
- $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
- $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->getGenre());
- $storedIds = $this->collectSpotifyIds($stored);
+ $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedSpotifyAlbums);
+ $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
+ $storedIds = $this->collectSpotifyIds($storedSpotifyAlbums);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
- $shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit;
+ $shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit;
if (!$shouldFetchFromSpotify) {
return [
@@ -142,9 +150,10 @@ 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);
- $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre());
+ $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
@@ -160,7 +169,7 @@ class AlbumSearchService
*/
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
{
- $result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
+ $result = $this->spotifyClient->searchAlbums($spotifyQuery, $criteria->limit);
$searchItems = $result['albums']['items'] ?? [];
$this->logger->info('Album search results received', [
'query' => $spotifyQuery,
@@ -176,7 +185,7 @@ class AlbumSearchService
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
- $full = $this->spotify->getAlbums($ids);
+ $full = $this->spotifyClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
$albumsPayload = $searchItems;
@@ -196,7 +205,7 @@ class AlbumSearchService
);
$upserted++;
}
- $this->em->flush();
+ $this->entityManager->flush();
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
@@ -227,7 +236,7 @@ class AlbumSearchService
$criteria->query,
$criteria->albumName,
$criteria->artist,
- $criteria->getGenre(),
+ $criteria->genre,
$criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0,
$criteria->limit
@@ -260,6 +269,7 @@ class AlbumSearchService
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
+ // Templates never see entity IDs, so stats must be re-keyed to the user-facing local IDs.
$mapped[$localId] = $userStats[$entityId];
}
}
@@ -373,7 +383,7 @@ class AlbumSearchService
if ($id !== null && isset($seen[$id])) {
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;
if ($id !== null) {
$seen[$id] = true;
diff --git a/src/Service/CatalogResetService.php b/src/Service/CatalogResetService.php
index 5a0475e..fb3621a 100644
--- a/src/Service/CatalogResetService.php
+++ b/src/Service/CatalogResetService.php
@@ -14,15 +14,18 @@ class CatalogResetService
}
/**
+ * Deletes all reviews and albums from the catalog and returns summary counts.
+ *
* @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();
$albums = $this->albumRepository->findAll();
$albumCount = count($albums);
foreach ($albums as $album) {
+ // Remove entities one-by-one so Doctrine cascades delete related tracks/reviews as configured.
$this->entityManager->remove($album);
}
$this->entityManager->flush();
diff --git a/src/Service/ConsoleCommandRunner.php b/src/Service/CommandRunner.php
similarity index 70%
rename from src/Service/ConsoleCommandRunner.php
rename to src/Service/CommandRunner.php
index 7fdc9ca..f7856e2 100644
--- a/src/Service/ConsoleCommandRunner.php
+++ b/src/Service/CommandRunner.php
@@ -9,18 +9,21 @@ use Symfony\Component\Console\Output\BufferedOutput;
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)
{
}
/**
+ * Executes a Symfony console command and returns its buffered output.
+ *
* @param array $options
*/
- public function run(string $commandName, array $options = []): string
+ public function runConsoleCommand(string $commandName, array $options = []): string
{
$application = new Application($this->kernel);
$application->setAutoExit(false);
@@ -30,6 +33,7 @@ class ConsoleCommandRunner
$exitCode = $application->run($input, $output);
if ($exitCode !== Command::SUCCESS) {
+ // Surface the underlying command failure so the caller can surface the message in UI.
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
}
diff --git a/src/Service/ImageStorage.php b/src/Service/ImageStorage.php
deleted file mode 100644
index 5acd067..0000000
--- a/src/Service/ImageStorage.php
+++ /dev/null
@@ -1,78 +0,0 @@
-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;
- }
-}
-
diff --git a/src/Service/RegistrationToggle.php b/src/Service/RegistrationToggle.php
index 897bf43..ad5831a 100644
--- a/src/Service/RegistrationToggle.php
+++ b/src/Service/RegistrationToggle.php
@@ -19,7 +19,7 @@ final class RegistrationToggle
/**
* Returns the environment override when present, otherwise null.
*/
- public function envOverride(): ?bool
+ public function getEnvOverride(): ?bool
{
return $this->envOverride;
}
@@ -49,6 +49,7 @@ final class RegistrationToggle
*/
private function detectEnvOverride(): ?bool
{
+ // Symfony loads env vars into both $_ENV and $_SERVER; prefer $_ENV for consistency.
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
if ($raw === null) {
return null;
diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php
index 454a346..915460d 100644
--- a/src/Service/SpotifyClient.php
+++ b/src/Service/SpotifyClient.php
@@ -24,8 +24,8 @@ class SpotifyClient
public function __construct(
HttpClientInterface $httpClient,
CacheInterface $cache,
- string $clientId,
- string $clientSecret,
+ ?string $clientId,
+ ?string $clientSecret,
SettingRepository $settings
) {
$this->httpClient = $httpClient;
@@ -129,6 +129,8 @@ class SpotifyClient
$offset += $limit;
$total = isset($page['total']) ? (int) $page['total'] : null;
$hasNext = isset($page['next']) && $page['next'] !== null;
+ // Guard against Spotify omitting total by relying on the "next" cursor.
+ // Ensures album requests stop when Spotify has no more pages.
} while ($hasNext && ($total === null || $offset < $total));
return $items;
@@ -197,6 +199,7 @@ class SpotifyClient
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
if ($shouldCache) {
+ // Cache fingerprint mixes URL and query only; headers are static (Bearer token).
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
$item->expiresAfter($cacheTtlSeconds);
@@ -268,12 +271,22 @@ class SpotifyClient
});
if ($token === null) {
- // Remove failed entries so the next request retries instead of serving cached nulls.
+ // Nuke cached nulls so the next request retries instead of reusing the failure sentinel.
$this->cache->delete($cacheKey);
}
return $token;
}
+
+ /**
+ * Returns true when credentials are available from DB or environment.
+ */
+ public function isConfigured(): bool
+ {
+ $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
+ $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
+ return $clientId !== '' && $clientSecret !== '';
+ }
}
diff --git a/src/Service/SpotifyGenreResolver.php b/src/Service/SpotifyGenreResolver.php
index d81f835..8f1d287 100644
--- a/src/Service/SpotifyGenreResolver.php
+++ b/src/Service/SpotifyGenreResolver.php
@@ -7,7 +7,7 @@ namespace App\Service;
*/
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
{
$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.
- $payload = $this->spotify->getArtists($chunk);
+ $payload = $this->spotifyClient->getArtists($artistIdChunk);
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
foreach ($artists as $artist) {
$id = (string) ($artist['id'] ?? '');
diff --git a/src/Service/SpotifyMetadataRefresher.php b/src/Service/SpotifyMetadataRefresher.php
index a20e71c..6713f4e 100644
--- a/src/Service/SpotifyMetadataRefresher.php
+++ b/src/Service/SpotifyMetadataRefresher.php
@@ -15,10 +15,10 @@ class SpotifyMetadataRefresher
private const BATCH_SIZE = 20;
public function __construct(
- private readonly SpotifyClient $spotify,
+ private readonly SpotifyClient $spotifyClient,
private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository,
- private readonly EntityManagerInterface $em,
+ private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) {
@@ -34,45 +34,46 @@ class SpotifyMetadataRefresher
return 0;
}
- $updated = 0;
- foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $chunk) {
- $payload = $this->spotify->getAlbums($chunk);
+ $updatedAlbumCount = 0;
+ foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) {
+ $payload = $this->spotifyClient->getAlbums($albumIdChunk);
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
if ($albums === []) {
- $this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($chunk)]);
+ $this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
continue;
}
// Share the same genre resolution logic used during search so existing rows gain genre data too.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
- foreach ($albums as $albumData) {
+ foreach ($albums as $albumPayload) {
try {
- $albumId = (string) ($albumData['id'] ?? '');
+ $albumId = (string) ($albumPayload['id'] ?? '');
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
- (array) $albumData,
+ (array) $albumPayload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
if ($albumId !== '' && $albumEntity !== null) {
- $tracks = $this->resolveTrackPayloads($albumId, (array) $albumData);
+ $tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
if ($tracks !== []) {
+ // Replace tracks wholesale to simplify diffs (instead of diffing rows).
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
$albumEntity->setTotalTracks(count($tracks));
}
}
- $updated++;
+ $updatedAlbumCount++;
} catch (\Throwable $e) {
$this->logger->error('Failed to upsert Spotify album', [
'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);
if ($total > count($tracks)) {
- $full = $this->spotify->getAlbumTracks($albumId);
- if ($full !== []) {
- return $full;
+ $fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId);
+ if ($fullTrackPayloads !== []) {
+ return $fullTrackPayloads;
}
}
diff --git a/src/Service/UploadStorage.php b/src/Service/UploadStorage.php
new file mode 100644
index 0000000..621deeb
--- /dev/null
+++ b/src/Service/UploadStorage.php
@@ -0,0 +1,117 @@
+/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;
+ }
+}
+
+
diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig
index 58ebb4f..15dda2b 100644
--- a/templates/album/search.html.twig
+++ b/templates/album/search.html.twig
@@ -28,6 +28,14 @@
{% endif %}
Search Albums
+ {% if source_value != 'user' and spotifyConfigured is defined and not spotifyConfigured %}
+
+ Spotify is not configured yet. Results will only include user-created albums.
+ {% if is_granted('ROLE_ADMIN') %}
+ Enter Spotify credentials.
+ {% endif %}
+