I lowkey forgot to commit
This commit is contained in:
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
47
src/Command/PromoteAdminCommand.php
Normal file
47
src/Command/PromoteAdminCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
|
||||
class PromoteAdminCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$email = (string) $input->getArgument('email');
|
||||
$user = $this->users->findOneByEmail($email);
|
||||
if (!$user) {
|
||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
||||
$roles[] = 'ROLE_ADMIN';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
src/Controller/AccountController.php
Normal file
56
src/Controller/AccountController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\ProfileFormType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
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;
|
||||
|
||||
#[IsGranted('ROLE_USER')]
|
||||
class AccountController extends AbstractController
|
||||
{
|
||||
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])]
|
||||
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): 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/dashboard.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Profile updated.');
|
||||
return $this->redirectToRoute('account_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('account/dashboard.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
|
||||
public function settings(): Response
|
||||
{
|
||||
return $this->render('account/settings.html.twig');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/Controller/Admin/SiteSettingsController.php
Normal file
37
src/Controller/Admin/SiteSettingsController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\SiteSettingsType;
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SiteSettingsController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||
public function settings(Request $request, SettingRepository $settings): Response
|
||||
{
|
||||
$form = $this->createForm(SiteSettingsType::class);
|
||||
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
||||
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
||||
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
||||
$this->addFlash('success', 'Settings saved.');
|
||||
return $this->redirectToRoute('admin_settings');
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
src/Controller/AlbumController.php
Normal file
119
src/Controller/AlbumController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\SpotifyClient;
|
||||
use App\Entity\Review;
|
||||
use App\Form\ReviewType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class AlbumController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'album_search', methods: ['GET'])]
|
||||
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
|
||||
{
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
$albumName = trim($request->query->getString('album', ''));
|
||||
$artist = trim($request->query->getString('artist', ''));
|
||||
// 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 = [];
|
||||
$stats = [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$result = $spotifyClient->searchAlbums($q, 20);
|
||||
$albums = $result['albums']['items'] ?? [];
|
||||
if ($albums) {
|
||||
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
|
||||
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
|
||||
if ($ids) {
|
||||
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('album/search.html.twig', [
|
||||
'query' => $query,
|
||||
'album' => $albumName,
|
||||
'artist' => $artist,
|
||||
'year_from' => $yearFrom ?: '',
|
||||
'year_to' => $yearTo ?: '',
|
||||
'albums' => $albums,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
|
||||
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
|
||||
{
|
||||
$album = $spotifyClient->getAlbum($id);
|
||||
if ($album === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
|
||||
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
|
||||
$count = count($existing);
|
||||
$avg = 0.0;
|
||||
if ($count > 0) {
|
||||
$sum = 0;
|
||||
foreach ($existing as $rev) { $sum += (int) $rev->getRating(); }
|
||||
$avg = round($sum / $count, 1);
|
||||
}
|
||||
|
||||
// Pre-populate required album metadata before validation so entity constraints pass
|
||||
$review = new Review();
|
||||
$review->setSpotifyAlbumId($id);
|
||||
$review->setAlbumName($album['name'] ?? '');
|
||||
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
|
||||
|
||||
$form = $this->createForm(ReviewType::class, $review);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
$review->setAuthor($this->getUser());
|
||||
$em->persist($review);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Review added.');
|
||||
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||
}
|
||||
|
||||
return $this->render('album/show.html.twig', [
|
||||
'album' => $album,
|
||||
'albumId' => $id,
|
||||
'reviews' => $existing,
|
||||
'avg' => $avg,
|
||||
'count' => $count,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
src/Controller/RegistrationController.php
Normal file
62
src/Controller/RegistrationController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\RegistrationFormType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class RegistrationController extends AbstractController
|
||||
{
|
||||
#[Route('/register', name: 'app_register')]
|
||||
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
|
||||
{
|
||||
// For GET (non-XHR), redirect to home and let the modal open
|
||||
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
|
||||
return $this->redirectToRoute('album_search', ['auth' => 'register']);
|
||||
}
|
||||
$user = new User();
|
||||
|
||||
$form = $this->createForm(RegistrationFormType::class, $user);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||
$hashed = $passwordHasher->hashPassword($user, $plainPassword);
|
||||
$user->setPassword($hashed);
|
||||
|
||||
$entityManager->persist($user);
|
||||
$entityManager->flush();
|
||||
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return new JsonResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Account created. You can now sign in.');
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
// Flatten form errors for the modal
|
||||
$errors = [];
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$origin = $error->getOrigin();
|
||||
$name = $origin ? $origin->getName() : 'form';
|
||||
$errors[$name][] = $error->getMessage();
|
||||
}
|
||||
return new JsonResponse(['ok' => false, 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
return $this->render('registration/register.html.twig', [
|
||||
'registrationForm' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
86
src/Controller/ReviewController.php
Normal file
86
src/Controller/ReviewController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/reviews')]
|
||||
class ReviewController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'review_index', methods: ['GET'])]
|
||||
public function index(ReviewRepository $reviewRepository): Response
|
||||
{
|
||||
$reviews = $reviewRepository->findLatest(50);
|
||||
return $this->render('review/index.html.twig', [
|
||||
'reviews' => $reviews,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function new(Request $request): Response
|
||||
{
|
||||
$albumId = (string) $request->query->get('album_id', '');
|
||||
if ($albumId !== '') {
|
||||
return $this->redirectToRoute('album_show', ['id' => $albumId]);
|
||||
}
|
||||
$this->addFlash('info', 'Select an album first.');
|
||||
return $this->redirectToRoute('album_search');
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||
public function show(Review $review): Response
|
||||
{
|
||||
return $this->render('review/show.html.twig', [
|
||||
'review' => $review,
|
||||
]);
|
||||
}
|
||||
|
||||
#[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
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
|
||||
|
||||
$form = $this->createForm(ReviewType::class, $review);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Review updated.');
|
||||
return $this->redirectToRoute('review_show', ['id' => $review->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('review/edit.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'review' => $review,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
||||
if ($this->isCsrfTokenValid('delete_review_' . $review->getId(), (string) $request->request->get('_token'))) {
|
||||
$em->remove($review);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Review deleted.');
|
||||
}
|
||||
return $this->redirectToRoute('review_index');
|
||||
}
|
||||
|
||||
// fetchAlbumById no longer needed; album view handles retrieval and creation
|
||||
}
|
||||
|
||||
|
||||
32
src/Controller/SecurityController.php
Normal file
32
src/Controller/SecurityController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
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;
|
||||
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/login', name: 'app_login')]
|
||||
public function login(Request $request, AuthenticationUtils $authenticationUtils): 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.
|
||||
if ($request->isMethod('GET')) {
|
||||
return new RedirectResponse($this->generateUrl('album_search', ['auth' => 'login']));
|
||||
}
|
||||
return new Response(status: 204);
|
||||
}
|
||||
|
||||
#[Route('/logout', name: 'app_logout')]
|
||||
public function logout(): void
|
||||
{
|
||||
// Controller can be blank: it will be intercepted by the logout key on your firewall
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
88
src/Entity/Review.php
Normal file
88
src/Entity/Review.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ReviewRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
|
||||
#[ORM\Table(name: 'reviews')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Review
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?User $author = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64)]
|
||||
#[Assert\NotBlank]
|
||||
private string $spotifyAlbumId = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private string $albumName = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
private string $albumArtist = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 160)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 160)]
|
||||
private string $title = '';
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 20, max: 5000)]
|
||||
private string $content = '';
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
#[Assert\Range(min: 1, max: 10)]
|
||||
private int $rating = 5;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getAuthor(): ?User { return $this->author; }
|
||||
public function setAuthor(User $author): void { $this->author = $author; }
|
||||
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
|
||||
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
|
||||
public function getAlbumName(): string { return $this->albumName; }
|
||||
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
|
||||
public function getAlbumArtist(): string { return $this->albumArtist; }
|
||||
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
|
||||
public function 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; }
|
||||
}
|
||||
|
||||
|
||||
33
src/Entity/Setting.php
Normal file
33
src/Entity/Setting.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SettingRepository::class)]
|
||||
#[ORM\Table(name: 'settings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
|
||||
class Setting
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 100)]
|
||||
#[Assert\NotBlank]
|
||||
private string $name = '';
|
||||
|
||||
#[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; }
|
||||
}
|
||||
|
||||
|
||||
116
src/Entity/User.php
Normal file
116
src/Entity/User.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
private string $email = '';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = [];
|
||||
|
||||
/**
|
||||
* @var string The hashed password
|
||||
*/
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $password = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120)]
|
||||
private ?string $displayName = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): void
|
||||
{
|
||||
$this->email = strtolower($email);
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function setRoles(array $roles): void
|
||||
{
|
||||
$this->roles = array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
public function addRole(string $role): void
|
||||
{
|
||||
$roles = $this->getRoles();
|
||||
if (!in_array($role, $roles, true)) {
|
||||
$roles[] = $role;
|
||||
}
|
||||
$this->roles = $roles;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $hashedPassword): void
|
||||
{
|
||||
$this->password = $hashedPassword;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function getDisplayName(): ?string
|
||||
{
|
||||
return $this->displayName;
|
||||
}
|
||||
|
||||
public function setDisplayName(?string $displayName): void
|
||||
{
|
||||
$this->displayName = $displayName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
src/Form/ProfileFormType.php
Normal file
51
src/Form/ProfileFormType.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
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\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ProfileFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('email', EmailType::class, [
|
||||
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
|
||||
])
|
||||
->add('displayName', TextType::class, [
|
||||
'required' => false,
|
||||
'constraints' => [new Assert\Length(max: 120)],
|
||||
])
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'Current password (required to change password)'
|
||||
])
|
||||
->add('newPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'first_options' => ['label' => 'New password (optional)'],
|
||||
'second_options' => ['label' => 'Repeat new password'],
|
||||
'invalid_message' => 'The password fields must match.',
|
||||
'constraints' => [new Assert\Length(min: 8)],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => User::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
src/Form/RegistrationFormType.php
Normal file
53
src/Form/RegistrationFormType.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
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\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class RegistrationFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('email', EmailType::class, [
|
||||
'required' => true,
|
||||
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
|
||||
])
|
||||
->add('displayName', TextType::class, [
|
||||
'required' => false,
|
||||
'constraints' => [new Assert\Length(max: 120)],
|
||||
])
|
||||
->add('plainPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'mapped' => false,
|
||||
'first_options' => ['label' => 'Password'],
|
||||
'second_options' => ['label' => 'Repeat Password'],
|
||||
'invalid_message' => 'The password fields must match.',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(groups: ['registration']),
|
||||
new Assert\Length(min: 8, groups: ['registration']),
|
||||
],
|
||||
])
|
||||
->add('register', SubmitType::class, [
|
||||
'label' => 'Create account',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => User::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
src/Form/ReviewType.php
Normal file
46
src/Form/ReviewType.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Review;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RangeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ReviewType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('title', TextType::class, [
|
||||
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 160)],
|
||||
])
|
||||
->add('content', TextareaType::class, [
|
||||
'constraints' => [new Assert\NotBlank(), new Assert\Length(min: 20, max: 5000)],
|
||||
'attr' => ['rows' => 8],
|
||||
])
|
||||
->add('rating', RangeType::class, [
|
||||
'constraints' => [new Assert\Range(min: 1, max: 10)],
|
||||
'attr' => [
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
'step' => 1,
|
||||
'class' => 'form-range',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Review::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
src/Form/SiteSettingsType.php
Normal file
33
src/Form/SiteSettingsType.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SiteSettingsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('SPOTIFY_CLIENT_ID', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client ID',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client Secret',
|
||||
'mapped' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
src/Repository/ReviewRepository.php
Normal file
60
src/Repository/ReviewRepository.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Review;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Review>
|
||||
*/
|
||||
class ReviewRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Review::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Review>
|
||||
*/
|
||||
public function findLatest(int $limit = 20): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->orderBy('r.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return aggregates for albums: [albumId => ['count' => int, 'avg' => float]].
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
*/
|
||||
public function getAggregatesForAlbumIds(array $albumIds): array
|
||||
{
|
||||
if ($albumIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('r')
|
||||
->select('r.spotifyAlbumId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
|
||||
->where('r.spotifyAlbumId IN (:ids)')
|
||||
->setParameter('ids', $albumIds)
|
||||
->groupBy('r.spotifyAlbumId')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$avg = isset($row['avgRating']) ? round((float) $row['avgRating'], 1) : 0.0;
|
||||
$out[$row['albumId']] = ['count' => (int) $row['cnt'], 'avg' => $avg];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/Repository/SettingRepository.php
Normal file
32
src/Repository/SettingRepository.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Setting;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SettingRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Setting::class);
|
||||
}
|
||||
|
||||
public function getValue(string $name, ?string $default = null): ?string
|
||||
{
|
||||
$setting = $this->findOneBy(['name' => $name]);
|
||||
return $setting?->getValue() ?? $default;
|
||||
}
|
||||
|
||||
public function setValue(string $name, ?string $value): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$setting = $this->findOneBy(['name' => $name]) ?? (new Setting())->setName($name);
|
||||
$setting->setValue($value);
|
||||
$em->persist($setting);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
src/Repository/UserRepository.php
Normal file
29
src/Repository/UserRepository.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*/
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function findOneByEmail(string $email): ?User
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->andWhere('LOWER(u.email) = :email')
|
||||
->setParameter('email', strtolower($email))
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/Security/ReviewVoter.php
Normal file
37
src/Security/ReviewVoter.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
class ReviewVoter extends Voter
|
||||
{
|
||||
public const EDIT = 'REVIEW_EDIT';
|
||||
public const DELETE = 'REVIEW_DELETE';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Review;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var Review $review */
|
||||
$review = $subject;
|
||||
return $review->getAuthor()?->getId() === $user->getId();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
220
src/Service/SpotifyClient.php
Normal file
220
src/Service/SpotifyClient.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class SpotifyClient
|
||||
{
|
||||
private HttpClientInterface $httpClient;
|
||||
private CacheInterface $cache;
|
||||
private ?string $clientId;
|
||||
private ?string $clientSecret;
|
||||
private SettingRepository $settings;
|
||||
private int $rateWindowSeconds;
|
||||
private int $rateMaxRequests;
|
||||
private int $rateMaxRequestsSensitive;
|
||||
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
CacheInterface $cache,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
SettingRepository $settings
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->cache = $cache;
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Spotify albums by query string.
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function searchAlbums(string $query, int $limit = 12): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
if ($accessToken === null) {
|
||||
return ['albums' => ['items' => []]];
|
||||
}
|
||||
|
||||
$url = 'https://api.spotify.com/v1/search';
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ],
|
||||
];
|
||||
return $this->sendRequest('GET', $url, $options, 600, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single album by Spotify ID.
|
||||
*
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbum(string $albumId): ?array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId);
|
||||
$options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600, false);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple albums with one call.
|
||||
*
|
||||
* @param list<string> $albumIds
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbums(array $albumIds): ?array
|
||||
{
|
||||
if ($albumIds === []) { return []; }
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) { return null; }
|
||||
$url = 'https://api.spotify.com/v1/albums';
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'ids' => implode(',', $albumIds) ],
|
||||
];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 3600, false);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized request with basic throttling, caching and 429 handling.
|
||||
*
|
||||
* @param array<string,mixed> $options
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
|
||||
{
|
||||
$cacheKey = null;
|
||||
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
|
||||
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
|
||||
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
|
||||
// placeholder; we'll set item value explicitly below on miss
|
||||
$item->expiresAfter(1);
|
||||
return null;
|
||||
});
|
||||
if (is_array($cached) && !empty($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
return $this->cache->get('spotify_client_credentials_token', function ($item) {
|
||||
// Default to 1 hour, will adjust based on response
|
||||
$item->expiresAfter(3500);
|
||||
|
||||
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
|
||||
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret),
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => 'grant_type=client_credentials',
|
||||
]);
|
||||
|
||||
$data = $response->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($data['expires_in']) && is_int($data['expires_in'])) {
|
||||
$ttl = max(60, $data['expires_in'] - 60);
|
||||
$item->expiresAfter($ttl);
|
||||
}
|
||||
|
||||
return $data['access_token'];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user