From 054e970df9bbbe2383ffe3f4b37e4d9d23941282 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 27 Nov 2025 20:03:12 +0000 Subject: [PATCH] what the fuck --- config/services.yaml | 9 + migrations/Version20251127191813.php | 34 + migrations/Version20251205123000.php | 29 + public/uploads/.gitignore | 3 + src/Command/PromoteAdminCommand.php | 9 + src/Controller/AccountController.php | 56 ++ ...Controller.php => DashboardController.php} | 10 +- ...sController.php => SettingsController.php} | 12 +- src/Controller/AlbumController.php | 646 +++++++++++++----- src/Controller/RegistrationController.php | 6 + src/Controller/ReviewController.php | 24 +- src/Controller/SecurityController.php | 12 +- src/Entity/Album.php | 252 ++++++- src/Entity/Review.php | 125 +++- src/Entity/Setting.php | 47 +- src/Entity/User.php | 54 +- src/Form/AlbumType.php | 12 +- src/Form/ChangePasswordFormType.php | 6 + src/Form/ProfileFormType.php | 13 + src/Form/RegistrationFormType.php | 6 + src/Form/ReviewType.php | 6 + src/Form/SiteSettingsType.php | 6 + src/Kernel.php | 3 + src/Repository/AlbumRepository.php | 112 ++- src/Repository/ReviewRepository.php | 11 +- src/Repository/SettingRepository.php | 12 + src/Repository/UserRepository.php | 8 + src/Security/ReviewVoter.php | 9 + src/Service/ImageStorage.php | 58 ++ src/Service/SpotifyClient.php | 90 +-- templates/_partials/navbar.html.twig | 7 +- templates/account/dashboard.html.twig | 38 +- templates/account/profile.html.twig | 46 ++ templates/admin/site_dashboard.html.twig | 22 +- templates/album/edit.html.twig | 2 +- templates/album/new.html.twig | 2 +- 36 files changed, 1434 insertions(+), 363 deletions(-) create mode 100644 migrations/Version20251127191813.php create mode 100644 migrations/Version20251205123000.php create mode 100644 public/uploads/.gitignore rename src/Controller/Admin/{SiteDashboardController.php => DashboardController.php} (88%) rename src/Controller/Admin/{SiteSettingsController.php => SettingsController.php} (87%) create mode 100644 src/Service/ImageStorage.php create mode 100644 templates/account/profile.html.twig diff --git a/config/services.yaml b/config/services.yaml index 5c4ce08..333d521 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,7 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%' services: # default configuration for services in *this* file @@ -23,3 +24,11 @@ services: arguments: $clientId: '%env(SPOTIFY_CLIENT_ID)%' $clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%' + + App\Service\ImageStorage: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Controller\AlbumController: + arguments: + $searchLimit: '%album_search_limit%' diff --git a/migrations/Version20251127191813.php b/migrations/Version20251127191813.php new file mode 100644 index 0000000..a91ab7e --- /dev/null +++ b/migrations/Version20251127191813.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE albums ALTER source DROP DEFAULT'); + $this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\''); + $this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id'); + } +} diff --git a/migrations/Version20251205123000.php b/migrations/Version20251205123000.php new file mode 100644 index 0000000..22ad8ff --- /dev/null +++ b/migrations/Version20251205123000.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP profile_image_path'); + $this->addSql('ALTER TABLE albums DROP cover_image_path'); + } +} + diff --git a/public/uploads/.gitignore b/public/uploads/.gitignore new file mode 100644 index 0000000..a5baada --- /dev/null +++ b/public/uploads/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore + diff --git a/src/Command/PromoteAdminCommand.php b/src/Command/PromoteAdminCommand.php index 27f6b65..08f8168 100644 --- a/src/Command/PromoteAdminCommand.php +++ b/src/Command/PromoteAdminCommand.php @@ -13,16 +13,25 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')] class PromoteAdminCommand extends Command { + /** + * Stores injected dependencies for later use. + */ public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em) { parent::__construct(); } + /** + * Declares the required email argument. + */ protected function configure(): void { $this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote'); } + /** + * Promotes the provided account to administrator if found. + */ protected function execute(InputInterface $input, OutputInterface $output): int { $email = (string) $input->getArgument('email'); diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 4027854..047a030 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -7,18 +7,26 @@ use App\Form\ProfileFormType; use App\Form\ChangePasswordFormType; use App\Repository\ReviewRepository; 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\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Form\FormError; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +/** + * AccountController hosts authenticated self-service pages. + */ #[IsGranted('ROLE_USER')] class AccountController extends AbstractController { + /** + * Summarizes the signed-in user's recent activity. + */ #[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])] public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response { @@ -50,6 +58,7 @@ class AccountController extends AbstractController return $this->render('account/dashboard.html.twig', [ 'email' => $user->getEmail(), 'displayName' => $user->getDisplayName(), + 'profileImage' => $user->getProfileImagePath(), 'reviewCount' => $reviewCount, 'albumCount' => $albumCount, 'userType' => $userType, @@ -58,12 +67,59 @@ 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 + { + /** @var User $user */ + $user = $this->getUser(); + $form = $this->createForm(ProfileFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $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(), + ]); + } + $user->setPassword($hasher->hashPassword($user, $newPassword)); + } + + $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'); + } + + return $this->render('account/profile.html.twig', [ + 'form' => $form->createView(), + 'profileImage' => $user->getProfileImagePath(), + ]); + } + + /** + * Shows account-level settings options. + */ #[Route('/settings', name: 'account_settings', methods: ['GET'])] public function settings(): Response { return $this->render('account/settings.html.twig'); } + /** + * Validates the password change form and updates the hash. + */ #[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])] public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response { diff --git a/src/Controller/Admin/SiteDashboardController.php b/src/Controller/Admin/DashboardController.php similarity index 88% rename from src/Controller/Admin/SiteDashboardController.php rename to src/Controller/Admin/DashboardController.php index b8df39e..798d25c 100644 --- a/src/Controller/Admin/SiteDashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -10,9 +10,15 @@ use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +/** + * DashboardController shows high-level site activity to admins. + */ #[IsGranted('ROLE_ADMIN')] -class SiteDashboardController extends AbstractController +class DashboardController extends AbstractController { + /** + * Renders overall activity metrics for administrators. + */ #[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])] public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response { @@ -43,5 +49,3 @@ class SiteDashboardController extends AbstractController ]); } } - - diff --git a/src/Controller/Admin/SiteSettingsController.php b/src/Controller/Admin/SettingsController.php similarity index 87% rename from src/Controller/Admin/SiteSettingsController.php rename to src/Controller/Admin/SettingsController.php index cec2ff2..ae04384 100644 --- a/src/Controller/Admin/SiteSettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -10,9 +10,15 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +/** + * SettingsController lets admins adjust key integration settings. + */ #[IsGranted('ROLE_ADMIN')] -class SiteSettingsController extends AbstractController +class SettingsController extends AbstractController { + /** + * Displays and persists Spotify credential settings. + */ #[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])] public function settings(Request $request, SettingRepository $settings): Response { @@ -32,6 +38,4 @@ class SiteSettingsController extends AbstractController 'form' => $form->createView(), ]); } -} - - +} \ No newline at end of file diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index 37f8658..3621360 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -3,157 +3,92 @@ namespace App\Controller; use App\Service\SpotifyClient; +use App\Service\ImageStorage; use App\Repository\AlbumRepository; +use App\Entity\Album; use App\Entity\Review; +use App\Entity\User; use App\Form\ReviewType; use App\Form\AlbumType; use App\Repository\ReviewRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; 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 Psr\Log\LoggerInterface; +/** + * AlbumController orchestrates search, CRUD, and review entry on albums. + */ class AlbumController extends AbstractController { + public function __construct( + private readonly ImageStorage $imageStorage, + private readonly int $searchLimit = 20 + ) { + } + + /** + * Searches Spotify plus local albums and decorates results with review stats. + */ #[Route('/', name: 'album_search', methods: ['GET'])] - public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository, AlbumRepository $albumsRepo, EntityManagerInterface $em, LoggerInterface $logger): Response + public function search(Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em, LoggerInterface $logger): Response { - $query = trim((string) $request->query->get('q', '')); - $albumName = trim($request->query->getString('album', '')); - $artist = trim($request->query->getString('artist', '')); - $source = $request->query->getString('source', 'all'); // 'all' | 'spotify' | 'user' - // Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues - $yearFromRaw = trim((string) $request->query->get('year_from', '')); - $yearToRaw = trim((string) $request->query->get('year_to', '')); - $yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0; - $yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0; - $albums = []; + $filters = $this->buildSearchFilters($request); $stats = []; $savedIds = []; - // Build Spotify fielded search if advanced inputs are supplied - $advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0); - $q = $query; - if ($advancedUsed) { - $parts = []; - if ($albumName !== '') { $parts[] = 'album:' . $albumName; } - if ($artist !== '') { $parts[] = 'artist:' . $artist; } - if ($yearFrom > 0 || $yearTo > 0) { - if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) { - $parts[] = 'year:' . $yearFrom . '-' . $yearTo; - } else { - $y = $yearFrom > 0 ? $yearFrom : $yearTo; - $parts[] = 'year:' . $y; - } - } - // also include free-text if provided - if ($query !== '') { $parts[] = $query; } - $q = implode(' ', $parts); - } + $spotifyData = $this->resolveSpotifyAlbums($filters, $spotify, $albumRepo, $reviewRepo, $em, $logger); + $stats = $this->mergeStats($stats, $spotifyData['stats']); + $savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']); - if ($q !== '' || $source === 'user') { - $result = $spotifyClient->searchAlbums($q, 20); - $searchItems = $result['albums']['items'] ?? []; - $logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]); - if ($searchItems && ($source === 'all' || $source === 'spotify')) { - // Build ordered list of IDs from search results - $ids = []; - foreach ($searchItems as $it) { - $id = isset($it['id']) ? (string) $it['id'] : ''; - if ($id !== '') { $ids[] = $id; } - } - $ids = array_values(array_unique($ids)); - $logger->info('Album IDs extracted from search', ['count' => count($ids)]); + $userData = $this->resolveUserAlbums($filters, $albumRepo, $reviewRepo); + $stats = $this->mergeStats($stats, $userData['stats']); - // Fetch full album objects to have consistent fields, then upsert - $full = $spotifyClient->getAlbums($ids); - $albumsPayload = is_array($full) ? ($full['albums'] ?? []) : []; - if ($albumsPayload === [] && $searchItems !== []) { - // Fallback to search items if getAlbums failed - $albumsPayload = $searchItems; - $logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]); - } - $upserted = 0; - foreach ($albumsPayload as $sa) { - $albumsRepo->upsertFromSpotifyAlbum((array) $sa); - $upserted++; - } - $em->flush(); - $logger->info('Albums upserted to DB', ['upserted' => $upserted]); - - if ($ids) { - if ($source === 'spotify' || $source === 'all') { - $stats = $reviewRepository->getAggregatesForAlbumIds($ids); - } - $existing = $albumsRepo->findBySpotifyIdsKeyed($ids); - $savedIds = array_keys($existing); - // Preserve Spotify order and render from DB - $albums = []; - foreach ($ids as $sid) { - if (isset($existing[$sid])) { - $albums[] = $existing[$sid]->toTemplateArray(); - } - } - } - } - // User-created search results - if ($source === 'user' || $source === 'all') { - $userAlbums = $albumsRepo->searchUserAlbums($albumName, $artist, $yearFrom, $yearTo, 20); - if ($userAlbums) { - $entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums)); - $userStatsByEntityId = $reviewRepository->getAggregatesForAlbumEntityIds($entityIds); - // Merge into stats keyed by localId - foreach ($userAlbums as $ua) { - $localId = (string) $ua->getLocalId(); - $entityId = (int) $ua->getId(); - if (isset($userStatsByEntityId[$entityId])) { - $stats[$localId] = $userStatsByEntityId[$entityId]; - } - } - $userAlbumPayloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums); - // Prepend user albums to list - $albums = array_merge($userAlbumPayloads, $albums); - } - } - } + $albums = $this->composeAlbumList( + $filters['source'], + $userData['payloads'], + $spotifyData['payloads'], + $filters['limit'] + ); + $savedIds = $this->mergeSavedIds($savedIds, []); return $this->render('album/search.html.twig', [ - 'query' => $query, - 'album' => $albumName, - 'artist' => $artist, - 'year_from' => $yearFrom ?: '', - 'year_to' => $yearTo ?: '', + 'query' => $filters['query'], + 'album' => $filters['albumName'], + 'artist' => $filters['artist'], + 'year_from' => $filters['yearFrom'] ?: '', + 'year_to' => $filters['yearTo'] ?: '', 'albums' => $albums, 'stats' => $stats, 'savedIds' => $savedIds, - 'source' => $source, + 'source' => $filters['source'], ]); } + /** + * Creates a user-authored album entry. + */ #[IsGranted('ROLE_USER')] #[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])] - public function new(Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { - $album = new \App\Entity\Album(); + $album = new Album(); $album->setSource('user'); $form = $this->createForm(AlbumType::class, $album); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - // Map artistsCsv -> artists[] - $artistsCsv = (string) $form->get('artistsCsv')->getData(); - $artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== '')); - $album->setArtists($artists); - // Normalize release date to YYYY-MM-DD - $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); - // Assign createdBy and generate unique localId - $u = $this->getUser(); - if ($u instanceof \App\Entity\User) { - $album->setCreatedBy($u); + $this->applyAlbumFormData($album, $form); + $user = $this->getUser(); + if ($user instanceof User) { + $album->setCreatedBy($user); } - $album->setLocalId($this->generateLocalId($albumsRepo)); + $this->handleAlbumCoverUpload($album, $form); + $album->setLocalId($this->generateLocalId($albumRepo)); $em->persist($album); $em->flush(); $this->addFlash('success', 'Album created.'); @@ -164,31 +99,26 @@ class AlbumController extends AbstractController ]); } + /** + * Renders a detailed album view plus inline review form. + */ #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] - public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { - // Prefer DB: only fetch from Spotify if not present - $albumEntity = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id); + $albumEntity = $this->findAlbum($id, $albumRepo); + $isSaved = $albumEntity !== null; if (!$albumEntity) { - $spotifyAlbum = $spotifyClient->getAlbum($id); + $spotifyAlbum = $spotify->getAlbum($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $albumEntity = $albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); $em->flush(); } - $isSaved = $albumEntity !== null; - $album = $albumEntity->toTemplateArray(); - $isAdmin = $this->isGranted('ROLE_ADMIN'); - $current = $this->getUser(); - $isOwner = false; - if ($current instanceof \App\Entity\User) { - $isOwner = ($albumEntity->getCreatedBy()?->getId() === $current->getId()); - } - $allowedEdit = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner); - $allowedDelete = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner); + $albumCard = $albumEntity->toTemplateArray(); + $canManage = $this->canManageAlbum($albumEntity); - $existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); + $existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); $count = count($existing); $avg = 0.0; if ($count > 0) { @@ -197,7 +127,6 @@ class AlbumController extends AbstractController $avg = round($sum / $count, 1); } - // Pre-populate required album metadata before validation so entity constraints pass $review = new Review(); $review->setAlbum($albumEntity); @@ -213,11 +142,11 @@ class AlbumController extends AbstractController } return $this->render('album/show.html.twig', [ - 'album' => $album, + 'album' => $albumCard, 'albumId' => $id, 'isSaved' => $isSaved, - 'allowedEdit' => $allowedEdit, - 'allowedDelete' => $allowedDelete, + 'allowedEdit' => $canManage, + 'allowedDelete' => $canManage, 'reviews' => $existing, 'avg' => $avg, 'count' => $count, @@ -225,21 +154,24 @@ class AlbumController extends AbstractController ]); } + /** + * Saves a Spotify album locally for quicker access. + */ #[IsGranted('ROLE_USER')] #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] - public function save(string $id, Request $request, SpotifyClient $spotifyClient, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } - $existing = $albumsRepo->findOneBySpotifyId($id); + $existing = $albumRepo->findOneBySpotifyId($id); if (!$existing) { - $spotifyAlbum = $spotifyClient->getAlbum($id); + $spotifyAlbum = $spotify->getAlbum($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); $em->flush(); $this->addFlash('success', 'Album saved.'); } else { @@ -248,31 +180,22 @@ class AlbumController extends AbstractController return $this->redirectToRoute('album_show', ['id' => $id]); } + /** + * Deletes a user-created album when authorized. + */ #[IsGranted('ROLE_USER')] #[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])] - public function delete(string $id, Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + public function delete(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } - $album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id); + $album = $this->findAlbum($id, $albumRepo); if ($album) { - // Only owner or admin can delete user albums; Spotify albums require admin - $isAdmin = $this->isGranted('ROLE_ADMIN'); + $this->ensureCanManageAlbum($album); if ($album->getSource() === 'user') { - $current = $this->getUser(); - $isOwner = false; - if ($current instanceof \App\Entity\User) { - $isOwner = ($album->getCreatedBy()?->getId() === $current->getId()); - } - if (!$isAdmin && !$isOwner) { - throw $this->createAccessDeniedException(); - } - } else { - if (!$isAdmin) { - throw $this->createAccessDeniedException(); - } + $this->imageStorage->remove($album->getCoverImagePath()); } $em->remove($album); $em->flush(); @@ -283,14 +206,20 @@ class AlbumController extends AbstractController return $this->redirectToRoute('album_search'); } - private function generateLocalId(AlbumRepository $albumsRepo): string + /** + * Generates a unique user album identifier. + */ + private function generateLocalId(AlbumRepository $albumRepo): string { do { $id = 'u_' . bin2hex(random_bytes(6)); - } while ($albumsRepo->findOneByLocalId($id) !== null); + } while ($albumRepo->findOneByLocalId($id) !== null); return $id; } + /** + * Normalizes a human-entered release date (YYYY[-MM[-DD]]). + */ private function normalizeReleaseDate(?string $input): ?string { if ($input === null || trim($input) === '') { @@ -318,38 +247,25 @@ class AlbumController extends AbstractController } } + /** + * Edits a saved album when the current user may manage it. + */ #[IsGranted('ROLE_USER')] #[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])] - public function edit(string $id, Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { - $album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id); + $album = $this->findAlbum($id, $albumRepo); if (!$album) { throw $this->createNotFoundException('Album not found'); } - $isAdmin = $this->isGranted('ROLE_ADMIN'); - $current = $this->getUser(); - $isOwner = false; - if ($current instanceof \App\Entity\User) { - $isOwner = ($album->getCreatedBy()?->getId() === $current->getId()); - } - if ($album->getSource() === 'user') { - if (!$isAdmin && !$isOwner) { - throw $this->createAccessDeniedException(); - } - } else { - if (!$isAdmin) { - throw $this->createAccessDeniedException(); - } - } + $this->ensureCanManageAlbum($album); + $form = $this->createForm(AlbumType::class, $album); - // Prepopulate artistsCsv $form->get('artistsCsv')->setData(implode(', ', $album->getArtists())); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $artistsCsv = (string) $form->get('artistsCsv')->getData(); - $artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== '')); - $album->setArtists($artists); - $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); + $this->applyAlbumFormData($album, $form); + $this->handleAlbumCoverUpload($album, $form); $em->flush(); $this->addFlash('success', 'Album updated.'); return $this->redirectToRoute('album_show', ['id' => $id]); @@ -359,6 +275,382 @@ class AlbumController extends AbstractController 'albumId' => $id, ]); } + + /** + * Looks up an album by either local or Spotify identifier. + */ + private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album + { + return str_starts_with($id, 'u_') + ? $albumRepo->findOneByLocalId($id) + : $albumRepo->findOneBySpotifyId($id); + } + + /** + * Returns true when the authenticated user can manage the album. + */ + private function canManageAlbum(Album $album): bool + { + if ($this->isGranted('ROLE_ADMIN')) { + return true; + } + return $album->getSource() === 'user' && $this->isAlbumOwner($album); + } + + /** + * Throws if the authenticated user cannot manage the album. + */ + private function ensureCanManageAlbum(Album $album): void + { + if (!$this->canManageAlbum($album)) { + throw $this->createAccessDeniedException(); + } + } + + /** + * Checks whether the current user created the album. + */ + private function isAlbumOwner(Album $album): bool + { + $user = $this->getUser(); + return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId(); + } + + /** + * Applies normalized metadata from the album form. + */ + private function applyAlbumFormData(Album $album, FormInterface $form): 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')) { + return; + } + $file = $form->get('coverUpload')->getData(); + if ($file instanceof UploadedFile) { + $this->imageStorage->remove($album->getCoverImagePath()); + $album->setCoverImagePath($this->imageStorage->storeAlbumCover($file)); + } + } + + /** + * @return array{ + * query:string, + * albumName:string, + * artist:string, + * source:string, + * yearFrom:int, + * yearTo:int, + * limit:int, + * spotifyQuery:string, + * hasUserFilters:bool, + * useSpotify:bool + * } + */ + private function buildSearchFilters(Request $request): array + { + $query = trim((string) $request->query->get('q', '')); + $albumName = trim($request->query->getString('album', '')); + $artist = trim($request->query->getString('artist', '')); + $source = $request->query->getString('source', 'all'); + $yearFromRaw = trim((string) $request->query->get('year_from', '')); + $yearToRaw = trim((string) $request->query->get('year_to', '')); + $yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0; + $yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0; + $spotifyQuery = $this->buildSpotifyQuery($query, $albumName, $artist, $yearFrom, $yearTo); + $hasUserFilters = ($spotifyQuery !== '' || $albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0); + $useSpotify = ($source === 'all' || $source === 'spotify'); + + return [ + 'query' => $query, + 'albumName' => $albumName, + 'artist' => $artist, + 'source' => $source, + 'yearFrom' => $yearFrom, + 'yearTo' => $yearTo, + 'limit' => $this->searchLimit, + 'spotifyQuery' => $spotifyQuery, + 'hasUserFilters' => $hasUserFilters, + 'useSpotify' => $useSpotify, + ]; + } + + private function buildSpotifyQuery(string $query, string $albumName, string $artist, int $yearFrom, int $yearTo): string + { + $parts = []; + if ($albumName !== '') { $parts[] = 'album:' . $albumName; } + if ($artist !== '') { $parts[] = 'artist:' . $artist; } + if ($yearFrom > 0 || $yearTo > 0) { + if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) { + $parts[] = 'year:' . $yearFrom . '-' . $yearTo; + } else { + $y = $yearFrom > 0 ? $yearFrom : $yearTo; + $parts[] = 'year:' . $y; + } + } + if ($query !== '') { $parts[] = $query; } + return implode(' ', $parts); + } + + /** + * @return array{payloads:array,stats:array,savedIds:array} + */ + private function resolveSpotifyAlbums( + array $filters, + SpotifyClient $spotify, + AlbumRepository $albumRepo, + ReviewRepository $reviewRepo, + EntityManagerInterface $em, + LoggerInterface $logger + ): array { + if (!$filters['useSpotify'] || $filters['spotifyQuery'] === '') { + return ['payloads' => [], 'stats' => [], 'savedIds' => []]; + } + + $stored = $albumRepo->searchSpotifyAlbums( + $filters['spotifyQuery'], + $filters['albumName'], + $filters['artist'], + $filters['yearFrom'], + $filters['yearTo'], + $filters['limit'] + ); + $storedPayloads = array_map(static fn($a) => $a->toTemplateArray(), $stored); + $storedIds = $this->collectSpotifyIds($stored); + $stats = $storedIds ? $reviewRepo->getAggregatesForAlbumIds($storedIds) : []; + $savedIds = $storedIds; + + if (count($stored) >= $filters['limit']) { + return [ + 'payloads' => array_slice($storedPayloads, 0, $filters['limit']), + 'stats' => $stats, + 'savedIds' => $savedIds, + ]; + } + + $apiPayloads = $this->fetchSpotifyPayloads( + $filters, + $spotify, + $albumRepo, + $reviewRepo, + $em, + $logger + ); + + $payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $filters['limit']); + $stats = $this->mergeStats($stats, $apiPayloads['stats']); + $savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']); + + return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds]; + } + + /** + * @return array{payloads:array,stats:array,savedIds:array} + */ + private function fetchSpotifyPayloads( + array $filters, + SpotifyClient $spotify, + AlbumRepository $albumRepo, + ReviewRepository $reviewRepo, + EntityManagerInterface $em, + LoggerInterface $logger + ): array { + $result = $spotify->searchAlbums($filters['spotifyQuery'], $filters['limit']); + $searchItems = $result['albums']['items'] ?? []; + $logger->info('Album search results received', ['query' => $filters['spotifyQuery'], 'items' => is_countable($searchItems) ? count($searchItems) : 0]); + + if (!$searchItems) { + return ['payloads' => [], 'stats' => [], 'savedIds' => []]; + } + + $ids = $this->extractSpotifyIds($searchItems); + if ($ids === []) { + return ['payloads' => [], 'stats' => [], 'savedIds' => []]; + } + + $full = $spotify->getAlbums($ids); + $albumsPayload = is_array($full) ? ($full['albums'] ?? []) : []; + if ($albumsPayload === [] && $searchItems !== []) { + $albumsPayload = $searchItems; + $logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]); + } + + $upserted = 0; + foreach ($albumsPayload as $sa) { + $albumRepo->upsertFromSpotifyAlbum((array) $sa); + $upserted++; + } + $em->flush(); + $logger->info('Albums upserted to DB', ['upserted' => $upserted]); + + $existing = $albumRepo->findBySpotifyIdsKeyed($ids); + $payloads = []; + foreach ($ids as $sid) { + if (isset($existing[$sid])) { + $payloads[] = $existing[$sid]->toTemplateArray(); + } + } + + $stats = $reviewRepo->getAggregatesForAlbumIds($ids); + + return [ + 'payloads' => $payloads, + 'stats' => $stats, + 'savedIds' => array_keys($existing), + ]; + } + + /** + * @return array{payloads:array,stats:array} + */ + private function resolveUserAlbums(array $filters, AlbumRepository $albumRepo, ReviewRepository $reviewRepo): array + { + if (!$filters['hasUserFilters'] || ($filters['source'] !== 'user' && $filters['source'] !== 'all')) { + return ['payloads' => [], 'stats' => []]; + } + + $userAlbums = $albumRepo->searchUserAlbums( + $filters['query'], + $filters['albumName'], + $filters['artist'], + $filters['yearFrom'], + $filters['yearTo'], + $filters['limit'] + ); + if ($userAlbums === []) { + return ['payloads' => [], 'stats' => []]; + } + + $entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums)); + $userStats = $reviewRepo->getAggregatesForAlbumEntityIds($entityIds); + $payloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums); + + return ['payloads' => $payloads, 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats)]; + } + + /** + * @param list $userAlbums + * @param array $userStats + * @return array + */ + private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array + { + $mapped = []; + foreach ($userAlbums as $album) { + $entityId = (int) $album->getId(); + $localId = (string) $album->getLocalId(); + if ($localId !== '' && isset($userStats[$entityId])) { + $mapped[$localId] = $userStats[$entityId]; + } + } + return $mapped; + } + + private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array + { + if ($source === 'user') { + return array_slice($userPayloads, 0, $limit); + } + if ($source === 'spotify') { + return array_slice($spotifyPayloads, 0, $limit); + } + return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit); + } + + /** + * @param list $albums + * @return list + */ + private function collectSpotifyIds(array $albums): array + { + $ids = []; + foreach ($albums as $album) { + $sid = (string) $album->getSpotifyId(); + if ($sid !== '') { + $ids[] = $sid; + } + } + return array_values(array_unique($ids)); + } + + /** + * @param array $searchItems + * @return list + */ + private function extractSpotifyIds(array $searchItems): array + { + $ids = []; + foreach ($searchItems as $item) { + $id = isset($item['id']) ? (string) $item['id'] : ''; + if ($id !== '') { + $ids[] = $id; + } + } + return array_values(array_unique($ids)); + } + + private function mergeStats(array $current, array $updates): array + { + foreach ($updates as $key => $value) { + $current[$key] = $value; + } + return $current; + } + + private function mergeSavedIds(array $current, array $updates): array + { + $merged = array_merge($current, array_filter($updates, static fn($id) => $id !== '')); + return array_values(array_unique($merged)); + } + + /** + * @param array> $primary + * @param array> $secondary + * @return array> + */ + private function mergePayloadLists(array $primary, array $secondary, int $limit): array + { + $seen = []; + $merged = []; + foreach ($primary as $payload) { + $merged[] = $payload; + if (isset($payload['id'])) { + $seen[$payload['id']] = true; + } + if (count($merged) >= $limit) { + return array_slice($merged, 0, $limit); + } + } + foreach ($secondary as $payload) { + $id = $payload['id'] ?? null; + if ($id !== null && isset($seen[$id])) { + continue; + } + $merged[] = $payload; + if ($id !== null) { + $seen[$id] = true; + } + if (count($merged) >= $limit) { + break; + } + } + return array_slice($merged, 0, $limit); + } } diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index c91c083..888c29f 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -12,8 +12,14 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +/** + * RegistrationController handles signup workflows (HTML + XHR). + */ class RegistrationController extends AbstractController { + /** + * Processes registration submissions or serves the form modal. + */ #[Route('/register', name: 'app_register')] public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response { diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php index 28b017a..a412bd5 100644 --- a/src/Controller/ReviewController.php +++ b/src/Controller/ReviewController.php @@ -4,8 +4,6 @@ namespace App\Controller; use App\Entity\Review; use App\Form\ReviewType; -use App\Repository\ReviewRepository; -use App\Service\SpotifyClient; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; @@ -14,19 +12,27 @@ 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. + */ #[Route('/reviews')] class ReviewController extends AbstractController { - // Exclusively for compat, used to route to standalone reviews page. + /** + * Maintains backwards compatibility by redirecting to the dashboard. + */ #[Route('', name: 'review_index', methods: ['GET'])] public function index(): Response { return $this->redirectToRoute('account_dashboard'); } + /** + * Redirects users to the album flow when starting a review. + */ #[Route('/new', name: 'review_new', methods: ['GET', 'POST'])] #[IsGranted('ROLE_USER')] - public function new(Request $request): Response + public function create(Request $request): Response { $albumId = (string) $request->query->get('album_id', ''); if ($albumId !== '') { @@ -36,6 +42,9 @@ class ReviewController extends AbstractController return $this->redirectToRoute('album_search'); } + /** + * Shows a standalone review page. + */ #[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] public function show(Review $review): Response { @@ -44,6 +53,9 @@ class ReviewController extends AbstractController ]); } + /** + * Handles review form edits with authorization. + */ #[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])] #[IsGranted('ROLE_USER')] public function edit(Request $request, Review $review, EntityManagerInterface $em): Response @@ -65,6 +77,9 @@ class ReviewController extends AbstractController ]); } + /** + * Deletes a review when the CSRF token and permission check pass. + */ #[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])] #[IsGranted('ROLE_USER')] public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse @@ -78,7 +93,6 @@ class ReviewController extends AbstractController return $this->redirectToRoute('account_dashboard'); } - // fetchAlbumById no longer needed; album view handles retrieval and creation } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 60098e3..ecf6df5 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -7,12 +7,17 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; +/** + * SecurityController keeps login/logout routes alive for the firewall. + */ class SecurityController extends AbstractController { + /** + * Redirects GET requests to the SPA and lets Symfony handle POST auth. + */ #[Route('/login', name: 'app_login')] - public function login(Request $request, AuthenticationUtils $authenticationUtils): Response + public function login(Request $request): Response { // Keep this route so the firewall can use it as check_path for POST. // For GET requests, redirect to the main page and let the modal handle UI. @@ -22,6 +27,9 @@ class SecurityController extends AbstractController return new Response(status: 204); } + /** + * Symfony intercepts this route to log the user out. + */ #[Route('/logout', name: 'app_logout')] public function logout(): void { diff --git a/src/Entity/Album.php b/src/Entity/Album.php index 99967f8..d8a9aea 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -7,6 +7,9 @@ use App\Entity\User; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +/** + * Album aggregates Spotify or user-submitted metadata persisted in the catalog. + */ #[ORM\Entity(repositoryClass: AlbumRepository::class)] #[ORM\Table(name: 'albums')] #[ORM\HasLifecycleCallbacks] @@ -49,6 +52,9 @@ class Album #[ORM\Column(type: 'string', length: 1024, nullable: true)] private ?string $coverUrl = null; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $coverImagePath = null; + #[ORM\Column(type: 'string', length: 1024, nullable: true)] private ?string $externalUrl = null; @@ -62,6 +68,9 @@ class Album #[ORM\Column(type: 'datetime_immutable')] private ?\DateTimeImmutable $updatedAt = null; + /** + * Initializes timestamps right before first persistence. + */ #[ORM\PrePersist] public function onPrePersist(): void { @@ -70,62 +79,231 @@ class Album $this->updatedAt = $now; } + /** + * Refreshes the updated timestamp prior to every update. + */ #[ORM\PreUpdate] public function onPreUpdate(): void { $this->updatedAt = new \DateTimeImmutable(); } - public function getId(): ?int { return $this->id; } - - public function getSpotifyId(): ?string { return $this->spotifyId; } - public function setSpotifyId(?string $spotifyId): void { $this->spotifyId = $spotifyId; } - public function getLocalId(): ?string { return $this->localId; } - public function setLocalId(?string $localId): void { $this->localId = $localId; } - public function getSource(): string { return $this->source; } - public function setSource(string $source): void { $this->source = $source; } - - public function getName(): string { return $this->name; } - public function setName(string $name): void { $this->name = $name; } - /** - * @return list + * Returns the database identifier. */ - public function getArtists(): array { return $this->artists; } + public function getId(): ?int + { + return $this->id; + } + /** - * @param list $artists + * Gets the Spotify album identifier when sourced from Spotify. */ - public function setArtists(array $artists): void { $this->artists = array_values($artists); } - - public function getReleaseDate(): ?string { return $this->releaseDate; } - public function setReleaseDate(?string $releaseDate): void { $this->releaseDate = $releaseDate; } - - public function getTotalTracks(): int { return $this->totalTracks; } - public function setTotalTracks(int $totalTracks): void { $this->totalTracks = $totalTracks; } - - public function getCoverUrl(): ?string { return $this->coverUrl; } - public function setCoverUrl(?string $coverUrl): void { $this->coverUrl = $coverUrl; } - - public function getExternalUrl(): ?string { return $this->externalUrl; } - public function setExternalUrl(?string $externalUrl): void { $this->externalUrl = $externalUrl; } - public function getCreatedBy(): ?User { return $this->createdBy; } - public function setCreatedBy(?User $user): void { $this->createdBy = $user; } - - public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } - public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; } + public function getSpotifyId(): ?string + { + return $this->spotifyId; + } /** - * Shape the album like the Spotify payload expected by Twig templates. + * Stores the Spotify album identifier. + */ + public function setSpotifyId(?string $spotifyId): void + { + $this->spotifyId = $spotifyId; + } + + /** + * Gets the local unique identifier for user-created albums. + */ + public function getLocalId(): ?string + { + return $this->localId; + } + + /** + * Assigns the local identifier for user-created albums. + */ + public function setLocalId(?string $localId): void + { + $this->localId = $localId; + } + + /** + * Returns the album source flag ("spotify" or "user"). + */ + public function getSource(): string + { + return $this->source; + } + + /** + * Sets the album source flag ("spotify" or "user"). + */ + public function setSource(string $source): void + { + $this->source = $source; + } + + /** + * Returns the human readable album title. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Updates the human readable album title. + */ + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return list Ordered performer names. + */ + public function getArtists(): array + { + return $this->artists; + } + + /** + * @param list $artists Ordered performer names. + */ + public function setArtists(array $artists): void + { + $this->artists = array_values($artists); + } + + /** + * Returns the stored release date string. + */ + public function getReleaseDate(): ?string + { + return $this->releaseDate; + } + + /** + * Sets the release date string (YYYY[-MM[-DD]]). + */ + public function setReleaseDate(?string $releaseDate): void + { + $this->releaseDate = $releaseDate; + } + + /** + * Returns the total number of tracks. + */ + public function getTotalTracks(): int + { + return $this->totalTracks; + } + + /** + * Sets the track count. + */ + public function setTotalTracks(int $totalTracks): void + { + $this->totalTracks = $totalTracks; + } + + /** + * Returns the preferred cover art URL. + */ + public function getCoverUrl(): ?string + { + return $this->coverUrl; + } + + /** + * Sets the preferred cover art URL. + */ + public function setCoverUrl(?string $coverUrl): void + { + $this->coverUrl = $coverUrl; + } + + /** + * Returns an external link (defaults to Spotify). + */ + public function getExternalUrl(): ?string + { + return $this->externalUrl; + } + + /** + * Sets the external reference link. + */ + public function setExternalUrl(?string $externalUrl): void + { + $this->externalUrl = $externalUrl; + } + + /** + * Returns the user that created the album, when applicable. + */ + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + /** + * Returns the locally stored cover image path for user albums. + */ + public function getCoverImagePath(): ?string + { + return $this->coverImagePath; + } + + /** + * Updates the locally stored cover image path for user albums. + */ + public function setCoverImagePath(?string $coverImagePath): void + { + $this->coverImagePath = $coverImagePath; + } + + /** + * Sets the owner responsible for the album. + */ + public function setCreatedBy(?User $user): void + { + $this->createdBy = $user; + } + + /** + * Returns the creation timestamp. + */ + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + /** + * Returns the last update timestamp. + */ + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + /** + * Shapes the entity to the payload Twig templates expect. * * @return array */ public function toTemplateArray(): array { $images = []; - if ($this->coverUrl) { + $imageUrl = $this->coverUrl; + if ($this->source === 'user' && $this->coverImagePath) { + $imageUrl = $this->coverImagePath; + } + if ($imageUrl) { $images = [ - ['url' => $this->coverUrl], - ['url' => $this->coverUrl], + ['url' => $imageUrl], + ['url' => $imageUrl], ]; } $artists = array_map(static fn(string $n) => ['name' => $n], $this->artists); diff --git a/src/Entity/Review.php b/src/Entity/Review.php index 1ce7947..c1d1ca1 100644 --- a/src/Entity/Review.php +++ b/src/Entity/Review.php @@ -7,6 +7,9 @@ use App\Entity\Album; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +/** + * Review captures a user-authored rating and narrative about an album. + */ #[ORM\Entity(repositoryClass: ReviewRepository::class)] #[ORM\Table(name: 'reviews')] #[ORM\HasLifecycleCallbacks] @@ -45,6 +48,9 @@ class Review #[ORM\Column(type: 'datetime_immutable')] private ?\DateTimeImmutable $updatedAt = null; + /** + * Sets timestamps prior to the first persist. + */ #[ORM\PrePersist] public function onPrePersist(): void { @@ -53,25 +59,118 @@ class Review $this->updatedAt = $now; } + /** + * Updates the modified timestamp before every update. + */ #[ORM\PreUpdate] public function onPreUpdate(): void { $this->updatedAt = new \DateTimeImmutable(); } - public function getId(): ?int { return $this->id; } - public function getAuthor(): ?User { return $this->author; } - public function setAuthor(User $author): void { $this->author = $author; } - public function getAlbum(): ?Album { return $this->album; } - public function setAlbum(Album $album): void { $this->album = $album; } - public function getTitle(): string { return $this->title; } - public function setTitle(string $title): void { $this->title = $title; } - public function getContent(): string { return $this->content; } - public function setContent(string $content): void { $this->content = $content; } - public function getRating(): int { return $this->rating; } - public function setRating(int $rating): void { $this->rating = $rating; } - public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } - public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; } + /** + * Returns the database identifier. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Returns the authoring user. + */ + public function getAuthor(): ?User + { + return $this->author; + } + + /** + * Assigns the authoring user. + */ + public function setAuthor(User $author): void + { + $this->author = $author; + } + + /** + * Returns the reviewed album. + */ + public function getAlbum(): ?Album + { + return $this->album; + } + + /** + * Assigns the reviewed album. + */ + public function setAlbum(Album $album): void + { + $this->album = $album; + } + + /** + * Returns the short review title. + */ + public function getTitle(): string + { + return $this->title; + } + + /** + * Sets the short review title. + */ + public function setTitle(string $title): void + { + $this->title = $title; + } + + /** + * Returns the long-form review content. + */ + public function getContent(): string + { + return $this->content; + } + + /** + * Sets the review content body. + */ + public function setContent(string $content): void + { + $this->content = $content; + } + + /** + * Returns the 1-10 numeric rating. + */ + public function getRating(): int + { + return $this->rating; + } + + /** + * Assigns the 1-10 numeric rating. + */ + public function setRating(int $rating): void + { + $this->rating = $rating; + } + + /** + * Returns the creation timestamp. + */ + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + /** + * Returns the last updated timestamp. + */ + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } } diff --git a/src/Entity/Setting.php b/src/Entity/Setting.php index 8d91b0e..329a735 100644 --- a/src/Entity/Setting.php +++ b/src/Entity/Setting.php @@ -6,6 +6,9 @@ use App\Repository\SettingRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +/** + * Setting stores lightweight key/value configuration entries. + */ #[ORM\Entity(repositoryClass: SettingRepository::class)] #[ORM\Table(name: 'settings')] #[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])] @@ -23,11 +26,45 @@ class Setting #[ORM\Column(type: 'text', nullable: true)] private ?string $value = null; - public function getId(): ?int { return $this->id; } - public function getName(): string { return $this->name; } - public function setName(string $name): void { $this->name = $name; } - public function getValue(): ?string { return $this->value; } - public function setValue(?string $value): void { $this->value = $value; } + /** + * Returns the unique identifier. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Returns the configuration key. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Sets the configuration key. + */ + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * Returns the stored configuration value. + */ + public function getValue(): ?string + { + return $this->value; + } + + /** + * Sets the stored configuration value. + */ + public function setValue(?string $value): void + { + $this->value = $value; + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 8bfc27d..597a30a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -9,6 +9,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints as Assert; +/** + * User models an authenticated account that can create reviews and albums. + */ #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: 'users')] #[UniqueEntity(fields: ['email'], message: 'This email is already registered.')] @@ -40,30 +43,49 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Assert\Length(max: 120)] private ?string $displayName = null; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $profileImagePath = null; + + /** + * Returns the database identifier. + */ public function getId(): ?int { return $this->id; } + /** + * Returns the normalized email address. + */ public function getEmail(): string { return $this->email; } + /** + * Sets and normalizes the email address. + */ public function setEmail(string $email): void { $this->email = strtolower($email); } + /** + * Symfony security identifier alias for the email. + */ public function getUserIdentifier(): string { return $this->email; } + /** + * Returns the unique role list plus the implicit ROLE_USER. + * + * @return list + */ public function getRoles(): array { $roles = $this->roles; - // guarantee every user at least has ROLE_USER if (!in_array('ROLE_USER', $roles, true)) { $roles[] = 'ROLE_USER'; } @@ -71,6 +93,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface } /** + * Replaces the granted role list. + * * @param list $roles */ public function setRoles(array $roles): void @@ -78,6 +102,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->roles = array_values(array_unique($roles)); } + /** + * Adds a single role if not already present. + */ public function addRole(string $role): void { $roles = $this->getRoles(); @@ -87,30 +114,55 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->roles = $roles; } + /** + * Returns the hashed password string. + */ public function getPassword(): string { return $this->password; } + /** + * Stores the hashed password string. + */ public function setPassword(string $hashedPassword): void { $this->password = $hashedPassword; } + /** + * Removes any sensitive transient data (no-op here). + */ public function eraseCredentials(): void { // no-op } + /** + * Returns the optional display name. + */ public function getDisplayName(): ?string { return $this->displayName; } + /** + * Sets the optional display name. + */ public function setDisplayName(?string $displayName): void { $this->displayName = $displayName; } + + public function getProfileImagePath(): ?string + { + return $this->profileImagePath; + } + + public function setProfileImagePath(?string $profileImagePath): void + { + $this->profileImagePath = $profileImagePath; + } } diff --git a/src/Form/AlbumType.php b/src/Form/AlbumType.php index 29fe34e..41fbc8e 100644 --- a/src/Form/AlbumType.php +++ b/src/Form/AlbumType.php @@ -4,6 +4,7 @@ namespace App\Form; 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; @@ -13,6 +14,9 @@ use Symfony\Component\Validator\Constraints as Assert; class AlbumType extends AbstractType { + /** + * Defines the album creation/editing fields. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -31,8 +35,11 @@ class AlbumType extends AbstractType ->add('totalTracks', IntegerType::class, [ 'constraints' => [new Assert\Range(min: 0, max: 500)], ]) - ->add('coverUrl', TextType::class, [ + ->add('coverUpload', FileType::class, [ + 'mapped' => false, 'required' => false, + 'label' => 'Album cover', + 'constraints' => [new Assert\Image(maxSize: '5M')], ]) ->add('externalUrl', TextType::class, [ 'required' => false, @@ -40,6 +47,9 @@ class AlbumType extends AbstractType ]); } + /** + * Points the form to the Album entity. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php index 655bb50..7326c50 100644 --- a/src/Form/ChangePasswordFormType.php +++ b/src/Form/ChangePasswordFormType.php @@ -11,6 +11,9 @@ use Symfony\Component\Validator\Constraints as Assert; class ChangePasswordFormType extends AbstractType { + /** + * Builds the password change fields with validation. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -26,6 +29,9 @@ class ChangePasswordFormType extends AbstractType ]); } + /** + * Leaves default form options untouched. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); diff --git a/src/Form/ProfileFormType.php b/src/Form/ProfileFormType.php index e87b473..389fe9f 100644 --- a/src/Form/ProfileFormType.php +++ b/src/Form/ProfileFormType.php @@ -5,6 +5,7 @@ namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -14,6 +15,9 @@ use Symfony\Component\Validator\Constraints as Assert; class ProfileFormType extends AbstractType { + /** + * Defines profile fields including optional password updates. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -24,6 +28,12 @@ class ProfileFormType extends AbstractType 'required' => false, 'constraints' => [new Assert\Length(max: 120)], ]) + ->add('profileImage', FileType::class, [ + 'mapped' => false, + 'required' => false, + 'label' => 'Profile picture', + 'constraints' => [new Assert\Image(maxSize: '4M')], + ]) ->add('currentPassword', PasswordType::class, [ 'mapped' => false, 'required' => false, @@ -40,6 +50,9 @@ class ProfileFormType extends AbstractType ]); } + /** + * Binds the form to the User entity. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php index 7c41e1d..6bdcdf8 100644 --- a/src/Form/RegistrationFormType.php +++ b/src/Form/RegistrationFormType.php @@ -15,6 +15,9 @@ use Symfony\Component\Validator\Constraints as Assert; class RegistrationFormType extends AbstractType { + /** + * Configures the registration form fields and validation rules. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -42,6 +45,9 @@ class RegistrationFormType extends AbstractType ]); } + /** + * Binds the form to the User entity. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/ReviewType.php b/src/Form/ReviewType.php index 6c1f139..aa6302c 100644 --- a/src/Form/ReviewType.php +++ b/src/Form/ReviewType.php @@ -14,6 +14,9 @@ use Symfony\Component\Validator\Constraints as Assert; class ReviewType extends AbstractType { + /** + * Declares the review submission fields and validation. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -35,6 +38,9 @@ class ReviewType extends AbstractType ]); } + /** + * Associates the form with the Review entity. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php index 605cf39..1a492c3 100644 --- a/src/Form/SiteSettingsType.php +++ b/src/Form/SiteSettingsType.php @@ -9,6 +9,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class SiteSettingsType extends AbstractType { + /** + * Exposes Spotify credential inputs for administrators. + */ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -24,6 +27,9 @@ class SiteSettingsType extends AbstractType ]); } + /** + * Leaves default options unchanged. + */ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([]); diff --git a/src/Kernel.php b/src/Kernel.php index a0f55d0..7b7eb91 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -5,6 +5,9 @@ namespace App; +/** + * MicroKernelTrait used over KernelTrait for smaller footprint; full HttpKernel is not needed. + */ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\HttpKernel\Kernel as BaseKernel; diff --git a/src/Repository/AlbumRepository.php b/src/Repository/AlbumRepository.php index 62a45e2..ae2c82b 100644 --- a/src/Repository/AlbumRepository.php +++ b/src/Repository/AlbumRepository.php @@ -7,28 +7,41 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** + * AlbumRepository centralizes album persistence helpers and aggregations. + * * @extends ServiceEntityRepository */ class AlbumRepository extends ServiceEntityRepository { + /** + * Wires the repository to Doctrine's registry. + */ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Album::class); } + /** + * Finds one album by Spotify identifier. + */ public function findOneBySpotifyId(string $spotifyId): ?Album { return $this->findOneBy(['spotifyId' => $spotifyId]); } + /** + * Finds one album by user-local identifier. + */ public function findOneByLocalId(string $localId): ?Album { return $this->findOneBy(['localId' => $localId]); } /** + * Returns albums keyed by Spotify identifiers for quick lookup. + * * @param list $spotifyIds - * @return array keyed by spotifyId + * @return array */ public function findBySpotifyIdsKeyed(array $spotifyIds): array { @@ -42,7 +55,7 @@ class AlbumRepository extends ServiceEntityRepository ->getResult(); $out = []; foreach ($rows as $row) { - if ($row instanceof Album) { + if ($row instanceof Album && $row->getSpotifyId() !== null) { $out[$row->getSpotifyId()] = $row; } } @@ -50,39 +63,23 @@ class AlbumRepository extends ServiceEntityRepository } /** - * @return list - */ - public function searchUserAlbumsByNameLike(string $query, int $limit = 20): array - { - $qb = $this->createQueryBuilder('a') - ->where('a.source = :src') - ->andWhere('LOWER(a.name) LIKE :q') - ->setParameter('src', 'user') - ->setParameter('q', '%' . mb_strtolower($query) . '%') - ->setMaxResults($limit); - return $qb->getQuery()->getResult(); - } - - /** - * Search user-created albums by optional fields. + * Filters user albums by optional metadata. * * @return list */ - public function searchUserAlbums(?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array + public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array { $qb = $this->createQueryBuilder('a') ->where('a.source = :src') ->setParameter('src', 'user') - ->setMaxResults($limit); + ->setMaxResults($limit * 2); + if ($freeText !== null && $freeText !== '') { + $qb->andWhere('LOWER(a.name) LIKE :qName')->setParameter('qName', '%' . mb_strtolower($freeText) . '%'); + } if ($albumName !== null && $albumName !== '') { $qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%'); } - if ($artist !== null && $artist !== '') { - // artists is JSON; use text match - $qb->andWhere("CAST(a.artists as text) ILIKE :ar")->setParameter('ar', '%' . $artist . '%'); - } if ($yearFrom > 0 || $yearTo > 0) { - // releaseDate is YYYY-MM-DD; compare by year via substring if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) { $qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt") ->setParameter('yf', (string) $yearFrom) @@ -92,11 +89,45 @@ class AlbumRepository extends ServiceEntityRepository $qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y); } } - return $qb->getQuery()->getResult(); + $results = $qb->getQuery()->getResult(); + $artistNeedle = $artist ?? $freeText; + return $this->filterByArtistAndLimit($results, $artistNeedle, $limit); } /** - * Upsert based on a Spotify album payload. + * Filters persisted Spotify albums before falling back to the API. + * + * @return list + */ + public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array + { + $qb = $this->createQueryBuilder('a') + ->where('a.source = :src') + ->setParameter('src', 'spotify') + ->setMaxResults($limit * 2); + if ($freeText !== null && $freeText !== '') { + $qb->andWhere('LOWER(a.name) LIKE :qName') + ->setParameter('qName', '%' . mb_strtolower($freeText) . '%'); + } + if ($albumName !== null && $albumName !== '') { + $qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%'); + } + if ($yearFrom > 0 || $yearTo > 0) { + if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) { + $qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt") + ->setParameter('yf', (string) $yearFrom) + ->setParameter('yt', (string) $yearTo); + } else { + $y = $yearFrom > 0 ? $yearFrom : $yearTo; + $qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y); + } + } + $results = $qb->getQuery()->getResult(); + return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit); + } + + /** + * Upserts data from a Spotify album payload and keeps DB entities in sync. * * @param array $spotifyAlbum */ @@ -131,9 +162,36 @@ class AlbumRepository extends ServiceEntityRepository $album->setCoverUrl($coverUrl); $album->setExternalUrl($external); $em->persist($album); - // flush outside for batching return $album; } + + /** + * @param list $albums + * @return list + */ + private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array + { + if ($needle === null || trim($needle) === '') { + return array_slice($albums, 0, $limit); + } + $needle = mb_strtolower(trim($needle)); + $filtered = []; + foreach ($albums as $album) { + foreach ($album->getArtists() as $artist) { + if (str_contains(mb_strtolower($artist), $needle)) { + $filtered[] = $album; + break; + } + } + if (count($filtered) >= $limit) { + break; + } + } + if ($filtered === []) { + return array_slice($albums, 0, $limit); + } + return $filtered; + } } diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php index d5b3fa7..504c543 100644 --- a/src/Repository/ReviewRepository.php +++ b/src/Repository/ReviewRepository.php @@ -7,16 +7,23 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** + * ReviewRepository streamlines review lookups and aggregate queries. + * * @extends ServiceEntityRepository */ class ReviewRepository extends ServiceEntityRepository { + /** + * Wires Doctrine's registry to the repository. + */ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Review::class); } /** + * Returns the newest reviews limited to the requested size. + * * @return list */ public function findLatest(int $limit = 20): array @@ -29,7 +36,7 @@ class ReviewRepository extends ServiceEntityRepository } /** - * Return aggregates for albums by Spotify IDs: [spotifyId => {count, avg}]. + * Aggregates review counts and averages for Spotify IDs. * * @param list $albumIds * @return array @@ -57,7 +64,7 @@ class ReviewRepository extends ServiceEntityRepository } /** - * Aggregates keyed by album entity id. + * Aggregates review counts and averages for album entity IDs. * * @param list $albumEntityIds * @return array diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php index 9247aca..dcbdd3d 100644 --- a/src/Repository/SettingRepository.php +++ b/src/Repository/SettingRepository.php @@ -6,19 +6,31 @@ use App\Entity\Setting; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** + * SettingRepository provides helper accessors for app configuration storage. + */ class SettingRepository extends ServiceEntityRepository { + /** + * Injects the Doctrine registry reference. + */ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Setting::class); } + /** + * Returns a setting value falling back to the supplied default. + */ public function getValue(string $name, ?string $default = null): ?string { $setting = $this->findOneBy(['name' => $name]); return $setting?->getValue() ?? $default; } + /** + * Persists the supplied configuration value. + */ public function setValue(string $name, ?string $value): void { $em = $this->getEntityManager(); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index a91b986..464a29a 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -7,15 +7,23 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** + * UserRepository handles account lookups and helpers. + * * @extends ServiceEntityRepository */ class UserRepository extends ServiceEntityRepository { + /** + * Registers the repository with Doctrine. + */ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } + /** + * Retrieves a user by case-insensitive email. + */ public function findOneByEmail(string $email): ?User { return $this->createQueryBuilder('u') diff --git a/src/Security/ReviewVoter.php b/src/Security/ReviewVoter.php index d199db1..745f113 100644 --- a/src/Security/ReviewVoter.php +++ b/src/Security/ReviewVoter.php @@ -7,16 +7,25 @@ use App\Entity\User; 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. + */ class ReviewVoter extends Voter { public const EDIT = 'REVIEW_EDIT'; public const DELETE = 'REVIEW_DELETE'; + /** + * Ensures this voter only evaluates review edit/delete attributes. + */ protected function supports(string $attribute, mixed $subject): bool { return in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Review; } + /** + * Grants access to admins or the review author. + */ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); diff --git a/src/Service/ImageStorage.php b/src/Service/ImageStorage.php new file mode 100644 index 0000000..4ec455c --- /dev/null +++ b/src/Service/ImageStorage.php @@ -0,0 +1,58 @@ +fs = new Filesystem(); + } + + public function storeProfileImage(UploadedFile $file): string + { + return $this->store($file, 'avatars'); + } + + public function storeAlbumCover(UploadedFile $file): string + { + return $this->store($file, 'album_covers'); + } + + public function remove(?string $webPath): void + { + if ($webPath === null || $webPath === '') { + return; + } + $path = $this->projectDir . '/public' . $webPath; + if ($this->fs->exists($path)) { + $this->fs->remove($path); + } + } + + private function store(UploadedFile $file, string $subDirectory): string + { + $targetDir = $this->projectDir . '/public/uploads/' . $subDirectory; + if (!$this->fs->exists($targetDir)) { + $this->fs->mkdir($targetDir); + } + + $originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME); + $safeName = $this->slugger->slug($originalName ?: 'image'); + $extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin'; + $filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension); + + $file->move($targetDir, $filename); + + return '/uploads/' . $subDirectory . '/' . $filename; + } +} + diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php index 673bb21..67a86eb 100644 --- a/src/Service/SpotifyClient.php +++ b/src/Service/SpotifyClient.php @@ -1,12 +1,13 @@ clientId = $clientId; $this->clientSecret = $clientSecret; $this->settings = $settings; - // Allow tuning via env vars; fallback to conservative defaults - $this->rateWindowSeconds = (int) (getenv('SPOTIFY_RATE_WINDOW_SECONDS') ?: 30); - $this->rateMaxRequests = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS') ?: 50); - $this->rateMaxRequestsSensitive = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE') ?: 20); } /** @@ -56,7 +53,7 @@ class SpotifyClient 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], 'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ], ]; - return $this->sendRequest('GET', $url, $options, 600, false); + return $this->sendRequest('GET', $url, $options, 600); } /** @@ -74,7 +71,7 @@ class SpotifyClient $url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId); $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ]; try { - return $this->sendRequest('GET', $url, $options, 3600, false); + return $this->sendRequest('GET', $url, $options, 3600); } catch (\Throwable) { return null; } @@ -97,19 +94,19 @@ class SpotifyClient 'query' => [ 'ids' => implode(',', $albumIds) ], ]; try { - return $this->sendRequest('GET', $url, $options, 3600, false); + return $this->sendRequest('GET', $url, $options, 3600); } catch (\Throwable) { return null; } } /** - * Centralized request with basic throttling, caching and 429 handling. + * Centralized request helper with lightweight caching. * * @param array $options * @return array */ - private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array + private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array { $cacheKey = null; if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') { @@ -124,63 +121,20 @@ class SpotifyClient } } - $this->throttle($sensitive); - - $attempts = 0; - while (true) { - ++$attempts; - $response = $this->httpClient->request($method, $url, $options); - $status = $response->getStatusCode(); - - if ($status === 429) { - $retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1); - $retryAfter = max(1, min(30, $retryAfter)); - sleep($retryAfter); - if ($attempts < 3) { continue; } - } - - $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; + $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; } - private function throttle(bool $sensitive): void - { - $windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate'; - $max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests; - $now = time(); - $entry = $this->cache->get($windowKey, function($item) use ($now) { - $item->expiresAfter($this->rateWindowSeconds); - return ['start' => $now, 'count' => 0]; - }); - if (!is_array($entry) || !isset($entry['start'], $entry['count'])) { - $entry = ['start' => $now, 'count' => 0]; - } - $start = (int) $entry['start']; - $count = (int) $entry['count']; - $elapsed = $now - $start; - if ($elapsed >= $this->rateWindowSeconds) { - $start = $now; $count = 0; - } - if ($count >= $max) { - $sleep = max(1, $this->rateWindowSeconds - $elapsed); - sleep($sleep); - $start = time(); $count = 0; - } - $count++; - $newEntry = ['start' => $start, 'count' => $count]; - $this->cache->get($windowKey, function($item) use ($newEntry) { - $item->expiresAfter($this->rateWindowSeconds); - return $newEntry; - }); - } - + /** + * Retrieves a cached access token or refreshes credentials when missing. + */ private function getAccessToken(): ?string { return $this->cache->get('spotify_client_credentials_token', function ($item) { diff --git a/templates/_partials/navbar.html.twig b/templates/_partials/navbar.html.twig index 6e8db8e..90df350 100644 --- a/templates/_partials/navbar.html.twig +++ b/templates/_partials/navbar.html.twig @@ -22,11 +22,14 @@