diff --git a/README.md b/README.md index 3904119..0eb3cf1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ docker compose exec php php bin/console app:promote-admin you@example.com 5) Visit `http://localhost:8000` to search for albums. +6) (Optional) Seed demo data + +```bash +docker compose exec php php bin/console app:seed-demo-users --count=50 +docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users +docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8 +``` + ## Database driver - Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`. diff --git a/config/packages/doctrine.php b/config/packages/doctrine.php index f560c7d..bbdb9d8 100644 --- a/config/packages/doctrine.php +++ b/config/packages/doctrine.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Symfony\Component\Filesystem\Filesystem; use Symfony\Config\DoctrineConfig; return static function (DoctrineConfig $doctrine): void { @@ -32,6 +33,19 @@ return static function (DoctrineConfig $doctrine): void { if ($hasCustomPath) { $connection->path('%env(resolve:DATABASE_SQLITE_PATH)%'); } else { + $projectDir = dirname(__DIR__, 2); + $databasePath = sprintf('%s/var/data/database.sqlite', $projectDir); + $databaseDir = dirname($databasePath); + + $filesystem = new Filesystem(); + if (!$filesystem->exists($databaseDir)) { + $filesystem->mkdir($databaseDir, 0o775); + } + + if (!$filesystem->exists($databasePath)) { + $filesystem->touch($databasePath); + } + $connection->path('%kernel.project_dir%/var/data/database.sqlite'); } } else { @@ -39,5 +53,4 @@ return static function (DoctrineConfig $doctrine): void { $connection->serverVersion('16'); $connection->charset('utf8'); } -}; - +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a0a7f1f..d2bbbd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,9 @@ services: - APP_ENV=dev container_name: php restart: unless-stopped - environment: + #environment: # 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} + #DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8} volumes: # Mount only source and config; vendors are installed in-container - ./bin:/var/www/html/bin diff --git a/docs/01-setup.md b/docs/01-setup.md index 56b7f15..c6db1dc 100644 --- a/docs/01-setup.md +++ b/docs/01-setup.md @@ -16,6 +16,10 @@ docker compose exec php php bin/console doctrine:migrations:diff --no-interactio docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction ``` +### Switching database drivers +- `DATABASE_DRIVER=postgres` (default) continues to use the Postgres 16 service from `docker-compose.yml` and reads credentials from `DATABASE_URL`. +- `DATABASE_DRIVER=sqlite` runs Doctrine against a local SQLite file at `var/data/database.sqlite`. `DATABASE_URL` is ignored; override the SQLite file path with `DATABASE_SQLITE_PATH` if desired. + ## Admin user ```bash docker compose exec php php bin/console app:promote-admin you@example.com diff --git a/docs/03-auth-and-users.md b/docs/03-auth-and-users.md index 4b99ea7..857ffaf 100644 --- a/docs/03-auth-and-users.md +++ b/docs/03-auth-and-users.md @@ -10,6 +10,11 @@ - `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings. - `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access. +### Demo accounts +- Generate placeholder accounts locally with `php bin/console app:seed-demo-users --count=50` (default password: `password`). +- Emails use the pattern `demo+@example.com`, making them easy to spot in the admin UI. +- Give existing accounts avatars with `php bin/console app:seed-user-avatars`; pass `--overwrite` to refresh everyone or tweak `--style` to try other DiceBear sets. + ### Access flow - Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically. - Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy. diff --git a/docs/04-spotify-integration.md b/docs/04-spotify-integration.md index 49d2bc1..d0264cb 100644 --- a/docs/04-spotify-integration.md +++ b/docs/04-spotify-integration.md @@ -8,7 +8,9 @@ - `src/Service/SpotifyClient.php` - Client Credentials token fetch (cached) - `searchAlbums(q, limit)` - - `getAlbum(id)` and `getAlbums([ids])` + - `getAlbum(id)` / `getAlbums([ids])` + - `getAlbumWithTracks(id)` fetches metadata plus a hydrated tracklist + - `getAlbumTracks(id)` provides the raw paginated track payload when needed ## Advanced search - The search page builds Spotify fielded queries: diff --git a/docs/05-reviews-and-albums.md b/docs/05-reviews-and-albums.md index 460ba6a..622fd7f 100644 --- a/docs/05-reviews-and-albums.md +++ b/docs/05-reviews-and-albums.md @@ -2,6 +2,7 @@ ## Album page - Shows album artwork, metadata, average rating and review count. +- Displays the full Spotify tracklist (duration, ordering, preview links) when available. - Lists reviews newest-first. - Logged-in users can submit a review inline. @@ -13,4 +14,9 @@ ## UI - Rating uses a slider (1–10) with ticks; badge shows current value. +## Demo data +- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately. +- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity. + - Use `--only-empty` when you want to focus on albums that currently have no reviews. + diff --git a/migrations/Version20251031224841.php b/migrations/Version20251031224841.php index f85be80..707e5cd 100644 --- a/migrations/Version20251031224841.php +++ b/migrations/Version20251031224841.php @@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + // SQLite uses the dedicated schema bootstrap migration later in the chain. + return; + } + + // Skip if the tables somehow already exist to keep reruns idempotent. + if ($schema->hasTable('reviews') || $schema->hasTable('users') || $schema->hasTable('messenger_messages')) { + return; + } + $this->addSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT 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 INDEX IDX_6970EB0FF675F31B ON reviews (author_id)'); $this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\''); @@ -46,11 +55,17 @@ final class Version20251031224841 extends AbstractMigration public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SCHEMA public'); + if ($this->isSqlite()) { + return; + } $this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B'); $this->addSql('DROP TABLE reviews'); $this->addSql('DROP TABLE users'); $this->addSql('DROP TABLE messenger_messages'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251031231033.php b/migrations/Version20251031231033.php index d6ad6c7..444ec1b 100644 --- a/migrations/Version20251031231033.php +++ b/migrations/Version20251031231033.php @@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + return; + } $this->addSql('CREATE SCHEMA public'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251031231715.php b/migrations/Version20251031231715.php index d61bb7a..8a385e5 100644 --- a/migrations/Version20251031231715.php +++ b/migrations/Version20251031231715.php @@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + return; + } $this->addSql('CREATE SCHEMA public'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251101001514.php b/migrations/Version20251101001514.php index ed97a60..f6fadc4 100644 --- a/migrations/Version20251101001514.php +++ b/migrations/Version20251101001514.php @@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + // SQLite bootstraps settings in Version20251127235840. + return; + } + if ($schema->hasTable('settings')) { + return; + } $this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)'); } public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SCHEMA public'); + if ($this->isSqlite()) { + return; + } $this->addSql('DROP TABLE settings'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251114111853.php b/migrations/Version20251114111853.php index c2cdbe4..c4f51d6 100644 --- a/migrations/Version20251114111853.php +++ b/migrations/Version20251114111853.php @@ -19,6 +19,10 @@ final class Version20251114111853 extends AbstractMigration public function up(Schema $schema): void { + if ($this->isSqlite()) { + // SQLite installs get albums from Version20251127235840. + return; + } // Idempotent guard: if table already exists (from previous migration), skip if ($schema->hasTable('albums')) { return; @@ -31,9 +35,17 @@ final class Version20251114111853 extends AbstractMigration public function down(Schema $schema): void { + if ($this->isSqlite()) { + return; + } // Be defensive: only drop the table if it exists if ($schema->hasTable('albums')) { $this->addSql('DROP TABLE albums'); } } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251114112016.php b/migrations/Version20251114112016.php index 222bf35..05a263d 100644 --- a/migrations/Version20251114112016.php +++ b/migrations/Version20251114112016.php @@ -19,7 +19,9 @@ final class Version20251114112016 extends AbstractMigration public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs + if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') { + return; + } $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)\''); @@ -29,7 +31,9 @@ final class Version20251114112016 extends AbstractMigration public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs + if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') { + return; + } $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'); diff --git a/migrations/Version20251114113000.php b/migrations/Version20251114113000.php index 3181316..513481e 100644 --- a/migrations/Version20251114113000.php +++ b/migrations/Version20251114113000.php @@ -16,7 +16,24 @@ final class Version20251114113000 extends AbstractMigration public function up(Schema $schema): void { - // Add nullable album_id first + if (!$schema->hasTable('reviews')) { + return; + } + $reviews = $schema->getTable('reviews'); + if ($reviews->hasColumn('album_id')) { + // Already migrated (common for SQLite dev DBs) + return; + } + + if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') { + // SQLite cannot add FK constraints after table creation; add the column + index and rely on app-level validation. + $this->addSql('ALTER TABLE reviews ADD COLUMN album_id INTEGER DEFAULT NULL'); + $this->addSql('CREATE INDEX IF NOT EXISTS IDX_6970EF78E0C31AF9 ON reviews (album_id)'); + $this->addSql('UPDATE reviews SET album_id = (SELECT a.id FROM albums a WHERE a.spotify_id = reviews.spotify_album_id) WHERE album_id IS NULL'); + return; + } + + // Add nullable album_id first (PostgreSQL / others that support full DDL) $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)'); @@ -53,6 +70,20 @@ SQL); public function down(Schema $schema): void { + if (!$schema->hasTable('reviews')) { + return; + } + $reviews = $schema->getTable('reviews'); + if (!$reviews->hasColumn('album_id')) { + return; + } + + if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') { + $this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9'); + $this->addSql('ALTER TABLE reviews DROP COLUMN album_id'); + return; + } + $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'); diff --git a/migrations/Version20251114114000.php b/migrations/Version20251114114000.php index 0096ecd..baeac77 100644 --- a/migrations/Version20251114114000.php +++ b/migrations/Version20251114114000.php @@ -16,6 +16,10 @@ final class Version20251114114000 extends AbstractMigration public function up(Schema $schema): void { + if ($this->isSqlite()) { + // SQLite schema never created the legacy columns. + return; + } // Guard: drop columns only if they exist $this->addSql(<<<'SQL' DO $$ @@ -35,11 +39,19 @@ SQL); public function down(Schema $schema): void { + if ($this->isSqlite()) { + return; + } // 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'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251114120500.php b/migrations/Version20251114120500.php index bd9cfd0..bb4a0bc 100644 --- a/migrations/Version20251114120500.php +++ b/migrations/Version20251114120500.php @@ -16,6 +16,10 @@ final class Version20251114120500 extends AbstractMigration public function up(Schema $schema): void { + if ($this->isSqlite()) { + // SQLite schema already ships with these columns/defaults. + return; + } $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"); @@ -28,6 +32,9 @@ final class Version20251114120500 extends AbstractMigration public function down(Schema $schema): void { + if ($this->isSqlite()) { + return; + } $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"); @@ -36,6 +43,11 @@ final class Version20251114120500 extends AbstractMigration $this->addSql("ALTER TABLE albums DROP COLUMN created_by_id"); $this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL"); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251120174722.php b/migrations/Version20251120174722.php index 6b75a85..119f556 100644 --- a/migrations/Version20251120174722.php +++ b/migrations/Version20251120174722.php @@ -19,16 +19,24 @@ final class Version20251120174722 extends AbstractMigration public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + return; + } $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'); + if ($this->isSqlite()) { + return; + } $this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL'); $this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251120175034.php b/migrations/Version20251120175034.php index 53396cc..8f4aed3 100644 --- a/migrations/Version20251120175034.php +++ b/migrations/Version20251120175034.php @@ -25,7 +25,14 @@ final class Version20251120175034 extends AbstractMigration public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + return; + } $this->addSql('CREATE SCHEMA public'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251127191813.php b/migrations/Version20251127191813.php index a91ab7e..2b3aca8 100644 --- a/migrations/Version20251127191813.php +++ b/migrations/Version20251127191813.php @@ -19,16 +19,24 @@ final class Version20251127191813 extends AbstractMigration public function up(Schema $schema): void { - // this up() migration is auto-generated, please modify it to your needs + if ($this->isSqlite()) { + return; + } $this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT'); $this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101'); } public function down(Schema $schema): void { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('CREATE SCHEMA public'); + if ($this->isSqlite()) { + return; + } $this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\''); $this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id'); } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } } diff --git a/migrations/Version20251127235840.php b/migrations/Version20251127235840.php new file mode 100644 index 0000000..9e53742 --- /dev/null +++ b/migrations/Version20251127235840.php @@ -0,0 +1,63 @@ +isSqlite()) { + return; + } + if ($schema->hasTable('users')) { + // Already initialized. + return; + } + $this->addSql('CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER DEFAULT NULL, spotify_id VARCHAR(64) DEFAULT NULL, local_id VARCHAR(64) DEFAULT NULL, source VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, artists CLOB NOT NULL --(DC2Type:json) + , release_date VARCHAR(20) DEFAULT NULL, total_tracks INTEGER NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, cover_image_path VARCHAR(255) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable) + , updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable) + , CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)'); + $this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)'); + $this->addSql('CREATE TABLE reviews (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, album_id INTEGER NOT NULL, title VARCHAR(160) NOT NULL, content CLOB NOT NULL, rating SMALLINT NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable) + , updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable) + , CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6970EB0F1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)'); + $this->addSql('CREATE INDEX IDX_6970EB0F1137ABCF ON reviews (album_id)'); + $this->addSql('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value CLOB DEFAULT NULL)'); + $this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)'); + $this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json) + , password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, profile_image_path VARCHAR(255) DEFAULT NULL)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)'); + } + + public function down(Schema $schema): void + { + if (!$this->isSqlite()) { + return; + } + $this->addSql('DROP TABLE albums'); + $this->addSql('DROP TABLE reviews'); + $this->addSql('DROP TABLE settings'); + $this->addSql('DROP TABLE users'); + } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } +} diff --git a/migrations/Version20251205123000.php b/migrations/Version20251205123000.php index 22ad8ff..b6ad101 100644 --- a/migrations/Version20251205123000.php +++ b/migrations/Version20251205123000.php @@ -16,14 +16,39 @@ final class Version20251205123000 extends AbstractMigration public function up(Schema $schema): void { - $this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL'); - $this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL'); + if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) { + $this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL'); + } + if ($this->shouldAddColumn($schema, 'albums', 'cover_image_path')) { + $this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL'); + } } public function down(Schema $schema): void { - $this->addSql('ALTER TABLE users DROP profile_image_path'); - $this->addSql('ALTER TABLE albums DROP cover_image_path'); + if ($this->isSqlite()) { + // SQLite cannot drop columns; leave them in place. + return; + } + if ($schema->hasTable('users') && $schema->getTable('users')->hasColumn('profile_image_path')) { + $this->addSql('ALTER TABLE users DROP profile_image_path'); + } + if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('cover_image_path')) { + $this->addSql('ALTER TABLE albums DROP cover_image_path'); + } + } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } + + private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool + { + if (!$schema->hasTable($tableName)) { + return false; + } + return !$schema->getTable($tableName)->hasColumn($column); } } diff --git a/migrations/Version20251205133000.php b/migrations/Version20251205133000.php new file mode 100644 index 0000000..354599a --- /dev/null +++ b/migrations/Version20251205133000.php @@ -0,0 +1,50 @@ +isSqlite()) { + if ($schema->hasTable('album_tracks')) { + return; + } + $this->addSql('CREATE TABLE album_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, album_id INTEGER NOT NULL, spotify_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('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)'); + return; + } + $this->addSql('CREATE TABLE album_tracks (id SERIAL NOT NULL, album_id INT NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INT NOT NULL, track_number INT NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INT NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)'); + $this->addSql('ALTER TABLE album_tracks ADD CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + if ($this->isSqlite()) { + $this->addSql('DROP TABLE IF EXISTS album_tracks'); + return; + } + $this->addSql('ALTER TABLE album_tracks DROP CONSTRAINT FK_5E4A3B3B1137ABCF'); + $this->addSql('DROP TABLE album_tracks'); + } + + private function isSqlite(): bool + { + return $this->connection->getDatabasePlatform()->getName() === 'sqlite'; + } +} + + diff --git a/public/css/app.css b/public/css/app.css index 8e89ae2..3ee6ca7 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -185,3 +185,119 @@ a:hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); } +/* Material-inspired data tables */ +.mui-table-wrapper { + border: 1px solid var(--md-card-border); + border-radius: 20px; + background-color: var(--md-card); + box-shadow: var(--md-shadow-ambient); + overflow-x: auto; +} + +.mui-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: 620px; +} + +.mui-table thead th { + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--md-text-secondary); + padding: 0.85rem 1.25rem; + background-color: var(--md-surface-variant); + border-bottom: 1px solid var(--md-outline); +} + +.mui-table tbody td { + padding: 1rem 1.25rem; + border-bottom: 1px solid color-mix(in srgb, var(--md-outline) 75%, transparent); + color: var(--md-text-primary); +} + +.mui-table--compact tbody td { + padding: 0.65rem 1rem; +} + +.mui-table tbody tr:last-child td { + border-bottom: none; +} + +.mui-table tbody tr { + transition: background-color 0.15s ease, transform 0.15s ease; +} + +.mui-table tbody tr:hover { + background-color: color-mix(in srgb, var(--accent-color) 6%, transparent); +} + +.mui-table--striped tbody tr:nth-child(even) { + background-color: color-mix(in srgb, var(--accent-color) 4%, transparent); +} + +.mui-table--striped tbody tr:nth-child(even):hover { + background-color: color-mix(in srgb, var(--accent-color) 9%, transparent); +} + +.mui-table__number { + font-variant-numeric: tabular-nums; + color: var(--md-text-secondary); + width: 72px; +} + +.mui-table__metric { + font-variant-numeric: tabular-nums; + text-align: center; + color: var(--md-text-secondary); +} + +.mui-table__title { + font-weight: 600; +} + +.mui-table__title-avatar { + display: flex; + align-items: center; + gap: 0.85rem; +} + +.mui-table__title-avatar img { + width: 40px; + height: 40px; + border-radius: 12px; + object-fit: cover; + border: 1px solid var(--md-card-border); +} + +.mui-table__subtitle { + font-size: 0.85rem; + color: var(--md-text-secondary); +} + +.mui-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 999px; + border: 1px solid var(--md-outline); + color: var(--accent-color); + background-color: transparent; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +.mui-icon-button:hover { + background-color: color-mix(in srgb, var(--accent-color) 12%, transparent); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); +} + +.mui-icon-button svg, +.mui-icon-button span { + font-size: 1rem; + line-height: 1; +} + diff --git a/src/Command/SeedDemoAlbumsCommand.php b/src/Command/SeedDemoAlbumsCommand.php new file mode 100644 index 0000000..6d6f2f3 --- /dev/null +++ b/src/Command/SeedDemoAlbumsCommand.php @@ -0,0 +1,144 @@ +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 + { + $io = new SymfonyStyle($input, $output); + $count = max(1, (int) $input->getOption('count')); + $attachUsers = (bool) $input->getOption('attach-users'); + $users = $attachUsers ? $this->userRepository->findAll() : []; + + $created = 0; + $seenLocalIds = []; + + while ($created < $count) { + $localId = $this->generateLocalId(); + if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) { + continue; + } + + $album = new Album(); + $album->setSource('user'); + $album->setLocalId($localId); + $album->setName($this->generateAlbumName()); + $album->setArtists($this->generateArtists()); + $album->setReleaseDate($this->generateReleaseDate()); + $album->setTotalTracks(random_int(6, 16)); + $album->setCoverUrl($this->generateCoverUrl($localId)); + $album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId)); + + if ($attachUsers && $users !== []) { + /** @var User $user */ + $user = $users[array_rand($users)]; + $album->setCreatedBy($user); + } + + $this->entityManager->persist($album); + $seenLocalIds[$localId] = true; + $created++; + } + + $this->entityManager->flush(); + + $io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : '')); + + return Command::SUCCESS; + } + + private function generateLocalId(): string + { + return 'demo_' . bin2hex(random_bytes(4)); + } + + private function generateAlbumName(): string + { + $adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)]; + $noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)]; + $genre = self::GENRES[random_int(0, count(self::GENRES) - 1)]; + + return sprintf('%s %s of %s', $adj, $noun, $genre); + } + + /** + * @return list + */ + private function generateArtists(): array + { + $artists = []; + $artistCount = random_int(1, 3); + for ($i = 0; $i < $artistCount; $i++) { + $artists[] = sprintf( + '%s %s', + self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)], + self::NOUNS[random_int(0, count(self::NOUNS) - 1)] + ); + } + + return array_values(array_unique($artists)); + } + + private function generateReleaseDate(): string + { + $year = random_int(1990, (int) date('Y')); + $month = random_int(1, 12); + $day = random_int(1, 28); + + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + + private function generateCoverUrl(string $seed): string + { + return sprintf('https://picsum.photos/seed/%s/640/640', $seed); + } +} + diff --git a/src/Command/SeedDemoReviewsCommand.php b/src/Command/SeedDemoReviewsCommand.php new file mode 100644 index 0000000..5093e13 --- /dev/null +++ b/src/Command/SeedDemoReviewsCommand.php @@ -0,0 +1,207 @@ +addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10) + ->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1) + ->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60) + ->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $albums = $this->albumRepository->findAll(); + $users = $this->userRepository->findAll(); + + if ($albums === [] || $users === []) { + $io->warning('Need at least one album and one user to seed reviews.'); + return Command::FAILURE; + } + + $minPerAlbum = max(0, (int) $input->getOption('min-per-album')); + $maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album')); + $coverPercent = max(0, min(100, (int) $input->getOption('cover-percent'))); + + $selectedAlbums = $this->selectAlbums($albums, $coverPercent); + $onlyEmpty = (bool) $input->getOption('only-empty'); + + $created = 0; + $processedAlbums = 0; + foreach ($selectedAlbums as $album) { + if ($onlyEmpty && $this->albumHasReviews($album)) { + continue; + } + $targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum)); + $created += $this->seedForAlbum($album, $users, $targetReviews); + $processedAlbums++; + } + + $this->entityManager->flush(); + + if ($created === 0) { + $io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.'); + return Command::SUCCESS; + } + + $io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1))); + + return Command::SUCCESS; + } + + /** + * @param list $albums + * @return list + */ + private function selectAlbums(array $albums, int $coverPercent): array + { + if ($coverPercent >= 100) { + return $albums; + } + + $selected = []; + foreach ($albums as $album) { + if (random_int(1, 100) <= $coverPercent) { + $selected[] = $album; + } + } + + return $selected === [] ? [$albums[array_rand($albums)]] : $selected; + } + + /** + * @param list $users + */ + private function seedForAlbum(Album $album, array $users, int $targetReviews): int + { + $created = 0; + $existingAuthors = $this->fetchExistingAuthors($album); + $availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1])); + + if ($availableUsers === []) { + return 0; + } + + $targetReviews = min($targetReviews, count($availableUsers)); + shuffle($availableUsers); + $selectedUsers = array_slice($availableUsers, 0, $targetReviews); + + foreach ($selectedUsers as $user) { + $review = new Review(); + $review->setAlbum($album); + $review->setAuthor($user); + $review->setRating(random_int(4, 10)); + $review->setTitle($this->generateTitle()); + $review->setContent($this->generateContent($album)); + + $this->entityManager->persist($review); + $created++; + } + + return $created; + } + + /** + * @return array + */ + private function fetchExistingAuthors(Album $album): array + { + $qb = $this->entityManager->createQueryBuilder() + ->select('IDENTITY(r.author) AS authorId') + ->from(Review::class, 'r') + ->where('r.album = :album') + ->setParameter('album', $album); + + $rows = $qb->getQuery()->getScalarResult(); + $out = []; + foreach ($rows as $row) { + $out[(int) $row['authorId']] = true; + } + + return $out; + } + + private function albumHasReviews(Album $album): bool + { + $count = (int) $this->entityManager->createQueryBuilder() + ->select('COUNT(r.id)') + ->from(Review::class, 'r') + ->where('r.album = :album') + ->setParameter('album', $album) + ->getQuery() + ->getSingleScalarResult(); + + return $count > 0; + } + + private function generateTitle(): string + { + $subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)]; + $verb = self::VERBS[random_int(0, count(self::VERBS) - 1)]; + + return sprintf('%s %s the vibe', $subject, $verb); + } + + private function generateContent(Album $album): string + { + $qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]; + + return sprintf( + 'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.', + $album->getName(), + $qualifier, + self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)], + self::VERBS[random_int(0, count(self::VERBS) - 1)], + self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)] + ); + } +} + diff --git a/src/Command/SeedDemoUsersCommand.php b/src/Command/SeedDemoUsersCommand.php new file mode 100644 index 0000000..b6255b9 --- /dev/null +++ b/src/Command/SeedDemoUsersCommand.php @@ -0,0 +1,98 @@ +addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50) + ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $count = (int) $input->getOption('count'); + $count = $count > 0 ? $count : 50; + $plainPassword = (string) $input->getOption('password'); + + $created = 0; + $seenEmails = []; + + while ($created < $count) { + $email = $this->generateEmail(); + if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) { + continue; + } + + $user = new User(); + $user->setEmail($email); + $user->setDisplayName($this->generateDisplayName()); + $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); + $user->setRoles(['ROLE_USER']); + + $this->entityManager->persist($user); + $seenEmails[$email] = true; + $created++; + } + + $this->entityManager->flush(); + + $io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword)); + + return Command::SUCCESS; + } + + private function generateEmail(): string + { + $token = bin2hex(random_bytes(4)); + + return sprintf('demo+%s@example.com', $token); + } + + private function generateDisplayName(): string + { + $first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)]; + $last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)]; + + return sprintf('%s %s', $first, $last); + } +} + diff --git a/src/Command/SeedUserAvatarsCommand.php b/src/Command/SeedUserAvatarsCommand.php new file mode 100644 index 0000000..d3e6e6f --- /dev/null +++ b/src/Command/SeedUserAvatarsCommand.php @@ -0,0 +1,83 @@ +addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set') + ->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs') + ->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $overwrite = (bool) $input->getOption('overwrite'); + $style = (string) $input->getOption('style'); + $seedPrefix = (string) $input->getOption('seed-prefix'); + + $users = $this->userRepository->findAll(); + if ($users === []) { + $io->warning('No users found.'); + return Command::FAILURE; + } + + $updated = 0; + foreach ($users as $user) { + if (!$user instanceof User) { + continue; + } + if (!$overwrite && $user->getProfileImagePath()) { + continue; + } + $user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix)); + $updated++; + } + + if ($updated === 0) { + $io->info('No avatars needed updating.'); + return Command::SUCCESS; + } + + $this->entityManager->flush(); + $io->success(sprintf('Assigned avatars to %d user(s).', $updated)); + + return Command::SUCCESS; + } + + private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string + { + $identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail())); + $seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32); + + return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed); + } +} + + diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index bef61b0..844cda8 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -9,6 +9,7 @@ use App\Entity\User; use App\Form\ReviewType; use App\Form\AlbumType; use App\Repository\AlbumRepository; +use App\Repository\AlbumTrackRepository; use App\Repository\ReviewRepository; use App\Service\AlbumSearchService; use App\Service\ImageStorage; @@ -89,20 +90,25 @@ class AlbumController extends AbstractController * Renders a detailed album view plus inline review form. */ #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] - public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em): Response + public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $albumEntity = $this->findAlbum($id, $albumRepo); $isSaved = $albumEntity !== null; if (!$albumEntity) { - $spotifyAlbum = $spotify->getAlbum($id); + $spotifyAlbum = $spotify->getAlbumWithTracks($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $em->flush(); + } else { + if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) { + $em->flush(); + } } $albumCard = $albumEntity->toTemplateArray(); $canManage = $this->canManageAlbum($albumEntity); + $trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray()); $existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); $count = count($existing); @@ -137,6 +143,9 @@ class AlbumController extends AbstractController 'avg' => $avg, 'count' => $count, 'form' => $form->createView(), + 'albumOwner' => $albumEntity->getCreatedBy(), + 'albumCreatedAt' => $albumEntity->getCreatedAt(), + 'tracks' => $trackRows, ]); } @@ -145,7 +154,7 @@ class AlbumController extends AbstractController */ #[IsGranted('ROLE_USER')] #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] - public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, EntityManagerInterface $em): Response + public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response { $token = (string) $request->request->get('_token'); if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { @@ -153,11 +162,11 @@ class AlbumController extends AbstractController } $existing = $albumRepo->findOneBySpotifyId($id); if (!$existing) { - $spotifyAlbum = $spotify->getAlbum($id); + $spotifyAlbum = $spotify->getAlbumWithTracks($id); if ($spotifyAlbum === null) { throw $this->createNotFoundException('Album not found'); } - $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo); $em->flush(); $this->addFlash('success', 'Album saved.'); } else { @@ -267,9 +276,12 @@ class AlbumController extends AbstractController */ private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album { - return str_starts_with($id, 'u_') - ? $albumRepo->findOneByLocalId($id) - : $albumRepo->findOneBySpotifyId($id); + $local = $albumRepo->findOneByLocalId($id); + if ($local instanceof Album) { + return $local; + } + + return $albumRepo->findOneBySpotifyId($id); } /** @@ -334,6 +346,51 @@ class AlbumController extends AbstractController } } + /** + * @param array $spotifyAlbum + */ + private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album + { + $album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $tracks = $spotifyAlbum['tracks']['items'] ?? []; + if (is_array($tracks) && $tracks !== []) { + $trackRepo->replaceAlbumTracks($album, $tracks); + $album->setTotalTracks(count($tracks)); + } + return $album; + } + + private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool + { + if ($album->getSource() !== 'spotify') { + return false; + } + $spotifyId = $album->getSpotifyId(); + if ($spotifyId === null) { + return false; + } + $storedCount = $album->getTracks()->count(); + $needsSync = $storedCount === 0; + if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) { + $needsSync = true; + } + if (!$needsSync) { + return false; + } + $spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId); + if ($spotifyAlbum === null) { + return false; + } + $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); + $tracks = $spotifyAlbum['tracks']['items'] ?? []; + if (!is_array($tracks) || $tracks === []) { + return false; + } + $trackRepo->replaceAlbumTracks($album, $tracks); + $album->setTotalTracks(count($tracks)); + return true; + } + } diff --git a/src/Entity/Album.php b/src/Entity/Album.php index d8a9aea..6fc4f04 100644 --- a/src/Entity/Album.php +++ b/src/Entity/Album.php @@ -2,8 +2,11 @@ namespace App\Entity; -use App\Repository\AlbumRepository; +use App\Entity\AlbumTrack; use App\Entity\User; +use App\Repository\AlbumRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -15,6 +18,10 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\HasLifecycleCallbacks] class Album { + #[ORM\OneToMany(mappedBy: 'album', targetEntity: AlbumTrack::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['discNumber' => 'ASC', 'trackNumber' => 'ASC'])] + private Collection $tracks; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -68,6 +75,11 @@ class Album #[ORM\Column(type: 'datetime_immutable')] private ?\DateTimeImmutable $updatedAt = null; + public function __construct() + { + $this->tracks = new ArrayCollection(); + } + /** * Initializes timestamps right before first persistence. */ @@ -324,6 +336,29 @@ class Album 'source' => $this->source, ]; } + + /** + * @return Collection + */ + public function getTracks(): Collection + { + return $this->tracks; + } + + public function addTrack(AlbumTrack $track): void + { + if (!$this->tracks->contains($track)) { + $this->tracks->add($track); + $track->setAlbum($this); + } + } + + public function removeTrack(AlbumTrack $track): void + { + if ($this->tracks->removeElement($track) && $track->getAlbum() === $this) { + $track->setAlbum(null); + } + } } diff --git a/src/Entity/AlbumTrack.php b/src/Entity/AlbumTrack.php new file mode 100644 index 0000000..ed18e67 --- /dev/null +++ b/src/Entity/AlbumTrack.php @@ -0,0 +1,140 @@ +id; + } + + public function getAlbum(): ?Album + { + return $this->album; + } + + public function setAlbum(?Album $album): void + { + $this->album = $album; + } + + public function getSpotifyTrackId(): ?string + { + return $this->spotifyTrackId; + } + + public function setSpotifyTrackId(?string $spotifyTrackId): void + { + $this->spotifyTrackId = $spotifyTrackId; + } + + public function getDiscNumber(): int + { + return $this->discNumber; + } + + public function setDiscNumber(int $discNumber): void + { + $this->discNumber = max(1, $discNumber); + } + + public function getTrackNumber(): int + { + return $this->trackNumber; + } + + public function setTrackNumber(int $trackNumber): void + { + $this->trackNumber = max(1, $trackNumber); + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDurationMs(): int + { + return $this->durationMs; + } + + public function setDurationMs(int $durationMs): void + { + $this->durationMs = max(0, $durationMs); + } + + public function getPreviewUrl(): ?string + { + return $this->previewUrl; + } + + public function setPreviewUrl(?string $previewUrl): void + { + $this->previewUrl = $previewUrl; + } + + /** + * Normalizes the track for template rendering. + * + * @return array{disc:int,track:int,name:string,duration_label:string,duration_seconds:int,preview_url:?string} + */ + public function toTemplateArray(): array + { + $seconds = (int) floor($this->durationMs / 1000); + $minutes = intdiv($seconds, 60); + $remainingSeconds = $seconds % 60; + + return [ + 'disc' => $this->discNumber, + 'track' => $this->trackNumber, + 'name' => $this->name, + 'duration_label' => sprintf('%d:%02d', $minutes, $remainingSeconds), + 'duration_seconds' => $seconds, + 'preview_url' => $this->previewUrl, + ]; + } +} + + diff --git a/src/Repository/AlbumTrackRepository.php b/src/Repository/AlbumTrackRepository.php new file mode 100644 index 0000000..eef2a94 --- /dev/null +++ b/src/Repository/AlbumTrackRepository.php @@ -0,0 +1,75 @@ + + */ +class AlbumTrackRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AlbumTrack::class); + } + + /** + * Replaces an album's stored tracklist with the provided Spotify payload. + * + * @param list> $trackPayloads + */ + public function replaceAlbumTracks(Album $album, array $trackPayloads): void + { + $em = $this->getEntityManager(); + + foreach ($album->getTracks()->toArray() as $existing) { + if ($existing instanceof AlbumTrack) { + $album->removeTrack($existing); + } + } + + $position = 1; + foreach ($trackPayloads as $payload) { + $name = trim((string) ($payload['name'] ?? '')); + if ($name === '') { + continue; + } + + $track = new AlbumTrack(); + $track->setAlbum($album); + $track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null)); + $track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1)); + $track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position)); + $track->setName($name); + $track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0))); + $track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null)); + + $album->addTrack($track); + $em->persist($track); + $position++; + } + } + + private function stringOrNull(mixed $value): ?string + { + if ($value === null) { + return null; + } + $string = trim((string) $value); + return $string === '' ? null : $string; + } + + private function normalizePositiveInt(mixed $value): int + { + $int = (int) $value; + return $int > 0 ? $int : 1; + } +} + + diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php index 67a86eb..a8b9af4 100644 --- a/src/Service/SpotifyClient.php +++ b/src/Service/SpotifyClient.php @@ -1,8 +1,10 @@ |null + */ + public function getAlbumWithTracks(string $albumId): ?array + { + $album = $this->getAlbum($albumId); + if ($album === null) { + return null; + } + $tracks = $this->getAlbumTracks($albumId); + if ($tracks !== []) { + $album['tracks'] = $album['tracks'] ?? []; + $album['tracks']['items'] = $tracks; + $album['tracks']['total'] = count($tracks); + $album['tracks']['limit'] = count($tracks); + $album['tracks']['offset'] = 0; + $album['tracks']['next'] = null; + $album['tracks']['previous'] = null; + } + return $album; + } + + /** + * Retrieves the complete tracklist for an album. + * + * @return list> + */ + public function getAlbumTracks(string $albumId): array + { + $accessToken = $this->getAccessToken(); + if ($accessToken === null) { + return []; + } + + $items = []; + $limit = 50; + $offset = 0; + do { + $page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset); + if ($page === null) { + break; + } + $batch = (array) ($page['items'] ?? []); + $items = array_merge($items, $batch); + $offset += $limit; + $total = isset($page['total']) ? (int) $page['total'] : null; + $hasNext = isset($page['next']) && $page['next'] !== null; + } while ($hasNext && ($total === null || $offset < $total)); + + return $items; + } + /** * Fetch multiple albums with one call. * @@ -132,18 +188,38 @@ class SpotifyClient return $data; } + /** + * @return array|null + */ + private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array + { + $url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId)); + $options = [ + 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ], + 'query' => [ 'limit' => $limit, 'offset' => $offset ], + ]; + try { + return $this->sendRequest('GET', $url, $options, 1200); + } catch (\Throwable) { + return null; + } + } + /** * Retrieves a cached access token or refreshes credentials when missing. */ private function getAccessToken(): ?string { - return $this->cache->get('spotify_client_credentials_token', function ($item) { - // Default to 1 hour, will adjust based on response + $cacheKey = 'spotify_client_credentials_token'; + $token = $this->cache->get($cacheKey, function (ItemInterface $item) { + // Default to ~1 hour, adjusted after Spotify response $item->expiresAfter(3500); - $clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''); - $clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''); + $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '')); + $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '')); if ($clientId === '' || $clientSecret === '') { + // surface the miss quickly so the cache can be recomputed on the next request + $item->expiresAfter(60); return null; } @@ -158,6 +234,7 @@ class SpotifyClient $data = $response->toArray(false); if (!isset($data['access_token'])) { + $item->expiresAfter(60); return null; } @@ -168,6 +245,13 @@ class SpotifyClient return $data['access_token']; }); + + if ($token === null) { + // Remove failed entries so the next request retries instead of serving cached nulls. + $this->cache->delete($cacheKey); + } + + return $token; } } diff --git a/templates/admin/users.html.twig b/templates/admin/users.html.twig index f91a66a..5568bd3 100644 --- a/templates/admin/users.html.twig +++ b/templates/admin/users.html.twig @@ -2,17 +2,61 @@ {% block title %}User Management{% endblock %} {% block body %} -

User management

+ {% set createPanelOpen = form.vars.submitted and not form.vars.valid %} +
+

User management

+ +
-
+
+
+
+
+
+

Create user

+ +
+ {{ form_start(form, {attr: {novalidate: 'novalidate'}}) }} +
+ {{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }} + {{ form_widget(form.email, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.email) }} +
+
+ {{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }} + {{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.displayName) }} +
+
+ {{ form_label(form.plainPassword.first, null, {label_attr: {class: 'form-label'}}) }} + {{ form_widget(form.plainPassword.first, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.plainPassword.first) }} +
+
+ {{ form_label(form.plainPassword.second, null, {label_attr: {class: 'form-label'}}) }} + {{ form_widget(form.plainPassword.second, {attr: {class: 'form-control'}}) }} + {{ form_errors(form.plainPassword.second) }} +
+ {{ form_errors(form.plainPassword) }} + + {{ form_end(form) }} +
+
+
+
+

Accounts

{{ rows|length }} total
-
- +
+
@@ -51,10 +95,23 @@ {% endif %} - - + - - + +
Name
-
{{ user.displayName ?? '—' }}
+
+ {% set avatar = user.profileImagePath %} + {% if avatar %} + Avatar for {{ user.displayName ?? user.email }} + {% else %} +
+ {{ (user.displayName ?? user.email)|slice(0,1)|upper }} +
+ {% endif %} +
+
{{ user.displayName ?? '—' }}
+
{{ user.email }}
+
+
{{ user.email }} + {{ user.email }} {% for role in user.roles %} {% if role == 'ROLE_ADMIN' %} Admin @@ -65,8 +122,8 @@ {% endif %} {% endfor %} {{ row.albumCount }}{{ row.reviewCount }}{{ row.albumCount }}{{ row.reviewCount }}
@@ -97,37 +154,6 @@
-
-
-
-

Create user

- {{ form_start(form, {attr: {novalidate: 'novalidate'}}) }} -
- {{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }} - {{ form_widget(form.email, {attr: {class: 'form-control'}}) }} - {{ form_errors(form.email) }} -
-
- {{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }} - {{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }} - {{ form_errors(form.displayName) }} -
-
- {{ form_label(form.plainPassword.first, null, {label_attr: {class: 'form-label'}}) }} - {{ form_widget(form.plainPassword.first, {attr: {class: 'form-control'}}) }} - {{ form_errors(form.plainPassword.first) }} -
-
- {{ form_label(form.plainPassword.second, null, {label_attr: {class: 'form-label'}}) }} - {{ form_widget(form.plainPassword.second, {attr: {class: 'form-control'}}) }} - {{ form_errors(form.plainPassword.second) }} -
- {{ form_errors(form.plainPassword) }} - - {{ form_end(form) }} -
-
-