Configured swagger-php for documentation at /docs, probably needs a refactor lol; paginated searchAlbums to fix limit bug with null filters.

This commit is contained in:
boris committed 2026-02-27 20:15:57 +00:00
1 parent 56dea097d6
commit c1b1c2f372
19 files changed
+824 -257

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ Notes:
- REST API under `/api/v1` for albums/reviews with JSON responses and proper HTTP verbs
- JWT authentication for protected API endpoints (`/api/v1/auth/login`) via LexikJWTAuthenticationBundle
- REST response handling via FOSRestBundle
- OpenAPI documentation via swagger-php at `/docs` (`/docs/openapi.json`)
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
- Auth modal (Login/Sign up) with remember‑me; no separate pages
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
+1
View File
@@ -12,6 +12,7 @@
"friendsofsymfony/rest-bundle": "^3.8",
"guzzlehttp/guzzle": "^7.9",
"lexik/jwt-authentication-bundle": "^3.1",
"zircote/swagger-php": "^4.10",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.5",
+2 -2
View File
@@ -10,8 +10,8 @@ services:
container_name: tonehaus
restart: unless-stopped
volumes:
- uploads:/var/www/html/public/uploads
- sqlite_data:/var/www/html/var/data
- ./public/uploads:/var/www/html/public/uploads
- ./var/data:/var/www/html/var/data
ports:
- "8085:80"
env_file:
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227174500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop legacy Spotify and unused album columns (created_by_id, cover_url, local_id, cover_image_path)';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite column drops/constraint rewrites are intentionally skipped in this migration.
return;
}
if ($this->hasTable($schema, 'albums')) {
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
}
if ($this->hasColumn($schema, 'albums', 'local_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
}
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
}
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
}
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
}
}
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
}
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
}
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
if ($this->hasTable($schema, 'albums')) {
if (!$this->hasColumn($schema, 'albums', 'spotify_id')) {
$this->addSql('ALTER TABLE albums ADD spotify_id VARCHAR(64) DEFAULT NULL');
}
if (!$this->hasColumn($schema, 'albums', 'local_id')) {
$this->addSql('ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL');
}
if (!$this->hasColumn($schema, 'albums', 'created_by_id')) {
$this->addSql('ALTER TABLE albums ADD created_by_id INT DEFAULT NULL');
}
if (!$this->hasColumn($schema, 'albums', 'cover_url')) {
$this->addSql('ALTER TABLE albums ADD cover_url VARCHAR(1024) DEFAULT NULL');
}
if (!$this->hasColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
}
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474FA905FC5C')) {
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
}
if (!$this->indexExists($schema, 'albums', 'UNIQ_F4E2474F5D5A2101')) {
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
}
if (!$this->indexExists($schema, 'albums', 'IDX_F4E2474FB03A8386')) {
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
}
$this->addSql('ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
}
if (!$this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
$this->addSql('ALTER TABLE album_tracks ADD spotify_track_id VARCHAR(64) DEFAULT NULL');
}
if (!$this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
}
}
private function hasTable(Schema $schema, string $tableName): bool
{
return $schema->hasTable($tableName);
}
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
}
private function indexExists(Schema $schema, string $tableName, string $indexName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasIndex($indexName);
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227181000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Apply SQLite-safe table rebuilds to remove legacy Spotify and unused album columns';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
$this->migrateSqlite();
return;
}
if ($this->hasTable($schema, 'albums')) {
$this->addSql('ALTER TABLE albums DROP CONSTRAINT IF EXISTS FK_F4E2474FB03A8386');
$this->addSql('DROP INDEX IF EXISTS IDX_F4E2474FB03A8386');
$this->addSql('DROP INDEX IF EXISTS uniq_album_local_id');
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474F5D5A2101');
$this->addSql('DROP INDEX IF EXISTS UNIQ_F4E2474FA905FC5C');
if ($this->hasColumn($schema, 'albums', 'created_by_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN created_by_id');
}
if ($this->hasColumn($schema, 'albums', 'local_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN local_id');
}
if ($this->hasColumn($schema, 'albums', 'cover_url')) {
$this->addSql('ALTER TABLE albums DROP COLUMN cover_url');
}
if ($this->hasColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums DROP COLUMN cover_image_path');
}
if ($this->hasColumn($schema, 'albums', 'spotify_id')) {
$this->addSql('ALTER TABLE albums DROP COLUMN spotify_id');
}
}
if ($this->hasColumn($schema, 'album_tracks', 'spotify_track_id')) {
$this->addSql('ALTER TABLE album_tracks DROP COLUMN spotify_track_id');
}
if ($this->hasColumn($schema, 'reviews', 'spotify_album_id')) {
$this->addSql('ALTER TABLE reviews DROP COLUMN spotify_album_id');
}
}
public function down(Schema $schema): void
{
// Forward-only migration: reverting would require data-loss-prone table rewrites.
}
private function migrateSqlite(): void
{
$this->addSql('PRAGMA foreign_keys = OFF');
$this->addSql(
"CREATE TABLE albums_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
external_id VARCHAR(64) DEFAULT NULL,
source VARCHAR(16) NOT NULL,
name VARCHAR(255) NOT NULL,
artists CLOB NOT NULL,
release_date VARCHAR(20) DEFAULT NULL,
total_tracks INTEGER NOT NULL,
external_url VARCHAR(1024) DEFAULT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
genres CLOB NOT NULL DEFAULT '[]'
)"
);
$this->addSql(
"INSERT INTO albums_new (
id, external_id, source, name, artists, release_date, total_tracks, external_url, created_at, updated_at, genres
)
SELECT
id,
CASE
WHEN external_id IS NOT NULL AND TRIM(external_id) <> '' THEN external_id
WHEN local_id IS NOT NULL AND TRIM(local_id) <> '' THEN local_id
ELSE 'legacy:' || id
END AS external_id,
CASE
WHEN source = 'spotify' OR source IS NULL OR TRIM(source) = '' THEN 'external'
ELSE source
END AS source,
name,
artists,
release_date,
total_tracks,
external_url,
created_at,
updated_at,
COALESCE(genres, '[]') AS genres
FROM albums"
);
$this->addSql('DROP TABLE albums');
$this->addSql('ALTER TABLE albums_new RENAME TO albums');
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_external_id ON albums (external_id)');
$this->addSql(
"CREATE TABLE album_tracks_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
album_id INTEGER NOT NULL,
external_track_id VARCHAR(64) DEFAULT NULL,
disc_number INTEGER NOT NULL,
track_number INTEGER NOT NULL,
name VARCHAR(512) NOT NULL,
duration_ms INTEGER NOT NULL,
preview_url VARCHAR(1024) DEFAULT NULL,
CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)"
);
$this->addSql(
"INSERT INTO album_tracks_new (
id, album_id, external_track_id, disc_number, track_number, name, duration_ms, preview_url
)
SELECT
id,
album_id,
CASE
WHEN external_track_id IS NOT NULL AND TRIM(external_track_id) <> '' THEN external_track_id
WHEN spotify_track_id IS NOT NULL AND TRIM(spotify_track_id) <> '' THEN spotify_track_id
ELSE NULL
END AS external_track_id,
disc_number,
track_number,
name,
duration_ms,
preview_url
FROM album_tracks"
);
$this->addSql('DROP TABLE album_tracks');
$this->addSql('ALTER TABLE album_tracks_new RENAME TO album_tracks');
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
$this->addSql('PRAGMA foreign_keys = ON');
}
private function hasTable(Schema $schema, string $tableName): bool
{
return $schema->hasTable($tableName);
}
private function hasColumn(Schema $schema, string $tableName, string $columnName): bool
{
return $this->hasTable($schema, $tableName) && $schema->getTable($tableName)->hasColumn($columnName);
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}
+13 -32
View File
@@ -5,9 +5,7 @@ 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;
@@ -23,9 +21,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Seeds the database with synthetic user-sourced albums.
*
* - Always marked as "user" source with a unique localId.
* - Include randomized names, artists, genres, release dates, and cover URLs.
* - Optionally link to existing users as creators when --attach-users is set.
* - Always marked as "user" source with a unique public ID.
* - Include randomized names, artists, genres, and release dates.
*/
class SeedDemoAlbumsCommand extends Command
{
@@ -46,7 +43,6 @@ class SeedDemoAlbumsCommand extends Command
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
@@ -54,59 +50,49 @@ class SeedDemoAlbumsCommand extends Command
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');
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40);
}
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;
// Track generated localIds so we never attempt to persist obvious duplicates.
$seenLocalIds = [];
// Track generated album IDs so we never attempt to persist obvious duplicates.
$seenAlbumIds = [];
while ($created < $count) {
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
$localId = $this->generateLocalId();
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
// Generate an ID that is unique in-memory and in the database to avoid constraint violations.
$albumId = $this->generateAlbumId();
if (isset($seenAlbumIds[$albumId]) || $this->albumRepository->findOneByExternalId($albumId) !== null) {
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
continue;
}
$album = new Album();
$album->setSource('user');
$album->setLocalId($localId);
$album->setExternalId($albumId);
$album->setName($this->generateAlbumName());
$album->setArtists($this->generateArtists());
$album->setGenres($this->generateGenres());
$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);
}
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $albumId));
$this->entityManager->persist($album);
$seenLocalIds[$localId] = true;
$seenAlbumIds[$albumId] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
$io->success(sprintf('Created %d demo albums.', $created));
return Command::SUCCESS;
}
private function generateLocalId(): string
private function generateAlbumId(): string
{
return 'demo_' . bin2hex(random_bytes(4));
}
@@ -160,9 +146,4 @@ class SeedDemoAlbumsCommand extends Command
return array_values(array_unique($genres));
}
private function generateCoverUrl(string $seed): string
{
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
}
}
+10 -11
View File
@@ -5,7 +5,6 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository;
use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -27,7 +26,7 @@ class AccountController extends AbstractController
* Summarizes the signed-in user's recent activity.
*/
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
public function dashboard(ReviewRepository $reviews): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -36,10 +35,10 @@ class AccountController extends AbstractController
->select('COUNT(r.id)')
->where('r.author = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$albumCount = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
$albumCount = (int) $reviews->createQueryBuilder('r')
->select('COUNT(DISTINCT a.id)')
->innerJoin('r.album', 'a')
->where('r.author = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
if ($this->isGranted('ROLE_ADMIN')) {
$userType = 'Admin';
@@ -53,10 +52,11 @@ class AccountController extends AbstractController
->orderBy('r.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
$userAlbums = $albums->createQueryBuilder('a')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->orderBy('a.createdAt', 'DESC')
$userAlbums = $reviews->createQueryBuilder('r')
->select('DISTINCT a')
->innerJoin('r.album', 'a')
->where('r.author = :u')->setParameter('u', $user)
->orderBy('r.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
@@ -124,4 +124,3 @@ class AccountController extends AbstractController
}
}
+42 -58
View File
@@ -5,22 +5,18 @@ namespace App\Controller;
use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Entity\Review;
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\UploadStorage;
use App\Service\LastFmClient;
use App\Service\LastFmTagResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -30,7 +26,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AlbumController extends AbstractController
{
public function __construct(
private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch,
private readonly LastFmTagResolver $genreResolver,
private readonly int $searchLimit = 20
@@ -74,16 +69,11 @@ class AlbumController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->normalizeAlbumFormData($album);
$user = $this->getUser();
if ($user instanceof User) {
$album->setCreatedBy($user);
}
$this->handleAlbumCoverUpload($album, $form);
$album->setLocalId($this->generateLocalId($albumRepo));
$album->setExternalId($this->generateUserAlbumId($albumRepo));
$em->persist($album);
$em->flush();
$this->addFlash('success', 'Album created.');
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
return $this->redirectToRoute('album_show', ['id' => $album->getExternalId()]);
}
return $this->render('album/new.html.twig', [
'form' => $form->createView(),
@@ -101,7 +91,7 @@ class AlbumController extends AbstractController
if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via Last.fm before rendering.
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
if ($externalAlbum === null) {
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
throw $this->createNotFoundException('Album not found');
}
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
@@ -150,7 +140,7 @@ class AlbumController extends AbstractController
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
'albumOwner' => $albumEntity->getCreatedBy(),
'albumOwner' => null,
'albumCreatedAt' => $albumEntity->getCreatedAt(),
'tracks' => $trackRows,
]);
@@ -170,7 +160,7 @@ class AlbumController extends AbstractController
$existing = $albumRepo->findOneByExternalId($id);
if (!$existing) {
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
if ($externalAlbum === null) {
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
throw $this->createNotFoundException('Album not found');
}
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
@@ -196,9 +186,6 @@ class AlbumController extends AbstractController
$album = $this->findAlbum($id, $albumRepo);
if ($album) {
$this->ensureCanManageAlbum($album);
if ($album->getSource() === 'user') {
$this->uploadStorage->remove($album->getCoverImagePath());
}
$em->remove($album);
$em->flush();
$this->addFlash('success', 'Album deleted.');
@@ -211,11 +198,11 @@ class AlbumController extends AbstractController
/**
* Generates a unique user album identifier.
*/
private function generateLocalId(AlbumRepository $albumRepo): string
private function generateUserAlbumId(AlbumRepository $albumRepo): string
{
do {
$id = 'u_' . bin2hex(random_bytes(6));
} while ($albumRepo->findOneByLocalId($id) !== null);
} while ($albumRepo->findOneByExternalId($id) !== null);
return $id;
}
@@ -267,7 +254,6 @@ class AlbumController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->normalizeAlbumFormData($album);
$this->handleAlbumCoverUpload($album, $form);
$em->flush();
$this->addFlash('success', 'Album updated.');
return $this->redirectToRoute('album_show', ['id' => $id]);
@@ -279,15 +265,10 @@ class AlbumController extends AbstractController
}
/**
* Looks up an album by either local or external identifier.
* Looks up an album by public identifier.
*/
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
{
$local = $albumRepo->findOneByLocalId($id);
if ($local instanceof Album) {
return $local;
}
return $albumRepo->findOneByExternalId($id);
}
@@ -296,10 +277,7 @@ class AlbumController extends AbstractController
*/
private function canManageAlbum(Album $album): bool
{
if ($this->isGranted('ROLE_MODERATOR')) {
return true;
}
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
return $this->isGranted('ROLE_MODERATOR');
}
/**
@@ -312,37 +290,20 @@ class AlbumController extends AbstractController
}
}
/**
* Checks whether the current user created the album.
*/
private function isAlbumOwner(Album $album): bool
{
$user = $this->getUser();
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
}
private function normalizeAlbumFormData(Album $album): void
{
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
}
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
{
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
return;
}
$file = $form->get('coverUpload')->getData();
if ($file instanceof UploadedFile) {
$this->uploadStorage->remove($album->getCoverImagePath());
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
}
}
/**
* @param array<string,mixed> $externalAlbum
*/
private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
{
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
throw new \InvalidArgumentException('Album payload must include a release date and at least one track.');
}
// Bring genres along when we persist external albums so templates can display them immediately.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
$albumId = (string) ($externalAlbum['id'] ?? '');
@@ -350,11 +311,9 @@ class AlbumController extends AbstractController
$externalAlbum,
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
);
$tracks = $externalAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) {
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
}
$album->setTotalTracks($album->getTracks()->count());
return $album;
}
@@ -377,7 +336,7 @@ class AlbumController extends AbstractController
return false;
}
$externalAlbum = $lastFmClient->getAlbumWithTracks($externalId);
if ($externalAlbum === null) {
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
return false;
}
// Rehydrate genres during syncs as well, in case source metadata has updated.
@@ -389,10 +348,35 @@ class AlbumController extends AbstractController
return false;
}
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
$album->setTotalTracks($album->getTracks()->count());
return true;
}
/**
* @param array<string,mixed> $externalAlbum
*/
private function isIngestableExternalAlbum(array $externalAlbum): bool
{
$releaseDate = trim((string) ($externalAlbum['release_date'] ?? ''));
if ($releaseDate === '') {
return false;
}
$tracks = $externalAlbum['tracks']['items'] ?? null;
if (!is_array($tracks) || $tracks === []) {
return false;
}
foreach ($tracks as $track) {
$name = trim((string) ($track['name'] ?? ''));
if ($name !== '') {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Controller\Api;
use OpenApi\Generator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ApiDocsController extends AbstractController
{
#[Route('/docs/openapi.json', name: 'api_docs_json', methods: ['GET'])]
public function openApiJson(): JsonResponse
{
$projectDir = (string) $this->getParameter('kernel.project_dir');
$openApi = Generator::scan([
$projectDir . '/src/OpenApi',
$projectDir . '/src/Controller/Api/V1',
]);
return new JsonResponse(
json_decode($openApi->toJson(), true, 512, JSON_THROW_ON_ERROR),
Response::HTTP_OK
);
}
#[Route('/docs', name: 'api_docs_ui', methods: ['GET'])]
public function ui(): Response
{
return $this->render('api/docs.html.twig', [
'specUrl' => $this->generateUrl('api_docs_json'),
]);
}
}
+114 -16
View File
@@ -12,6 +12,7 @@ use App\Service\LastFmClient;
use App\Service\LastFmTagResolver;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -33,6 +34,29 @@ class AlbumApiController extends AbstractFOSRestController
}
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
#[OA\Get(
path: '/api/v1/albums',
operationId: 'api_v1_album_index',
summary: 'List albums from local catalog and Last.fm',
tags: ['Albums'],
parameters: [
new OA\Parameter(name: 'query', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'album', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'artist', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'genre', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'year_from', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'year_to', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'source', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['all', 'external', 'user'])),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Album list response',
content: new OA\JsonContent(type: 'object')
),
]
)]
public function index(Request $request): Response
{
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
@@ -52,6 +76,30 @@ class AlbumApiController extends AbstractFOSRestController
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
#[OA\Post(
path: '/api/v1/albums/ingest',
operationId: 'api_v1_album_ingest',
summary: 'Ingest an external album into local storage',
tags: ['Albums'],
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'external_id', type: 'string', nullable: true),
new OA\Property(property: 'query', type: 'string', nullable: true),
new OA\Property(property: 'artist', type: 'string', nullable: true),
new OA\Property(property: 'album', type: 'string', nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Album ingested', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 400, description: 'Invalid request', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 404, description: 'External album not found', content: new OA\JsonContent(type: 'object')),
]
)]
public function ingest(Request $request): Response
{
$payload = $this->readJsonPayload($request);
@@ -81,6 +129,9 @@ class AlbumApiController extends AbstractFOSRestController
if (!is_array($externalAlbum)) {
return $this->handleView($this->view(['error' => 'external_album_not_found'], Response::HTTP_NOT_FOUND));
}
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
return $this->handleView($this->view(['error' => 'album_missing_release_date_or_tracks'], Response::HTTP_UNPROCESSABLE_ENTITY));
}
$album = $this->persistExternalAlbumPayload($externalAlbum);
$this->entityManager->flush();
@@ -93,6 +144,19 @@ class AlbumApiController extends AbstractFOSRestController
}
#[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])]
#[OA\Get(
path: '/api/v1/albums/{id}',
operationId: 'api_v1_album_show',
summary: 'Get album details by public ID',
tags: ['Albums'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Album details', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
]
)]
public function show(string $id): Response
{
$album = $this->findAlbum($id);
@@ -101,26 +165,38 @@ class AlbumApiController extends AbstractFOSRestController
if (!is_array($externalAlbum)) {
return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND));
}
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
return $this->handleView($this->view(['error' => 'album_missing_release_date_or_tracks'], Response::HTTP_UNPROCESSABLE_ENTITY));
}
$album = $this->persistExternalAlbumPayload($externalAlbum);
$this->entityManager->flush();
}
$albumId = $album->getSource() === 'user' ? (string) $album->getLocalId() : (string) $album->getExternalId();
$stats = $album->getSource() === 'user'
? $this->reviewRepository->getAggregatesForAlbumEntityIds([(int) $album->getId()])
: $this->reviewRepository->getAggregatesForAlbumIds([$albumId]);
$albumId = (string) $album->getExternalId();
$stats = $albumId !== '' ? $this->reviewRepository->getAggregatesForAlbumIds([$albumId]) : [];
$payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album);
return $this->handleView($this->view([
'data' => $payload,
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
'stats' => $album->getSource() === 'user'
? ($stats[(int) $album->getId()] ?? ['count' => 0, 'avg' => 0.0])
: ($stats[$albumId] ?? ['count' => 0, 'avg' => 0.0]),
'stats' => $stats[$albumId] ?? ['count' => 0, 'avg' => 0.0],
], Response::HTTP_OK));
}
#[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])]
#[OA\Get(
path: '/api/v1/albums/{id}/reviews',
operationId: 'api_v1_album_reviews',
summary: 'List reviews for an album',
tags: ['Albums'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
]
)]
public function reviews(string $id): Response
{
$album = $this->findAlbum($id);
@@ -147,11 +223,6 @@ class AlbumApiController extends AbstractFOSRestController
private function findAlbum(string $id): ?Album
{
$local = $this->albumRepository->findOneByLocalId($id);
if ($local instanceof Album) {
return $local;
}
return $this->albumRepository->findOneByExternalId($id);
}
@@ -160,6 +231,10 @@ class AlbumApiController extends AbstractFOSRestController
*/
private function persistExternalAlbumPayload(array $externalAlbum): Album
{
if (!$this->isIngestableExternalAlbum($externalAlbum)) {
throw new \InvalidArgumentException('Album payload must include a release date and at least one track.');
}
$genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]);
$albumId = (string) ($externalAlbum['id'] ?? '');
@@ -168,15 +243,38 @@ class AlbumApiController extends AbstractFOSRestController
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
);
$tracks = $externalAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) {
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
$this->trackRepository->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
}
$album->setTotalTracks($album->getTracks()->count());
return $album;
}
/**
* @param array<string,mixed> $externalAlbum
*/
private function isIngestableExternalAlbum(array $externalAlbum): bool
{
$releaseDate = trim((string) ($externalAlbum['release_date'] ?? ''));
if ($releaseDate === '') {
return false;
}
$tracks = $externalAlbum['tracks']['items'] ?? null;
if (!is_array($tracks) || $tracks === []) {
return false;
}
foreach ($tracks as $track) {
$name = trim((string) ($track['name'] ?? ''));
if ($name !== '') {
return true;
}
}
return false;
}
/**
* @return array<string,mixed>|null
*/
@@ -3,6 +3,7 @@
namespace App\Controller\Api\V1;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -10,6 +11,19 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiIndexController extends AbstractFOSRestController
{
#[Route('', name: 'api_v1_index', methods: ['GET'])]
#[OA\Get(
path: '/api/v1',
operationId: 'api_v1_index',
summary: 'API index',
tags: ['Auth'],
responses: [
new OA\Response(
response: 200,
description: 'API metadata and available endpoints',
content: new OA\JsonContent(type: 'object')
),
]
)]
public function index(): Response
{
return $this->handleView($this->view([
+118 -6
View File
@@ -9,6 +9,7 @@ use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -25,6 +26,18 @@ class ReviewApiController extends AbstractFOSRestController
}
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
#[OA\Get(
path: '/api/v1/reviews',
operationId: 'api_v1_review_index',
summary: 'List latest reviews',
tags: ['Reviews'],
parameters: [
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 50)),
],
responses: [
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
]
)]
public function index(Request $request): Response
{
$limit = max(1, min(100, (int) $request->query->get('limit', 50)));
@@ -36,6 +49,18 @@ class ReviewApiController extends AbstractFOSRestController
}
#[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
#[OA\Get(
path: '/api/v1/reviews/{id}',
operationId: 'api_v1_review_show',
summary: 'Get a review by ID',
tags: ['Reviews'],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Review details', content: new OA\JsonContent(type: 'object')),
]
)]
public function show(Review $review): Response
{
return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK));
@@ -43,6 +68,31 @@ class ReviewApiController extends AbstractFOSRestController
#[Route('', name: 'api_v1_review_create', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
#[OA\Post(
path: '/api/v1/reviews',
operationId: 'api_v1_review_create',
summary: 'Create a review',
tags: ['Reviews'],
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['album_id', 'title', 'content', 'rating'],
properties: [
new OA\Property(property: 'album_id', type: 'string'),
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'content', type: 'string'),
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Review created', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')),
]
)]
public function create(Request $request): Response
{
$payload = $this->readJsonPayload($request);
@@ -88,6 +138,58 @@ class ReviewApiController extends AbstractFOSRestController
#[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])]
#[IsGranted('ROLE_USER')]
#[OA\Put(
path: '/api/v1/reviews/{id}',
operationId: 'api_v1_review_update_put',
summary: 'Replace a review',
tags: ['Reviews'],
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'content', type: 'string'),
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
]
)]
#[OA\Patch(
path: '/api/v1/reviews/{id}',
operationId: 'api_v1_review_update_patch',
summary: 'Partially update a review',
tags: ['Reviews'],
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'content', type: 'string'),
new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
]
)]
public function update(Review $review, Request $request): Response
{
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
@@ -131,6 +233,21 @@ class ReviewApiController extends AbstractFOSRestController
#[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])]
#[IsGranted('ROLE_USER')]
#[OA\Delete(
path: '/api/v1/reviews/{id}',
operationId: 'api_v1_review_delete',
summary: 'Delete a review',
tags: ['Reviews'],
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 204, description: 'Review deleted'),
new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')),
new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')),
]
)]
public function delete(Review $review): Response
{
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
@@ -161,7 +278,7 @@ class ReviewApiController extends AbstractFOSRestController
'display_name' => $review->getAuthor()?->getDisplayName(),
],
'album' => [
'id' => $album?->getSource() === 'user' ? $album?->getLocalId() : $album?->getExternalId(),
'id' => $album?->getExternalId(),
'name' => $album?->getName(),
'source' => $album?->getSource(),
],
@@ -174,11 +291,6 @@ class ReviewApiController extends AbstractFOSRestController
return null;
}
$local = $this->albumRepository->findOneByLocalId($albumId);
if ($local instanceof Album) {
return $local;
}
return $this->albumRepository->findOneByExternalId($albumId);
}
+3 -96
View File
@@ -3,7 +3,6 @@
namespace App\Entity;
use App\Entity\AlbumTrack;
use App\Entity\User;
use App\Repository\AlbumRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -27,14 +26,10 @@ class Album
#[ORM\Column(type: 'integer')]
private ?int $id = null;
// For externally sourced albums; null for user-created
// Public identifier used by APIs and UI.
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $externalId = null;
// Public identifier for user-created albums (e.g., "u_abc123"); null for external
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $localId = null;
// 'external' or 'user'
#[ORM\Column(type: 'string', length: 16)]
private string $source = 'external';
@@ -62,19 +57,9 @@ class Album
#[ORM\Column(type: 'integer')]
private int $totalTracks = 0;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $coverUrl = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $coverImagePath = null;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $externalUrl = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $createdBy = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt = null;
@@ -130,22 +115,6 @@ class Album
$this->externalId = $externalId;
}
/**
* Gets the local unique identifier for user-created albums.
*/
public function getLocalId(): ?string
{
return $this->localId;
}
/**
* Assigns the local identifier for user-created albums.
*/
public function setLocalId(?string $localId): void
{
$this->localId = $localId;
}
/**
* Returns the album source flag ("external" or "user").
*/
@@ -247,22 +216,6 @@ class Album
$this->totalTracks = $totalTracks;
}
/**
* Returns the preferred cover art URL.
*/
public function getCoverUrl(): ?string
{
return $this->coverUrl;
}
/**
* Sets the preferred cover art URL.
*/
public function setCoverUrl(?string $coverUrl): void
{
$this->coverUrl = $coverUrl;
}
/**
* Returns an external link.
*/
@@ -279,38 +232,6 @@ class Album
$this->externalUrl = $externalUrl;
}
/**
* Returns the user that created the album, when applicable.
*/
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
/**
* Returns the locally stored cover image path for user albums.
*/
public function getCoverImagePath(): ?string
{
return $this->coverImagePath;
}
/**
* Updates the locally stored cover image path for user albums.
*/
public function setCoverImagePath(?string $coverImagePath): void
{
$this->coverImagePath = $coverImagePath;
}
/**
* Sets the owner responsible for the album.
*/
public function setCreatedBy(?User $user): void
{
$this->createdBy = $user;
}
/**
* Returns the creation timestamp.
*/
@@ -334,30 +255,16 @@ class Album
*/
public function toTemplateArray(): array
{
$images = [];
$imageUrl = null;
if ($this->source === 'user' && $this->coverImagePath) {
$imageUrl = $this->coverImagePath;
} elseif ($this->source !== 'external') {
$imageUrl = $this->coverUrl;
}
if ($imageUrl) {
$images = [
['url' => $imageUrl],
['url' => $imageUrl],
];
}
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
$external = $this->externalUrl;
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->externalId;
$genres = $this->source === 'external'
? $this->normalizeTopGenreForOutput($this->genres)
: array_slice($this->genres, 0, 5);
return [
'id' => $publicId,
'id' => (string) $this->externalId,
'name' => $this->name,
'images' => $images,
'images' => [],
'artists' => $artists,
'genres' => $genres,
'release_date' => $this->releaseDate,
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\OpenApi;
use OpenApi\Attributes as OA;
#[OA\Info(
version: '1.0.0',
title: 'Tonehaus API',
description: 'REST API for albums, reviews, ingestion, and JWT authentication.'
)]
#[OA\Server(
url: '/',
description: 'Current application host'
)]
#[OA\SecurityScheme(
securityScheme: 'bearerAuth',
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
)]
#[OA\Tag(name: 'Auth', description: 'Authentication endpoints')]
#[OA\Tag(name: 'Albums', description: 'Album catalog and ingestion endpoints')]
#[OA\Tag(name: 'Reviews', description: 'Review read/write endpoints')]
#[OA\Post(
path: '/api/v1/auth/login',
operationId: 'api_v1_auth_login',
summary: 'Obtain JWT access token',
tags: ['Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['email', 'password'],
properties: [
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'JWT token response',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'token', type: 'string', example: 'eyJ...'),
]
)
),
new OA\Response(
response: 401,
description: 'Invalid credentials',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'code', type: 'integer', example: 401),
new OA\Property(property: 'message', type: 'string', example: 'Invalid credentials.'),
]
)
),
]
)]
final class OpenApiSpec
{
}
-10
View File
@@ -32,14 +32,6 @@ class AlbumRepository extends ServiceEntityRepository
return $this->findOneBy(['externalId' => $externalId]);
}
/**
* Finds one album by user-local identifier.
*/
public function findOneByLocalId(string $localId): ?Album
{
return $this->findOneBy(['localId' => $localId]);
}
/**
* Returns albums keyed by External identifiers for quick lookup.
*
@@ -184,8 +176,6 @@ class AlbumRepository extends ServiceEntityRepository
$album->setGenres($genres);
$album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks);
// External cover URLs should be fetched live from Last.fm at response time.
$album->setCoverUrl(null);
$album->setExternalUrl($external);
$em->persist($album);
return $album;
+1 -2
View File
@@ -58,8 +58,8 @@ class UserRepository extends ServiceEntityRepository
{
$rows = $this->createQueryBuilder('u')
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
->leftJoin(Album::class, 'a', 'WITH', 'a.createdBy = u')
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
->leftJoin(Album::class, 'a', 'WITH', 'a = r.album')
->groupBy('u.id')
->orderBy('u.email', 'ASC')
->getQuery()
@@ -79,4 +79,3 @@ class UserRepository extends ServiceEntityRepository
}, $rows);
}
}
+86 -23
View File
@@ -146,57 +146,120 @@ class AlbumSearchService
*/
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
{
$result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit);
$perPage = max($criteria->limit, 10);
$maxPages = 5;
$seenSearchIds = [];
$persistableIds = [];
$persistableLookup = [];
$pagesScanned = 0;
for ($page = 1; $page <= $maxPages && count($persistableIds) < $criteria->limit; $page++) {
$pagesScanned++;
$result = $this->lastFmClient->searchAlbums($externalQuery, $perPage, $page);
$searchItems = (array) ($result['albums']['items'] ?? []);
$this->logger->info('Album search results received', [
'query' => $externalQuery,
'items' => count($searchItems),
]);
if ($searchItems === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
break;
}
$ids = $this->extractExternalIds($searchItems);
$ids = array_values(array_filter($ids, function (string $id) use (&$seenSearchIds): bool {
if ($id === '' || isset($seenSearchIds[$id])) {
return false;
}
$seenSearchIds[$id] = true;
return true;
}));
if ($ids === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
continue;
}
$full = $this->lastFmClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : [];
if ($albumsPayload === []) {
$albumsPayload = $searchItems;
continue;
}
$genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload);
foreach ($albumsPayload as $payload) {
$albumId = (string) ($payload['id'] ?? '');
if ($albumId === '' || !$this->isIngestableExternalAlbum((array) $payload)) {
continue;
}
$this->albumRepository->upsertFromExternalAlbum(
(array) $payload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
$genresByAlbum[$albumId] ?? []
);
if (!isset($persistableLookup[$albumId])) {
$persistableLookup[$albumId] = true;
$persistableIds[] = $albumId;
}
}
}
$this->logger->info('External album ingestion candidate resolution complete', [
'query' => $externalQuery,
'requested_limit' => $criteria->limit,
'persistable_found' => count($persistableIds),
'pages_scanned' => $pagesScanned,
]);
if ($persistableIds === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$this->entityManager->flush();
$existing = $this->albumRepository->findByExternalIdsKeyed($ids);
$payloads = [];
foreach ($ids as $externalId) {
$existing = $this->albumRepository->findByExternalIdsKeyed($persistableIds);
$orderedIds = [];
foreach ($persistableIds as $externalId) {
if (isset($existing[$externalId])) {
$payloads[] = $existing[$externalId]->toTemplateArray();
$orderedIds[] = $externalId;
}
if (count($orderedIds) >= $criteria->limit) {
break;
}
}
$payloads = [];
foreach ($orderedIds as $externalId) {
$payloads[] = $existing[$externalId]->toTemplateArray();
}
$payloads = $this->hydrateExternalImages($payloads);
return [
'payloads' => $payloads,
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids),
'savedIds' => array_keys($existing),
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($orderedIds),
'savedIds' => $orderedIds,
];
}
/**
* @param array<string,mixed> $payload
*/
private function isIngestableExternalAlbum(array $payload): bool
{
$releaseDate = trim((string) ($payload['release_date'] ?? ''));
if ($releaseDate === '') {
return false;
}
$tracks = $payload['tracks']['items'] ?? null;
if (!is_array($tracks) || $tracks === []) {
return false;
}
foreach ($tracks as $track) {
$name = trim((string) ($track['name'] ?? ''));
if ($name !== '') {
return true;
}
}
return false;
}
/**
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/
@@ -221,7 +284,7 @@ class AlbumSearchService
return [
'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
'stats' => $this->mapUserStatsToAlbumIds($userAlbums, $userStats),
];
}
@@ -230,14 +293,14 @@ class AlbumSearchService
* @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}>
*/
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
private function mapUserStatsToAlbumIds(array $userAlbums, array $userStats): array
{
$mapped = [];
foreach ($userAlbums as $album) {
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
$mapped[$localId] = $userStats[$entityId];
$albumId = (string) $album->getExternalId();
if ($albumId !== '' && isset($userStats[$entityId])) {
$mapped[$albumId] = $userStats[$entityId];
}
}
+2 -1
View File
@@ -34,7 +34,7 @@ class LastFmClient
*
* @return array{albums:array{items:list<array<string,mixed>>}}
*/
public function searchAlbums(string $query, int $limit = 12): array
public function searchAlbums(string $query, int $limit = 12, int $page = 1): array
{
$apiKey = $this->getApiKey();
if ($apiKey === null || trim($query) === '') {
@@ -46,6 +46,7 @@ class LastFmClient
'method' => 'album.search',
'album' => $query,
'limit' => max(1, $limit),
'page' => max(1, $page),
], 600);
} catch (\Throwable) {
return ['albums' => ['items' => []]];
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tonehaus API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
window.ui = SwaggerUIBundle({
url: '{{ specUrl }}',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: 'StandaloneLayout'
});
</script>
</body>
</html>