From d52eb6bd81fc33dd621a9b72721ed2c1e402981c Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 28 Nov 2025 08:14:13 +0000 Subject: [PATCH] documentation and env changes --- .DS_Store | Bin 10244 -> 10244 bytes .env.example | 25 ++-- .github/workflows/ci.yml | 19 +++ .idea/musicratings.iml | 10 -- .idea/php.xml | 10 -- README.md | 139 ++++++------------ config/packages/doctrine.php | 27 ++++ config/services.yaml | 9 +- docker-compose.yml | 101 +------------ docker/prod/nginx.conf | 2 +- docs/01-setup.md | 47 ------ docs/02-features.md | 16 -- docs/03-auth-and-users.md | 38 ----- docs/04-spotify-integration.md | 20 --- docs/05-reviews-and-albums.md | 22 --- docs/07-troubleshooting.md | 20 --- ...-and-settings.md => admin-and-settings.md} | 5 +- docs/architecture.md | 90 ++++++++++++ docs/auth-and-users.md | 48 ++++++ docs/deployment.md | 70 +++++++++ docs/features.md | 31 ++++ docs/reviews-and-albums.md | 31 ++++ docs/setup.md | 63 ++++++++ docs/spotify-integration.md | 30 ++++ docs/troubleshooting.md | 46 ++++++ src/Command/PromoteAdminCommand.php | 9 +- src/Command/PromoteModeratorCommand.php | 9 +- src/Command/SeedDemoAlbumsCommand.php | 10 ++ src/Command/SeedDemoReviewsCommand.php | 25 ++++ src/Command/SeedDemoUsersCommand.php | 11 ++ src/Command/SeedUserAvatarsCommand.php | 12 ++ src/Controller/AccountController.php | 9 +- src/Controller/Admin/DashboardController.php | 3 + src/Controller/Admin/SettingsController.php | 13 +- src/Controller/Admin/UserController.php | 21 +-- src/Controller/AlbumController.php | 17 ++- src/Dto/AdminUserData.php | 11 +- src/Dto/AlbumSearchCriteria.php | 30 +++- src/Dto/AlbumSearchResult.php | 4 + src/Form/AdminUserType.php | 6 + src/Form/AlbumType.php | 5 + src/Form/ProfileFormType.php | 3 + src/Form/RegistrationFormType.php | 3 + src/Form/ReviewType.php | 3 + src/Form/SiteSettingsType.php | 3 + src/Repository/AlbumRepository.php | 14 +- src/Repository/AlbumTrackRepository.php | 14 ++ src/Repository/SettingRepository.php | 4 +- src/Security/ReviewVoter.php | 6 +- src/Service/AlbumSearchService.php | 52 ++++--- src/Service/CatalogResetService.php | 5 +- ...oleCommandRunner.php => CommandRunner.php} | 10 +- src/Service/ImageStorage.php | 78 ---------- src/Service/RegistrationToggle.php | 3 +- src/Service/SpotifyClient.php | 19 ++- src/Service/SpotifyGenreResolver.php | 6 +- src/Service/SpotifyMetadataRefresher.php | 35 ++--- src/Service/UploadStorage.php | 117 +++++++++++++++ templates/album/search.html.twig | 8 + 59 files changed, 932 insertions(+), 565 deletions(-) delete mode 100644 docs/01-setup.md delete mode 100644 docs/02-features.md delete mode 100644 docs/03-auth-and-users.md delete mode 100644 docs/04-spotify-integration.md delete mode 100644 docs/05-reviews-and-albums.md delete mode 100644 docs/07-troubleshooting.md rename docs/{06-admin-and-settings.md => admin-and-settings.md} (83%) create mode 100644 docs/architecture.md create mode 100644 docs/auth-and-users.md create mode 100644 docs/deployment.md create mode 100644 docs/features.md create mode 100644 docs/reviews-and-albums.md create mode 100644 docs/setup.md create mode 100644 docs/spotify-integration.md create mode 100644 docs/troubleshooting.md rename src/Service/{ConsoleCommandRunner.php => CommandRunner.php} (70%) delete mode 100644 src/Service/ImageStorage.php create mode 100644 src/Service/UploadStorage.php diff --git a/.DS_Store b/.DS_Store index 6d4ad6f71cfb1d3f3fef110f675ee97e486263da..13d7095c7d431fb5f6033febbdad7dd6f915a16f 100644 GIT binary patch delta 47 zcmZn(XbG6$&*-u-U^hRb%Vr({ZpO*dLW-MD2 zxj;IPp@cz~!HJ=WArr_;1F{{#a+wV2Kskg8L!e$Guvw-+H4v5=P^=7ShaN)#LkdHh zXHI@{Qcivn0|SEq1H? - - - - @@ -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 %} +
+ {% endif %}
{% if landing_view %}