diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..926551c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.DS_Store +.cursor +.env.local +.git +.gitea +.github +.idea +.public +backup_manifests/ +docs/ +tests/ +var/ +# Symfony cache/logs generated inside the container should not come from the host +var/cache/ +var/log/ + +# Uploaded files stay on the host, not baked into images +public/uploads/ + diff --git a/docker-compose.yml b/docker-compose.yml index d2bbbd1..ff72901 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,93 +4,110 @@ ## - 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 +# 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 + container_name: tonehaus restart: unless-stopped - #environment: - # Doctrine DATABASE_URL consumed by Symfony/Doctrine - #DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8} volumes: - # Mount only source and config; vendors are installed in-container - - ./bin:/var/www/html/bin - - ./config:/var/www/html/config - - ./migrations:/var/www/html/migrations - - ./public:/var/www/html/public - - ./templates:/var/www/html/templates - - ./src:/var/www/html/src - - ./var:/var/www/html/var - ./.env:/var/www/html/.env:ro - - ./vendor:/var/www/html/vendor - # Keep composer manifests on host for version control - - ./composer.json:/var/www/html/composer.json - - ./composer.lock:/var/www/html/composer.lock - - ./symfony.lock:/var/www/html/symfony.lock - # Speed up composer installs by caching download artifacts - - composer_cache:/tmp/composer - healthcheck: - test: ["CMD-SHELL", "php -v || exit 1"] - interval: 10s - timeout: 3s - retries: 5 - depends_on: - - db - nginx: - image: nginx:alpine - container_name: nginx + - sqlite_data:/var/www/html/var/data ports: - - "8000:80" - volumes: - # Serve built assets and front controller from Symfony public dir - - ./public:/var/www/html/public - # Custom vhost with PHP FastCGI proxy - - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - depends_on: - - php + - "8085:8080" + env_file: + - .env healthcheck: test: ["CMD", "curl", "-f", "http://localhost:80/healthz"] 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 + # 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 + # pgadmin: + # image: dpage/pgadmin4 + # container_name: pgadmin + # environment: + # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com} + # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password} + # ports: + # - "8081:80" + # volumes: + # - pgadmin_data:/var/lib/pgadmin + # depends_on: + # - db volumes: - db_data: - composer_cache: - pgadmin_data: \ No newline at end of file + sqlite_data: +# composer_cache: +# pgadmin_data: \ No newline at end of file diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 7faa41d..7ae346c 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name localhost; # host header (not used locally) + server_name _; # host header (not used locally) root /var/www/html/public; # Symfony's public/ dir (front controller) location / { @@ -11,7 +11,7 @@ server { location ~ \.php$ { # Forward PHP requests to php-fpm service include fastcgi_params; - fastcgi_pass php:9000; + fastcgi_pass tonehaus:9000; # Use resolved path to avoid path traversal issues fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $realpath_root; diff --git a/docker/prod/entrypoint.sh b/docker/prod/entrypoint.sh index df5ff22..f7a5ec0 100755 --- a/docker/prod/entrypoint.sh +++ b/docker/prod/entrypoint.sh @@ -8,6 +8,21 @@ require_app_secret() { fi } +install_runtime() { + if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then + return + fi + + echo "Installing Composer dependencies..." + su-exec www-data composer install \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress +} + +install_runtime + if [ -f bin/console ]; then require_app_secret fi diff --git a/migrations/Version20251205134500.php b/migrations/Version20251205134500.php new file mode 100644 index 0000000..0511e4c --- /dev/null +++ b/migrations/Version20251205134500.php @@ -0,0 +1,58 @@ +shouldAddColumn($schema, 'albums', 'genres')) { + return; + } + + if ($this->isSqlite()) { + $this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'"); + return; + } + + $this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'"); + } + + public function down(Schema $schema): void + { + if ($this->isSqlite()) { + // SQLite cannot drop columns without rebuilding the table; leave as-is. + return; + } + + if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) { + $this->addSql('ALTER TABLE albums DROP genres'); + } + } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } + + private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool + { + if (!$schema->hasTable($tableName)) { + return false; + } + + return !$schema->getTable($tableName)->hasColumn($column); + } +} + + diff --git a/src/Command/SeedDemoAlbumsCommand.php b/src/Command/SeedDemoAlbumsCommand.php index 6d6f2f3..9a14c6e 100644 --- a/src/Command/SeedDemoAlbumsCommand.php +++ b/src/Command/SeedDemoAlbumsCommand.php @@ -72,6 +72,7 @@ class SeedDemoAlbumsCommand extends Command $album->setLocalId($localId); $album->setName($this->generateAlbumName()); $album->setArtists($this->generateArtists()); + $album->setGenres($this->generateGenres()); $album->setReleaseDate($this->generateReleaseDate()); $album->setTotalTracks(random_int(6, 16)); $album->setCoverUrl($this->generateCoverUrl($localId)); @@ -136,6 +137,19 @@ class SeedDemoAlbumsCommand extends Command return sprintf('%04d-%02d-%02d', $year, $month, $day); } + /** + * @return list + */ + private function generateGenres(): array + { + $count = random_int(1, 3); + $genres = []; + for ($i = 0; $i < $count; $i++) { + $genres[] = self::GENRES[random_int(0, count(self::GENRES) - 1)]; + } + return array_values(array_unique($genres)); + } + private function generateCoverUrl(string $seed): string { return sprintf('https://picsum.photos/seed/%s/640/640', $seed); diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index d5bae7d..35a52b5 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -9,7 +9,7 @@ use App\Repository\AlbumRepository; use App\Service\ImageStorage; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -83,29 +83,28 @@ class AccountController extends AbstractController $form = $this->createForm(ProfileFormType::class, $user); $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { + if ($form->isSubmitted()) { $newPassword = (string) $form->get('newPassword')->getData(); if ($newPassword !== '') { $current = (string) $form->get('currentPassword')->getData(); if ($current === '' || !$hasher->isPasswordValid($user, $current)) { $form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); - return $this->render('account/profile.html.twig', [ - 'form' => $form->createView(), - 'profileImage' => $user->getProfileImagePath(), - ]); + } else { + $user->setPassword($hasher->hashPassword($user, $newPassword)); } - $user->setPassword($hasher->hashPassword($user, $newPassword)); } - $upload = $form->get('profileImage')->getData(); - if ($upload instanceof UploadedFile) { - $images->remove($user->getProfileImagePath()); - $user->setProfileImagePath($images->storeProfileImage($upload)); - } + if ($form->isValid()) { + $upload = $form->get('profileImage')->getData(); + if ($upload instanceof UploadedFile) { + $images->remove($user->getProfileImagePath()); + $user->setProfileImagePath($images->storeProfileImage($upload)); + } - $em->flush(); - $this->addFlash('success', 'Profile updated.'); - return $this->redirectToRoute('account_profile'); + $em->flush(); + $this->addFlash('success', 'Profile updated.'); + return $this->redirectToRoute('account_profile'); + } } return $this->render('account/profile.html.twig', [ diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index a55147b..751ba92 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -5,8 +5,10 @@ namespace App\Controller\Admin; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; use App\Repository\UserRepository; +use App\Service\SpotifyMetadataRefresher; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -48,4 +50,24 @@ class DashboardController extends AbstractController 'recentAlbums' => $recentAlbums, ]); } + + #[Route('/admin/dashboard/refresh-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])] + public function refreshSpotify( + Request $request, + SpotifyMetadataRefresher $refresher + ): Response { + $token = (string) $request->request->get('_token'); + if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $updated = $refresher->refreshAllSpotifyAlbums(); + if ($updated === 0) { + $this->addFlash('info', 'No Spotify albums needed refresh or none are saved.'); + } else { + $this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated)); + } + + return $this->redirectToRoute('admin_dashboard'); + } } diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php index 81cd9b3..70ecc50 100644 --- a/src/Controller/Admin/SettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -4,12 +4,14 @@ namespace App\Controller\Admin; use App\Form\SiteSettingsType; use App\Repository\SettingRepository; +use App\Service\CatalogResetService; +use App\Service\ConsoleCommandRunner; use App\Service\RegistrationToggle; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; /** * SettingsController lets admins adjust key integration settings. @@ -17,6 +19,47 @@ use Symfony\Component\Routing\Attribute\Route; #[IsGranted('ROLE_ADMIN')] class SettingsController extends AbstractController { + private const DEMO_COMMANDS = [ + 'users' => [ + 'command' => 'app:seed-demo-users', + 'label' => 'Demo users', + 'description' => 'Creates demo accounts with randomized emails.', + 'fields' => [ + ['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50], + ['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'], + ], + ], + 'albums' => [ + 'command' => 'app:seed-demo-albums', + 'label' => 'Demo albums', + 'description' => 'Creates user albums with randomized metadata.', + 'fields' => [ + ['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40], + ['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true], + ], + ], + 'reviews' => [ + 'command' => 'app:seed-demo-reviews', + 'label' => 'Demo reviews', + 'description' => 'Adds sample reviews for existing albums.', + 'fields' => [ + ['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50], + ['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1], + ['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3], + ['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'], + ], + ], + 'avatars' => [ + 'command' => 'app:seed-user-avatars', + 'label' => 'Profile pictures', + 'description' => 'Assigns generated avatars to users (skips existing).', + 'fields' => [ + ['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'], + ['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'], + ], + ], + ]; + /** * Displays and persists Spotify credential settings. */ @@ -46,6 +89,80 @@ class SettingsController extends AbstractController 'form' => $form->createView(), 'registrationImmutable' => $registrationOverride !== null, 'registrationOverrideValue' => $registrationOverride, + 'demoCommands' => self::DEMO_COMMANDS, ]); } + + #[Route('/admin/settings/reset-catalog', name: 'admin_settings_reset_catalog', methods: ['POST'])] + public function resetCatalog(Request $request, CatalogResetService $resetService): Response + { + $token = (string) $request->request->get('_token'); + if (!$this->isCsrfTokenValid('admin_settings_reset_catalog', $token)) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $result = $resetService->reset(); + $this->addFlash('success', sprintf( + 'Reset catalog: deleted %d reviews and %d albums.', + $result['reviews'], + $result['albums'] + )); + + return $this->redirectToRoute('admin_settings'); + } + + #[Route('/admin/settings/generate-demo/{type}', name: 'admin_settings_generate_demo', methods: ['POST'])] + public function generateDemo( + string $type, + Request $request, + ConsoleCommandRunner $runner + ): Response { + $config = self::DEMO_COMMANDS[$type] ?? null; + if ($config === null) { + throw $this->createNotFoundException('Unknown demo data type.'); + } + $token = (string) $request->request->get('_token'); + if (!$this->isCsrfTokenValid('admin_settings_generate_' . $type, $token)) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + try { + $options = $this->buildCommandOptions($config, $request); + $runner->run($config['command'], $options); + $this->addFlash('success', sprintf('%s generation complete.', $config['label'])); + } catch (\Throwable $e) { + $this->addFlash('danger', sprintf( + '%s failed: %s', + $config['label'], + $e->getMessage() + )); + } + + return $this->redirectToRoute('admin_settings'); + } + + /** + * @param array $config + * @return array + */ + private function buildCommandOptions(array $config, Request $request): array + { + $options = []; + foreach (($config['fields'] ?? []) as $field) { + $name = (string) $field['name']; + $type = $field['type'] ?? 'text'; + $value = $request->request->get($name); + if ($type === 'checkbox') { + if ($value) { + $options['--' . $name] = true; + } + continue; + } + if ($value === null || $value === '') { + continue; + } + $options['--' . $name] = $value; + } + return $options; + } } \ No newline at end of file diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index aea99a6..8eac474 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -8,7 +8,7 @@ use App\Form\AdminUserType; use App\Repository\UserRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index 844cda8..7c458e5 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -14,6 +14,7 @@ use App\Repository\ReviewRepository; use App\Service\AlbumSearchService; use App\Service\ImageStorage; use App\Service\SpotifyClient; +use App\Service\SpotifyGenreResolver; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -21,7 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Form\FormInterface; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGranted; /** * AlbumController orchestrates search, CRUD, and review entry on albums. @@ -31,6 +32,7 @@ class AlbumController extends AbstractController public function __construct( private readonly ImageStorage $imageStorage, private readonly AlbumSearchService $albumSearch, + private readonly SpotifyGenreResolver $genreResolver, private readonly int $searchLimit = 20 ) { } @@ -48,6 +50,7 @@ class AlbumController extends AbstractController 'query' => $criteria->query, 'album' => $criteria->albumName, 'artist' => $criteria->artist, + 'genre' => $criteria->getGenre(), 'year_from' => $criteria->yearFrom ?? '', 'year_to' => $criteria->yearTo ?? '', 'albums' => $result->albums, @@ -69,7 +72,7 @@ class AlbumController extends AbstractController $form = $this->createForm(AlbumType::class, $album); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->applyAlbumFormData($album, $form); + $this->normalizeAlbumFormData($album); $user = $this->getUser(); if ($user instanceof User) { $album->setCreatedBy($user); @@ -256,10 +259,9 @@ class AlbumController extends AbstractController $this->ensureCanManageAlbum($album); $form = $this->createForm(AlbumType::class, $album); - $form->get('artistsCsv')->setData(implode(', ', $album->getArtists())); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->applyAlbumFormData($album, $form); + $this->normalizeAlbumFormData($album); $this->handleAlbumCoverUpload($album, $form); $em->flush(); $this->addFlash('success', 'Album updated.'); @@ -314,26 +316,11 @@ class AlbumController extends AbstractController return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId(); } - /** - * Applies normalized metadata from the album form. - */ - private function applyAlbumFormData(Album $album, FormInterface $form): void + private function normalizeAlbumFormData(Album $album): void { - $album->setArtists($this->parseArtistsCsv((string) $form->get('artistsCsv')->getData())); $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); } - /** - * Splits the artists CSV input into a normalized list. - * - * @return list - */ - private function parseArtistsCsv(string $csv): array - { - $parts = array_map(static fn($s) => trim((string) $s), explode(',', $csv)); - return array_values(array_filter($parts, static fn($s) => $s !== '')); - } - private function handleAlbumCoverUpload(Album $album, FormInterface $form): void { if ($album->getSource() !== 'user' || !$form->has('coverUpload')) { @@ -351,7 +338,13 @@ class AlbumController extends AbstractController */ private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album { - $album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + // Bring genres along when we persist Spotify albums so templates can display them immediately. + $genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); + $albumId = (string) ($spotifyAlbum['id'] ?? ''); + $album = $albumRepo->upsertFromSpotifyAlbum( + $spotifyAlbum, + $albumId !== '' ? ($genresMap[$albumId] ?? []) : [] + ); $tracks = $spotifyAlbum['tracks']['items'] ?? []; if (is_array($tracks) && $tracks !== []) { $trackRepo->replaceAlbumTracks($album, $tracks); @@ -381,7 +374,10 @@ class AlbumController extends AbstractController if ($spotifyAlbum === null) { return false; } - $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + // Rehydrate genres during syncs as well, in case Spotify has updated the metadata. + $genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]); + $albumGenres = $genresMap[$spotifyId] ?? []; + $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres); $tracks = $spotifyAlbum['tracks']['items'] ?? []; if (!is_array($tracks) || $tracks === []) { return false; diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php index a412bd5..d34fb0d 100644 --- a/src/Controller/ReviewController.php +++ b/src/Controller/ReviewController.php @@ -6,14 +6,14 @@ use App\Entity\Review; use App\Form\ReviewType; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; +use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; /** - * ReviewController funnels CRUD flows through album pages and simple routes. + * ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow. */ #[Route('/reviews')] class ReviewController extends AbstractController @@ -28,7 +28,7 @@ class ReviewController extends AbstractController } /** - * Redirects users to the album flow when starting a review. + * Nudges users back to the album flow so new reviews always start from a specific album. */ #[Route('/new', name: 'review_new', methods: ['GET', 'POST'])] #[IsGranted('ROLE_USER')] @@ -43,7 +43,7 @@ class ReviewController extends AbstractController } /** - * Shows a standalone review page. + * Shows a read-only page for a single review. */ #[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] public function show(Review $review): Response @@ -54,7 +54,7 @@ class ReviewController extends AbstractController } /** - * Handles review form edits with authorization. + * Handles review edits by running the voter, binding the form, and saving valid updates. */ #[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])] #[IsGranted('ROLE_USER')] @@ -78,7 +78,7 @@ class ReviewController extends AbstractController } /** - * Deletes a review when the CSRF token and permission check pass. + * Deletes a review after both the CSRF token and voter checks pass. */ #[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])] #[IsGranted('ROLE_USER')] diff --git a/src/Dto/AdminUserData.php b/src/Dto/AdminUserData.php index 1532b78..a4cecb7 100644 --- a/src/Dto/AdminUserData.php +++ b/src/Dto/AdminUserData.php @@ -5,8 +5,8 @@ namespace App\Dto; use Symfony\Component\Validator\Constraints as Assert; /** - * AdminUserData transports user creation input from the admin form. - * This is a Data Transfer Object to avoid direct entity manipulation. + * AdminUserData 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. * Used to allow user creation in the user management panel without invalidating active token. * (This took too long to figure out) */ diff --git a/src/Dto/AlbumSearchCriteria.php b/src/Dto/AlbumSearchCriteria.php index 017f6f8..3927d4b 100644 --- a/src/Dto/AlbumSearchCriteria.php +++ b/src/Dto/AlbumSearchCriteria.php @@ -12,6 +12,7 @@ final class AlbumSearchCriteria public readonly string $query; public readonly string $albumName; public readonly string $artist; + public readonly string $genre; public readonly ?int $yearFrom; public readonly ?int $yearTo; public readonly string $source; @@ -21,6 +22,7 @@ final class AlbumSearchCriteria string $query, string $albumName, string $artist, + string $genre, ?int $yearFrom, ?int $yearTo, string $source, @@ -29,6 +31,7 @@ final class AlbumSearchCriteria $this->query = $query; $this->albumName = $albumName; $this->artist = $artist; + $this->genre = $genre; $this->yearFrom = $yearFrom; $this->yearTo = $yearTo; $this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all'; @@ -44,6 +47,7 @@ final class AlbumSearchCriteria query: trim((string) $request->query->get('q', '')), albumName: trim($request->query->getString('album', '')), artist: trim($request->query->getString('artist', '')), + genre: trim($request->query->getString('genre', '')), yearFrom: self::normalizeYear($request->query->get('year_from')), yearTo: self::normalizeYear($request->query->get('year_to')), source: self::normalizeSource($request->query->getString('source', 'all')), @@ -61,6 +65,11 @@ final class AlbumSearchCriteria 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 3a97f7c..edb8f56 100644 --- a/src/Dto/AlbumSearchResult.php +++ b/src/Dto/AlbumSearchResult.php @@ -3,7 +3,9 @@ namespace App\Dto; /** - * AlbumSearchResult encapsulates merged payloads for presentation layers. + * AlbumSearchResult is the value object returned by AlbumSearchService. + * It keeps the original criteria alongside the merged album payloads, + * per-album review stats, and a list of Spotify IDs stored locally. */ final class AlbumSearchResult { diff --git a/src/Entity/Album.php b/src/Entity/Album.php index 6fc4f04..3dc03db 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -49,6 +49,12 @@ class Album #[ORM\Column(type: 'json')] private array $artists = []; + /** + * @var list + */ + #[ORM\Column(type: 'json')] + private array $genres = []; + // Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD #[ORM\Column(type: 'string', length: 20, nullable: true)] private ?string $releaseDate = null; @@ -188,6 +194,27 @@ class Album $this->artists = array_values($artists); } + /** + * @return list + */ + public function getGenres(): array + { + return $this->genres; + } + + /** + * @param list $genres + */ + public function setGenres(array $genres): void + { + $normalized = array_map( + static fn($genre) => trim((string) $genre), + $genres + ); + $filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== '')); + $this->genres = $filtered; + } + /** * Returns the stored release date string. */ @@ -324,12 +351,14 @@ class Album $external = 'https://open.spotify.com/album/' . $this->spotifyId; } $publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId; + $genres = array_slice($this->genres, 0, 5); return [ 'id' => $publicId, 'name' => $this->name, 'images' => $images, 'artists' => $artists, + 'genres' => $genres, 'release_date' => $this->releaseDate, 'total_tracks' => $this->totalTracks, 'external_urls' => [ 'spotify' => $external ], diff --git a/src/Entity/User.php b/src/Entity/User.php index 597a30a..cae2a06 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -133,6 +133,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * Removes any sensitive transient data (no-op here). */ + #[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')] public function eraseCredentials(): void { // no-op diff --git a/src/Form/AlbumType.php b/src/Form/AlbumType.php index 3c4159e..8784a8c 100644 --- a/src/Form/AlbumType.php +++ b/src/Form/AlbumType.php @@ -6,9 +6,10 @@ use App\Entity\Album; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; @@ -28,6 +29,12 @@ class AlbumType extends AbstractType 'label' => 'Artists (comma-separated)', 'constraints' => [new Assert\NotBlank()], ]) + ->add('genresCsv', TextType::class, [ + 'mapped' => false, + 'required' => false, + 'label' => 'Genres (comma-separated)', + 'help' => 'Optional: e.g. Dream pop, Shoegaze', + ]) ->add('releaseDate', TextType::class, [ 'required' => false, 'help' => 'YYYY or YYYY-MM or YYYY-MM-DD', @@ -46,6 +53,34 @@ class AlbumType extends AbstractType 'required' => false, 'label' => 'External link', ]); + + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { + $album = $event->getData(); + if (!$album instanceof Album) { + return; + } + $form = $event->getForm(); + if ($form->has('artistsCsv')) { + $form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists())); + } + if ($form->has('genresCsv')) { + $form->get('genresCsv')->setData($this->implodeCsv($album->getGenres())); + } + }); + + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + $album = $event->getData(); + if (!$album instanceof Album) { + return; + } + $form = $event->getForm(); + if ($form->has('artistsCsv')) { + $album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData())); + } + if ($form->has('genresCsv')) { + $album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData())); + } + }); } /** @@ -57,6 +92,37 @@ class AlbumType extends AbstractType 'data_class' => Album::class, ]); } + + /** + * Converts a comma-separated string into a normalized, de-duplicated list. + * + * @return list + */ + private function splitCsv(string $input): array + { + if ($input === '') { + return []; + } + $normalized = str_replace(["\n", "\r", ';'], ',', $input); + $parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized)); + $filtered = array_values(array_filter($parts, static fn(string $value) => $value !== '')); + + return array_values(array_unique($filtered)); + } + + /** + * Joins a list back into a user-friendly CSV string for display. + * + * @param list $items + */ + private function implodeCsv(array $items): string + { + if ($items === []) { + return ''; + } + + return implode(', ', array_map(static fn(string $item) => trim($item), $items)); + } } diff --git a/src/Repository/AlbumRepository.php b/src/Repository/AlbumRepository.php index ae2c82b..79dbd5d 100644 --- a/src/Repository/AlbumRepository.php +++ b/src/Repository/AlbumRepository.php @@ -62,12 +62,36 @@ class AlbumRepository extends ServiceEntityRepository return $out; } + /** + * @return list + */ + public function findAllSpotifyIds(): array + { + $rows = $this->createQueryBuilder('a') + ->select('a.spotifyId') + ->where('a.source = :src') + ->andWhere('a.spotifyId IS NOT NULL') + ->setParameter('src', 'spotify') + ->getQuery() + ->getScalarResult(); + + $ids = []; + foreach ($rows as $row) { + $sid = (string) ($row['spotifyId'] ?? ''); + if ($sid !== '') { + $ids[] = $sid; + } + } + + return array_values(array_unique($ids)); + } + /** * Filters user albums by optional metadata. * * @return list */ - public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array + public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') @@ -91,7 +115,7 @@ class AlbumRepository extends ServiceEntityRepository } $results = $qb->getQuery()->getResult(); $artistNeedle = $artist ?? $freeText; - return $this->filterByArtistAndLimit($results, $artistNeedle, $limit); + return $this->filterByArtistAndGenreAndLimit($results, $artistNeedle, $genre, $limit); } /** @@ -99,7 +123,7 @@ class AlbumRepository extends ServiceEntityRepository * * @return list */ - public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array + public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') @@ -123,7 +147,7 @@ class AlbumRepository extends ServiceEntityRepository } } $results = $qb->getQuery()->getResult(); - return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit); + return $this->filterByArtistAndGenreAndLimit($results, $artist ?? $freeText, $genre, $limit); } /** @@ -131,11 +155,16 @@ class AlbumRepository extends ServiceEntityRepository * * @param array $spotifyAlbum */ - public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album + /** + * @param list $resolvedGenres Optional, precomputed genres (typically from artist lookups). + */ + public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album { $spotifyId = (string) ($spotifyAlbum['id'] ?? ''); $name = (string) ($spotifyAlbum['name'] ?? ''); $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? []))); + $rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($spotifyAlbum['genres'] ?? []); + $genres = array_values(array_filter(array_map(static fn($g) => trim((string) $g), $rawGenres), static fn($g) => $g !== '')); $releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null; $totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0); @@ -157,6 +186,7 @@ class AlbumRepository extends ServiceEntityRepository $album->setSpotifyId($spotifyId); $album->setName($name); $album->setArtists($artists); + $album->setGenres($genres); $album->setReleaseDate($releaseDate); $album->setTotalTracks($totalTracks); $album->setCoverUrl($coverUrl); @@ -166,19 +196,48 @@ class AlbumRepository extends ServiceEntityRepository } /** + * Applies artist/genre substring filters and the requested limit in the environment. + * * @param list $albums * @return list */ - private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array + private function filterByArtistAndGenreAndLimit(array $albums, ?string $artistNeedle, ?string $genreNeedle, int $limit): array { - if ($needle === null || trim($needle) === '') { + $filtered = $albums; + $appliedFilter = false; + $normalizedArtist = $this->normalizeNeedle($artistNeedle); + $normalizedGenre = $this->normalizeNeedle($genreNeedle); + + if ($normalizedArtist !== null) { + $filtered = $this->filterByNeedle($filtered, $normalizedArtist, static fn(Album $album) => $album->getArtists(), $limit); + $appliedFilter = true; + } + if ($normalizedGenre !== null) { + $filtered = $this->filterByNeedle($filtered, $normalizedGenre, static fn(Album $album) => $album->getGenres(), $limit); + $appliedFilter = true; + } + + if (!$appliedFilter) { return array_slice($albums, 0, $limit); } - $needle = mb_strtolower(trim($needle)); + + return array_slice($filtered, 0, $limit); + } + + /** + * Filters albums by matching a lowercase needle against extracted values. + * + * @param callable(Album):list $valueExtractor + * @param list $albums + * @return list + */ + private function filterByNeedle(array $albums, string $needle, callable $valueExtractor, int $limit): array + { $filtered = []; foreach ($albums as $album) { - foreach ($album->getArtists() as $artist) { - if (str_contains(mb_strtolower($artist), $needle)) { + $haystack = $valueExtractor($album); + foreach ($haystack as $value) { + if (str_contains(mb_strtolower($value), $needle)) { $filtered[] = $album; break; } @@ -187,11 +246,20 @@ class AlbumRepository extends ServiceEntityRepository break; } } - if ($filtered === []) { - return array_slice($albums, 0, $limit); - } return $filtered; } + + private function normalizeNeedle(?string $needle): ?string + { + if ($needle === null) { + return null; + } + $trimmed = trim($needle); + if ($trimmed === '') { + return null; + } + return mb_strtolower($trimmed); + } } diff --git a/src/Repository/AlbumTrackRepository.php b/src/Repository/AlbumTrackRepository.php index eef2a94..d7ce6db 100644 --- a/src/Repository/AlbumTrackRepository.php +++ b/src/Repository/AlbumTrackRepository.php @@ -28,13 +28,14 @@ class AlbumTrackRepository extends ServiceEntityRepository { $em = $this->getEntityManager(); - foreach ($album->getTracks()->toArray() as $existing) { - if ($existing instanceof AlbumTrack) { - $album->removeTrack($existing); - } - } + // Remove existing rows with a single query so unique constraints don't conflict during reinsert. + $em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE t.album = :album') + ->setParameter('album', $album) + ->execute(); + $album->getTracks()->clear(); $position = 1; + $occupied = []; foreach ($trackPayloads as $payload) { $name = trim((string) ($payload['name'] ?? '')); if ($name === '') { @@ -44,8 +45,12 @@ class AlbumTrackRepository extends ServiceEntityRepository $track = new AlbumTrack(); $track->setAlbum($album); $track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null)); - $track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1)); - $track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position)); + $disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1); + $trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position); + // Some Spotify payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique. + $trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber); + $track->setDiscNumber($disc); + $track->setTrackNumber($trackNumber); $track->setName($name); $track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0))); $track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null)); @@ -70,6 +75,18 @@ class AlbumTrackRepository extends ServiceEntityRepository $int = (int) $value; return $int > 0 ? $int : 1; } + + private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int + { + // Track which disc/track slots have already been claimed in this upsert run. + $discMap = $occupied[$disc] ?? []; + while (isset($discMap[$track])) { + $track++; + } + $discMap[$track] = true; + $occupied[$disc] = $discMap; + return $track; + } } diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php index 07286e9..b45806d 100644 --- a/src/Service/AlbumSearchService.php +++ b/src/Service/AlbumSearchService.php @@ -7,11 +7,13 @@ use App\Dto\AlbumSearchResult; use App\Entity\Album; use App\Repository\AlbumRepository; use App\Repository\ReviewRepository; +use App\Service\SpotifyGenreResolver; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** - * AlbumSearchService composes Spotify and user albums into reusable payloads. + * AlbumSearchService pulls albums from Spotify and the local catalog, + * adds review stats, and hands the UI one uniform structure to render. */ class AlbumSearchService { @@ -21,20 +23,30 @@ class AlbumSearchService private readonly ReviewRepository $reviewRepository, private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, + private readonly SpotifyGenreResolver $genreResolver, ) { } + /** + * Runs the end-to-end search flow: figures out which sources to query, merges the payloads, + * and returns review stats plus saved IDs so templates get everything in one package. + */ public function search(AlbumSearchCriteria $criteria): AlbumSearchResult { $spotifyQuery = $this->buildSpotifyQuery($criteria); $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'); $stats = []; $savedIds = []; $spotifyPayloads = []; $userPayloads = []; - if ($criteria->useSpotify() && $spotifyQuery !== '') { + if ($shouldQuerySpotify) { + // Try to reuse cached Spotify albums and only hit the API if we still need more. $spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery); $spotifyPayloads = $spotifyData['payloads']; $stats = $this->mergeStats($stats, $spotifyData['stats']); @@ -42,6 +54,7 @@ class AlbumSearchService } if ($criteria->useUser() && $hasUserFilters) { + // Skip the user query unless at least one meaningful filter is present. $userData = $this->resolveUserAlbums($criteria); $userPayloads = $userData['payloads']; $stats = $this->mergeStats($stats, $userData['stats']); @@ -52,6 +65,9 @@ class AlbumSearchService return new AlbumSearchResult($criteria, $albums, $stats, $savedIds); } + /** + * Turns structured filters into Spotify's free-form query syntax. + */ private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string { $parts = []; @@ -77,34 +93,48 @@ class AlbumSearchService return implode(' ', $parts); } + /** + * Quick gate to tell if it's worth running the user catalog query. + */ private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool { + if ($criteria->source === 'user') { + return true; + } + return $spotifyQuery !== '' || $criteria->albumName !== '' || $criteria->artist !== '' + || $criteria->getGenre() !== '' || $criteria->yearFrom !== null || $criteria->yearTo !== null; } /** + * Looks up cached Spotify albums and, if needed, tops them off with fresh API data. + * * @return array{payloads:array>,stats:array,savedIds:array} */ private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array { $stored = $this->albumRepository->searchSpotifyAlbums( - $spotifyQuery, + $criteria->query, $criteria->albumName, $criteria->artist, + $criteria->getGenre(), $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); $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : []; $savedIds = $storedIds; - if (count($stored) >= $criteria->limit) { + $shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit; + + if (!$shouldFetchFromSpotify) { return [ 'payloads' => array_slice($storedPayloads, 0, $criteria->limit), 'stats' => $stats, @@ -112,8 +142,10 @@ class AlbumSearchService ]; } + // Mix cached payloads with just enough fresh API data to satisfy the limit. $apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads); - $payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit); + $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre()); + $payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); $stats = $this->mergeStats($stats, $apiPayloads['stats']); $savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']); @@ -121,6 +153,8 @@ class AlbumSearchService } /** + * Calls Spotify search/albums endpoints, syncs what we find, and returns normalized payloads, stats, and IDs. + * * @param array> $storedPayloads * @return array{payloads:array>,stats:array,savedIds:array} */ @@ -149,9 +183,17 @@ class AlbumSearchService $this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]); } + // Resolve genres up-front (either from album payloads or artist fallbacks) so we store richer data. + $genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload); + $upserted = 0; foreach ($albumsPayload as $payload) { - $this->albumRepository->upsertFromSpotifyAlbum((array) $payload); + // Persist each album so the next search can reuse it without another API call. + $albumId = (string) ($payload['id'] ?? ''); + $this->albumRepository->upsertFromSpotifyAlbum( + (array) $payload, + $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] + ); $upserted++; } $this->em->flush(); @@ -175,6 +217,8 @@ class AlbumSearchService } /** + * Searches user-created albums and attaches review aggregates keyed to each payload. + * * @return array{payloads:array>,stats:array} */ private function resolveUserAlbums(AlbumSearchCriteria $criteria): array @@ -183,6 +227,7 @@ class AlbumSearchService $criteria->query, $criteria->albumName, $criteria->artist, + $criteria->getGenre(), $criteria->yearFrom ?? 0, $criteria->yearTo ?? 0, $criteria->limit @@ -202,6 +247,8 @@ class AlbumSearchService } /** + * Re-keys entity-based stats to the local IDs that templates actually use. + * * @param list $userAlbums * @param array $userStats * @return array @@ -219,6 +266,13 @@ class AlbumSearchService return $mapped; } + /** + * Chooses which payload list to return based on the requested source preference. + * + * @param array> $userPayloads + * @param array> $spotifyPayloads + * @return array> + */ private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array { if ($source === 'user') { @@ -231,6 +285,8 @@ class AlbumSearchService } /** + * Collects Spotify IDs from hydrated album entities while skipping blanks. + * * @param list $albums * @return list */ @@ -247,6 +303,8 @@ class AlbumSearchService } /** + * Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls. + * * @param array $searchItems * @return list */ @@ -262,6 +320,13 @@ class AlbumSearchService return array_values(array_unique($ids)); } + /** + * Overwrites stats with the newest aggregates from the update set. + * + * @param array $current + * @param array $updates + * @return array + */ private function mergeStats(array $current, array $updates): array { foreach ($updates as $key => $value) { @@ -270,6 +335,13 @@ class AlbumSearchService return $current; } + /** + * Adds newly saved Spotify IDs while tossing blanks and duplicates. + * + * @param list $current + * @param list $updates + * @return list + */ private function mergeSavedIds(array $current, array $updates): array { $merged = array_merge($current, array_filter($updates, static fn($id) => $id !== '')); @@ -277,6 +349,8 @@ class AlbumSearchService } /** + * Combines payload lists without duplicates, preferring primary items and honoring the limit. + * * @param array> $primary * @param array> $secondary * @return array> @@ -299,6 +373,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. $merged[] = $payload; if ($id !== null) { $seen[$id] = true; @@ -309,5 +384,30 @@ class AlbumSearchService } return array_slice($merged, 0, $limit); } + + /** + * @param array> $payloads + * @return array> + */ + private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array + { + $genreNeedle = trim($genreNeedle); + if ($genreNeedle === '') { + return $payloads; + } + $needle = mb_strtolower($genreNeedle); + return array_values(array_filter($payloads, static function(array $payload) use ($needle): bool { + $genres = $payload['genres'] ?? []; + if (!is_array($genres)) { + return false; + } + foreach ($genres as $genre) { + if (str_contains(mb_strtolower((string) $genre), $needle)) { + return true; + } + } + return false; + })); + } } diff --git a/src/Service/CatalogResetService.php b/src/Service/CatalogResetService.php new file mode 100644 index 0000000..5a0475e --- /dev/null +++ b/src/Service/CatalogResetService.php @@ -0,0 +1,37 @@ +entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute(); + + $albums = $this->albumRepository->findAll(); + $albumCount = count($albums); + foreach ($albums as $album) { + $this->entityManager->remove($album); + } + $this->entityManager->flush(); + + return [ + 'albums' => $albumCount, + 'reviews' => $deletedReviews, + ]; + } +} + + diff --git a/src/Service/ConsoleCommandRunner.php b/src/Service/ConsoleCommandRunner.php new file mode 100644 index 0000000..7fdc9ca --- /dev/null +++ b/src/Service/ConsoleCommandRunner.php @@ -0,0 +1,40 @@ + $options + */ + public function run(string $commandName, array $options = []): string + { + $application = new Application($this->kernel); + $application->setAutoExit(false); + + $input = new ArrayInput(array_merge(['command' => $commandName], $options)); + $output = new BufferedOutput(); + + $exitCode = $application->run($input, $output); + if ($exitCode !== Command::SUCCESS) { + throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode)); + } + + return trim($output->fetch()); + } +} + + diff --git a/src/Service/ImageStorage.php b/src/Service/ImageStorage.php index 4ec455c..5acd067 100644 --- a/src/Service/ImageStorage.php +++ b/src/Service/ImageStorage.php @@ -6,6 +6,10 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\String\Slugger\SluggerInterface; +/** + * ImageStorage handles moving uploaded images under /public/uploads, + * making sure directories exist and returning web-ready paths. + */ class ImageStorage { private Filesystem $fs; @@ -17,16 +21,25 @@ class ImageStorage $this->fs = new Filesystem(); } + /** + * Saves a profile avatar and returns the path the front end can render. + */ public function storeProfileImage(UploadedFile $file): string { return $this->store($file, 'avatars'); } + /** + * Saves an album cover and returns the path the front end can render. + */ public function storeAlbumCover(UploadedFile $file): string { return $this->store($file, 'album_covers'); } + /** + * Removes a stored image when the provided web path points to a file. + */ public function remove(?string $webPath): void { if ($webPath === null || $webPath === '') { @@ -38,6 +51,12 @@ class ImageStorage } } + /** + * 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; @@ -48,6 +67,7 @@ class ImageStorage $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); diff --git a/src/Service/RegistrationToggle.php b/src/Service/RegistrationToggle.php index 2289a01..897bf43 100644 --- a/src/Service/RegistrationToggle.php +++ b/src/Service/RegistrationToggle.php @@ -5,7 +5,7 @@ namespace App\Service; use App\Repository\SettingRepository; /** - * RegistrationToggle centralizes the logic around the registration switch. + * RegistrationToggle decides whether sign-ups are allowed, respecting env overrides and DB settings. */ final class RegistrationToggle { @@ -17,7 +17,7 @@ final class RegistrationToggle } /** - * Returns the environment-provided override, or null when unset. + * Returns the environment override when present, otherwise null. */ public function envOverride(): ?bool { @@ -25,7 +25,7 @@ final class RegistrationToggle } /** - * Resolves whether registration should currently be enabled. + * Tells callers whether registration is currently enabled. */ public function isEnabled(): bool { @@ -37,13 +37,16 @@ final class RegistrationToggle } /** - * Persists a new database-backed toggle value. + * Saves the given toggle value to the settings store. */ public function persist(bool $enabled): void { $this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0'); } + /** + * Normalizes APP_ALLOW_REGISTRATION from the environment into a bool or null. + */ private function detectEnvOverride(): ?bool { $raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null; diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php index a8b9af4..454a346 100644 --- a/src/Service/SpotifyClient.php +++ b/src/Service/SpotifyClient.php @@ -119,6 +119,7 @@ class SpotifyClient $limit = 50; $offset = 0; do { + // Spotify returns tracks in pages of 50, so iterate until there are no further pages. $page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset); if ($page === null) { break; @@ -156,6 +157,31 @@ class SpotifyClient } } + /** + * Fetch multiple artists to gather genre information. + * + * @param list $artistIds + * @return array|null + */ + public function getArtists(array $artistIds): ?array + { + if ($artistIds === []) { return []; } + $accessToken = $this->getAccessToken(); + if ($accessToken === null) { return null; } + + $url = 'https://api.spotify.com/v1/artists'; + $options = [ + 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], + 'query' => [ 'ids' => implode(',', $artistIds) ], + ]; + + try { + return $this->sendRequest('GET', $url, $options, 1800); + } catch (\Throwable) { + return null; + } + } + /** * Centralized request helper with lightweight caching. * @@ -164,31 +190,26 @@ class SpotifyClient */ private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array { - $cacheKey = null; - if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') { + $request = function () use ($method, $url, $options): array { + $response = $this->httpClient->request($method, $url, $options); + return $response->toArray(false); + }; + + $shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET'; + if ($shouldCache) { $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? [])); - $cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) { - // placeholder; we'll set item value explicitly below on miss - $item->expiresAfter(1); - return null; + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) { + $item->expiresAfter($cacheTtlSeconds); + return $request(); }); - if (is_array($cached) && !empty($cached)) { - return $cached; - } } - $response = $this->httpClient->request($method, $url, $options); - $data = $response->toArray(false); - if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) { - $this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) { - $item->expiresAfter($cacheTtlSeconds); - return $data; - }); - } - return $data; + return $request(); } /** + * Requests one paginated track list page for an album using the provided OAuth token. + * * @return array|null */ private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array diff --git a/src/Service/SpotifyGenreResolver.php b/src/Service/SpotifyGenreResolver.php new file mode 100644 index 0000000..d81f835 --- /dev/null +++ b/src/Service/SpotifyGenreResolver.php @@ -0,0 +1,111 @@ +> $albums + * @return array> Map of album ID => genres list + */ + public function resolveGenresForAlbums(array $albums): array + { + $albumGenres = []; + $artistIds = []; + + foreach ($albums as $album) { + $albumId = (string) ($album['id'] ?? ''); + if ($albumId === '') { + continue; + } + + $genres = $this->normalizeGenres((array) ($album['genres'] ?? [])); + if ($genres !== []) { + $albumGenres[$albumId] = $genres; + continue; + } + + // Collect artist IDs so we can fetch their genres in one pass rather than per album. + foreach ((array) ($album['artists'] ?? []) as $artist) { + $artistId = (string) ($artist['id'] ?? ''); + if ($artistId !== '') { + $artistIds[$artistId] = true; + } + } + } + + if ($artistIds === []) { + return $albumGenres; + } + + $artistGenres = $this->fetchArtistsGenres(array_keys($artistIds)); + if ($artistGenres === []) { + return $albumGenres; + } + + foreach ($albums as $album) { + $albumId = (string) ($album['id'] ?? ''); + if ($albumId === '' || isset($albumGenres[$albumId])) { + continue; + } + + $combined = []; + foreach ((array) ($album['artists'] ?? []) as $artist) { + $artistId = (string) ($artist['id'] ?? ''); + if ($artistId !== '' && isset($artistGenres[$artistId])) { + // Merge artist genres; duplicates removed later. + $combined = array_merge($combined, $artistGenres[$artistId]); + } + } + + if ($combined !== []) { + $albumGenres[$albumId] = array_values(array_unique($combined)); + } + } + + return $albumGenres; + } + + /** + * @param list $artistIds + * @return array> + */ + private function fetchArtistsGenres(array $artistIds): array + { + $genres = []; + foreach (array_chunk($artistIds, 50) as $chunk) { + // Spotify allows up to 50 artist IDs per request; batching keeps calls minimal. + $payload = $this->spotify->getArtists($chunk); + $artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : []; + foreach ($artists as $artist) { + $id = (string) ($artist['id'] ?? ''); + if ($id === '') { + continue; + } + $genres[$id] = $this->normalizeGenres((array) ($artist['genres'] ?? [])); + } + } + return $genres; + } + + /** + * @param array $values + * @return list + */ + private function normalizeGenres(array $values): array + { + return array_values(array_filter(array_map( + static fn($genre) => trim((string) $genre), + $values + ), static fn($genre) => $genre !== '')); + } +} + + diff --git a/src/Service/SpotifyMetadataRefresher.php b/src/Service/SpotifyMetadataRefresher.php new file mode 100644 index 0000000..a20e71c --- /dev/null +++ b/src/Service/SpotifyMetadataRefresher.php @@ -0,0 +1,97 @@ +albumRepository->findAllSpotifyIds(); + if ($spotifyIds === []) { + return 0; + } + + $updated = 0; + foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $chunk) { + $payload = $this->spotify->getAlbums($chunk); + $albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : []; + if ($albums === []) { + $this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($chunk)]); + 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) { + try { + $albumId = (string) ($albumData['id'] ?? ''); + $albumEntity = $this->albumRepository->upsertFromSpotifyAlbum( + (array) $albumData, + $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] + ); + if ($albumId !== '' && $albumEntity !== null) { + $tracks = $this->resolveTrackPayloads($albumId, (array) $albumData); + if ($tracks !== []) { + $this->trackRepository->replaceAlbumTracks($albumEntity, $tracks); + $albumEntity->setTotalTracks(count($tracks)); + } + } + $updated++; + } catch (\Throwable $e) { + $this->logger->error('Failed to upsert Spotify album', [ + 'error' => $e->getMessage(), + 'album' => $albumData['id'] ?? null, + ]); + } + } + + $this->em->flush(); + } + + return $updated; + } + + /** + * @return list> + */ + private function resolveTrackPayloads(string $albumId, array $albumPayload): array + { + $tracks = (array) ($albumPayload['tracks']['items'] ?? []); + $total = (int) ($albumPayload['tracks']['total'] ?? 0); + + if ($total > count($tracks)) { + $full = $this->spotify->getAlbumTracks($albumId); + if ($full !== []) { + return $full; + } + } + + return $tracks; + } +} + + diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index bae0671..30e09de 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -5,7 +5,7 @@ {% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} {% for msg in app.flashes('info') %}
{{ msg }}
{% endfor %} -
+
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
@@ -34,6 +34,62 @@ {{ form_end(form) }}
+ +
+
+

Maintenance

+
+ + +
Deletes all albums (and tracks) plus reviews. Users and settings stay intact.
+
+
+
+ +
+
+

Generate demo data

+
+ {% for key, command in demoCommands %} +
+
+
+
{{ command.label }}
+
{{ command.description }}
+
Command: php bin/console {{ command.command }}
+
+
+
+ {% for field in command.fields %} + {% if field.type != 'checkbox' %} +
+ + +
+ {% endif %} + {% endfor %} +
+
+ {% for field in command.fields %} + {% if field.type == 'checkbox' %} +
+ + +
+ {% endif %} + {% endfor %} +
+
+
+
+ + +
+
+ {% endfor %} +
+
+
{% endblock %} diff --git a/templates/admin/site_dashboard.html.twig b/templates/admin/site_dashboard.html.twig index a3527e7..eeaa7df 100644 --- a/templates/admin/site_dashboard.html.twig +++ b/templates/admin/site_dashboard.html.twig @@ -1,7 +1,15 @@ {% extends 'base.html.twig' %} {% block title %}Site Dashboard{% endblock %} {% block body %} -

Site dashboard

+
+

Site dashboard

+
+ + +
+
diff --git a/templates/album/edit.html.twig b/templates/album/edit.html.twig index 6d87c2b..5681d82 100644 --- a/templates/album/edit.html.twig +++ b/templates/album/edit.html.twig @@ -5,6 +5,7 @@ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
+
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}
diff --git a/templates/album/new.html.twig b/templates/album/new.html.twig index 5c90bf0..0c865d6 100644 --- a/templates/album/new.html.twig +++ b/templates/album/new.html.twig @@ -5,6 +5,7 @@ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}
{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}
+
{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}
{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}
{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}
{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}
diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig index 1803e60..58ebb4f 100644 --- a/templates/album/search.html.twig +++ b/templates/album/search.html.twig @@ -6,9 +6,10 @@ {% set artist_value = artist|default('') %} {% set year_from_value = year_from|default('') %} {% set year_to_value = year_to|default('') %} + {% set genre_value = genre|default('') %} {% set source_value = source|default('all') %} - {% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} - {% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} + {% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} + {% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} {% set landing_view = not has_search %} {% if landing_view %} @@ -52,6 +53,9 @@
+
+ +
@@ -84,6 +88,9 @@
+
+ +
@@ -109,7 +116,12 @@
{{ album.name }}

{{ album.artists|map(a => a.name)|join(', ') }}

-

Released {{ album.release_date }} • {{ album.total_tracks }} tracks

+

+ Released {{ album.release_date }} • {{ album.total_tracks }} tracks + {% if album.genres is defined and album.genres is not empty %} +
Genre: {{ album.genres|join(', ') }} + {% endif %} +

{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}

User score: {{ s.avg }}/10 ({{ s.count }})

@@ -133,7 +145,7 @@
{% endfor %}
- {% elseif query or album or artist or year_from or year_to %} + {% elseif query or album or artist or genre or year_from or year_to %}

No albums found.

{% endif %} {% endblock %} diff --git a/templates/album/show.html.twig b/templates/album/show.html.twig index 2d95e34..1e790e0 100644 --- a/templates/album/show.html.twig +++ b/templates/album/show.html.twig @@ -24,6 +24,9 @@ {% endif %}

+ {% if album.genres is defined and album.genres is not empty %} +

Genres: {{ album.genres|join(', ') }}

+ {% endif %}

User score: {{ avg }}/10 ({{ count }})

{% if album.external_urls.spotify %} Open in Spotify