searchLimit); $result = $this->albumSearch->search($criteria); return $this->render('album/search.html.twig', [ 'query' => $criteria->query, 'album' => $criteria->albumName, 'artist' => $criteria->artist, 'genre' => $criteria->genre, 'year_from' => $criteria->yearFrom ?? '', 'year_to' => $criteria->yearTo ?? '', 'albums' => $result->albums, 'stats' => $result->stats, 'savedIds' => $result->savedIds, 'source' => $criteria->source, 'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(), ]); } /** * Creates a user-authored album entry. */ #[IsGranted('ROLE_USER')] #[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])] public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response { $album = new Album(); $album->setSource('user'); $form = $this->createForm(AlbumType::class, $album); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->normalizeAlbumFormData($album); $user = $this->getUser(); if ($user instanceof User) { $album->setCreatedBy($user); } $this->handleAlbumCoverUpload($album, $form); $album->setLocalId($this->generateLocalId($albumRepo)); $em->persist($album); $em->flush(); $this->addFlash('success', 'Album created.'); return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]); } return $this->render('album/new.html.twig', [ 'form' => $form->createView(), ]); } /** * 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 $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $albumEntity = $this->findAlbum($id, $albumRepo); $isSaved = $albumEntity !== null; if (!$albumEntity) { // Album has never been saved locally, so hydrate it via Spotify before rendering. $spotifyAlbum = $spotify->getAlbumWithTracks($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } $albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $em->flush(); } else { if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) { // Track sync mutated the entity: persist before we build template arrays. $em->flush(); } } $albumCard = $albumEntity->toTemplateArray(); $canManage = $this->canManageAlbum($albumEntity); $trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray()); $existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); $count = count($existing); $avg = 0.0; if ($count > 0) { $sum = 0; foreach ($existing as $rev) { $sum += (int) $rev->getRating(); } $avg = round($sum / $count, 1); } $review = new Review(); $review->setAlbum($albumEntity); $form = $this->createForm(ReviewType::class, $review); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->denyAccessUnlessGranted('ROLE_USER'); $review->setAuthor($this->getUser()); $em->persist($review); $em->flush(); $this->addFlash('success', 'Review added.'); return $this->redirectToRoute('album_show', ['id' => $id]); } return $this->render('album/show.html.twig', [ 'album' => $albumCard, 'albumId' => $id, 'isSaved' => $isSaved, 'allowedEdit' => $canManage, 'allowedDelete' => $canManage, 'reviews' => $existing, 'avg' => $avg, 'count' => $count, 'form' => $form->createView(), 'albumOwner' => $albumEntity->getCreatedBy(), 'albumCreatedAt' => $albumEntity->getCreatedAt(), 'tracks' => $trackRows, ]); } /** * 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 $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } $existing = $albumRepo->findOneBySpotifyId($id); if (!$existing) { $spotifyAlbum = $spotify->getAlbumWithTracks($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $em->flush(); $this->addFlash('success', 'Album saved.'); } else { $this->addFlash('info', 'Album already saved.'); } 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 $albumRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } $album = $this->findAlbum($id, $albumRepo); if ($album) { $this->ensureCanManageAlbum($album); if ($album->getSource() === 'user') { $this->uploadStorage->remove($album->getCoverImagePath()); } $em->remove($album); $em->flush(); $this->addFlash('success', 'Album deleted.'); } else { $this->addFlash('info', 'Album not found.'); } return $this->redirectToRoute('album_search'); } /** * Generates a unique user album identifier. */ private function generateLocalId(AlbumRepository $albumRepo): string { do { $id = 'u_' . bin2hex(random_bytes(6)); } 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) === '') { return null; } $s = trim($input); // YYYY if (preg_match('/^\d{4}$/', $s)) { return $s . '-01-01'; } // YYYY-MM if (preg_match('/^\d{4}-\d{2}$/', $s)) { return $s . '-01'; } // YYYY-MM-DD if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) { return $s; } // Fallback: attempt to parse try { // Trust PHP's parser only as a last resort (it accepts many human formats). $dt = new \DateTimeImmutable($s); return $dt->format('Y-m-d'); } catch (\Throwable) { return null; } } /** * 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 $albumRepo, EntityManagerInterface $em): Response { $album = $this->findAlbum($id, $albumRepo); if (!$album) { throw $this->createNotFoundException('Album not found'); } $this->ensureCanManageAlbum($album); $form = $this->createForm(AlbumType::class, $album); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->normalizeAlbumFormData($album); $this->handleAlbumCoverUpload($album, $form); $em->flush(); $this->addFlash('success', 'Album updated.'); return $this->redirectToRoute('album_show', ['id' => $id]); } return $this->render('album/edit.html.twig', [ 'form' => $form->createView(), 'albumId' => $id, ]); } /** * Looks up an album by either local or Spotify identifier. */ private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album { $local = $albumRepo->findOneByLocalId($id); if ($local instanceof Album) { return $local; } return $albumRepo->findOneBySpotifyId($id); } /** * Returns true when the authenticated user can manage the album. */ private function canManageAlbum(Album $album): bool { if ($this->isGranted('ROLE_MODERATOR')) { 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(); } private function normalizeAlbumFormData(Album $album): void { $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); } 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->uploadStorage->remove($album->getCoverImagePath()); $album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file)); } } /** * @param array $spotifyAlbum */ private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album { // 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); $album->setTotalTracks(count($tracks)); } return $album; } private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool { if ($album->getSource() !== 'spotify') { return false; } $spotifyId = $album->getSpotifyId(); if ($spotifyId === null) { return false; } $storedCount = $album->getTracks()->count(); $needsSync = $storedCount === 0; if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) { // Spotify track counts do not match what we have stored; re-sync to avoid stale data. $needsSync = true; } if (!$needsSync) { return false; } $spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId); if ($spotifyAlbum === null) { return false; } // 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; } $trackRepo->replaceAlbumTracks($album, $tracks); $album->setTotalTracks(count($tracks)); return true; } }