CRUD Albums + Spotify API requests into DB.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s

This commit is contained in:
2025-11-20 19:53:45 +00:00
parent cd13f1478a
commit cd04fa5212
26 changed files with 6180 additions and 66 deletions

View File

@@ -3,19 +3,23 @@
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Repository\AlbumRepository;
use App\Entity\Review;
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\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Psr\Log\LoggerInterface;
class AlbumController extends AbstractController
{
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository, AlbumRepository $albumsRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
@@ -27,6 +31,7 @@ class AlbumController extends AbstractController
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
$albums = [];
$stats = [];
$savedIds = [];
// Build Spotify fielded search if advanced inputs are supplied
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
@@ -50,12 +55,45 @@ class AlbumController extends AbstractController
if ($q !== '') {
$result = $spotifyClient->searchAlbums($q, 20);
$albums = $result['albums']['items'] ?? [];
if ($albums) {
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
$searchItems = $result['albums']['items'] ?? [];
$logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
if ($searchItems) {
// 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)]);
// 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) {
$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();
}
}
}
}
}
@@ -68,18 +106,56 @@ class AlbumController extends AbstractController
'year_to' => $yearTo ?: '',
'albums' => $albums,
'stats' => $stats,
'savedIds' => $savedIds,
]);
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
public function new(Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$album = new \App\Entity\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);
// Assign createdBy and generate unique localId
$u = $this->getUser();
if ($u instanceof \App\Entity\User) {
$album->setCreatedBy($u);
}
$album->setLocalId($this->generateLocalId($albumsRepo));
$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(),
]);
}
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$album = $spotifyClient->getAlbum($id);
if ($album === null) {
throw $this->createNotFoundException('Album not found');
// Prefer DB: only fetch from Spotify if not present
$albumEntity = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
if (!$albumEntity) {
$spotifyAlbum = $spotifyClient->getAlbum($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$albumEntity = $albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$em->flush();
}
$isSaved = $albumEntity !== null;
$album = $albumEntity->toTemplateArray();
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
$existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing);
$avg = 0.0;
if ($count > 0) {
@@ -90,9 +166,7 @@ class AlbumController extends AbstractController
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review();
$review->setSpotifyAlbumId($id);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$review->setAlbum($albumEntity);
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
@@ -108,12 +182,79 @@ class AlbumController extends AbstractController
return $this->render('album/show.html.twig', [
'album' => $album,
'albumId' => $id,
'isSaved' => $isSaved,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
]);
}
#[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
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$existing = $albumsRepo->findOneBySpotifyId($id);
if (!$existing) {
$spotifyAlbum = $spotifyClient->getAlbum($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$em->flush();
$this->addFlash('success', 'Album saved.');
} else {
$this->addFlash('info', 'Album already saved.');
}
return $this->redirectToRoute('album_show', ['id' => $id]);
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])]
public function delete(string $id, Request $request, AlbumRepository $albumsRepo, 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);
if ($album) {
// Only owner or admin can delete user albums; Spotify albums require admin
$isAdmin = $this->isGranted('ROLE_ADMIN');
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();
}
}
$em->remove($album);
$em->flush();
$this->addFlash('success', 'Album deleted.');
} else {
$this->addFlash('info', 'Album not found.');
}
return $this->redirectToRoute('album_search');
}
private function generateLocalId(AlbumRepository $albumsRepo): string
{
do {
$id = 'u_' . bin2hex(random_bytes(6));
} while ($albumsRepo->findOneByLocalId($id) !== null);
return $id;
}
}