what the fuck
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m55s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m55s
This commit is contained in:
@@ -3,157 +3,92 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\SpotifyClient;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
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\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\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* AlbumController orchestrates search, CRUD, and review entry on albums.
|
||||
*/
|
||||
class AlbumController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImageStorage $imageStorage,
|
||||
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, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository, AlbumRepository $albumsRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
|
||||
public function search(Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
|
||||
{
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
$albumName = trim($request->query->getString('album', ''));
|
||||
$artist = trim($request->query->getString('artist', ''));
|
||||
$source = $request->query->getString('source', 'all'); // 'all' | 'spotify' | 'user'
|
||||
// Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues
|
||||
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
|
||||
$yearToRaw = trim((string) $request->query->get('year_to', ''));
|
||||
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
|
||||
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
|
||||
$albums = [];
|
||||
$filters = $this->buildSearchFilters($request);
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
|
||||
// Build Spotify fielded search if advanced inputs are supplied
|
||||
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
|
||||
$q = $query;
|
||||
if ($advancedUsed) {
|
||||
$parts = [];
|
||||
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
|
||||
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
|
||||
if ($yearFrom > 0 || $yearTo > 0) {
|
||||
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
|
||||
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
|
||||
} else {
|
||||
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
|
||||
$parts[] = 'year:' . $y;
|
||||
}
|
||||
}
|
||||
// also include free-text if provided
|
||||
if ($query !== '') { $parts[] = $query; }
|
||||
$q = implode(' ', $parts);
|
||||
}
|
||||
$spotifyData = $this->resolveSpotifyAlbums($filters, $spotify, $albumRepo, $reviewRepo, $em, $logger);
|
||||
$stats = $this->mergeStats($stats, $spotifyData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
||||
|
||||
if ($q !== '' || $source === 'user') {
|
||||
$result = $spotifyClient->searchAlbums($q, 20);
|
||||
$searchItems = $result['albums']['items'] ?? [];
|
||||
$logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
|
||||
if ($searchItems && ($source === 'all' || $source === 'spotify')) {
|
||||
// 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)]);
|
||||
$userData = $this->resolveUserAlbums($filters, $albumRepo, $reviewRepo);
|
||||
$stats = $this->mergeStats($stats, $userData['stats']);
|
||||
|
||||
// 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) {
|
||||
if ($source === 'spotify' || $source === 'all') {
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// User-created search results
|
||||
if ($source === 'user' || $source === 'all') {
|
||||
$userAlbums = $albumsRepo->searchUserAlbums($albumName, $artist, $yearFrom, $yearTo, 20);
|
||||
if ($userAlbums) {
|
||||
$entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums));
|
||||
$userStatsByEntityId = $reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
|
||||
// Merge into stats keyed by localId
|
||||
foreach ($userAlbums as $ua) {
|
||||
$localId = (string) $ua->getLocalId();
|
||||
$entityId = (int) $ua->getId();
|
||||
if (isset($userStatsByEntityId[$entityId])) {
|
||||
$stats[$localId] = $userStatsByEntityId[$entityId];
|
||||
}
|
||||
}
|
||||
$userAlbumPayloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums);
|
||||
// Prepend user albums to list
|
||||
$albums = array_merge($userAlbumPayloads, $albums);
|
||||
}
|
||||
}
|
||||
}
|
||||
$albums = $this->composeAlbumList(
|
||||
$filters['source'],
|
||||
$userData['payloads'],
|
||||
$spotifyData['payloads'],
|
||||
$filters['limit']
|
||||
);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, []);
|
||||
|
||||
return $this->render('album/search.html.twig', [
|
||||
'query' => $query,
|
||||
'album' => $albumName,
|
||||
'artist' => $artist,
|
||||
'year_from' => $yearFrom ?: '',
|
||||
'year_to' => $yearTo ?: '',
|
||||
'query' => $filters['query'],
|
||||
'album' => $filters['albumName'],
|
||||
'artist' => $filters['artist'],
|
||||
'year_from' => $filters['yearFrom'] ?: '',
|
||||
'year_to' => $filters['yearTo'] ?: '',
|
||||
'albums' => $albums,
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
'source' => $source,
|
||||
'source' => $filters['source'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user-authored album entry.
|
||||
*/
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
|
||||
public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$album = new \App\Entity\Album();
|
||||
$album = new 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);
|
||||
// Normalize release date to YYYY-MM-DD
|
||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||
// Assign createdBy and generate unique localId
|
||||
$u = $this->getUser();
|
||||
if ($u instanceof \App\Entity\User) {
|
||||
$album->setCreatedBy($u);
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$user = $this->getUser();
|
||||
if ($user instanceof User) {
|
||||
$album->setCreatedBy($user);
|
||||
}
|
||||
$album->setLocalId($this->generateLocalId($albumsRepo));
|
||||
$this->handleAlbumCoverUpload($album, $form);
|
||||
$album->setLocalId($this->generateLocalId($albumRepo));
|
||||
$em->persist($album);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album created.');
|
||||
@@ -164,31 +99,26 @@ class AlbumController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $spotifyClient, ReviewRepository $reviews, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
|
||||
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
// Prefer DB: only fetch from Spotify if not present
|
||||
$albumEntity = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
|
||||
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||
$isSaved = $albumEntity !== null;
|
||||
if (!$albumEntity) {
|
||||
$spotifyAlbum = $spotifyClient->getAlbum($id);
|
||||
$spotifyAlbum = $spotify->getAlbum($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$albumEntity = $albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$em->flush();
|
||||
}
|
||||
$isSaved = $albumEntity !== null;
|
||||
$album = $albumEntity->toTemplateArray();
|
||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||
$current = $this->getUser();
|
||||
$isOwner = false;
|
||||
if ($current instanceof \App\Entity\User) {
|
||||
$isOwner = ($albumEntity->getCreatedBy()?->getId() === $current->getId());
|
||||
}
|
||||
$allowedEdit = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner);
|
||||
$allowedDelete = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner);
|
||||
$albumCard = $albumEntity->toTemplateArray();
|
||||
$canManage = $this->canManageAlbum($albumEntity);
|
||||
|
||||
$existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
||||
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
||||
$count = count($existing);
|
||||
$avg = 0.0;
|
||||
if ($count > 0) {
|
||||
@@ -197,7 +127,6 @@ class AlbumController extends AbstractController
|
||||
$avg = round($sum / $count, 1);
|
||||
}
|
||||
|
||||
// Pre-populate required album metadata before validation so entity constraints pass
|
||||
$review = new Review();
|
||||
$review->setAlbum($albumEntity);
|
||||
|
||||
@@ -213,11 +142,11 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('album/show.html.twig', [
|
||||
'album' => $album,
|
||||
'album' => $albumCard,
|
||||
'albumId' => $id,
|
||||
'isSaved' => $isSaved,
|
||||
'allowedEdit' => $allowedEdit,
|
||||
'allowedDelete' => $allowedDelete,
|
||||
'allowedEdit' => $canManage,
|
||||
'allowedDelete' => $canManage,
|
||||
'reviews' => $existing,
|
||||
'avg' => $avg,
|
||||
'count' => $count,
|
||||
@@ -225,21 +154,24 @@ class AlbumController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $spotifyClient, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
|
||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, 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);
|
||||
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||
if (!$existing) {
|
||||
$spotifyAlbum = $spotifyClient->getAlbum($id);
|
||||
$spotifyAlbum = $spotify->getAlbum($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album saved.');
|
||||
} else {
|
||||
@@ -248,31 +180,22 @@ class AlbumController extends AbstractController
|
||||
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 $albumsRepo, EntityManagerInterface $em): Response
|
||||
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 = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
|
||||
$album = $this->findAlbum($id, $albumRepo);
|
||||
if ($album) {
|
||||
// Only owner or admin can delete user albums; Spotify albums require admin
|
||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||
$this->ensureCanManageAlbum($album);
|
||||
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();
|
||||
}
|
||||
$this->imageStorage->remove($album->getCoverImagePath());
|
||||
}
|
||||
$em->remove($album);
|
||||
$em->flush();
|
||||
@@ -283,14 +206,20 @@ class AlbumController extends AbstractController
|
||||
return $this->redirectToRoute('album_search');
|
||||
}
|
||||
|
||||
private function generateLocalId(AlbumRepository $albumsRepo): string
|
||||
/**
|
||||
* Generates a unique user album identifier.
|
||||
*/
|
||||
private function generateLocalId(AlbumRepository $albumRepo): string
|
||||
{
|
||||
do {
|
||||
$id = 'u_' . bin2hex(random_bytes(6));
|
||||
} while ($albumsRepo->findOneByLocalId($id) !== null);
|
||||
} 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) === '') {
|
||||
@@ -318,38 +247,25 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $albumsRepo, EntityManagerInterface $em): Response
|
||||
public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
|
||||
$album = $this->findAlbum($id, $albumRepo);
|
||||
if (!$album) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||
$current = $this->getUser();
|
||||
$isOwner = false;
|
||||
if ($current instanceof \App\Entity\User) {
|
||||
$isOwner = ($album->getCreatedBy()?->getId() === $current->getId());
|
||||
}
|
||||
if ($album->getSource() === 'user') {
|
||||
if (!$isAdmin && !$isOwner) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
} else {
|
||||
if (!$isAdmin) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
}
|
||||
$this->ensureCanManageAlbum($album);
|
||||
|
||||
$form = $this->createForm(AlbumType::class, $album);
|
||||
// Prepopulate artistsCsv
|
||||
$form->get('artistsCsv')->setData(implode(', ', $album->getArtists()));
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$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);
|
||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||
$this->applyAlbumFormData($album, $form);
|
||||
$this->handleAlbumCoverUpload($album, $form);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album updated.');
|
||||
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||
@@ -359,6 +275,382 @@ class AlbumController extends AbstractController
|
||||
'albumId' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up an album by either local or Spotify identifier.
|
||||
*/
|
||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||
{
|
||||
return str_starts_with($id, 'u_')
|
||||
? $albumRepo->findOneByLocalId($id)
|
||||
: $albumRepo->findOneBySpotifyId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the authenticated user can manage the album.
|
||||
*/
|
||||
private function canManageAlbum(Album $album): bool
|
||||
{
|
||||
if ($this->isGranted('ROLE_ADMIN')) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies normalized metadata from the album form.
|
||||
*/
|
||||
private function applyAlbumFormData(Album $album, FormInterface $form): void
|
||||
{
|
||||
$album->setArtists($this->parseArtistsCsv((string) $form->get('artistsCsv')->getData()));
|
||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the artists CSV input into a normalized list.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseArtistsCsv(string $csv): array
|
||||
{
|
||||
$parts = array_map(static fn($s) => trim((string) $s), explode(',', $csv));
|
||||
return array_values(array_filter($parts, static fn($s) => $s !== ''));
|
||||
}
|
||||
|
||||
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->imageStorage->remove($album->getCoverImagePath());
|
||||
$album->setCoverImagePath($this->imageStorage->storeAlbumCover($file));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* query:string,
|
||||
* albumName:string,
|
||||
* artist:string,
|
||||
* source:string,
|
||||
* yearFrom:int,
|
||||
* yearTo:int,
|
||||
* limit:int,
|
||||
* spotifyQuery:string,
|
||||
* hasUserFilters:bool,
|
||||
* useSpotify:bool
|
||||
* }
|
||||
*/
|
||||
private function buildSearchFilters(Request $request): array
|
||||
{
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
$albumName = trim($request->query->getString('album', ''));
|
||||
$artist = trim($request->query->getString('artist', ''));
|
||||
$source = $request->query->getString('source', 'all');
|
||||
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
|
||||
$yearToRaw = trim((string) $request->query->get('year_to', ''));
|
||||
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
|
||||
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
|
||||
$spotifyQuery = $this->buildSpotifyQuery($query, $albumName, $artist, $yearFrom, $yearTo);
|
||||
$hasUserFilters = ($spotifyQuery !== '' || $albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
|
||||
$useSpotify = ($source === 'all' || $source === 'spotify');
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'albumName' => $albumName,
|
||||
'artist' => $artist,
|
||||
'source' => $source,
|
||||
'yearFrom' => $yearFrom,
|
||||
'yearTo' => $yearTo,
|
||||
'limit' => $this->searchLimit,
|
||||
'spotifyQuery' => $spotifyQuery,
|
||||
'hasUserFilters' => $hasUserFilters,
|
||||
'useSpotify' => $useSpotify,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSpotifyQuery(string $query, string $albumName, string $artist, int $yearFrom, int $yearTo): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
|
||||
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
|
||||
if ($yearFrom > 0 || $yearTo > 0) {
|
||||
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
|
||||
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
|
||||
} else {
|
||||
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
|
||||
$parts[] = 'year:' . $y;
|
||||
}
|
||||
}
|
||||
if ($query !== '') { $parts[] = $query; }
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function resolveSpotifyAlbums(
|
||||
array $filters,
|
||||
SpotifyClient $spotify,
|
||||
AlbumRepository $albumRepo,
|
||||
ReviewRepository $reviewRepo,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
): array {
|
||||
if (!$filters['useSpotify'] || $filters['spotifyQuery'] === '') {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$stored = $albumRepo->searchSpotifyAlbums(
|
||||
$filters['spotifyQuery'],
|
||||
$filters['albumName'],
|
||||
$filters['artist'],
|
||||
$filters['yearFrom'],
|
||||
$filters['yearTo'],
|
||||
$filters['limit']
|
||||
);
|
||||
$storedPayloads = array_map(static fn($a) => $a->toTemplateArray(), $stored);
|
||||
$storedIds = $this->collectSpotifyIds($stored);
|
||||
$stats = $storedIds ? $reviewRepo->getAggregatesForAlbumIds($storedIds) : [];
|
||||
$savedIds = $storedIds;
|
||||
|
||||
if (count($stored) >= $filters['limit']) {
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $filters['limit']),
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
];
|
||||
}
|
||||
|
||||
$apiPayloads = $this->fetchSpotifyPayloads(
|
||||
$filters,
|
||||
$spotify,
|
||||
$albumRepo,
|
||||
$reviewRepo,
|
||||
$em,
|
||||
$logger
|
||||
);
|
||||
|
||||
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $filters['limit']);
|
||||
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchSpotifyPayloads(
|
||||
array $filters,
|
||||
SpotifyClient $spotify,
|
||||
AlbumRepository $albumRepo,
|
||||
ReviewRepository $reviewRepo,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
): array {
|
||||
$result = $spotify->searchAlbums($filters['spotifyQuery'], $filters['limit']);
|
||||
$searchItems = $result['albums']['items'] ?? [];
|
||||
$logger->info('Album search results received', ['query' => $filters['spotifyQuery'], 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
|
||||
|
||||
if (!$searchItems) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$ids = $this->extractSpotifyIds($searchItems);
|
||||
if ($ids === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$full = $spotify->getAlbums($ids);
|
||||
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
||||
if ($albumsPayload === [] && $searchItems !== []) {
|
||||
$albumsPayload = $searchItems;
|
||||
$logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
|
||||
}
|
||||
|
||||
$upserted = 0;
|
||||
foreach ($albumsPayload as $sa) {
|
||||
$albumRepo->upsertFromSpotifyAlbum((array) $sa);
|
||||
$upserted++;
|
||||
}
|
||||
$em->flush();
|
||||
$logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
||||
|
||||
$existing = $albumRepo->findBySpotifyIdsKeyed($ids);
|
||||
$payloads = [];
|
||||
foreach ($ids as $sid) {
|
||||
if (isset($existing[$sid])) {
|
||||
$payloads[] = $existing[$sid]->toTemplateArray();
|
||||
}
|
||||
}
|
||||
|
||||
$stats = $reviewRepo->getAggregatesForAlbumIds($ids);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'stats' => $stats,
|
||||
'savedIds' => array_keys($existing),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>}
|
||||
*/
|
||||
private function resolveUserAlbums(array $filters, AlbumRepository $albumRepo, ReviewRepository $reviewRepo): array
|
||||
{
|
||||
if (!$filters['hasUserFilters'] || ($filters['source'] !== 'user' && $filters['source'] !== 'all')) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$userAlbums = $albumRepo->searchUserAlbums(
|
||||
$filters['query'],
|
||||
$filters['albumName'],
|
||||
$filters['artist'],
|
||||
$filters['yearFrom'],
|
||||
$filters['yearTo'],
|
||||
$filters['limit']
|
||||
);
|
||||
if ($userAlbums === []) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums));
|
||||
$userStats = $reviewRepo->getAggregatesForAlbumEntityIds($entityIds);
|
||||
$payloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $userAlbums
|
||||
* @param array<int,array{count:int,avg:float}> $userStats
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
*/
|
||||
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($userAlbums as $album) {
|
||||
$entityId = (int) $album->getId();
|
||||
$localId = (string) $album->getLocalId();
|
||||
if ($localId !== '' && isset($userStats[$entityId])) {
|
||||
$mapped[$localId] = $userStats[$entityId];
|
||||
}
|
||||
}
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
|
||||
{
|
||||
if ($source === 'user') {
|
||||
return array_slice($userPayloads, 0, $limit);
|
||||
}
|
||||
if ($source === 'spotify') {
|
||||
return array_slice($spotifyPayloads, 0, $limit);
|
||||
}
|
||||
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectSpotifyIds(array $albums): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($albums as $album) {
|
||||
$sid = (string) $album->getSpotifyId();
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $searchItems
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractSpotifyIds(array $searchItems): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($searchItems as $item) {
|
||||
$id = isset($item['id']) ? (string) $item['id'] : '';
|
||||
if ($id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function mergeStats(array $current, array $updates): array
|
||||
{
|
||||
foreach ($updates as $key => $value) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
|
||||
private function mergeSavedIds(array $current, array $updates): array
|
||||
{
|
||||
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
|
||||
return array_values(array_unique($merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $primary
|
||||
* @param array<int,array<mixed>> $secondary
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function mergePayloadLists(array $primary, array $secondary, int $limit): array
|
||||
{
|
||||
$seen = [];
|
||||
$merged = [];
|
||||
foreach ($primary as $payload) {
|
||||
$merged[] = $payload;
|
||||
if (isset($payload['id'])) {
|
||||
$seen[$payload['id']] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
foreach ($secondary as $payload) {
|
||||
$id = $payload['id'] ?? null;
|
||||
if ($id !== null && isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$merged[] = $payload;
|
||||
if ($id !== null) {
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user