wtf
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s

This commit is contained in:
2025-11-28 02:00:11 +00:00
parent 1c98a634c3
commit dae8f3d999
35 changed files with 1510 additions and 82 deletions

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

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

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

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

View File

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

View File

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

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

View File

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