CRUD Albums + Spotify API requests into DB.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
This commit is contained in:
@@ -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
151
src/Entity/Album.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
51
src/Form/AlbumType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
107
src/Repository/AlbumRepository.php
Normal file
107
src/Repository/AlbumRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user