CRUD Albums + Spotify API requests into DB.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
This commit is contained in:
5313
.idea/commandlinetools/Symfony_10_11_2025__13_00.xml
generated
Normal file
5313
.idea/commandlinetools/Symfony_10_11_2025__13_00.xml
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
.idea/commandlinetools/schemas/frameworkDescriptionVersion1.1.4.xsd
generated
Normal file
47
.idea/commandlinetools/schemas/frameworkDescriptionVersion1.1.4.xsd
generated
Normal 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>
|
||||
4
.idea/musicratings.iml
generated
4
.idea/musicratings.iml
generated
@@ -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
4
.idea/php.xml
generated
@@ -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" />
|
||||
|
||||
@@ -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:
|
||||
|
||||
39
migrations/Version20251114111853.php
Normal file
39
migrations/Version20251114111853.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
40
migrations/Version20251114112016.php
Normal file
40
migrations/Version20251114112016.php
Normal 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');
|
||||
}
|
||||
}
|
||||
62
migrations/Version20251114113000.php
Normal file
62
migrations/Version20251114113000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
migrations/Version20251114114000.php
Normal file
45
migrations/Version20251114114000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
41
migrations/Version20251114120500.php
Normal file
41
migrations/Version20251114120500.php
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
migrations/Version20251120174722.php
Normal file
34
migrations/Version20251120174722.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20251120175034.php
Normal file
31
migrations/Version20251120175034.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
151
src/Entity/Album.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
51
src/Form/AlbumType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
107
src/Repository/AlbumRepository.php
Normal file
107
src/Repository/AlbumRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
templates/album/new.html.twig
Normal file
16
templates/album/new.html.twig
Normal 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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user