From c1b1c2f372a882ba2698a37137db3843912a34d6 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 27 Feb 2026 20:15:57 +0000 Subject: [PATCH] Configured swagger-php for documentation at /docs, probably needs a refactor lol; paginated searchAlbums to fix limit bug with null filters. --- README.md | 1 + composer.json | 1 + docker-compose.yml | 4 +- migrations/Version20260227174500.php | 122 +++++++++++++ migrations/Version20260227181000.php | 165 ++++++++++++++++++ src/Command/SeedDemoAlbumsCommand.php | 45 ++--- src/Controller/AccountController.php | 21 ++- src/Controller/AlbumController.php | 102 +++++------ src/Controller/Api/ApiDocsController.php | 35 ++++ src/Controller/Api/V1/AlbumApiController.php | 132 ++++++++++++-- src/Controller/Api/V1/ApiIndexController.php | 14 ++ src/Controller/Api/V1/ReviewApiController.php | 124 ++++++++++++- src/Entity/Album.php | 99 +---------- src/OpenApi/OpenApiSpec.php | 64 +++++++ src/Repository/AlbumRepository.php | 10 -- src/Repository/UserRepository.php | 3 +- src/Service/AlbumSearchService.php | 137 +++++++++++---- src/Service/LastFmClient.php | 3 +- templates/api/docs.html.twig | 31 ++++ 19 files changed, 840 insertions(+), 273 deletions(-) create mode 100644 migrations/Version20260227174500.php create mode 100644 migrations/Version20260227181000.php create mode 100644 src/Controller/Api/ApiDocsController.php create mode 100644 src/OpenApi/OpenApiSpec.php create mode 100644 templates/api/docs.html.twig diff --git a/README.md b/README.md index 98d19aa..a6e8e33 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/composer.json b/composer.json index b804df1..e2d5f4f 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docker-compose.yml b/docker-compose.yml index 87c096f..61bfea6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/migrations/Version20260227174500.php b/migrations/Version20260227174500.php new file mode 100644 index 0000000..96d8dd7 --- /dev/null +++ b/migrations/Version20260227174500.php @@ -0,0 +1,122 @@ +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'; + } +} + diff --git a/migrations/Version20260227181000.php b/migrations/Version20260227181000.php new file mode 100644 index 0000000..d671a2f --- /dev/null +++ b/migrations/Version20260227181000.php @@ -0,0 +1,165 @@ +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'; + } +} + diff --git a/src/Command/SeedDemoAlbumsCommand.php b/src/Command/SeedDemoAlbumsCommand.php index e9140d3..2e8bbc0 100644 --- a/src/Command/SeedDemoAlbumsCommand.php +++ b/src/Command/SeedDemoAlbumsCommand.php @@ -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); - } } - diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index fdc69c7..e7333f2 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -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 } } - diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index d59d849..e34a006 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -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 $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 !== []) { - $trackRepo->replaceAlbumTracks($album, $tracks); - $album->setTotalTracks(count($tracks)); - } + $tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : []; + $trackRepo->replaceAlbumTracks($album, $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 $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 $payload * @return array diff --git a/src/Controller/Api/ApiDocsController.php b/src/Controller/Api/ApiDocsController.php new file mode 100644 index 0000000..b3abbcd --- /dev/null +++ b/src/Controller/Api/ApiDocsController.php @@ -0,0 +1,35 @@ +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'), + ]); + } +} diff --git a/src/Controller/Api/V1/AlbumApiController.php b/src/Controller/Api/V1/AlbumApiController.php index 2841d2d..0e23974 100644 --- a/src/Controller/Api/V1/AlbumApiController.php +++ b/src/Controller/Api/V1/AlbumApiController.php @@ -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 !== []) { - $this->trackRepository->replaceAlbumTracks($album, $tracks); - $album->setTotalTracks(count($tracks)); - } + $tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : []; + $this->trackRepository->replaceAlbumTracks($album, $tracks); + $album->setTotalTracks($album->getTracks()->count()); return $album; } + /** + * @param array $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|null */ diff --git a/src/Controller/Api/V1/ApiIndexController.php b/src/Controller/Api/V1/ApiIndexController.php index 1c2af08..a1f9707 100644 --- a/src/Controller/Api/V1/ApiIndexController.php +++ b/src/Controller/Api/V1/ApiIndexController.php @@ -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([ diff --git a/src/Controller/Api/V1/ReviewApiController.php b/src/Controller/Api/V1/ReviewApiController.php index 8d4a5e5..4b3ac9b 100644 --- a/src/Controller/Api/V1/ReviewApiController.php +++ b/src/Controller/Api/V1/ReviewApiController.php @@ -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); } diff --git a/src/Entity/Album.php b/src/Entity/Album.php index 81922d2..46e35df 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -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, diff --git a/src/OpenApi/OpenApiSpec.php b/src/OpenApi/OpenApiSpec.php new file mode 100644 index 0000000..f8e5c6b --- /dev/null +++ b/src/OpenApi/OpenApiSpec.php @@ -0,0 +1,64 @@ +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; diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 875f0d4..265e7d2 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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); } } - diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php index 7d7b35e..b646c12 100644 --- a/src/Service/AlbumSearchService.php +++ b/src/Service/AlbumSearchService.php @@ -146,57 +146,120 @@ class AlbumSearchService */ private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array { - $result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit); - $searchItems = (array) ($result['albums']['items'] ?? []); + $perPage = max($criteria->limit, 10); + $maxPages = 5; + $seenSearchIds = []; + $persistableIds = []; + $persistableLookup = []; + $pagesScanned = 0; - $this->logger->info('Album search results received', [ + for ($page = 1; $page <= $maxPages && count($persistableIds) < $criteria->limit; $page++) { + $pagesScanned++; + $result = $this->lastFmClient->searchAlbums($externalQuery, $perPage, $page); + $searchItems = (array) ($result['albums']['items'] ?? []); + if ($searchItems === []) { + 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 === []) { + continue; + } + + $full = $this->lastFmClient->getAlbums($ids); + $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; + if ($albumsPayload === []) { + 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, + $genresByAlbum[$albumId] ?? [] + ); + + if (!isset($persistableLookup[$albumId])) { + $persistableLookup[$albumId] = true; + $persistableIds[] = $albumId; + } + } + } + + $this->logger->info('External album ingestion candidate resolution complete', [ 'query' => $externalQuery, - 'items' => count($searchItems), + 'requested_limit' => $criteria->limit, + 'persistable_found' => count($persistableIds), + 'pages_scanned' => $pagesScanned, ]); - if ($searchItems === []) { + if ($persistableIds === []) { return ['payloads' => [], 'stats' => [], 'savedIds' => []]; } - $ids = $this->extractExternalIds($searchItems); - if ($ids === []) { - return ['payloads' => [], 'stats' => [], 'savedIds' => []]; - } - - $full = $this->lastFmClient->getAlbums($ids); - $albumsPayload = is_array($full) ? ((array) ($full['albums'] ?? [])) : []; - if ($albumsPayload === []) { - $albumsPayload = $searchItems; - } - - $genresByAlbum = $this->tagResolver->resolveGenresForAlbums($albumsPayload); - - foreach ($albumsPayload as $payload) { - $albumId = (string) ($payload['id'] ?? ''); - $this->albumRepository->upsertFromExternalAlbum( - (array) $payload, - $albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : [] - ); - } - $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 $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>,stats:array} */ @@ -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 $userStats * @return array */ - 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]; } } diff --git a/src/Service/LastFmClient.php b/src/Service/LastFmClient.php index e856569..cc9e484 100644 --- a/src/Service/LastFmClient.php +++ b/src/Service/LastFmClient.php @@ -34,7 +34,7 @@ class LastFmClient * * @return array{albums:array{items:list>}} */ - 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' => []]]; diff --git a/templates/api/docs.html.twig b/templates/api/docs.html.twig new file mode 100644 index 0000000..a2d5e0c --- /dev/null +++ b/templates/api/docs.html.twig @@ -0,0 +1,31 @@ + + + + + + Tonehaus API Docs + + + + +
+ + + + +