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;
}
}

151
src/Entity/Album.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
namespace App\Entity;
use App\Repository\AlbumRepository;
use App\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
#[ORM\Table(name: 'albums')]
#[ORM\HasLifecycleCallbacks]
class Album
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
// For Spotify-sourced albums; null for user-created
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $spotifyId = null;
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $localId = null;
// 'spotify' or 'user'
#[ORM\Column(type: 'string', length: 16)]
private string $source = 'spotify';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $name = '';
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $artists = [];
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null;
#[ORM\Column(type: 'integer')]
private int $totalTracks = 0;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $coverUrl = null;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $externalUrl = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $createdBy = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[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<string>
*/
public function getArtists(): array { return $this->artists; }
/**
* @param list<string> $artists
*/
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; }
/**
* Shape the album like the Spotify payload expected by Twig templates.
*
* @return array<string,mixed>
*/
public function toTemplateArray(): array
{
$images = [];
if ($this->coverUrl) {
$images = [
['url' => $this->coverUrl],
['url' => $this->coverUrl],
];
}
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
$external = $this->externalUrl;
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
}
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
return [
'id' => $publicId,
'name' => $this->name,
'images' => $images,
'artists' => $artists,
'release_date' => $this->releaseDate,
'total_tracks' => $this->totalTracks,
'external_urls' => [ 'spotify' => $external ],
'source' => $this->source,
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Entity;
use App\Repository\ReviewRepository;
use App\Entity\Album;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -20,17 +21,9 @@ class Review
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $author = null;
#[ORM\Column(type: 'string', length: 64)]
#[Assert\NotBlank]
private string $spotifyAlbumId = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumName = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumArtist = '';
#[ORM\ManyToOne(targetEntity: Album::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Album $album = null;
#[ORM\Column(type: 'string', length: 160)]
#[Assert\NotBlank]
@@ -69,12 +62,8 @@ class Review
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 getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
public function getAlbumName(): string { return $this->albumName; }
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
public function getAlbumArtist(): string { return $this->albumArtist; }
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
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; }

51
src/Form/AlbumType.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
])
->add('artistsCsv', TextType::class, [
'mapped' => false,
'label' => 'Artists (comma-separated)',
'constraints' => [new Assert\NotBlank()],
])
->add('releaseDate', TextType::class, [
'required' => false,
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
])
->add('totalTracks', IntegerType::class, [
'constraints' => [new Assert\Range(min: 0, max: 500)],
])
->add('coverUrl', TextType::class, [
'required' => false,
])
->add('externalUrl', TextType::class, [
'required' => false,
'label' => 'External link',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Album::class,
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Repository;
use App\Entity\Album;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Album>
*/
class AlbumRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Album::class);
}
public function findOneBySpotifyId(string $spotifyId): ?Album
{
return $this->findOneBy(['spotifyId' => $spotifyId]);
}
public function findOneByLocalId(string $localId): ?Album
{
return $this->findOneBy(['localId' => $localId]);
}
/**
* @param list<string> $spotifyIds
* @return array<string,Album> keyed by spotifyId
*/
public function findBySpotifyIdsKeyed(array $spotifyIds): array
{
if ($spotifyIds === []) {
return [];
}
$rows = $this->createQueryBuilder('a')
->where('a.spotifyId IN (:ids)')
->setParameter('ids', $spotifyIds)
->getQuery()
->getResult();
$out = [];
foreach ($rows as $row) {
if ($row instanceof Album) {
$out[$row->getSpotifyId()] = $row;
}
}
return $out;
}
/**
* @return list<Album>
*/
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();
}
/**
* Upsert based on a Spotify album payload.
*
* @param array<string,mixed> $spotifyAlbum
*/
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album
{
$spotifyId = (string) ($spotifyAlbum['id'] ?? '');
$name = (string) ($spotifyAlbum['name'] ?? '');
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? [])));
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
$images = (array) ($spotifyAlbum['images'] ?? []);
$coverUrl = null;
if (isset($images[1]['url'])) {
$coverUrl = (string) $images[1]['url'];
} elseif (isset($images[0]['url'])) {
$coverUrl = (string) $images[0]['url'];
}
$external = null;
if (isset($spotifyAlbum['external_urls']['spotify'])) {
$external = (string) $spotifyAlbum['external_urls']['spotify'];
}
$em = $this->getEntityManager();
$album = $this->findOneBy(['spotifyId' => $spotifyId]) ?? new Album();
$album->setSource('spotify');
$album->setSpotifyId($spotifyId);
$album->setName($name);
$album->setArtists($artists);
$album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks);
$album->setCoverUrl($coverUrl);
$album->setExternalUrl($external);
$em->persist($album);
// flush outside for batching
return $album;
}
}

View File

@@ -29,7 +29,7 @@ class ReviewRepository extends ServiceEntityRepository
}
/**
* Return aggregates for albums: [albumId => ['count' => int, 'avg' => float]].
* Return aggregates for albums by Spotify IDs: [spotifyId => {count, avg}].
*
* @param list<string> $albumIds
* @return array<string,array{count:int,avg:float}>
@@ -40,13 +40,13 @@ class ReviewRepository extends ServiceEntityRepository
return [];
}
$rows = $this->createQueryBuilder('r')
->select('r.spotifyAlbumId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('r.spotifyAlbumId IN (:ids)')
$qb = $this->createQueryBuilder('r')
->innerJoin('r.album', 'a')
->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('a.spotifyId IN (:ids)')
->setParameter('ids', $albumIds)
->groupBy('r.spotifyAlbumId')
->getQuery()
->getArrayResult();
->groupBy('a.spotifyId');
$rows = $qb->getQuery()->getArrayResult();
$out = [];
foreach ($rows as $row) {