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:
1 parent
56dea097d6
commit
c1b1c2f372
19 files changed
+840
-273
No files matched your search
@@ -46,6 +46,7 @@ Notes:
|
|||||||
- REST API under `/api/v1` for albums/reviews with JSON responses and proper HTTP verbs
|
- 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
|
- JWT authentication for protected API endpoints (`/api/v1/auth/login`) via LexikJWTAuthenticationBundle
|
||||||
- REST response handling via FOSRestBundle
|
- 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)
|
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
|
||||||
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
||||||
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"friendsofsymfony/rest-bundle": "^3.8",
|
"friendsofsymfony/rest-bundle": "^3.8",
|
||||||
"guzzlehttp/guzzle": "^7.9",
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
"lexik/jwt-authentication-bundle": "^3.1",
|
"lexik/jwt-authentication-bundle": "^3.1",
|
||||||
|
"zircote/swagger-php": "^4.10",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^3",
|
||||||
"doctrine/doctrine-bundle": "^2.18",
|
"doctrine/doctrine-bundle": "^2.18",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.5",
|
"doctrine/doctrine-migrations-bundle": "^3.5",
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ services:
|
|||||||
container_name: tonehaus
|
container_name: tonehaus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/var/www/html/public/uploads
|
- ./public/uploads:/var/www/html/public/uploads
|
||||||
- sqlite_data:/var/www/html/var/data
|
- ./var/data:/var/www/html/var/data
|
||||||
ports:
|
ports:
|
||||||
- "8085:80"
|
- "8085:80"
|
||||||
env_file:
|
env_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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,9 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Entity\Album;
|
use App\Entity\Album;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
use App\Repository\UserRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -23,9 +21,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
/**
|
/**
|
||||||
* Seeds the database with synthetic user-sourced albums.
|
* Seeds the database with synthetic user-sourced albums.
|
||||||
*
|
*
|
||||||
* - Always marked as "user" source with a unique localId.
|
* - Always marked as "user" source with a unique public ID.
|
||||||
* - Include randomized names, artists, genres, release dates, and cover URLs.
|
* - Include randomized names, artists, genres, and release dates.
|
||||||
* - Optionally link to existing users as creators when --attach-users is set.
|
|
||||||
*/
|
*/
|
||||||
class SeedDemoAlbumsCommand extends Command
|
class SeedDemoAlbumsCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -46,7 +43,6 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly AlbumRepository $albumRepository,
|
private readonly AlbumRepository $albumRepository,
|
||||||
private readonly UserRepository $userRepository,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -54,59 +50,49 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40);
|
||||||
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$count = max(1, (int) $input->getOption('count'));
|
$count = max(1, (int) $input->getOption('count'));
|
||||||
$attachUsers = (bool) $input->getOption('attach-users');
|
|
||||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
// Track generated localIds so we never attempt to persist obvious duplicates.
|
// Track generated album IDs so we never attempt to persist obvious duplicates.
|
||||||
$seenLocalIds = [];
|
$seenAlbumIds = [];
|
||||||
|
|
||||||
while ($created < $count) {
|
while ($created < $count) {
|
||||||
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
|
// Generate an ID that is unique in-memory and in the database to avoid constraint violations.
|
||||||
$localId = $this->generateLocalId();
|
$albumId = $this->generateAlbumId();
|
||||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
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.
|
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$album = new Album();
|
$album = new Album();
|
||||||
$album->setSource('user');
|
$album->setSource('user');
|
||||||
$album->setLocalId($localId);
|
$album->setExternalId($albumId);
|
||||||
$album->setName($this->generateAlbumName());
|
$album->setName($this->generateAlbumName());
|
||||||
$album->setArtists($this->generateArtists());
|
$album->setArtists($this->generateArtists());
|
||||||
$album->setGenres($this->generateGenres());
|
$album->setGenres($this->generateGenres());
|
||||||
$album->setReleaseDate($this->generateReleaseDate());
|
$album->setReleaseDate($this->generateReleaseDate());
|
||||||
$album->setTotalTracks(random_int(6, 16));
|
$album->setTotalTracks(random_int(6, 16));
|
||||||
$album->setCoverUrl($this->generateCoverUrl($localId));
|
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $albumId));
|
||||||
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
|
|
||||||
|
|
||||||
if ($attachUsers && $users !== []) {
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $users[array_rand($users)];
|
|
||||||
$album->setCreatedBy($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->persist($album);
|
$this->entityManager->persist($album);
|
||||||
$seenLocalIds[$localId] = true;
|
$seenAlbumIds[$albumId] = true;
|
||||||
$created++;
|
$created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$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;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateLocalId(): string
|
private function generateAlbumId(): string
|
||||||
{
|
{
|
||||||
return 'demo_' . bin2hex(random_bytes(4));
|
return 'demo_' . bin2hex(random_bytes(4));
|
||||||
}
|
}
|
||||||
@@ -160,9 +146,4 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
return array_values(array_unique($genres));
|
return array_values(array_unique($genres));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateCoverUrl(string $seed): string
|
|
||||||
{
|
|
||||||
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,6 @@ namespace App\Controller;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ProfileFormType;
|
use App\Form\ProfileFormType;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Repository\AlbumRepository;
|
|
||||||
use App\Service\UploadStorage;
|
use App\Service\UploadStorage;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -27,7 +26,7 @@ class AccountController extends AbstractController
|
|||||||
* Summarizes the signed-in user's recent activity.
|
* Summarizes the signed-in user's recent activity.
|
||||||
*/
|
*/
|
||||||
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
|
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
|
||||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
|
public function dashboard(ReviewRepository $reviews): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -36,10 +35,10 @@ class AccountController extends AbstractController
|
|||||||
->select('COUNT(r.id)')
|
->select('COUNT(r.id)')
|
||||||
->where('r.author = :u')->setParameter('u', $user)
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
$albumCount = (int) $albums->createQueryBuilder('a')
|
$albumCount = (int) $reviews->createQueryBuilder('r')
|
||||||
->select('COUNT(a.id)')
|
->select('COUNT(DISTINCT a.id)')
|
||||||
->where('a.source = :src')->setParameter('src', 'user')
|
->innerJoin('r.album', 'a')
|
||||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
if ($this->isGranted('ROLE_ADMIN')) {
|
if ($this->isGranted('ROLE_ADMIN')) {
|
||||||
$userType = 'Admin';
|
$userType = 'Admin';
|
||||||
@@ -53,10 +52,11 @@ class AccountController extends AbstractController
|
|||||||
->orderBy('r.createdAt', 'DESC')
|
->orderBy('r.createdAt', 'DESC')
|
||||||
->setMaxResults(10)
|
->setMaxResults(10)
|
||||||
->getQuery()->getResult();
|
->getQuery()->getResult();
|
||||||
$userAlbums = $albums->createQueryBuilder('a')
|
$userAlbums = $reviews->createQueryBuilder('r')
|
||||||
->where('a.source = :src')->setParameter('src', 'user')
|
->select('DISTINCT a')
|
||||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
->innerJoin('r.album', 'a')
|
||||||
->orderBy('a.createdAt', 'DESC')
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
|
->orderBy('r.createdAt', 'DESC')
|
||||||
->setMaxResults(10)
|
->setMaxResults(10)
|
||||||
->getQuery()->getResult();
|
->getQuery()->getResult();
|
||||||
|
|
||||||
@@ -124,4 +124,3 @@ class AccountController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,22 +5,18 @@ namespace App\Controller;
|
|||||||
use App\Dto\AlbumSearchCriteria;
|
use App\Dto\AlbumSearchCriteria;
|
||||||
use App\Entity\Album;
|
use App\Entity\Album;
|
||||||
use App\Entity\Review;
|
use App\Entity\Review;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Form\ReviewType;
|
use App\Form\ReviewType;
|
||||||
use App\Form\AlbumType;
|
use App\Form\AlbumType;
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
use App\Repository\AlbumTrackRepository;
|
use App\Repository\AlbumTrackRepository;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Service\AlbumSearchService;
|
use App\Service\AlbumSearchService;
|
||||||
use App\Service\UploadStorage;
|
|
||||||
use App\Service\LastFmClient;
|
use App\Service\LastFmClient;
|
||||||
use App\Service\LastFmTagResolver;
|
use App\Service\LastFmTagResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Form\FormInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -30,7 +26,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class AlbumController extends AbstractController
|
class AlbumController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UploadStorage $uploadStorage,
|
|
||||||
private readonly AlbumSearchService $albumSearch,
|
private readonly AlbumSearchService $albumSearch,
|
||||||
private readonly LastFmTagResolver $genreResolver,
|
private readonly LastFmTagResolver $genreResolver,
|
||||||
private readonly int $searchLimit = 20
|
private readonly int $searchLimit = 20
|
||||||
@@ -74,16 +69,11 @@ class AlbumController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->normalizeAlbumFormData($album);
|
$this->normalizeAlbumFormData($album);
|
||||||
$user = $this->getUser();
|
$album->setExternalId($this->generateUserAlbumId($albumRepo));
|
||||||
if ($user instanceof User) {
|
|
||||||
$album->setCreatedBy($user);
|
|
||||||
}
|
|
||||||
$this->handleAlbumCoverUpload($album, $form);
|
|
||||||
$album->setLocalId($this->generateLocalId($albumRepo));
|
|
||||||
$em->persist($album);
|
$em->persist($album);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album created.');
|
$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', [
|
return $this->render('album/new.html.twig', [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
@@ -101,7 +91,7 @@ class AlbumController extends AbstractController
|
|||||||
if (!$albumEntity) {
|
if (!$albumEntity) {
|
||||||
// Album has never been saved locally, so hydrate it via Last.fm before rendering.
|
// Album has never been saved locally, so hydrate it via Last.fm before rendering.
|
||||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
||||||
if ($externalAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
}
|
}
|
||||||
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
$albumEntity = $this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||||
@@ -150,7 +140,7 @@ class AlbumController extends AbstractController
|
|||||||
'avg' => $avg,
|
'avg' => $avg,
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
'albumOwner' => $albumEntity->getCreatedBy(),
|
'albumOwner' => null,
|
||||||
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||||
'tracks' => $trackRows,
|
'tracks' => $trackRows,
|
||||||
]);
|
]);
|
||||||
@@ -170,7 +160,7 @@ class AlbumController extends AbstractController
|
|||||||
$existing = $albumRepo->findOneByExternalId($id);
|
$existing = $albumRepo->findOneByExternalId($id);
|
||||||
if (!$existing) {
|
if (!$existing) {
|
||||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
$externalAlbum = $lastFmClient->getAlbumWithTracks($id);
|
||||||
if ($externalAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
}
|
}
|
||||||
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
$this->persistExternalAlbumPayload($externalAlbum, $albumRepo, $trackRepo);
|
||||||
@@ -196,9 +186,6 @@ class AlbumController extends AbstractController
|
|||||||
$album = $this->findAlbum($id, $albumRepo);
|
$album = $this->findAlbum($id, $albumRepo);
|
||||||
if ($album) {
|
if ($album) {
|
||||||
$this->ensureCanManageAlbum($album);
|
$this->ensureCanManageAlbum($album);
|
||||||
if ($album->getSource() === 'user') {
|
|
||||||
$this->uploadStorage->remove($album->getCoverImagePath());
|
|
||||||
}
|
|
||||||
$em->remove($album);
|
$em->remove($album);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album deleted.');
|
$this->addFlash('success', 'Album deleted.');
|
||||||
@@ -211,11 +198,11 @@ class AlbumController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* Generates a unique user album identifier.
|
* Generates a unique user album identifier.
|
||||||
*/
|
*/
|
||||||
private function generateLocalId(AlbumRepository $albumRepo): string
|
private function generateUserAlbumId(AlbumRepository $albumRepo): string
|
||||||
{
|
{
|
||||||
do {
|
do {
|
||||||
$id = 'u_' . bin2hex(random_bytes(6));
|
$id = 'u_' . bin2hex(random_bytes(6));
|
||||||
} while ($albumRepo->findOneByLocalId($id) !== null);
|
} while ($albumRepo->findOneByExternalId($id) !== null);
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +254,6 @@ class AlbumController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->normalizeAlbumFormData($album);
|
$this->normalizeAlbumFormData($album);
|
||||||
$this->handleAlbumCoverUpload($album, $form);
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album updated.');
|
$this->addFlash('success', 'Album updated.');
|
||||||
return $this->redirectToRoute('album_show', ['id' => $id]);
|
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
|
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||||
{
|
{
|
||||||
$local = $albumRepo->findOneByLocalId($id);
|
|
||||||
if ($local instanceof Album) {
|
|
||||||
return $local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $albumRepo->findOneByExternalId($id);
|
return $albumRepo->findOneByExternalId($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +277,7 @@ class AlbumController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private function canManageAlbum(Album $album): bool
|
private function canManageAlbum(Album $album): bool
|
||||||
{
|
{
|
||||||
if ($this->isGranted('ROLE_MODERATOR')) {
|
return $this->isGranted('ROLE_MODERATOR');
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
private function normalizeAlbumFormData(Album $album): void
|
||||||
{
|
{
|
||||||
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
$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
|
* @param array<string,mixed> $externalAlbum
|
||||||
*/
|
*/
|
||||||
private function persistExternalAlbumPayload(array $externalAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
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.
|
// Bring genres along when we persist external albums so templates can display them immediately.
|
||||||
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
$genresMap = $this->genreResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||||
$albumId = (string) ($externalAlbum['id'] ?? '');
|
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||||
@@ -350,11 +311,9 @@ class AlbumController extends AbstractController
|
|||||||
$externalAlbum,
|
$externalAlbum,
|
||||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||||
);
|
);
|
||||||
$tracks = $externalAlbum['tracks']['items'] ?? [];
|
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||||
if (is_array($tracks) && $tracks !== []) {
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
$album->setTotalTracks(count($tracks));
|
|
||||||
}
|
|
||||||
return $album;
|
return $album;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +336,7 @@ class AlbumController extends AbstractController
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$externalAlbum = $lastFmClient->getAlbumWithTracks($externalId);
|
$externalAlbum = $lastFmClient->getAlbumWithTracks($externalId);
|
||||||
if ($externalAlbum === null) {
|
if ($externalAlbum === null || !$this->isIngestableExternalAlbum($externalAlbum)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Rehydrate genres during syncs as well, in case source metadata has updated.
|
// Rehydrate genres during syncs as well, in case source metadata has updated.
|
||||||
@@ -389,10 +348,35 @@ class AlbumController extends AbstractController
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$trackRepo->replaceAlbumTracks($album, $tracks);
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
$album->setTotalTracks(count($tracks));
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
return true;
|
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
|
* @param array<string,mixed> $payload
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Service\LastFmClient;
|
|||||||
use App\Service\LastFmTagResolver;
|
use App\Service\LastFmTagResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -33,6 +34,29 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('', name: 'api_v1_album_index', methods: ['GET'])]
|
#[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
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||||
@@ -52,6 +76,30 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
|
|
||||||
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
|
#[Route('/ingest', name: 'api_v1_album_ingest', methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[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
|
public function ingest(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $this->readJsonPayload($request);
|
$payload = $this->readJsonPayload($request);
|
||||||
@@ -81,6 +129,9 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
if (!is_array($externalAlbum)) {
|
if (!is_array($externalAlbum)) {
|
||||||
return $this->handleView($this->view(['error' => 'external_album_not_found'], Response::HTTP_NOT_FOUND));
|
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);
|
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
@@ -93,6 +144,19 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/{id}', name: 'api_v1_album_show', methods: ['GET'])]
|
#[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
|
public function show(string $id): Response
|
||||||
{
|
{
|
||||||
$album = $this->findAlbum($id);
|
$album = $this->findAlbum($id);
|
||||||
@@ -101,26 +165,38 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
if (!is_array($externalAlbum)) {
|
if (!is_array($externalAlbum)) {
|
||||||
return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND));
|
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);
|
$album = $this->persistExternalAlbumPayload($externalAlbum);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
$albumId = $album->getSource() === 'user' ? (string) $album->getLocalId() : (string) $album->getExternalId();
|
$albumId = (string) $album->getExternalId();
|
||||||
$stats = $album->getSource() === 'user'
|
$stats = $albumId !== '' ? $this->reviewRepository->getAggregatesForAlbumIds([$albumId]) : [];
|
||||||
? $this->reviewRepository->getAggregatesForAlbumEntityIds([(int) $album->getId()])
|
|
||||||
: $this->reviewRepository->getAggregatesForAlbumIds([$albumId]);
|
|
||||||
$payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album);
|
$payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album);
|
||||||
|
|
||||||
return $this->handleView($this->view([
|
return $this->handleView($this->view([
|
||||||
'data' => $payload,
|
'data' => $payload,
|
||||||
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
'tracks' => array_map(static fn($track) => $track->toTemplateArray(), $album->getTracks()->toArray()),
|
||||||
'stats' => $album->getSource() === 'user'
|
'stats' => $stats[$albumId] ?? ['count' => 0, 'avg' => 0.0],
|
||||||
? ($stats[(int) $album->getId()] ?? ['count' => 0, 'avg' => 0.0])
|
|
||||||
: ($stats[$albumId] ?? ['count' => 0, 'avg' => 0.0]),
|
|
||||||
], Response::HTTP_OK));
|
], Response::HTTP_OK));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/{id}/reviews', name: 'api_v1_album_reviews', methods: ['GET'])]
|
#[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
|
public function reviews(string $id): Response
|
||||||
{
|
{
|
||||||
$album = $this->findAlbum($id);
|
$album = $this->findAlbum($id);
|
||||||
@@ -147,11 +223,6 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
|
|
||||||
private function findAlbum(string $id): ?Album
|
private function findAlbum(string $id): ?Album
|
||||||
{
|
{
|
||||||
$local = $this->albumRepository->findOneByLocalId($id);
|
|
||||||
if ($local instanceof Album) {
|
|
||||||
return $local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->albumRepository->findOneByExternalId($id);
|
return $this->albumRepository->findOneByExternalId($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +231,10 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
*/
|
*/
|
||||||
private function persistExternalAlbumPayload(array $externalAlbum): Album
|
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]);
|
$genresMap = $this->tagResolver->resolveGenresForAlbums([$externalAlbum]);
|
||||||
$albumId = (string) ($externalAlbum['id'] ?? '');
|
$albumId = (string) ($externalAlbum['id'] ?? '');
|
||||||
|
|
||||||
@@ -168,15 +243,38 @@ class AlbumApiController extends AbstractFOSRestController
|
|||||||
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
$tracks = $externalAlbum['tracks']['items'] ?? [];
|
$tracks = is_array($externalAlbum['tracks']['items'] ?? null) ? $externalAlbum['tracks']['items'] : [];
|
||||||
if (is_array($tracks) && $tracks !== []) {
|
$this->trackRepository->replaceAlbumTracks($album, $tracks);
|
||||||
$this->trackRepository->replaceAlbumTracks($album, $tracks);
|
$album->setTotalTracks($album->getTracks()->count());
|
||||||
$album->setTotalTracks(count($tracks));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $album;
|
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
|
* @return array<string,mixed>|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Controller\Api\V1;
|
namespace App\Controller\Api\V1;
|
||||||
|
|
||||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
@@ -10,6 +11,19 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
class ApiIndexController extends AbstractFOSRestController
|
class ApiIndexController extends AbstractFOSRestController
|
||||||
{
|
{
|
||||||
#[Route('', name: 'api_v1_index', methods: ['GET'])]
|
#[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
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->handleView($this->view([
|
return $this->handleView($this->view([
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Repository\AlbumRepository;
|
|||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -25,6 +26,18 @@ class ReviewApiController extends AbstractFOSRestController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
|
#[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
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$limit = max(1, min(100, (int) $request->query->get('limit', 50)));
|
$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'])]
|
#[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
|
public function show(Review $review): Response
|
||||||
{
|
{
|
||||||
return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK));
|
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'])]
|
#[Route('', name: 'api_v1_review_create', methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[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
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $this->readJsonPayload($request);
|
$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'])]
|
#[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[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
|
public function update(Review $review, Request $request): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
|
$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'])]
|
#[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[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
|
public function delete(Review $review): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
|
||||||
@@ -161,7 +278,7 @@ class ReviewApiController extends AbstractFOSRestController
|
|||||||
'display_name' => $review->getAuthor()?->getDisplayName(),
|
'display_name' => $review->getAuthor()?->getDisplayName(),
|
||||||
],
|
],
|
||||||
'album' => [
|
'album' => [
|
||||||
'id' => $album?->getSource() === 'user' ? $album?->getLocalId() : $album?->getExternalId(),
|
'id' => $album?->getExternalId(),
|
||||||
'name' => $album?->getName(),
|
'name' => $album?->getName(),
|
||||||
'source' => $album?->getSource(),
|
'source' => $album?->getSource(),
|
||||||
],
|
],
|
||||||
@@ -174,11 +291,6 @@ class ReviewApiController extends AbstractFOSRestController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$local = $this->albumRepository->findOneByLocalId($albumId);
|
|
||||||
if ($local instanceof Album) {
|
|
||||||
return $local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->albumRepository->findOneByExternalId($albumId);
|
return $this->albumRepository->findOneByExternalId($albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-96
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use App\Entity\AlbumTrack;
|
use App\Entity\AlbumTrack;
|
||||||
use App\Entity\User;
|
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -27,14 +26,10 @@ class Album
|
|||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
private ?int $id = null;
|
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)]
|
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||||
private ?string $externalId = null;
|
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'
|
// 'external' or 'user'
|
||||||
#[ORM\Column(type: 'string', length: 16)]
|
#[ORM\Column(type: 'string', length: 16)]
|
||||||
private string $source = 'external';
|
private string $source = 'external';
|
||||||
@@ -62,19 +57,9 @@ class Album
|
|||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
private int $totalTracks = 0;
|
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)]
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
private ?string $externalUrl = null;
|
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')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
@@ -130,22 +115,6 @@ class Album
|
|||||||
$this->externalId = $externalId;
|
$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").
|
* Returns the album source flag ("external" or "user").
|
||||||
*/
|
*/
|
||||||
@@ -247,22 +216,6 @@ class Album
|
|||||||
$this->totalTracks = $totalTracks;
|
$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.
|
* Returns an external link.
|
||||||
*/
|
*/
|
||||||
@@ -279,38 +232,6 @@ class Album
|
|||||||
$this->externalUrl = $externalUrl;
|
$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.
|
* Returns the creation timestamp.
|
||||||
*/
|
*/
|
||||||
@@ -334,30 +255,16 @@ class Album
|
|||||||
*/
|
*/
|
||||||
public function toTemplateArray(): array
|
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);
|
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
|
||||||
$external = $this->externalUrl;
|
$external = $this->externalUrl;
|
||||||
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->externalId;
|
|
||||||
$genres = $this->source === 'external'
|
$genres = $this->source === 'external'
|
||||||
? $this->normalizeTopGenreForOutput($this->genres)
|
? $this->normalizeTopGenreForOutput($this->genres)
|
||||||
: array_slice($this->genres, 0, 5);
|
: array_slice($this->genres, 0, 5);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $publicId,
|
'id' => (string) $this->externalId,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'images' => $images,
|
'images' => [],
|
||||||
'artists' => $artists,
|
'artists' => $artists,
|
||||||
'genres' => $genres,
|
'genres' => $genres,
|
||||||
'release_date' => $this->releaseDate,
|
'release_date' => $this->releaseDate,
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -32,14 +32,6 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
return $this->findOneBy(['externalId' => $externalId]);
|
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.
|
* Returns albums keyed by External identifiers for quick lookup.
|
||||||
*
|
*
|
||||||
@@ -184,8 +176,6 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
$album->setGenres($genres);
|
$album->setGenres($genres);
|
||||||
$album->setReleaseDate($releaseDate);
|
$album->setReleaseDate($releaseDate);
|
||||||
$album->setTotalTracks($totalTracks);
|
$album->setTotalTracks($totalTracks);
|
||||||
// External cover URLs should be fetched live from Last.fm at response time.
|
|
||||||
$album->setCoverUrl(null);
|
|
||||||
$album->setExternalUrl($external);
|
$album->setExternalUrl($external);
|
||||||
$em->persist($album);
|
$em->persist($album);
|
||||||
return $album;
|
return $album;
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
$rows = $this->createQueryBuilder('u')
|
$rows = $this->createQueryBuilder('u')
|
||||||
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
|
->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(Review::class, 'r', 'WITH', 'r.author = u')
|
||||||
|
->leftJoin(Album::class, 'a', 'WITH', 'a = r.album')
|
||||||
->groupBy('u.id')
|
->groupBy('u.id')
|
||||||
->orderBy('u.email', 'ASC')
|
->orderBy('u.email', 'ASC')
|
||||||
->getQuery()
|
->getQuery()
|
||||||
@@ -79,4 +79,3 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
}, $rows);
|
}, $rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,57 +146,120 @@ class AlbumSearchService
|
|||||||
*/
|
*/
|
||||||
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
|
private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array
|
||||||
{
|
{
|
||||||
$result = $this->lastFmClient->searchAlbums($externalQuery, $criteria->limit);
|
$perPage = max($criteria->limit, 10);
|
||||||
$searchItems = (array) ($result['albums']['items'] ?? []);
|
$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,
|
'query' => $externalQuery,
|
||||||
'items' => count($searchItems),
|
'requested_limit' => $criteria->limit,
|
||||||
|
'persistable_found' => count($persistableIds),
|
||||||
|
'pages_scanned' => $pagesScanned,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($searchItems === []) {
|
if ($persistableIds === []) {
|
||||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
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();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$existing = $this->albumRepository->findByExternalIdsKeyed($ids);
|
$existing = $this->albumRepository->findByExternalIdsKeyed($persistableIds);
|
||||||
$payloads = [];
|
$orderedIds = [];
|
||||||
foreach ($ids as $externalId) {
|
foreach ($persistableIds as $externalId) {
|
||||||
if (isset($existing[$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);
|
$payloads = $this->hydrateExternalImages($payloads);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'payloads' => $payloads,
|
'payloads' => $payloads,
|
||||||
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($ids),
|
'stats' => $this->reviewRepository->getAggregatesForAlbumIds($orderedIds),
|
||||||
'savedIds' => array_keys($existing),
|
'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}>}
|
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
|
||||||
*/
|
*/
|
||||||
@@ -221,7 +284,7 @@ class AlbumSearchService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'payloads' => array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums),
|
'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
|
* @param array<int,array{count:int,avg:float}> $userStats
|
||||||
* @return array<string,array{count:int,avg:float}>
|
* @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 = [];
|
$mapped = [];
|
||||||
foreach ($userAlbums as $album) {
|
foreach ($userAlbums as $album) {
|
||||||
$entityId = (int) $album->getId();
|
$entityId = (int) $album->getId();
|
||||||
$localId = (string) $album->getLocalId();
|
$albumId = (string) $album->getExternalId();
|
||||||
if ($localId !== '' && isset($userStats[$entityId])) {
|
if ($albumId !== '' && isset($userStats[$entityId])) {
|
||||||
$mapped[$localId] = $userStats[$entityId];
|
$mapped[$albumId] = $userStats[$entityId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class LastFmClient
|
|||||||
*
|
*
|
||||||
* @return array{albums:array{items:list<array<string,mixed>>}}
|
* @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();
|
$apiKey = $this->getApiKey();
|
||||||
if ($apiKey === null || trim($query) === '') {
|
if ($apiKey === null || trim($query) === '') {
|
||||||
@@ -46,6 +46,7 @@ class LastFmClient
|
|||||||
'method' => 'album.search',
|
'method' => 'album.search',
|
||||||
'album' => $query,
|
'album' => $query,
|
||||||
'limit' => max(1, $limit),
|
'limit' => max(1, $limit),
|
||||||
|
'page' => max(1, $page),
|
||||||
], 600);
|
], 600);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return ['albums' => ['items' => []]];
|
return ['albums' => ['items' => []]];
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user