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:
@@ -4,6 +4,7 @@
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -23,3 +24,11 @@ services:
|
||||
arguments:
|
||||
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
|
||||
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
|
||||
|
||||
App\Service\ImageStorage:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Controller\AlbumController:
|
||||
arguments:
|
||||
$searchLimit: '%album_search_limit%'
|
||||
|
||||
34
migrations/Version20251127191813.php
Normal file
34
migrations/Version20251127191813.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251127191813 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT');
|
||||
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
|
||||
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20251205123000.php
Normal file
29
migrations/Version20251205123000.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251205123000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add profile image path to users and cover image path to albums';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users DROP profile_image_path');
|
||||
$this->addSql('ALTER TABLE albums DROP cover_image_path');
|
||||
}
|
||||
}
|
||||
|
||||
3
public/uploads/.gitignore
vendored
Normal file
3
public/uploads/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
|
||||
@@ -13,16 +13,25 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
|
||||
class PromoteAdminCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Stores injected dependencies for later use.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the required email argument.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes the provided account to administrator if found.
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
|
||||
@@ -7,18 +7,26 @@ use App\Form\ProfileFormType;
|
||||
use App\Form\ChangePasswordFormType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Service\ImageStorage;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* AccountController hosts authenticated self-service pages.
|
||||
*/
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class AccountController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Summarizes the signed-in user's recent activity.
|
||||
*/
|
||||
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
|
||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
|
||||
{
|
||||
@@ -50,6 +58,7 @@ class AccountController extends AbstractController
|
||||
return $this->render('account/dashboard.html.twig', [
|
||||
'email' => $user->getEmail(),
|
||||
'displayName' => $user->getDisplayName(),
|
||||
'profileImage' => $user->getProfileImagePath(),
|
||||
'reviewCount' => $reviewCount,
|
||||
'albumCount' => $albumCount,
|
||||
'userType' => $userType,
|
||||
@@ -58,12 +67,59 @@ class AccountController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows users to update profile details and avatar.
|
||||
*/
|
||||
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ImageStorage $images): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$form = $this->createForm(ProfileFormType::class, $user);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$newPassword = (string) $form->get('newPassword')->getData();
|
||||
if ($newPassword !== '') {
|
||||
$current = (string) $form->get('currentPassword')->getData();
|
||||
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
||||
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
||||
return $this->render('account/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
|
||||
$upload = $form->get('profileImage')->getData();
|
||||
if ($upload instanceof UploadedFile) {
|
||||
$images->remove($user->getProfileImagePath());
|
||||
$user->setProfileImagePath($images->storeProfileImage($upload));
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_profile');
|
||||
}
|
||||
|
||||
return $this->render('account/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'profileImage' => $user->getProfileImagePath(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows account-level settings options.
|
||||
*/
|
||||
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
|
||||
public function settings(): Response
|
||||
{
|
||||
return $this->render('account/settings.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the password change form and updates the hash.
|
||||
*/
|
||||
#[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])]
|
||||
public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
|
||||
{
|
||||
|
||||
@@ -10,9 +10,15 @@ use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* DashboardController shows high-level site activity to admins.
|
||||
*/
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SiteDashboardController extends AbstractController
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Renders overall activity metrics for administrators.
|
||||
*/
|
||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
||||
{
|
||||
@@ -43,5 +49,3 @@ class SiteDashboardController extends AbstractController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* SettingsController lets admins adjust key integration settings.
|
||||
*/
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SiteSettingsController extends AbstractController
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Displays and persists Spotify credential settings.
|
||||
*/
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||
public function settings(Request $request, SettingRepository $settings): Response
|
||||
{
|
||||
@@ -33,5 +39,3 @@ class SiteSettingsController extends AbstractController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,14 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* RegistrationController handles signup workflows (HTML + XHR).
|
||||
*/
|
||||
class RegistrationController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Processes registration submissions or serves the form modal.
|
||||
*/
|
||||
#[Route('/register', name: 'app_register')]
|
||||
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
|
||||
{
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Review;
|
||||
use App\Form\ReviewType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\SpotifyClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
@@ -14,19 +12,27 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* ReviewController funnels CRUD flows through album pages and simple routes.
|
||||
*/
|
||||
#[Route('/reviews')]
|
||||
class ReviewController extends AbstractController
|
||||
{
|
||||
// Exclusively for compat, used to route to standalone reviews page.
|
||||
/**
|
||||
* Maintains backwards compatibility by redirecting to the dashboard.
|
||||
*/
|
||||
#[Route('', name: 'review_index', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->redirectToRoute('account_dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects users to the album flow when starting a review.
|
||||
*/
|
||||
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function new(Request $request): Response
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$albumId = (string) $request->query->get('album_id', '');
|
||||
if ($albumId !== '') {
|
||||
@@ -36,6 +42,9 @@ class ReviewController extends AbstractController
|
||||
return $this->redirectToRoute('album_search');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a standalone review page.
|
||||
*/
|
||||
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
public function show(Review $review): Response
|
||||
{
|
||||
@@ -44,6 +53,9 @@ class ReviewController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles review form edits with authorization.
|
||||
*/
|
||||
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
|
||||
@@ -65,6 +77,9 @@ class ReviewController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a review when the CSRF token and permission check pass.
|
||||
*/
|
||||
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
|
||||
@@ -78,7 +93,6 @@ class ReviewController extends AbstractController
|
||||
return $this->redirectToRoute('account_dashboard');
|
||||
}
|
||||
|
||||
// fetchAlbumById no longer needed; album view handles retrieval and creation
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,17 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* SecurityController keeps login/logout routes alive for the firewall.
|
||||
*/
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Redirects GET requests to the SPA and lets Symfony handle POST auth.
|
||||
*/
|
||||
#[Route('/login', name: 'app_login')]
|
||||
public function login(Request $request, AuthenticationUtils $authenticationUtils): Response
|
||||
public function login(Request $request): Response
|
||||
{
|
||||
// Keep this route so the firewall can use it as check_path for POST.
|
||||
// For GET requests, redirect to the main page and let the modal handle UI.
|
||||
@@ -22,6 +27,9 @@ class SecurityController extends AbstractController
|
||||
return new Response(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symfony intercepts this route to log the user out.
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout')]
|
||||
public function logout(): void
|
||||
{
|
||||
|
||||
@@ -7,6 +7,9 @@ use App\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Album aggregates Spotify or user-submitted metadata persisted in the catalog.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
|
||||
#[ORM\Table(name: 'albums')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
@@ -49,6 +52,9 @@ class Album
|
||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||
private ?string $coverUrl = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $coverImagePath = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||
private ?string $externalUrl = null;
|
||||
|
||||
@@ -62,6 +68,9 @@ class Album
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/**
|
||||
* Initializes timestamps right before first persistence.
|
||||
*/
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
@@ -70,62 +79,231 @@ class Album
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the updated timestamp prior to every update.
|
||||
*/
|
||||
#[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>
|
||||
* Returns the database identifier.
|
||||
*/
|
||||
public function getArtists(): array { return $this->artists; }
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $artists
|
||||
* Gets the Spotify album identifier when sourced from Spotify.
|
||||
*/
|
||||
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; }
|
||||
public function getSpotifyId(): ?string
|
||||
{
|
||||
return $this->spotifyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the album like the Spotify payload expected by Twig templates.
|
||||
* Stores the Spotify album identifier.
|
||||
*/
|
||||
public function setSpotifyId(?string $spotifyId): void
|
||||
{
|
||||
$this->spotifyId = $spotifyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local unique identifier for user-created albums.
|
||||
*/
|
||||
public function getLocalId(): ?string
|
||||
{
|
||||
return $this->localId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the local identifier for user-created albums.
|
||||
*/
|
||||
public function setLocalId(?string $localId): void
|
||||
{
|
||||
$this->localId = $localId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the album source flag ("spotify" or "user").
|
||||
*/
|
||||
public function getSource(): string
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the album source flag ("spotify" or "user").
|
||||
*/
|
||||
public function setSource(string $source): void
|
||||
{
|
||||
$this->source = $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human readable album title.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the human readable album title.
|
||||
*/
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Ordered performer names.
|
||||
*/
|
||||
public function getArtists(): array
|
||||
{
|
||||
return $this->artists;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $artists Ordered performer names.
|
||||
*/
|
||||
public function setArtists(array $artists): void
|
||||
{
|
||||
$this->artists = array_values($artists);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored release date string.
|
||||
*/
|
||||
public function getReleaseDate(): ?string
|
||||
{
|
||||
return $this->releaseDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the release date string (YYYY[-MM[-DD]]).
|
||||
*/
|
||||
public function setReleaseDate(?string $releaseDate): void
|
||||
{
|
||||
$this->releaseDate = $releaseDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of tracks.
|
||||
*/
|
||||
public function getTotalTracks(): int
|
||||
{
|
||||
return $this->totalTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the track count.
|
||||
*/
|
||||
public function setTotalTracks(int $totalTracks): void
|
||||
{
|
||||
$this->totalTracks = $totalTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preferred cover art URL.
|
||||
*/
|
||||
public function getCoverUrl(): ?string
|
||||
{
|
||||
return $this->coverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the preferred cover art URL.
|
||||
*/
|
||||
public function setCoverUrl(?string $coverUrl): void
|
||||
{
|
||||
$this->coverUrl = $coverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an external link (defaults to Spotify).
|
||||
*/
|
||||
public function getExternalUrl(): ?string
|
||||
{
|
||||
return $this->externalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the external reference link.
|
||||
*/
|
||||
public function setExternalUrl(?string $externalUrl): void
|
||||
{
|
||||
$this->externalUrl = $externalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user that created the album, when applicable.
|
||||
*/
|
||||
public function getCreatedBy(): ?User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the locally stored cover image path for user albums.
|
||||
*/
|
||||
public function getCoverImagePath(): ?string
|
||||
{
|
||||
return $this->coverImagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the locally stored cover image path for user albums.
|
||||
*/
|
||||
public function setCoverImagePath(?string $coverImagePath): void
|
||||
{
|
||||
$this->coverImagePath = $coverImagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the owner responsible for the album.
|
||||
*/
|
||||
public function setCreatedBy(?User $user): void
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the creation timestamp.
|
||||
*/
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last update timestamp.
|
||||
*/
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes the entity to the payload Twig templates expect.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function toTemplateArray(): array
|
||||
{
|
||||
$images = [];
|
||||
if ($this->coverUrl) {
|
||||
$imageUrl = $this->coverUrl;
|
||||
if ($this->source === 'user' && $this->coverImagePath) {
|
||||
$imageUrl = $this->coverImagePath;
|
||||
}
|
||||
if ($imageUrl) {
|
||||
$images = [
|
||||
['url' => $this->coverUrl],
|
||||
['url' => $this->coverUrl],
|
||||
['url' => $imageUrl],
|
||||
['url' => $imageUrl],
|
||||
];
|
||||
}
|
||||
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
|
||||
|
||||
@@ -7,6 +7,9 @@ use App\Entity\Album;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Review captures a user-authored rating and narrative about an album.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
|
||||
#[ORM\Table(name: 'reviews')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
@@ -45,6 +48,9 @@ class Review
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/**
|
||||
* Sets timestamps prior to the first persist.
|
||||
*/
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
@@ -53,25 +59,118 @@ class Review
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modified timestamp before every update.
|
||||
*/
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
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 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; }
|
||||
public function setContent(string $content): void { $this->content = $content; }
|
||||
public function getRating(): int { return $this->rating; }
|
||||
public function setRating(int $rating): void { $this->rating = $rating; }
|
||||
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
|
||||
/**
|
||||
* Returns the database identifier.
|
||||
*/
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authoring user.
|
||||
*/
|
||||
public function getAuthor(): ?User
|
||||
{
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the authoring user.
|
||||
*/
|
||||
public function setAuthor(User $author): void
|
||||
{
|
||||
$this->author = $author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the reviewed album.
|
||||
*/
|
||||
public function getAlbum(): ?Album
|
||||
{
|
||||
return $this->album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the reviewed album.
|
||||
*/
|
||||
public function setAlbum(Album $album): void
|
||||
{
|
||||
$this->album = $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the short review title.
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the short review title.
|
||||
*/
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the long-form review content.
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the review content body.
|
||||
*/
|
||||
public function setContent(string $content): void
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-10 numeric rating.
|
||||
*/
|
||||
public function getRating(): int
|
||||
{
|
||||
return $this->rating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the 1-10 numeric rating.
|
||||
*/
|
||||
public function setRating(int $rating): void
|
||||
{
|
||||
$this->rating = $rating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the creation timestamp.
|
||||
*/
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last updated timestamp.
|
||||
*/
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ use App\Repository\SettingRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Setting stores lightweight key/value configuration entries.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SettingRepository::class)]
|
||||
#[ORM\Table(name: 'settings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
|
||||
@@ -23,11 +26,45 @@ class Setting
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $value = null;
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function setName(string $name): void { $this->name = $name; }
|
||||
public function getValue(): ?string { return $this->value; }
|
||||
public function setValue(?string $value): void { $this->value = $value; }
|
||||
/**
|
||||
* Returns the unique identifier.
|
||||
*/
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration key.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the configuration key.
|
||||
*/
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored configuration value.
|
||||
*/
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the stored configuration value.
|
||||
*/
|
||||
public function setValue(?string $value): void
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* User models an authenticated account that can create reviews and albums.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
|
||||
@@ -40,30 +43,49 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[Assert\Length(max: 120)]
|
||||
private ?string $displayName = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $profileImagePath = null;
|
||||
|
||||
/**
|
||||
* Returns the database identifier.
|
||||
*/
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized email address.
|
||||
*/
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and normalizes the email address.
|
||||
*/
|
||||
public function setEmail(string $email): void
|
||||
{
|
||||
$this->email = strtolower($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symfony security identifier alias for the email.
|
||||
*/
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique role list plus the implicit ROLE_USER.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
// guarantee every user at least has ROLE_USER
|
||||
if (!in_array('ROLE_USER', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
@@ -71,6 +93,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the granted role list.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function setRoles(array $roles): void
|
||||
@@ -78,6 +102,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
$this->roles = array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single role if not already present.
|
||||
*/
|
||||
public function addRole(string $role): void
|
||||
{
|
||||
$roles = $this->getRoles();
|
||||
@@ -87,30 +114,55 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
$this->roles = $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hashed password string.
|
||||
*/
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the hashed password string.
|
||||
*/
|
||||
public function setPassword(string $hashedPassword): void
|
||||
{
|
||||
$this->password = $hashedPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any sensitive transient data (no-op here).
|
||||
*/
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the optional display name.
|
||||
*/
|
||||
public function getDisplayName(): ?string
|
||||
{
|
||||
return $this->displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the optional display name.
|
||||
*/
|
||||
public function setDisplayName(?string $displayName): void
|
||||
{
|
||||
$this->displayName = $displayName;
|
||||
}
|
||||
|
||||
public function getProfileImagePath(): ?string
|
||||
{
|
||||
return $this->profileImagePath;
|
||||
}
|
||||
|
||||
public function setProfileImagePath(?string $profileImagePath): void
|
||||
{
|
||||
$this->profileImagePath = $profileImagePath;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Form;
|
||||
|
||||
use App\Entity\Album;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -13,6 +14,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class AlbumType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Defines the album creation/editing fields.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -31,8 +35,11 @@ class AlbumType extends AbstractType
|
||||
->add('totalTracks', IntegerType::class, [
|
||||
'constraints' => [new Assert\Range(min: 0, max: 500)],
|
||||
])
|
||||
->add('coverUrl', TextType::class, [
|
||||
->add('coverUpload', FileType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'Album cover',
|
||||
'constraints' => [new Assert\Image(maxSize: '5M')],
|
||||
])
|
||||
->add('externalUrl', TextType::class, [
|
||||
'required' => false,
|
||||
@@ -40,6 +47,9 @@ class AlbumType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the form to the Album entity.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
@@ -11,6 +11,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ChangePasswordFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Builds the password change fields with validation.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -26,6 +29,9 @@ class ChangePasswordFormType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves default form options untouched.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Form;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
@@ -14,6 +15,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ProfileFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Defines profile fields including optional password updates.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -24,6 +28,12 @@ class ProfileFormType extends AbstractType
|
||||
'required' => false,
|
||||
'constraints' => [new Assert\Length(max: 120)],
|
||||
])
|
||||
->add('profileImage', FileType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'Profile picture',
|
||||
'constraints' => [new Assert\Image(maxSize: '4M')],
|
||||
])
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
@@ -40,6 +50,9 @@ class ProfileFormType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the form to the User entity.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
@@ -15,6 +15,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class RegistrationFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Configures the registration form fields and validation rules.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -42,6 +45,9 @@ class RegistrationFormType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the form to the User entity.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
@@ -14,6 +14,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ReviewType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Declares the review submission fields and validation.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -35,6 +38,9 @@ class ReviewType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates the form with the Review entity.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
@@ -9,6 +9,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SiteSettingsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Exposes Spotify credential inputs for administrators.
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
@@ -24,6 +27,9 @@ class SiteSettingsType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves default options unchanged.
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* MicroKernelTrait used over KernelTrait for smaller footprint; full HttpKernel is not needed.
|
||||
*/
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
|
||||
@@ -7,28 +7,41 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* AlbumRepository centralizes album persistence helpers and aggregations.
|
||||
*
|
||||
* @extends ServiceEntityRepository<Album>
|
||||
*/
|
||||
class AlbumRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Wires the repository to Doctrine's registry.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Album::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one album by Spotify identifier.
|
||||
*/
|
||||
public function findOneBySpotifyId(string $spotifyId): ?Album
|
||||
{
|
||||
return $this->findOneBy(['spotifyId' => $spotifyId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one album by user-local identifier.
|
||||
*/
|
||||
public function findOneByLocalId(string $localId): ?Album
|
||||
{
|
||||
return $this->findOneBy(['localId' => $localId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns albums keyed by Spotify identifiers for quick lookup.
|
||||
*
|
||||
* @param list<string> $spotifyIds
|
||||
* @return array<string,Album> keyed by spotifyId
|
||||
* @return array<string,Album>
|
||||
*/
|
||||
public function findBySpotifyIdsKeyed(array $spotifyIds): array
|
||||
{
|
||||
@@ -42,7 +55,7 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row instanceof Album) {
|
||||
if ($row instanceof Album && $row->getSpotifyId() !== null) {
|
||||
$out[$row->getSpotifyId()] = $row;
|
||||
}
|
||||
}
|
||||
@@ -50,39 +63,23 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search user-created albums by optional fields.
|
||||
* Filters user albums by optional metadata.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchUserAlbums(?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.source = :src')
|
||||
->setParameter('src', 'user')
|
||||
->setMaxResults($limit);
|
||||
->setMaxResults($limit * 2);
|
||||
if ($freeText !== null && $freeText !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :qName')->setParameter('qName', '%' . mb_strtolower($freeText) . '%');
|
||||
}
|
||||
if ($albumName !== null && $albumName !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%');
|
||||
}
|
||||
if ($artist !== null && $artist !== '') {
|
||||
// artists is JSON; use text match
|
||||
$qb->andWhere("CAST(a.artists as text) ILIKE :ar")->setParameter('ar', '%' . $artist . '%');
|
||||
}
|
||||
if ($yearFrom > 0 || $yearTo > 0) {
|
||||
// releaseDate is YYYY-MM-DD; compare by year via substring
|
||||
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
|
||||
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt")
|
||||
->setParameter('yf', (string) $yearFrom)
|
||||
@@ -92,11 +89,45 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y);
|
||||
}
|
||||
}
|
||||
return $qb->getQuery()->getResult();
|
||||
$results = $qb->getQuery()->getResult();
|
||||
$artistNeedle = $artist ?? $freeText;
|
||||
return $this->filterByArtistAndLimit($results, $artistNeedle, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert based on a Spotify album payload.
|
||||
* Filters persisted Spotify albums before falling back to the API.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->where('a.source = :src')
|
||||
->setParameter('src', 'spotify')
|
||||
->setMaxResults($limit * 2);
|
||||
if ($freeText !== null && $freeText !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :qName')
|
||||
->setParameter('qName', '%' . mb_strtolower($freeText) . '%');
|
||||
}
|
||||
if ($albumName !== null && $albumName !== '') {
|
||||
$qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%');
|
||||
}
|
||||
if ($yearFrom > 0 || $yearTo > 0) {
|
||||
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
|
||||
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt")
|
||||
->setParameter('yf', (string) $yearFrom)
|
||||
->setParameter('yt', (string) $yearTo);
|
||||
} else {
|
||||
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
|
||||
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y);
|
||||
}
|
||||
}
|
||||
$results = $qb->getQuery()->getResult();
|
||||
return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts data from a Spotify album payload and keeps DB entities in sync.
|
||||
*
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
*/
|
||||
@@ -131,9 +162,36 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
$album->setCoverUrl($coverUrl);
|
||||
$album->setExternalUrl($external);
|
||||
$em->persist($album);
|
||||
// flush outside for batching
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array
|
||||
{
|
||||
if ($needle === null || trim($needle) === '') {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
$needle = mb_strtolower(trim($needle));
|
||||
$filtered = [];
|
||||
foreach ($albums as $album) {
|
||||
foreach ($album->getArtists() as $artist) {
|
||||
if (str_contains(mb_strtolower($artist), $needle)) {
|
||||
$filtered[] = $album;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (count($filtered) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($filtered === []) {
|
||||
return array_slice($albums, 0, $limit);
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,16 +7,23 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* ReviewRepository streamlines review lookups and aggregate queries.
|
||||
*
|
||||
* @extends ServiceEntityRepository<Review>
|
||||
*/
|
||||
class ReviewRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Wires Doctrine's registry to the repository.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Review::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the newest reviews limited to the requested size.
|
||||
*
|
||||
* @return list<Review>
|
||||
*/
|
||||
public function findLatest(int $limit = 20): array
|
||||
@@ -29,7 +36,7 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Return aggregates for albums by Spotify IDs: [spotifyId => {count, avg}].
|
||||
* Aggregates review counts and averages for Spotify IDs.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
@@ -57,7 +64,7 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates keyed by album entity id.
|
||||
* Aggregates review counts and averages for album entity IDs.
|
||||
*
|
||||
* @param list<int> $albumEntityIds
|
||||
* @return array<int,array{count:int,avg:float}>
|
||||
|
||||
@@ -6,19 +6,31 @@ use App\Entity\Setting;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* SettingRepository provides helper accessors for app configuration storage.
|
||||
*/
|
||||
class SettingRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Injects the Doctrine registry reference.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Setting::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a setting value falling back to the supplied default.
|
||||
*/
|
||||
public function getValue(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$setting = $this->findOneBy(['name' => $name]);
|
||||
return $setting?->getValue() ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the supplied configuration value.
|
||||
*/
|
||||
public function setValue(string $name, ?string $value): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
@@ -7,15 +7,23 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* UserRepository handles account lookups and helpers.
|
||||
*
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*/
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Registers the repository with Doctrine.
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a user by case-insensitive email.
|
||||
*/
|
||||
public function findOneByEmail(string $email): ?User
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
|
||||
@@ -7,16 +7,25 @@ use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* ReviewVoter grants edit/delete access to review owners or admins.
|
||||
*/
|
||||
class ReviewVoter extends Voter
|
||||
{
|
||||
public const EDIT = 'REVIEW_EDIT';
|
||||
public const DELETE = 'REVIEW_DELETE';
|
||||
|
||||
/**
|
||||
* Ensures this voter only evaluates review edit/delete attributes.
|
||||
*/
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Review;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants access to admins or the review author.
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
58
src/Service/ImageStorage.php
Normal file
58
src/Service/ImageStorage.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class ImageStorage
|
||||
{
|
||||
private Filesystem $fs;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
private readonly SluggerInterface $slugger
|
||||
) {
|
||||
$this->fs = new Filesystem();
|
||||
}
|
||||
|
||||
public function storeProfileImage(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'avatars');
|
||||
}
|
||||
|
||||
public function storeAlbumCover(UploadedFile $file): string
|
||||
{
|
||||
return $this->store($file, 'album_covers');
|
||||
}
|
||||
|
||||
public function remove(?string $webPath): void
|
||||
{
|
||||
if ($webPath === null || $webPath === '') {
|
||||
return;
|
||||
}
|
||||
$path = $this->projectDir . '/public' . $webPath;
|
||||
if ($this->fs->exists($path)) {
|
||||
$this->fs->remove($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function store(UploadedFile $file, string $subDirectory): string
|
||||
{
|
||||
$targetDir = $this->projectDir . '/public/uploads/' . $subDirectory;
|
||||
if (!$this->fs->exists($targetDir)) {
|
||||
$this->fs->mkdir($targetDir);
|
||||
}
|
||||
|
||||
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$safeName = $this->slugger->slug($originalName ?: 'image');
|
||||
$extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
|
||||
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension);
|
||||
|
||||
$file->move($targetDir, $filename);
|
||||
|
||||
return '/uploads/' . $subDirectory . '/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* SpotifyClient wraps API calls with caching and error handling.
|
||||
*/
|
||||
class SpotifyClient
|
||||
{
|
||||
private HttpClientInterface $httpClient;
|
||||
@@ -14,10 +15,10 @@ class SpotifyClient
|
||||
private ?string $clientId;
|
||||
private ?string $clientSecret;
|
||||
private SettingRepository $settings;
|
||||
private int $rateWindowSeconds;
|
||||
private int $rateMaxRequests;
|
||||
private int $rateMaxRequestsSensitive;
|
||||
|
||||
/**
|
||||
* Builds the client with HTTP, cache, and configuration dependencies.
|
||||
*/
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
CacheInterface $cache,
|
||||
@@ -30,10 +31,6 @@ class SpotifyClient
|
||||
$this->clientId = $clientId;
|
||||
$this->clientSecret = $clientSecret;
|
||||
$this->settings = $settings;
|
||||
// Allow tuning via env vars; fallback to conservative defaults
|
||||
$this->rateWindowSeconds = (int) (getenv('SPOTIFY_RATE_WINDOW_SECONDS') ?: 30);
|
||||
$this->rateMaxRequests = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS') ?: 50);
|
||||
$this->rateMaxRequestsSensitive = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE') ?: 20);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +53,7 @@ class SpotifyClient
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ],
|
||||
];
|
||||
return $this->sendRequest('GET', $url, $options, 600, false);
|
||||
return $this->sendRequest('GET', $url, $options, 600);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +71,7 @@ class SpotifyClient
|
||||
$url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId);
|
||||
$options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600, false);
|
||||
return $this->sendRequest('GET', $url, $options, 3600);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
@@ -97,19 +94,19 @@ class SpotifyClient
|
||||
'query' => [ 'ids' => implode(',', $albumIds) ],
|
||||
];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600, false);
|
||||
return $this->sendRequest('GET', $url, $options, 3600);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized request with basic throttling, caching and 429 handling.
|
||||
* Centralized request helper with lightweight caching.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
|
||||
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array
|
||||
{
|
||||
$cacheKey = null;
|
||||
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
|
||||
@@ -124,63 +121,20 @@ class SpotifyClient
|
||||
}
|
||||
}
|
||||
|
||||
$this->throttle($sensitive);
|
||||
|
||||
$attempts = 0;
|
||||
while (true) {
|
||||
++$attempts;
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status === 429) {
|
||||
$retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1);
|
||||
$retryAfter = max(1, min(30, $retryAfter));
|
||||
sleep($retryAfter);
|
||||
if ($attempts < 3) { continue; }
|
||||
}
|
||||
|
||||
$data = $response->toArray(false);
|
||||
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
|
||||
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
return $data;
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$data = $response->toArray(false);
|
||||
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
|
||||
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
|
||||
$item->expiresAfter($cacheTtlSeconds);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function throttle(bool $sensitive): void
|
||||
{
|
||||
$windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate';
|
||||
$max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests;
|
||||
$now = time();
|
||||
$entry = $this->cache->get($windowKey, function($item) use ($now) {
|
||||
$item->expiresAfter($this->rateWindowSeconds);
|
||||
return ['start' => $now, 'count' => 0];
|
||||
});
|
||||
if (!is_array($entry) || !isset($entry['start'], $entry['count'])) {
|
||||
$entry = ['start' => $now, 'count' => 0];
|
||||
}
|
||||
$start = (int) $entry['start'];
|
||||
$count = (int) $entry['count'];
|
||||
$elapsed = $now - $start;
|
||||
if ($elapsed >= $this->rateWindowSeconds) {
|
||||
$start = $now; $count = 0;
|
||||
}
|
||||
if ($count >= $max) {
|
||||
$sleep = max(1, $this->rateWindowSeconds - $elapsed);
|
||||
sleep($sleep);
|
||||
$start = time(); $count = 0;
|
||||
}
|
||||
$count++;
|
||||
$newEntry = ['start' => $start, 'count' => $count];
|
||||
$this->cache->get($windowKey, function($item) use ($newEntry) {
|
||||
$item->expiresAfter($this->rateWindowSeconds);
|
||||
return $newEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached access token or refreshes credentials when missing.
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
return $this->cache->get('spotify_client_credentials_token', function ($item) {
|
||||
|
||||
@@ -22,11 +22,14 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Site dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site settings</a></li>
|
||||
<li><h6 class="dropdown-header">Site</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Site Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><h6 class="dropdown-header">User</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ path('account_dashboard') }}">Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('account_profile') }}">Profile</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('account_settings') }}">Settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ path('app_logout') }}">Logout</a></li>
|
||||
|
||||
@@ -34,17 +34,27 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Profile</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border" width="72" height="72" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white" style="width:72px;height:72px;">
|
||||
<span class="fw-semibold">{{ (displayName ?? email)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" value="{{ email }}" readonly />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Display name</label>
|
||||
<input class="form-control" value="{{ displayName }}" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-outline-primary me-2" href="{{ path('account_profile') }}">Edit profile</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ path('account_password') }}">Change password</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,9 +67,15 @@
|
||||
<h2 class="h6 mb-3">Your reviews</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for r in userReviews %}
|
||||
<div>
|
||||
<div><a href="{{ path('review_show', {id: r.id}) }}" class="text-decoration-none">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-2">
|
||||
<div><a href="{{ path('review_show', {id: r.id}) }}" class="text-decoration-none">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
<form method="post" action="{{ path('review_delete', {id: r.id}) }}" onsubmit="return confirm('Delete this review?');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ r.id) }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">You haven't written any reviews yet.</div>
|
||||
@@ -74,13 +90,17 @@
|
||||
<h2 class="h6 mb-3">Your albums</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for a in userAlbums %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-2">
|
||||
<div><a href="{{ path('album_show', {id: a.localId}) }}" class="text-decoration-none">{{ a.name }}</a></div>
|
||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}</div>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: a.localId}) }}">Edit</a>
|
||||
<form method="post" action="{{ path('album_delete', {id: a.localId}) }}" onsubmit="return confirm('Delete this album?');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ a.localId) }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
46
templates/account/profile.html.twig
Normal file
46
templates/account/profile.html.twig
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit Profile{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Edit profile</h1>
|
||||
{% for msg in app.flashes('success') %}
|
||||
<div class="alert alert-success">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="h6 mb-3">Current picture</h2>
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border mb-3" width="160" height="160" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center mb-3" style="width:160px;height:160px;">
|
||||
<span class="fs-3">{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-secondary small mb-0">Images up to 4MB. JPG or PNG recommended.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="mb-3">{{ form_row(form.email) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.displayName) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.profileImage) }}</div>
|
||||
<hr>
|
||||
<p class="text-secondary small mb-3">Password change is optional. Provide your current password only if you want to update it.</p>
|
||||
<div class="mb-3">{{ form_row(form.currentPassword) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.newPassword) }}</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('account_dashboard') }}">Cancel</a>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,9 +37,15 @@
|
||||
<h2 class="h6 mb-3">Latest reviews (50)</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for r in recentReviews %}
|
||||
<div>
|
||||
<div><a class="text-decoration-none" href="{{ path('review_show', {id: r.id}) }}">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-2">
|
||||
<div><a class="text-decoration-none" href="{{ path('review_show', {id: r.id}) }}">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
<form method="post" action="{{ path('review_delete', {id: r.id}) }}" onsubmit="return confirm('Delete this review?');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ r.id) }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">No reviews.</div>
|
||||
@@ -55,8 +61,8 @@
|
||||
<div class="vstack gap-2">
|
||||
{% for a in recentAlbums %}
|
||||
{% set publicId = a.source == 'user' ? a.localId : a.spotifyId %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-2">
|
||||
<div>
|
||||
{% if publicId %}
|
||||
<a class="text-decoration-none" href="{{ path('album_show', {id: publicId}) }}">{{ a.name }}</a>
|
||||
@@ -67,6 +73,12 @@
|
||||
</div>
|
||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
{% if publicId %}
|
||||
<form method="post" action="{{ path('album_delete', {id: publicId}) }}" onsubmit="return confirm('Delete this album?');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ publicId) }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">No albums.</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUrl) }}{{ form_widget(form.coverUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('album_show', {id: albumId}) }}">Cancel</a>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUrl) }}{{ form_widget(form.coverUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<button class="btn btn-success" type="submit">Create</button>
|
||||
{{ form_end(form) }}
|
||||
|
||||
Reference in New Issue
Block a user