what the fuck
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m55s

This commit is contained in:
2025-11-27 20:03:12 +00:00
parent f15d9a9cfd
commit 054e970df9
36 changed files with 1434 additions and 363 deletions

View File

@@ -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%'

View 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');
}
}

View 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
View File

@@ -0,0 +1,3 @@
*
!.gitignore

View File

@@ -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');

View File

@@ -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
{

View File

@@ -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
]);
}
}

View File

@@ -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
]);
}
}

View File

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

View File

@@ -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
{

View File

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

View File

@@ -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
{

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -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([

View File

@@ -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([]);

View File

@@ -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([

View File

@@ -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([

View File

@@ -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([

View File

@@ -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([]);

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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')

View File

@@ -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();

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

View File

@@ -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,21 +121,7 @@ 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) {
@@ -148,39 +131,10 @@ class SpotifyClient
}
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) {

View File

@@ -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>

View File

@@ -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,10 +67,16 @@
<h2 class="h6 mb-3">Your reviews</h2>
<div class="vstack gap-2">
{% for r in userReviews %}
<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>
{% endfor %}
@@ -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 %}

View 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 %}

View File

@@ -37,10 +37,16 @@
<h2 class="h6 mb-3">Latest reviews (50)</h2>
<div class="vstack gap-2">
{% for r in recentReviews %}
<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>
{% endfor %}
@@ -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>

View File

@@ -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>

View File

@@ -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) }}