eerrrrrr
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s

This commit is contained in:
2025-11-27 23:42:17 +00:00
parent 054e970df9
commit 1c98a634c3
50 changed files with 1666 additions and 593 deletions

View File

@@ -2,15 +2,17 @@
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Service\ImageStorage;
use App\Repository\AlbumRepository;
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\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\ImageStorage;
use App\Service\SpotifyClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -19,7 +21,6 @@ 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.
@@ -28,6 +29,7 @@ class AlbumController extends AbstractController
{
public function __construct(
private readonly ImageStorage $imageStorage,
private readonly AlbumSearchService $albumSearch,
private readonly int $searchLimit = 20
) {
}
@@ -36,37 +38,21 @@ class AlbumController extends AbstractController
* Searches Spotify plus local albums and decorates results with review stats.
*/
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
public function search(Request $request): Response
{
$filters = $this->buildSearchFilters($request);
$stats = [];
$savedIds = [];
$spotifyData = $this->resolveSpotifyAlbums($filters, $spotify, $albumRepo, $reviewRepo, $em, $logger);
$stats = $this->mergeStats($stats, $spotifyData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
$userData = $this->resolveUserAlbums($filters, $albumRepo, $reviewRepo);
$stats = $this->mergeStats($stats, $userData['stats']);
$albums = $this->composeAlbumList(
$filters['source'],
$userData['payloads'],
$spotifyData['payloads'],
$filters['limit']
);
$savedIds = $this->mergeSavedIds($savedIds, []);
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
$result = $this->albumSearch->search($criteria);
return $this->render('album/search.html.twig', [
'query' => $filters['query'],
'album' => $filters['albumName'],
'artist' => $filters['artist'],
'year_from' => $filters['yearFrom'] ?: '',
'year_to' => $filters['yearTo'] ?: '',
'albums' => $albums,
'stats' => $stats,
'savedIds' => $savedIds,
'source' => $filters['source'],
'query' => $criteria->query,
'album' => $criteria->albumName,
'artist' => $criteria->artist,
'year_from' => $criteria->yearFrom ?? '',
'year_to' => $criteria->yearTo ?? '',
'albums' => $result->albums,
'stats' => $result->stats,
'savedIds' => $result->savedIds,
'source' => $criteria->source,
]);
}
@@ -291,7 +277,7 @@ class AlbumController extends AbstractController
*/
private function canManageAlbum(Album $album): bool
{
if ($this->isGranted('ROLE_ADMIN')) {
if ($this->isGranted('ROLE_MODERATOR')) {
return true;
}
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
@@ -348,309 +334,6 @@ class AlbumController extends AbstractController
}
}
/**
* @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);
}
}