CRUD Albums + Spotify API requests into DB.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s

This commit is contained in:
2025-11-20 19:53:45 +00:00
parent cd13f1478a
commit cd04fa5212
26 changed files with 6180 additions and 66 deletions

BIN
.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="framework" type="frameworkType"/>
<xs:complexType name="commandType">
<xs:all>
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
</xs:all>
</xs:complexType>
<xs:complexType name="frameworkType">
<xs:sequence>
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="invoke" use="required"/>
<xs:attribute type="xs:string" name="alias" use="required"/>
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
<xs:attribute type="xs:integer" name="version" use="required"/>
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
</xs:complexType>
<xs:complexType name="optionsBeforeType">
<xs:sequence>
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="optionType">
<xs:sequence>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
<xs:attribute name="pattern" use="optional">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="space"/>
<xs:enumeration value="equals"/>
<xs:enumeration value="unknown"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:schema>

View File

@@ -91,7 +91,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
@@ -112,15 +111,12 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stimulus-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-turbo" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />

4
.idea/php.xml generated
View File

@@ -90,7 +90,6 @@
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
@@ -137,9 +136,6 @@
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />

View File

@@ -12,11 +12,9 @@ services:
target: dev # change to "prod" for production build
args:
- APP_ENV=dev
container_name: app-php
container_name: php
restart: unless-stopped
environment:
# Symfony Messenger (dev-safe default so CLI commands don't fail)
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://}
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
volumes:

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114111853 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// Idempotent guard: if table already exists (from previous migration), skip
if ($schema->hasTable('albums')) {
return;
}
$this->addSql('CREATE TABLE albums (id SERIAL NOT NULL, spotify_id VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, artists JSON NOT NULL, release_date VARCHAR(20) DEFAULT NULL, total_tracks INT NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// Be defensive: only drop the table if it exists
if ($schema->hasTable('albums')) {
$this->addSql('DROP TABLE albums');
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114112016 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX uniq_album_spotify_id RENAME TO UNIQ_F4E2474FA905FC5C');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS NULL');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS NULL');
$this->addSql('ALTER INDEX uniq_f4e2474fa905fc5c RENAME TO uniq_album_spotify_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Normalize reviews: add album_id FK, backfill from albums.spotify_id';
}
public function up(Schema $schema): void
{
// Add nullable album_id first
$this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
// Backfill using existing spotify_album_id if both columns exist
// Some environments may not have the legacy column; guard with DO blocks
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='reviews' AND column_name='spotify_album_id'
) THEN
UPDATE reviews r
SET album_id = a.id
FROM albums a
WHERE a.spotify_id = r.spotify_album_id
AND r.album_id IS NULL;
END IF;
END $$;
SQL);
// Optionally set NOT NULL if all rows are linked
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM reviews WHERE album_id IS NULL) THEN
ALTER TABLE reviews ALTER COLUMN album_id SET NOT NULL;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114114000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop legacy duplicated review columns: spotify_album_id, album_name, album_artist';
}
public function up(Schema $schema): void
{
// Guard: drop columns only if they exist
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='spotify_album_id') THEN
ALTER TABLE reviews DROP COLUMN spotify_album_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_name') THEN
ALTER TABLE reviews DROP COLUMN album_name;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_artist') THEN
ALTER TABLE reviews DROP COLUMN album_artist;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
// Recreate columns as nullable in down migration
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL');
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user-created album fields: local_id, source, created_by_id; make spotify_id nullable';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL");
$this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
$this->addSql("ALTER TABLE albums ALTER spotify_id DROP NOT NULL");
$this->addSql("CREATE UNIQUE INDEX uniq_album_local_id ON albums (local_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");
$this->addSql("CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)");
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source IS NULL");
}
public function down(Schema $schema): void
{
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
$this->addSql("DROP INDEX IF EXISTS uniq_album_local_id");
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
$this->addSql("ALTER TABLE albums DROP COLUMN local_id");
$this->addSql("ALTER TABLE albums DROP COLUMN source");
$this->addSql("ALTER TABLE albums DROP COLUMN created_by_id");
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120174722 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL');
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120175034 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@@ -3,19 +3,23 @@
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Repository\AlbumRepository;
use App\Entity\Review;
use App\Form\ReviewType;
use App\Form\AlbumType;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Psr\Log\LoggerInterface;
class AlbumController extends AbstractController
{
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository, AlbumRepository $albumsRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
@@ -27,6 +31,7 @@ class AlbumController extends AbstractController
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
$albums = [];
$stats = [];
$savedIds = [];
// Build Spotify fielded search if advanced inputs are supplied
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
@@ -50,12 +55,45 @@ class AlbumController extends AbstractController
if ($q !== '') {
$result = $spotifyClient->searchAlbums($q, 20);
$albums = $result['albums']['items'] ?? [];
if ($albums) {
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
$searchItems = $result['albums']['items'] ?? [];
$logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
if ($searchItems) {
// Build ordered list of IDs from search results
$ids = [];
foreach ($searchItems as $it) {
$id = isset($it['id']) ? (string) $it['id'] : '';
if ($id !== '') { $ids[] = $id; }
}
$ids = array_values(array_unique($ids));
$logger->info('Album IDs extracted from search', ['count' => count($ids)]);
// Fetch full album objects to have consistent fields, then upsert
$full = $spotifyClient->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
// Fallback to search items if getAlbums failed
$albumsPayload = $searchItems;
$logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
}
$upserted = 0;
foreach ($albumsPayload as $sa) {
$albumsRepo->upsertFromSpotifyAlbum((array) $sa);
$upserted++;
}
$em->flush();
$logger->info('Albums upserted to DB', ['upserted' => $upserted]);
if ($ids) {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
$existing = $albumsRepo->findBySpotifyIdsKeyed($ids);
$savedIds = array_keys($existing);
// Preserve Spotify order and render from DB
$albums = [];
foreach ($ids as $sid) {
if (isset($existing[$sid])) {
$albums[] = $existing[$sid]->toTemplateArray();
}
}
}
}
}
@@ -68,18 +106,56 @@ class AlbumController extends AbstractController
'year_to' => $yearTo ?: '',
'albums' => $albums,
'stats' => $stats,
'savedIds' => $savedIds,
]);
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
public function new(Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$album = new \App\Entity\Album();
$album->setSource('user');
$form = $this->createForm(AlbumType::class, $album);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Map artistsCsv -> artists[]
$artistsCsv = (string) $form->get('artistsCsv')->getData();
$artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== ''));
$album->setArtists($artists);
// Assign createdBy and generate unique localId
$u = $this->getUser();
if ($u instanceof \App\Entity\User) {
$album->setCreatedBy($u);
}
$album->setLocalId($this->generateLocalId($albumsRepo));
$em->persist($album);
$em->flush();
$this->addFlash('success', 'Album created.');
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
}
return $this->render('album/new.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$album = $spotifyClient->getAlbum($id);
if ($album === null) {
throw $this->createNotFoundException('Album not found');
// Prefer DB: only fetch from Spotify if not present
$albumEntity = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
if (!$albumEntity) {
$spotifyAlbum = $spotifyClient->getAlbum($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$albumEntity = $albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$em->flush();
}
$isSaved = $albumEntity !== null;
$album = $albumEntity->toTemplateArray();
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
$existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing);
$avg = 0.0;
if ($count > 0) {
@@ -90,9 +166,7 @@ class AlbumController extends AbstractController
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review();
$review->setSpotifyAlbumId($id);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$review->setAlbum($albumEntity);
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
@@ -108,12 +182,79 @@ class AlbumController extends AbstractController
return $this->render('album/show.html.twig', [
'album' => $album,
'albumId' => $id,
'isSaved' => $isSaved,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
]);
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
public function save(string $id, Request $request, SpotifyClient $spotifyClient, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$existing = $albumsRepo->findOneBySpotifyId($id);
if (!$existing) {
$spotifyAlbum = $spotifyClient->getAlbum($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$albumsRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$em->flush();
$this->addFlash('success', 'Album saved.');
} else {
$this->addFlash('info', 'Album already saved.');
}
return $this->redirectToRoute('album_show', ['id' => $id]);
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])]
public function delete(string $id, Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
if ($album) {
// Only owner or admin can delete user albums; Spotify albums require admin
$isAdmin = $this->isGranted('ROLE_ADMIN');
if ($album->getSource() === 'user') {
$current = $this->getUser();
$isOwner = false;
if ($current instanceof \App\Entity\User) {
$isOwner = ($album->getCreatedBy()?->getId() === $current->getId());
}
if (!$isAdmin && !$isOwner) {
throw $this->createAccessDeniedException();
}
} else {
if (!$isAdmin) {
throw $this->createAccessDeniedException();
}
}
$em->remove($album);
$em->flush();
$this->addFlash('success', 'Album deleted.');
} else {
$this->addFlash('info', 'Album not found.');
}
return $this->redirectToRoute('album_search');
}
private function generateLocalId(AlbumRepository $albumsRepo): string
{
do {
$id = 'u_' . bin2hex(random_bytes(6));
} while ($albumsRepo->findOneByLocalId($id) !== null);
return $id;
}
}

151
src/Entity/Album.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
namespace App\Entity;
use App\Repository\AlbumRepository;
use App\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
#[ORM\Table(name: 'albums')]
#[ORM\HasLifecycleCallbacks]
class Album
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
// For Spotify-sourced albums; null for user-created
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $spotifyId = null;
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $localId = null;
// 'spotify' or 'user'
#[ORM\Column(type: 'string', length: 16)]
private string $source = 'spotify';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $name = '';
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $artists = [];
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null;
#[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: 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;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getSpotifyId(): ?string { return $this->spotifyId; }
public function setSpotifyId(?string $spotifyId): void { $this->spotifyId = $spotifyId; }
public function getLocalId(): ?string { return $this->localId; }
public function setLocalId(?string $localId): void { $this->localId = $localId; }
public function getSource(): string { return $this->source; }
public function setSource(string $source): void { $this->source = $source; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
/**
* @return list<string>
*/
public function getArtists(): array { return $this->artists; }
/**
* @param list<string> $artists
*/
public function setArtists(array $artists): void { $this->artists = array_values($artists); }
public function getReleaseDate(): ?string { return $this->releaseDate; }
public function setReleaseDate(?string $releaseDate): void { $this->releaseDate = $releaseDate; }
public function getTotalTracks(): int { return $this->totalTracks; }
public function setTotalTracks(int $totalTracks): void { $this->totalTracks = $totalTracks; }
public function getCoverUrl(): ?string { return $this->coverUrl; }
public function setCoverUrl(?string $coverUrl): void { $this->coverUrl = $coverUrl; }
public function getExternalUrl(): ?string { return $this->externalUrl; }
public function setExternalUrl(?string $externalUrl): void { $this->externalUrl = $externalUrl; }
public function getCreatedBy(): ?User { return $this->createdBy; }
public function setCreatedBy(?User $user): void { $this->createdBy = $user; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
/**
* Shape the album like the Spotify payload expected by Twig templates.
*
* @return array<string,mixed>
*/
public function toTemplateArray(): array
{
$images = [];
if ($this->coverUrl) {
$images = [
['url' => $this->coverUrl],
['url' => $this->coverUrl],
];
}
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
$external = $this->externalUrl;
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
}
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
return [
'id' => $publicId,
'name' => $this->name,
'images' => $images,
'artists' => $artists,
'release_date' => $this->releaseDate,
'total_tracks' => $this->totalTracks,
'external_urls' => [ 'spotify' => $external ],
'source' => $this->source,
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Entity;
use App\Repository\ReviewRepository;
use App\Entity\Album;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -20,17 +21,9 @@ class Review
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $author = null;
#[ORM\Column(type: 'string', length: 64)]
#[Assert\NotBlank]
private string $spotifyAlbumId = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumName = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumArtist = '';
#[ORM\ManyToOne(targetEntity: Album::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Album $album = null;
#[ORM\Column(type: 'string', length: 160)]
#[Assert\NotBlank]
@@ -69,12 +62,8 @@ class Review
public function getId(): ?int { return $this->id; }
public function getAuthor(): ?User { return $this->author; }
public function setAuthor(User $author): void { $this->author = $author; }
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
public function getAlbumName(): string { return $this->albumName; }
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
public function getAlbumArtist(): string { return $this->albumArtist; }
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
public function getAlbum(): ?Album { return $this->album; }
public function setAlbum(Album $album): void { $this->album = $album; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getContent(): string { return $this->content; }

51
src/Form/AlbumType.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class AlbumType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
])
->add('artistsCsv', TextType::class, [
'mapped' => false,
'label' => 'Artists (comma-separated)',
'constraints' => [new Assert\NotBlank()],
])
->add('releaseDate', TextType::class, [
'required' => false,
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
])
->add('totalTracks', IntegerType::class, [
'constraints' => [new Assert\Range(min: 0, max: 500)],
])
->add('coverUrl', TextType::class, [
'required' => false,
])
->add('externalUrl', TextType::class, [
'required' => false,
'label' => 'External link',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Album::class,
]);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Repository;
use App\Entity\Album;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Album>
*/
class AlbumRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Album::class);
}
public function findOneBySpotifyId(string $spotifyId): ?Album
{
return $this->findOneBy(['spotifyId' => $spotifyId]);
}
public function findOneByLocalId(string $localId): ?Album
{
return $this->findOneBy(['localId' => $localId]);
}
/**
* @param list<string> $spotifyIds
* @return array<string,Album> keyed by spotifyId
*/
public function findBySpotifyIdsKeyed(array $spotifyIds): array
{
if ($spotifyIds === []) {
return [];
}
$rows = $this->createQueryBuilder('a')
->where('a.spotifyId IN (:ids)')
->setParameter('ids', $spotifyIds)
->getQuery()
->getResult();
$out = [];
foreach ($rows as $row) {
if ($row instanceof Album) {
$out[$row->getSpotifyId()] = $row;
}
}
return $out;
}
/**
* @return list<Album>
*/
public function searchUserAlbumsByNameLike(string $query, int $limit = 20): array
{
$qb = $this->createQueryBuilder('a')
->where('a.source = :src')
->andWhere('LOWER(a.name) LIKE :q')
->setParameter('src', 'user')
->setParameter('q', '%' . mb_strtolower($query) . '%')
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
/**
* Upsert based on a Spotify album payload.
*
* @param array<string,mixed> $spotifyAlbum
*/
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album
{
$spotifyId = (string) ($spotifyAlbum['id'] ?? '');
$name = (string) ($spotifyAlbum['name'] ?? '');
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? [])));
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
$images = (array) ($spotifyAlbum['images'] ?? []);
$coverUrl = null;
if (isset($images[1]['url'])) {
$coverUrl = (string) $images[1]['url'];
} elseif (isset($images[0]['url'])) {
$coverUrl = (string) $images[0]['url'];
}
$external = null;
if (isset($spotifyAlbum['external_urls']['spotify'])) {
$external = (string) $spotifyAlbum['external_urls']['spotify'];
}
$em = $this->getEntityManager();
$album = $this->findOneBy(['spotifyId' => $spotifyId]) ?? new Album();
$album->setSource('spotify');
$album->setSpotifyId($spotifyId);
$album->setName($name);
$album->setArtists($artists);
$album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks);
$album->setCoverUrl($coverUrl);
$album->setExternalUrl($external);
$em->persist($album);
// flush outside for batching
return $album;
}
}

View File

@@ -29,7 +29,7 @@ class ReviewRepository extends ServiceEntityRepository
}
/**
* Return aggregates for albums: [albumId => ['count' => int, 'avg' => float]].
* Return aggregates for albums by Spotify IDs: [spotifyId => {count, avg}].
*
* @param list<string> $albumIds
* @return array<string,array{count:int,avg:float}>
@@ -40,13 +40,13 @@ class ReviewRepository extends ServiceEntityRepository
return [];
}
$rows = $this->createQueryBuilder('r')
->select('r.spotifyAlbumId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('r.spotifyAlbumId IN (:ids)')
$qb = $this->createQueryBuilder('r')
->innerJoin('r.album', 'a')
->select('a.spotifyId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('a.spotifyId IN (:ids)')
->setParameter('ids', $albumIds)
->groupBy('r.spotifyAlbumId')
->getQuery()
->getArrayResult();
->groupBy('a.spotifyId');
$rows = $qb->getQuery()->getArrayResult();
$out = [];
foreach ($rows as $row) {

View File

@@ -0,0 +1,16 @@
{% extends 'base.html.twig' %}
{% block title %}Create Album{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Create album</h1>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.coverUrl) }}{{ form_widget(form.coverUrl, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
<button class="btn btn-success" type="submit">Create</button>
{{ form_end(form) }}
{% endblock %}

View File

@@ -54,6 +54,19 @@
<div class="mt-auto">
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
{% if album.source is defined and album.source == 'user' %}
<span class="badge text-bg-primary ms-2">User album</span>
{% endif %}
{% if app.user %}
{% if savedIds is defined and (album.id in savedIds) %}
<span class="badge text-bg-secondary ms-2">Saved</span>
{% else %}
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: album.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
<button class="btn btn-outline-primary btn-sm" type="submit">Save</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</div>

View File

@@ -14,6 +14,21 @@
<p class="text-secondary mb-2">Released {{ album.release_date }}{{ album.total_tracks }} tracks</p>
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
{% if album.source is defined and album.source == 'user' %}
<span class="badge text-bg-primary ms-2">User album</span>
{% endif %}
{% if app.user and (isSaved is defined) and (not isSaved) %}
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: albumId}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ albumId) }}">
<button class="btn btn-primary btn-sm" type="submit">Save album</button>
</form>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<form class="d-inline ms-2" method="post" action="{{ path('album_delete', {id: albumId}) }}" onsubmit="return confirm('Delete this album from the database?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ albumId) }}">
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% block title %}Edit Review{% endblock %}
{% block body %}
<h1 class="h4 mb-1">Edit review</h1>
<p class="text-secondary">{{ review.albumName }}{{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
<p class="text-secondary">{{ review.album.name }}{{ review.album.artists|join(', ') }}</p>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>

View File

@@ -14,7 +14,7 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h5>
<div class="text-secondary mb-2">{{ r.albumName }}{{ r.albumArtist }}</div>
<div class="text-secondary mb-2">{{ r.album.name }}{{ r.album.artists|join(', ') }}</div>
<p class="card-text">{{ r.content|u.truncate(220, '…', false) }}</p>
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
</div>

View File

@@ -2,21 +2,7 @@
{% block title %}New Review{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Write a review</h1>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label">Spotify Album ID</label>
<input class="form-control" type="text" name="spotifyAlbumId" value="{{ review.spotifyAlbumId }}" placeholder="e.g. 4m2880jivSbbyEGAKfITCa" />
</div>
<div class="col-md-6">
<label class="form-label">Artist</label>
<input class="form-control" type="text" name="albumArtist" value="{{ review.albumArtist }}" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Album Title</label>
<input class="form-control" type="text" name="albumName" value="{{ review.albumName }}" />
</div>
<div class="alert alert-info">Pick an album first, then write your review on its page.</div>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>

View File

@@ -3,7 +3,10 @@
{% block body %}
<p><a href="{{ path('review_index') }}">← Back</a></p>
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
<p class="text-secondary">{{ review.albumName }}{{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
<p class="text-secondary">
{{ review.album.name }}{{ review.album.artists|join(', ') }}
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId}) }}">View album</a>
</p>
<article class="mb-3">
<p>{{ review.content|nl2br }}</p>
</article>