wtf
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
This commit is contained in:
144
src/Command/SeedDemoAlbumsCommand.php
Normal file
144
src/Command/SeedDemoAlbumsCommand.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
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\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:seed-demo-albums',
|
||||
description: 'Create demo albums with randomized metadata for local development.'
|
||||
)]
|
||||
class SeedDemoAlbumsCommand extends Command
|
||||
{
|
||||
private const GENRES = [
|
||||
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
|
||||
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
|
||||
];
|
||||
|
||||
private const ADJECTIVES = [
|
||||
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
|
||||
];
|
||||
|
||||
private const NOUNS = [
|
||||
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
|
||||
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
||||
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$count = max(1, (int) $input->getOption('count'));
|
||||
$attachUsers = (bool) $input->getOption('attach-users');
|
||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
||||
|
||||
$created = 0;
|
||||
$seenLocalIds = [];
|
||||
|
||||
while ($created < $count) {
|
||||
$localId = $this->generateLocalId();
|
||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$album = new Album();
|
||||
$album->setSource('user');
|
||||
$album->setLocalId($localId);
|
||||
$album->setName($this->generateAlbumName());
|
||||
$album->setArtists($this->generateArtists());
|
||||
$album->setReleaseDate($this->generateReleaseDate());
|
||||
$album->setTotalTracks(random_int(6, 16));
|
||||
$album->setCoverUrl($this->generateCoverUrl($localId));
|
||||
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
|
||||
|
||||
if ($attachUsers && $users !== []) {
|
||||
/** @var User $user */
|
||||
$user = $users[array_rand($users)];
|
||||
$album->setCreatedBy($user);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($album);
|
||||
$seenLocalIds[$localId] = true;
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function generateLocalId(): string
|
||||
{
|
||||
return 'demo_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
private function generateAlbumName(): string
|
||||
{
|
||||
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
|
||||
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
|
||||
$genre = self::GENRES[random_int(0, count(self::GENRES) - 1)];
|
||||
|
||||
return sprintf('%s %s of %s', $adj, $noun, $genre);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function generateArtists(): array
|
||||
{
|
||||
$artists = [];
|
||||
$artistCount = random_int(1, 3);
|
||||
for ($i = 0; $i < $artistCount; $i++) {
|
||||
$artists[] = sprintf(
|
||||
'%s %s',
|
||||
self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)],
|
||||
self::NOUNS[random_int(0, count(self::NOUNS) - 1)]
|
||||
);
|
||||
}
|
||||
|
||||
return array_values(array_unique($artists));
|
||||
}
|
||||
|
||||
private function generateReleaseDate(): string
|
||||
{
|
||||
$year = random_int(1990, (int) date('Y'));
|
||||
$month = random_int(1, 12);
|
||||
$day = random_int(1, 28);
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
private function generateCoverUrl(string $seed): string
|
||||
{
|
||||
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
||||
}
|
||||
}
|
||||
|
||||
207
src/Command/SeedDemoReviewsCommand.php
Normal file
207
src/Command/SeedDemoReviewsCommand.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
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\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:seed-demo-reviews',
|
||||
description: 'Generate demo reviews across existing albums.'
|
||||
)]
|
||||
class SeedDemoReviewsCommand extends Command
|
||||
{
|
||||
private const SUBJECTS = [
|
||||
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
|
||||
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
|
||||
];
|
||||
|
||||
private const VERBS = [
|
||||
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
|
||||
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
|
||||
];
|
||||
|
||||
private const QUALIFIERS = [
|
||||
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
|
||||
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
|
||||
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10)
|
||||
->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1)
|
||||
->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60)
|
||||
->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$albums = $this->albumRepository->findAll();
|
||||
$users = $this->userRepository->findAll();
|
||||
|
||||
if ($albums === [] || $users === []) {
|
||||
$io->warning('Need at least one album and one user to seed reviews.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
||||
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
||||
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
||||
|
||||
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
||||
$onlyEmpty = (bool) $input->getOption('only-empty');
|
||||
|
||||
$created = 0;
|
||||
$processedAlbums = 0;
|
||||
foreach ($selectedAlbums as $album) {
|
||||
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
||||
continue;
|
||||
}
|
||||
$targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum));
|
||||
$created += $this->seedForAlbum($album, $users, $targetReviews);
|
||||
$processedAlbums++;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
if ($created === 0) {
|
||||
$io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<Album>
|
||||
*/
|
||||
private function selectAlbums(array $albums, int $coverPercent): array
|
||||
{
|
||||
if ($coverPercent >= 100) {
|
||||
return $albums;
|
||||
}
|
||||
|
||||
$selected = [];
|
||||
foreach ($albums as $album) {
|
||||
if (random_int(1, 100) <= $coverPercent) {
|
||||
$selected[] = $album;
|
||||
}
|
||||
}
|
||||
|
||||
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<User> $users
|
||||
*/
|
||||
private function seedForAlbum(Album $album, array $users, int $targetReviews): int
|
||||
{
|
||||
$created = 0;
|
||||
$existingAuthors = $this->fetchExistingAuthors($album);
|
||||
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
||||
|
||||
if ($availableUsers === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$targetReviews = min($targetReviews, count($availableUsers));
|
||||
shuffle($availableUsers);
|
||||
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
||||
|
||||
foreach ($selectedUsers as $user) {
|
||||
$review = new Review();
|
||||
$review->setAlbum($album);
|
||||
$review->setAuthor($user);
|
||||
$review->setRating(random_int(4, 10));
|
||||
$review->setTitle($this->generateTitle());
|
||||
$review->setContent($this->generateContent($album));
|
||||
|
||||
$this->entityManager->persist($review);
|
||||
$created++;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,bool>
|
||||
*/
|
||||
private function fetchExistingAuthors(Album $album): array
|
||||
{
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(r.author) AS authorId')
|
||||
->from(Review::class, 'r')
|
||||
->where('r.album = :album')
|
||||
->setParameter('album', $album);
|
||||
|
||||
$rows = $qb->getQuery()->getScalarResult();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(int) $row['authorId']] = true;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function albumHasReviews(Album $album): bool
|
||||
{
|
||||
$count = (int) $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(r.id)')
|
||||
->from(Review::class, 'r')
|
||||
->where('r.album = :album')
|
||||
->setParameter('album', $album)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
private function generateTitle(): string
|
||||
{
|
||||
$subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)];
|
||||
$verb = self::VERBS[random_int(0, count(self::VERBS) - 1)];
|
||||
|
||||
return sprintf('%s %s the vibe', $subject, $verb);
|
||||
}
|
||||
|
||||
private function generateContent(Album $album): string
|
||||
{
|
||||
$qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)];
|
||||
|
||||
return sprintf(
|
||||
'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.',
|
||||
$album->getName(),
|
||||
$qualifier,
|
||||
self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)],
|
||||
self::VERBS[random_int(0, count(self::VERBS) - 1)],
|
||||
self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
98
src/Command/SeedDemoUsersCommand.php
Normal file
98
src/Command/SeedDemoUsersCommand.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
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\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:seed-demo-users',
|
||||
description: 'Create demo users with random emails and display names.'
|
||||
)]
|
||||
class SeedDemoUsersCommand extends Command
|
||||
{
|
||||
private const FIRST_NAMES = [
|
||||
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
|
||||
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
|
||||
];
|
||||
|
||||
private const LAST_NAMES = [
|
||||
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
|
||||
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly UserRepository $userRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50)
|
||||
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$count = (int) $input->getOption('count');
|
||||
$count = $count > 0 ? $count : 50;
|
||||
$plainPassword = (string) $input->getOption('password');
|
||||
|
||||
$created = 0;
|
||||
$seenEmails = [];
|
||||
|
||||
while ($created < $count) {
|
||||
$email = $this->generateEmail();
|
||||
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->setEmail($email);
|
||||
$user->setDisplayName($this->generateDisplayName());
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$seenEmails[$email] = true;
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function generateEmail(): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(4));
|
||||
|
||||
return sprintf('demo+%s@example.com', $token);
|
||||
}
|
||||
|
||||
private function generateDisplayName(): string
|
||||
{
|
||||
$first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)];
|
||||
$last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)];
|
||||
|
||||
return sprintf('%s %s', $first, $last);
|
||||
}
|
||||
}
|
||||
|
||||
83
src/Command/SeedUserAvatarsCommand.php
Normal file
83
src/Command/SeedUserAvatarsCommand.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
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\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:seed-user-avatars',
|
||||
description: 'Assign generated profile images to existing users.'
|
||||
)]
|
||||
class SeedUserAvatarsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set')
|
||||
->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs')
|
||||
->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$overwrite = (bool) $input->getOption('overwrite');
|
||||
$style = (string) $input->getOption('style');
|
||||
$seedPrefix = (string) $input->getOption('seed-prefix');
|
||||
|
||||
$users = $this->userRepository->findAll();
|
||||
if ($users === []) {
|
||||
$io->warning('No users found.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($users as $user) {
|
||||
if (!$user instanceof User) {
|
||||
continue;
|
||||
}
|
||||
if (!$overwrite && $user->getProfileImagePath()) {
|
||||
continue;
|
||||
}
|
||||
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
||||
$updated++;
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$io->info('No avatars needed updating.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
$io->success(sprintf('Assigned avatars to %d user(s).', $updated));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
||||
{
|
||||
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
||||
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
||||
|
||||
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
||||
use App\Form\ReviewType;
|
||||
use App\Form\AlbumType;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\ImageStorage;
|
||||
@@ -89,20 +90,25 @@ 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 $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||
$isSaved = $albumEntity !== null;
|
||||
if (!$albumEntity) {
|
||||
$spotifyAlbum = $spotify->getAlbum($id);
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
} else {
|
||||
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
$albumCard = $albumEntity->toTemplateArray();
|
||||
$canManage = $this->canManageAlbum($albumEntity);
|
||||
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
|
||||
|
||||
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
||||
$count = count($existing);
|
||||
@@ -137,6 +143,9 @@ class AlbumController extends AbstractController
|
||||
'avg' => $avg,
|
||||
'count' => $count,
|
||||
'form' => $form->createView(),
|
||||
'albumOwner' => $albumEntity->getCreatedBy(),
|
||||
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||
'tracks' => $trackRows,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -145,7 +154,7 @@ class AlbumController extends AbstractController
|
||||
*/
|
||||
#[IsGranted('ROLE_USER')]
|
||||
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
|
||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||
{
|
||||
$token = (string) $request->request->get('_token');
|
||||
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
|
||||
@@ -153,11 +162,11 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||
if (!$existing) {
|
||||
$spotifyAlbum = $spotify->getAlbum($id);
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||
if ($spotifyAlbum === null) {
|
||||
throw $this->createNotFoundException('Album not found');
|
||||
}
|
||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Album saved.');
|
||||
} else {
|
||||
@@ -267,9 +276,12 @@ class AlbumController extends AbstractController
|
||||
*/
|
||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||
{
|
||||
return str_starts_with($id, 'u_')
|
||||
? $albumRepo->findOneByLocalId($id)
|
||||
: $albumRepo->findOneBySpotifyId($id);
|
||||
$local = $albumRepo->findOneByLocalId($id);
|
||||
if ($local instanceof Album) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
return $albumRepo->findOneBySpotifyId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,6 +346,51 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $spotifyAlbum
|
||||
*/
|
||||
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||
{
|
||||
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
if (is_array($tracks) && $tracks !== []) {
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
}
|
||||
return $album;
|
||||
}
|
||||
|
||||
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
|
||||
{
|
||||
if ($album->getSource() !== 'spotify') {
|
||||
return false;
|
||||
}
|
||||
$spotifyId = $album->getSpotifyId();
|
||||
if ($spotifyId === null) {
|
||||
return false;
|
||||
}
|
||||
$storedCount = $album->getTracks()->count();
|
||||
$needsSync = $storedCount === 0;
|
||||
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
||||
$needsSync = true;
|
||||
}
|
||||
if (!$needsSync) {
|
||||
return false;
|
||||
}
|
||||
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
|
||||
if ($spotifyAlbum === null) {
|
||||
return false;
|
||||
}
|
||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
||||
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||
if (!is_array($tracks) || $tracks === []) {
|
||||
return false;
|
||||
}
|
||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||
$album->setTotalTracks(count($tracks));
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Entity\AlbumTrack;
|
||||
use App\Entity\User;
|
||||
use App\Repository\AlbumRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
@@ -15,6 +18,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Album
|
||||
{
|
||||
#[ORM\OneToMany(mappedBy: 'album', targetEntity: AlbumTrack::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['discNumber' => 'ASC', 'trackNumber' => 'ASC'])]
|
||||
private Collection $tracks;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -68,6 +75,11 @@ class Album
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tracks = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes timestamps right before first persistence.
|
||||
*/
|
||||
@@ -324,6 +336,29 @@ class Album
|
||||
'source' => $this->source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AlbumTrack>
|
||||
*/
|
||||
public function getTracks(): Collection
|
||||
{
|
||||
return $this->tracks;
|
||||
}
|
||||
|
||||
public function addTrack(AlbumTrack $track): void
|
||||
{
|
||||
if (!$this->tracks->contains($track)) {
|
||||
$this->tracks->add($track);
|
||||
$track->setAlbum($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeTrack(AlbumTrack $track): void
|
||||
{
|
||||
if ($this->tracks->removeElement($track) && $track->getAlbum() === $this) {
|
||||
$track->setAlbum(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
140
src/Entity/AlbumTrack.php
Normal file
140
src/Entity/AlbumTrack.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AlbumTrackRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* AlbumTrack persists individual tracks fetched from Spotify.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
|
||||
#[ORM\Table(name: 'album_tracks')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_album_disc_track', columns: ['album_id', 'disc_number', 'track_number'])]
|
||||
class AlbumTrack
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Album::class, inversedBy: 'tracks')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Album $album = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $spotifyTrackId = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $discNumber = 1;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $trackNumber = 1;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 512)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $durationMs = 0;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||
private ?string $previewUrl = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAlbum(): ?Album
|
||||
{
|
||||
return $this->album;
|
||||
}
|
||||
|
||||
public function setAlbum(?Album $album): void
|
||||
{
|
||||
$this->album = $album;
|
||||
}
|
||||
|
||||
public function getSpotifyTrackId(): ?string
|
||||
{
|
||||
return $this->spotifyTrackId;
|
||||
}
|
||||
|
||||
public function setSpotifyTrackId(?string $spotifyTrackId): void
|
||||
{
|
||||
$this->spotifyTrackId = $spotifyTrackId;
|
||||
}
|
||||
|
||||
public function getDiscNumber(): int
|
||||
{
|
||||
return $this->discNumber;
|
||||
}
|
||||
|
||||
public function setDiscNumber(int $discNumber): void
|
||||
{
|
||||
$this->discNumber = max(1, $discNumber);
|
||||
}
|
||||
|
||||
public function getTrackNumber(): int
|
||||
{
|
||||
return $this->trackNumber;
|
||||
}
|
||||
|
||||
public function setTrackNumber(int $trackNumber): void
|
||||
{
|
||||
$this->trackNumber = max(1, $trackNumber);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getDurationMs(): int
|
||||
{
|
||||
return $this->durationMs;
|
||||
}
|
||||
|
||||
public function setDurationMs(int $durationMs): void
|
||||
{
|
||||
$this->durationMs = max(0, $durationMs);
|
||||
}
|
||||
|
||||
public function getPreviewUrl(): ?string
|
||||
{
|
||||
return $this->previewUrl;
|
||||
}
|
||||
|
||||
public function setPreviewUrl(?string $previewUrl): void
|
||||
{
|
||||
$this->previewUrl = $previewUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the track for template rendering.
|
||||
*
|
||||
* @return array{disc:int,track:int,name:string,duration_label:string,duration_seconds:int,preview_url:?string}
|
||||
*/
|
||||
public function toTemplateArray(): array
|
||||
{
|
||||
$seconds = (int) floor($this->durationMs / 1000);
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$remainingSeconds = $seconds % 60;
|
||||
|
||||
return [
|
||||
'disc' => $this->discNumber,
|
||||
'track' => $this->trackNumber,
|
||||
'name' => $this->name,
|
||||
'duration_label' => sprintf('%d:%02d', $minutes, $remainingSeconds),
|
||||
'duration_seconds' => $seconds,
|
||||
'preview_url' => $this->previewUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
src/Repository/AlbumTrackRepository.php
Normal file
75
src/Repository/AlbumTrackRepository.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\AlbumTrack;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* AlbumTrackRepository manages bulk track synchronization.
|
||||
*
|
||||
* @extends ServiceEntityRepository<AlbumTrack>
|
||||
*/
|
||||
class AlbumTrackRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AlbumTrack::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces an album's stored tracklist with the provided Spotify payload.
|
||||
*
|
||||
* @param list<array<string,mixed>> $trackPayloads
|
||||
*/
|
||||
public function replaceAlbumTracks(Album $album, array $trackPayloads): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
foreach ($album->getTracks()->toArray() as $existing) {
|
||||
if ($existing instanceof AlbumTrack) {
|
||||
$album->removeTrack($existing);
|
||||
}
|
||||
}
|
||||
|
||||
$position = 1;
|
||||
foreach ($trackPayloads as $payload) {
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$track = new AlbumTrack();
|
||||
$track->setAlbum($album);
|
||||
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null));
|
||||
$track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1));
|
||||
$track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position));
|
||||
$track->setName($name);
|
||||
$track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0)));
|
||||
$track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null));
|
||||
|
||||
$album->addTrack($track);
|
||||
$em->persist($track);
|
||||
$position++;
|
||||
}
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$string = trim((string) $value);
|
||||
return $string === '' ? null : $string;
|
||||
}
|
||||
|
||||
private function normalizePositiveInt(mixed $value): int
|
||||
{
|
||||
$int = (int) $value;
|
||||
return $int > 0 ? $int : 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,60 @@ class SpotifyClient
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches album metadata plus the full tracklist.
|
||||
*
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function getAlbumWithTracks(string $albumId): ?array
|
||||
{
|
||||
$album = $this->getAlbum($albumId);
|
||||
if ($album === null) {
|
||||
return null;
|
||||
}
|
||||
$tracks = $this->getAlbumTracks($albumId);
|
||||
if ($tracks !== []) {
|
||||
$album['tracks'] = $album['tracks'] ?? [];
|
||||
$album['tracks']['items'] = $tracks;
|
||||
$album['tracks']['total'] = count($tracks);
|
||||
$album['tracks']['limit'] = count($tracks);
|
||||
$album['tracks']['offset'] = 0;
|
||||
$album['tracks']['next'] = null;
|
||||
$album['tracks']['previous'] = null;
|
||||
}
|
||||
return $album;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the complete tracklist for an album.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function getAlbumTracks(string $albumId): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if ($accessToken === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$limit = 50;
|
||||
$offset = 0;
|
||||
do {
|
||||
$page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset);
|
||||
if ($page === null) {
|
||||
break;
|
||||
}
|
||||
$batch = (array) ($page['items'] ?? []);
|
||||
$items = array_merge($items, $batch);
|
||||
$offset += $limit;
|
||||
$total = isset($page['total']) ? (int) $page['total'] : null;
|
||||
$hasNext = isset($page['next']) && $page['next'] !== null;
|
||||
} while ($hasNext && ($total === null || $offset < $total));
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple albums with one call.
|
||||
*
|
||||
@@ -132,18 +188,38 @@ class SpotifyClient
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array
|
||||
{
|
||||
$url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId));
|
||||
$options = [
|
||||
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
|
||||
'query' => [ 'limit' => $limit, 'offset' => $offset ],
|
||||
];
|
||||
try {
|
||||
return $this->sendRequest('GET', $url, $options, 1200);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached access token or refreshes credentials when missing.
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
return $this->cache->get('spotify_client_credentials_token', function ($item) {
|
||||
// Default to 1 hour, will adjust based on response
|
||||
$cacheKey = 'spotify_client_credentials_token';
|
||||
$token = $this->cache->get($cacheKey, function (ItemInterface $item) {
|
||||
// Default to ~1 hour, adjusted after Spotify response
|
||||
$item->expiresAfter(3500);
|
||||
|
||||
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
|
||||
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
|
||||
$clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
|
||||
$clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
// surface the miss quickly so the cache can be recomputed on the next request
|
||||
$item->expiresAfter(60);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -158,6 +234,7 @@ class SpotifyClient
|
||||
$data = $response->toArray(false);
|
||||
|
||||
if (!isset($data['access_token'])) {
|
||||
$item->expiresAfter(60);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,6 +245,13 @@ class SpotifyClient
|
||||
|
||||
return $data['access_token'];
|
||||
});
|
||||
|
||||
if ($token === null) {
|
||||
// Remove failed entries so the next request retries instead of serving cached nulls.
|
||||
$this->cache->delete($cacheKey);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user