wtf
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
This commit is contained in:
@@ -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.
|
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
|
## Database driver
|
||||||
|
|
||||||
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
use Symfony\Config\DoctrineConfig;
|
use Symfony\Config\DoctrineConfig;
|
||||||
|
|
||||||
return static function (DoctrineConfig $doctrine): void {
|
return static function (DoctrineConfig $doctrine): void {
|
||||||
@@ -32,6 +33,19 @@ return static function (DoctrineConfig $doctrine): void {
|
|||||||
if ($hasCustomPath) {
|
if ($hasCustomPath) {
|
||||||
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||||
} else {
|
} 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');
|
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -39,5 +53,4 @@ return static function (DoctrineConfig $doctrine): void {
|
|||||||
$connection->serverVersion('16');
|
$connection->serverVersion('16');
|
||||||
$connection->charset('utf8');
|
$connection->charset('utf8');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ services:
|
|||||||
- APP_ENV=dev
|
- APP_ENV=dev
|
||||||
container_name: php
|
container_name: php
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
#environment:
|
||||||
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
|
# 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:
|
volumes:
|
||||||
# Mount only source and config; vendors are installed in-container
|
# Mount only source and config; vendors are installed in-container
|
||||||
- ./bin:/var/www/html/bin
|
- ./bin:/var/www/html/bin
|
||||||
|
|||||||
@@ -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
|
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
|
## Admin user
|
||||||
```bash
|
```bash
|
||||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||||
|
|||||||
@@ -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_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.
|
- `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+<token>@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
|
### Access flow
|
||||||
- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically.
|
- 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.
|
- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
- `src/Service/SpotifyClient.php`
|
- `src/Service/SpotifyClient.php`
|
||||||
- Client Credentials token fetch (cached)
|
- Client Credentials token fetch (cached)
|
||||||
- `searchAlbums(q, limit)`
|
- `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
|
## Advanced search
|
||||||
- The search page builds Spotify fielded queries:
|
- The search page builds Spotify fielded queries:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Album page
|
## Album page
|
||||||
- Shows album artwork, metadata, average rating and review count.
|
- Shows album artwork, metadata, average rating and review count.
|
||||||
|
- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
|
||||||
- Lists reviews newest-first.
|
- Lists reviews newest-first.
|
||||||
- Logged-in users can submit a review inline.
|
- Logged-in users can submit a review inline.
|
||||||
|
|
||||||
@@ -13,4 +14,9 @@
|
|||||||
## UI
|
## UI
|
||||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
- 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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 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('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
|
||||||
$this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\'');
|
$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
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
|
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
|
||||||
$this->addSql('DROP TABLE reviews');
|
$this->addSql('DROP TABLE reviews');
|
||||||
$this->addSql('DROP TABLE users');
|
$this->addSql('DROP TABLE users');
|
||||||
$this->addSql('DROP TABLE messenger_messages');
|
$this->addSql('DROP TABLE messenger_messages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
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');
|
$this->addSql('CREATE SCHEMA public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
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');
|
$this->addSql('CREATE SCHEMA public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 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)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('DROP TABLE settings');
|
$this->addSql('DROP TABLE settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ final class Version20251114111853 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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
|
// Idempotent guard: if table already exists (from previous migration), skip
|
||||||
if ($schema->hasTable('albums')) {
|
if ($schema->hasTable('albums')) {
|
||||||
return;
|
return;
|
||||||
@@ -31,9 +35,17 @@ final class Version20251114111853 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Be defensive: only drop the table if it exists
|
// Be defensive: only drop the table if it exists
|
||||||
if ($schema->hasTable('albums')) {
|
if ($schema->hasTable('albums')) {
|
||||||
$this->addSql('DROP TABLE albums');
|
$this->addSql('DROP TABLE albums');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ final class Version20251114112016 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
$this->addSql('ALTER TABLE albums ALTER updated_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.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
@@ -29,7 +31,9 @@ final class Version20251114112016 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
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('CREATE SCHEMA public');
|
||||||
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
$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('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
|
|||||||
@@ -16,7 +16,24 @@ final class Version20251114113000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 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('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)');
|
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
|
||||||
@@ -53,6 +70,20 @@ SQL);
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
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('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
|
||||||
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
|
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
|
||||||
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
|
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ final class Version20251114114000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite schema never created the legacy columns.
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Guard: drop columns only if they exist
|
// Guard: drop columns only if they exist
|
||||||
$this->addSql(<<<'SQL'
|
$this->addSql(<<<'SQL'
|
||||||
DO $$
|
DO $$
|
||||||
@@ -35,11 +39,19 @@ SQL);
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Recreate columns as nullable in down migration
|
// 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 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_name VARCHAR(255) DEFAULT NULL');
|
||||||
$this->addSql('ALTER TABLE reviews ADD album_artist 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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ final class Version20251114120500 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 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 source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
|
||||||
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
|
$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
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
|
$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 uniq_album_local_id");
|
||||||
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
|
$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 DROP COLUMN created_by_id");
|
||||||
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
|
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,24 @@ final class Version20251120174722 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 TABLE reviews ALTER album_id SET NOT NULL');
|
||||||
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
|
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
|
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
|
||||||
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
|
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ final class Version20251120175034 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
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');
|
$this->addSql('CREATE SCHEMA public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,24 @@ final class Version20251127191813 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
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 TABLE albums ALTER source DROP DEFAULT');
|
||||||
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
|
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
|
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
|
||||||
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
|
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
migrations/Version20251127235840.php
Normal file
63
migrations/Version20251127235840.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?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 Version20251127235840 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,14 +16,39 @@ final class Version20251205123000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
|
if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) {
|
||||||
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
$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
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
$this->addSql('ALTER TABLE users DROP profile_image_path');
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('ALTER TABLE albums DROP cover_image_path');
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
migrations/Version20251205133000.php
Normal file
50
migrations/Version20251205133000.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251205133000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create album_tracks table to persist Spotify tracklists';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -185,3 +185,119 @@ a:hover {
|
|||||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
144
src/Command/SeedDemoAlbumsCommand.php
Normal file
144
src/Command/SeedDemoAlbumsCommand.php
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-albums',
|
||||||
|
description: 'Create demo albums with randomized metadata for local development.'
|
||||||
|
)]
|
||||||
|
class SeedDemoAlbumsCommand extends Command
|
||||||
|
{
|
||||||
|
private const GENRES = [
|
||||||
|
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
|
||||||
|
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ADJECTIVES = [
|
||||||
|
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const NOUNS = [
|
||||||
|
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
|
||||||
|
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
||||||
|
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
207
src/Command/SeedDemoReviewsCommand.php
Normal file
207
src/Command/SeedDemoReviewsCommand.php
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\Review;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-reviews',
|
||||||
|
description: 'Generate demo reviews across existing albums.'
|
||||||
|
)]
|
||||||
|
class SeedDemoReviewsCommand extends Command
|
||||||
|
{
|
||||||
|
private const SUBJECTS = [
|
||||||
|
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
|
||||||
|
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const VERBS = [
|
||||||
|
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
|
||||||
|
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const QUALIFIERS = [
|
||||||
|
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
|
||||||
|
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
|
||||||
|
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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<Album> $albums
|
||||||
|
* @return list<Album>
|
||||||
|
*/
|
||||||
|
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<User> $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<int,bool>
|
||||||
|
*/
|
||||||
|
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)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
98
src/Command/SeedDemoUsersCommand.php
Normal file
98
src/Command/SeedDemoUsersCommand.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-users',
|
||||||
|
description: 'Create demo users with random emails and display names.'
|
||||||
|
)]
|
||||||
|
class SeedDemoUsersCommand extends Command
|
||||||
|
{
|
||||||
|
private const FIRST_NAMES = [
|
||||||
|
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
|
||||||
|
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const LAST_NAMES = [
|
||||||
|
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
|
||||||
|
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
src/Command/SeedUserAvatarsCommand.php
Normal file
83
src/Command/SeedUserAvatarsCommand.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-user-avatars',
|
||||||
|
description: 'Assign generated profile images to existing users.'
|
||||||
|
)]
|
||||||
|
class SeedUserAvatarsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ use App\Entity\User;
|
|||||||
use App\Form\ReviewType;
|
use App\Form\ReviewType;
|
||||||
use App\Form\AlbumType;
|
use App\Form\AlbumType;
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Service\AlbumSearchService;
|
use App\Service\AlbumSearchService;
|
||||||
use App\Service\ImageStorage;
|
use App\Service\ImageStorage;
|
||||||
@@ -89,20 +90,25 @@ class AlbumController extends AbstractController
|
|||||||
* Renders a detailed album view plus inline review form.
|
* Renders a detailed album view plus inline review form.
|
||||||
*/
|
*/
|
||||||
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
|
#[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);
|
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||||
$isSaved = $albumEntity !== null;
|
$isSaved = $albumEntity !== null;
|
||||||
if (!$albumEntity) {
|
if (!$albumEntity) {
|
||||||
$spotifyAlbum = $spotify->getAlbum($id);
|
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||||
if ($spotifyAlbum === null) {
|
if ($spotifyAlbum === null) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
}
|
}
|
||||||
$albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
} else {
|
||||||
|
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$albumCard = $albumEntity->toTemplateArray();
|
$albumCard = $albumEntity->toTemplateArray();
|
||||||
$canManage = $this->canManageAlbum($albumEntity);
|
$canManage = $this->canManageAlbum($albumEntity);
|
||||||
|
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
|
||||||
|
|
||||||
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
||||||
$count = count($existing);
|
$count = count($existing);
|
||||||
@@ -137,6 +143,9 @@ class AlbumController extends AbstractController
|
|||||||
'avg' => $avg,
|
'avg' => $avg,
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'albumOwner' => $albumEntity->getCreatedBy(),
|
||||||
|
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||||
|
'tracks' => $trackRows,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@ class AlbumController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
|
#[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');
|
$token = (string) $request->request->get('_token');
|
||||||
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
|
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
|
||||||
@@ -153,11 +162,11 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
$existing = $albumRepo->findOneBySpotifyId($id);
|
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||||
if (!$existing) {
|
if (!$existing) {
|
||||||
$spotifyAlbum = $spotify->getAlbum($id);
|
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||||
if ($spotifyAlbum === null) {
|
if ($spotifyAlbum === null) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
}
|
}
|
||||||
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
|
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Album saved.');
|
$this->addFlash('success', 'Album saved.');
|
||||||
} else {
|
} else {
|
||||||
@@ -267,9 +276,12 @@ class AlbumController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||||
{
|
{
|
||||||
return str_starts_with($id, 'u_')
|
$local = $albumRepo->findOneByLocalId($id);
|
||||||
? $albumRepo->findOneByLocalId($id)
|
if ($local instanceof Album) {
|
||||||
: $albumRepo->findOneBySpotifyId($id);
|
return $local;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $albumRepo->findOneBySpotifyId($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,6 +346,51 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use App\Repository\AlbumRepository;
|
use App\Entity\AlbumTrack;
|
||||||
use App\Entity\User;
|
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 Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
@@ -15,6 +18,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
class Album
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
@@ -68,6 +75,11 @@ class Album
|
|||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tracks = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes timestamps right before first persistence.
|
* Initializes timestamps right before first persistence.
|
||||||
*/
|
*/
|
||||||
@@ -324,6 +336,29 @@ class Album
|
|||||||
'source' => $this->source,
|
'source' => $this->source,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, AlbumTrack>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
140
src/Entity/AlbumTrack.php
Normal file
140
src/Entity/AlbumTrack.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumTrack persists individual tracks fetched from Spotify.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
|
||||||
|
#[ORM\Table(name: 'album_tracks')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_album_disc_track', columns: ['album_id', 'disc_number', 'track_number'])]
|
||||||
|
class AlbumTrack
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Album::class, inversedBy: 'tracks')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Album $album = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||||
|
private ?string $spotifyTrackId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $discNumber = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $trackNumber = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 512)]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $durationMs = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
|
private ?string $previewUrl = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
75
src/Repository/AlbumTrackRepository.php
Normal file
75
src/Repository/AlbumTrackRepository.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumTrackRepository manages bulk track synchronization.
|
||||||
|
*
|
||||||
|
* @extends ServiceEntityRepository<AlbumTrack>
|
||||||
|
*/
|
||||||
|
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<array<string,mixed>> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Repository\SettingRepository;
|
use App\Repository\SettingRepository;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,6 +79,60 @@ class SpotifyClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches album metadata plus the full tracklist.
|
||||||
|
*
|
||||||
|
* @return array<mixed>|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<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
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.
|
* Fetch multiple albums with one call.
|
||||||
*
|
*
|
||||||
@@ -132,18 +188,38 @@ class SpotifyClient
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<mixed>|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.
|
* Retrieves a cached access token or refreshes credentials when missing.
|
||||||
*/
|
*/
|
||||||
private function getAccessToken(): ?string
|
private function getAccessToken(): ?string
|
||||||
{
|
{
|
||||||
return $this->cache->get('spotify_client_credentials_token', function ($item) {
|
$cacheKey = 'spotify_client_credentials_token';
|
||||||
// Default to 1 hour, will adjust based on response
|
$token = $this->cache->get($cacheKey, function (ItemInterface $item) {
|
||||||
|
// Default to ~1 hour, adjusted after Spotify response
|
||||||
$item->expiresAfter(3500);
|
$item->expiresAfter(3500);
|
||||||
|
|
||||||
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
|
$clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
|
||||||
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
|
$clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
|
||||||
if ($clientId === '' || $clientSecret === '') {
|
if ($clientId === '' || $clientSecret === '') {
|
||||||
|
// surface the miss quickly so the cache can be recomputed on the next request
|
||||||
|
$item->expiresAfter(60);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +234,7 @@ class SpotifyClient
|
|||||||
$data = $response->toArray(false);
|
$data = $response->toArray(false);
|
||||||
|
|
||||||
if (!isset($data['access_token'])) {
|
if (!isset($data['access_token'])) {
|
||||||
|
$item->expiresAfter(60);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +245,13 @@ class SpotifyClient
|
|||||||
|
|
||||||
return $data['access_token'];
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,61 @@
|
|||||||
{% block title %}User Management{% endblock %}
|
{% block title %}User Management{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="h4 mb-4">User management</h1>
|
{% set createPanelOpen = form.vars.submitted and not form.vars.valid %}
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||||
|
<h1 class="h4 mb-0">User management</h1>
|
||||||
|
<button class="btn btn-accent" type="button" data-bs-toggle="collapse" data-bs-target="#create-user-panel" aria-expanded="{{ createPanelOpen ? 'true' : 'false' }}" aria-controls="create-user-panel">
|
||||||
|
Create user
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-8">
|
<div class="col-12">
|
||||||
|
<div class="collapse {{ createPanelOpen ? 'show' : '' }}" id="create-user-panel">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h6 mb-0">Create user</h2>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#create-user-panel" aria-controls="create-user-panel">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
|
||||||
|
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.email) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
|
||||||
|
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
|
||||||
|
{{ form_errors(form.displayName) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ 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) }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ 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) }}
|
||||||
|
</div>
|
||||||
|
{{ form_errors(form.plainPassword) }}
|
||||||
|
<button class="btn btn-success w-100" type="submit">Create account</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h2 class="h6 mb-0">Accounts</h2>
|
<h2 class="h6 mb-0">Accounts</h2>
|
||||||
<span class="text-secondary small">{{ rows|length }} total</span>
|
<span class="text-secondary small">{{ rows|length }} total</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="mui-table-wrapper">
|
||||||
<table class="table table-sm align-middle">
|
<table class="mui-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
@@ -51,10 +95,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">{{ user.displayName ?? '—' }}</div>
|
<div class="mui-table__title-avatar">
|
||||||
|
{% set avatar = user.profileImagePath %}
|
||||||
|
{% if avatar %}
|
||||||
|
<img src="{{ avatar }}" alt="Avatar for {{ user.displayName ?? user.email }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-3 bg-secondary-subtle text-secondary fw-semibold d-flex align-items-center justify-content-center" style="width:40px;height:40px;">
|
||||||
|
{{ (user.displayName ?? user.email)|slice(0,1)|upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="mui-table__title">{{ user.displayName ?? '—' }}</div>
|
||||||
|
<div class="mui-table__subtitle d-md-none">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.email }}</td>
|
<td class="align-middle d-none d-md-table-cell">{{ user.email }}</td>
|
||||||
<td>
|
<td class="align-middle">
|
||||||
{% for role in user.roles %}
|
{% for role in user.roles %}
|
||||||
{% if role == 'ROLE_ADMIN' %}
|
{% if role == 'ROLE_ADMIN' %}
|
||||||
<span class="badge text-bg-danger">Admin</span>
|
<span class="badge text-bg-danger">Admin</span>
|
||||||
@@ -65,8 +122,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">{{ row.albumCount }}</td>
|
<td class="mui-table__metric">{{ row.albumCount }}</td>
|
||||||
<td class="text-center">{{ row.reviewCount }}</td>
|
<td class="mui-table__metric">{{ row.reviewCount }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-flex gap-2 justify-content-end">
|
<div class="d-flex gap-2 justify-content-end">
|
||||||
<form method="post" action="{{ path('admin_users_promote', {id: user.id}) }}" onsubmit="return confirm('{% if isModerator %}Remove moderator access from {{ user.email }}?{% else %}Promote {{ user.email }} to moderator?{% endif %}');">
|
<form method="post" action="{{ path('admin_users_promote', {id: user.id}) }}" onsubmit="return confirm('{% if isModerator %}Remove moderator access from {{ user.email }}?{% else %}Promote {{ user.email }} to moderator?{% endif %}');">
|
||||||
@@ -97,37 +154,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="h6 mb-3">Create user</h2>
|
|
||||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
|
||||||
<div class="mb-3">
|
|
||||||
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
|
|
||||||
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
|
|
||||||
{{ form_errors(form.email) }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
|
|
||||||
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
|
|
||||||
{{ form_errors(form.displayName) }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
{{ 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) }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
{{ 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) }}
|
|
||||||
</div>
|
|
||||||
{{ form_errors(form.plainPassword) }}
|
|
||||||
<button class="btn btn-success w-100" type="submit">Create account</button>
|
|
||||||
{{ form_end(form) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|||||||
@@ -11,7 +11,19 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-1">{{ album.name }}</h5>
|
<h5 class="card-title mb-1">{{ album.name }}</h5>
|
||||||
<div class="text-secondary mb-2">{{ album.artists|map(a => a.name)|join(', ') }}</div>
|
<div class="text-secondary mb-2">{{ album.artists|map(a => a.name)|join(', ') }}</div>
|
||||||
<p class="text-secondary mb-2">Released {{ album.release_date }} • {{ album.total_tracks }} tracks</p>
|
{% set release_text = album.release_date is defined and album.release_date ? album.release_date : 'Not provided' %}
|
||||||
|
{% set track_count = album.total_tracks is defined and album.total_tracks ? album.total_tracks : 0 %}
|
||||||
|
{% set track_text = track_count > 0 ? track_count ~ ' tracks' : 'Track count unknown' %}
|
||||||
|
<p class="text-secondary mb-2">
|
||||||
|
Released {{ release_text }} • {{ track_text }}
|
||||||
|
{% if album.source is defined and album.source == 'user' %}
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
Added{{ albumOwner ? ' by ' ~ (albumOwner.displayName ?? albumOwner.userIdentifier) : '' }}
|
||||||
|
{% if albumCreatedAt %}on {{ albumCreatedAt|date('Y-m-d') }}{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
|
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
|
||||||
{% if album.external_urls.spotify %}
|
{% if album.external_urls.spotify %}
|
||||||
<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-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||||
@@ -38,6 +50,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
|
{% if tracks is defined and tracks is not empty %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 mb-0">Tracklist</h2>
|
||||||
|
<span class="text-secondary">{{ tracks|length }} tracks</span>
|
||||||
|
</div>
|
||||||
|
<div class="mui-table-wrapper w-100">
|
||||||
|
<table class="mui-table mui-table--compact mui-table--striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Title</th>
|
||||||
|
<th scope="col" class="text-end">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for track in tracks %}
|
||||||
|
<tr>
|
||||||
|
<td class="mui-table__number">{{ track.disc > 1 ? track.disc ~ '.' : '' }}{{ track.track }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="mui-table__title">{{ track.name }}</div>
|
||||||
|
{% if track.preview_url %}
|
||||||
|
<a class="mui-icon-button" href="{{ track.preview_url }}" target="_blank" rel="noopener" title="Preview track">
|
||||||
|
►
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end mui-table__metric">{{ track.duration_label }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h2 class="h5 mb-0">Reviews</h2>
|
<h2 class="h5 mb-0">Reviews</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
BIN
var/.DS_Store
vendored
BIN
var/.DS_Store
vendored
Binary file not shown.
Reference in New Issue
Block a user