Files
tonehaus/src/Controller/AlbumController.php
boris d52eb6bd81
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s
documentation and env changes
2025-11-28 08:14:13 +00:00

398 lines
14 KiB
PHP

<?php
namespace App\Controller;
use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Form\ReviewType;
use App\Form\AlbumType;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\UploadStorage;
use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver;
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\Component\Security\Http\Attribute\IsGranted;
/**
* AlbumController orchestrates search, CRUD, and review entry on albums.
*/
class AlbumController extends AbstractController
{
public function __construct(
private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver,
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): Response
{
$criteria = AlbumSearchCriteria::fromRequest($request, $this->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<string,mixed> $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;
}
}