its 7am i havent slept i have no idea
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m5s
CI (Gitea) / docker-image (push) Successful in 2m22s

This commit is contained in:
2025-11-28 06:40:10 +00:00
parent 336dcc4d3a
commit f77f3a9e40
34 changed files with 1142 additions and 183 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
.DS_Store
.cursor
.env.local
.git
.gitea
.github
.idea
.public
backup_manifests/
docs/
tests/
var/
# Symfony cache/logs generated inside the container should not come from the host
var/cache/
var/log/
# Uploaded files stay on the host, not baked into images
public/uploads/

View File

@@ -4,93 +4,110 @@
## - db: Postgres database for Doctrine ## - db: Postgres database for Doctrine
## - pgadmin: Optional UI to inspect the database ## - pgadmin: Optional UI to inspect the database
services: services:
php: # php:
# Build multi-stage image defined in docker/php/Dockerfile # # Build multi-stage image defined in docker/php/Dockerfile
build: # build:
context: . # context: .
dockerfile: docker/php/Dockerfile # dockerfile: docker/php/Dockerfile
target: dev # target: dev
args: # args:
- APP_ENV=dev # - APP_ENV=dev
container_name: php # container_name: php
# restart: unless-stopped
# #environment:
# # Doctrine DATABASE_URL consumed by Symfony/Doctrine
# #DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
# volumes:
# # Mount only source and config; vendors are installed in-container
# - ./bin:/var/www/html/bin
# - ./config:/var/www/html/config
# - ./migrations:/var/www/html/migrations
# - ./public:/var/www/html/public
# - ./templates:/var/www/html/templates
# - ./src:/var/www/html/src
# - ./var:/var/www/html/var
# - ./.env:/var/www/html/.env:ro
# - ./vendor:/var/www/html/vendor
# # Keep composer manifests on host for version control
# - ./composer.json:/var/www/html/composer.json
# - ./composer.lock:/var/www/html/composer.lock
# - ./symfony.lock:/var/www/html/symfony.lock
# # Speed up composer installs by caching download artifacts
# - composer_cache:/tmp/composer
# healthcheck:
# test: ["CMD-SHELL", "php -v || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 5
# depends_on:
# - db
tonehaus:
image: tonehaus/tonehaus:dev-arm64
container_name: tonehaus
restart: unless-stopped restart: unless-stopped
#environment:
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
#DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
volumes: volumes:
# Mount only source and config; vendors are installed in-container
- ./bin:/var/www/html/bin
- ./config:/var/www/html/config
- ./migrations:/var/www/html/migrations
- ./public:/var/www/html/public
- ./templates:/var/www/html/templates
- ./src:/var/www/html/src
- ./var:/var/www/html/var
- ./.env:/var/www/html/.env:ro - ./.env:/var/www/html/.env:ro
- ./vendor:/var/www/html/vendor - sqlite_data:/var/www/html/var/data
# Keep composer manifests on host for version control
- ./composer.json:/var/www/html/composer.json
- ./composer.lock:/var/www/html/composer.lock
- ./symfony.lock:/var/www/html/symfony.lock
# Speed up composer installs by caching download artifacts
- composer_cache:/tmp/composer
healthcheck:
test: ["CMD-SHELL", "php -v || exit 1"]
interval: 10s
timeout: 3s
retries: 5
depends_on:
- db
nginx:
image: nginx:alpine
container_name: nginx
ports: ports:
- "8000:80" - "8085:8080"
volumes: env_file:
# Serve built assets and front controller from Symfony public dir - .env
- ./public:/var/www/html/public
# Custom vhost with PHP FastCGI proxy
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"] test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 5 retries: 5
# nginx:
# image: nginx:alpine
# container_name: nginx
# ports:
# - "8000:80"
# volumes:
# # Serve built assets and front controller from Symfony public dir
# - ./public:/var/www/html/public
# # Custom vhost with PHP FastCGI proxy
# - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
# depends_on:
# - tonehaus
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
# interval: 10s
# timeout: 3s
# retries: 5
db: # db:
image: postgres:16-alpine # image: postgres:16-alpine
container_name: postgres # container_name: postgres
restart: unless-stopped # restart: unless-stopped
environment: # environment:
POSTGRES_DB: ${POSTGRES_DB:-symfony} # POSTGRES_DB: ${POSTGRES_DB:-symfony}
POSTGRES_USER: ${POSTGRES_USER:-symfony} # POSTGRES_USER: ${POSTGRES_USER:-symfony}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony} # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
ports: # ports:
- 5432:5432 # - 5432:5432
volumes: # volumes:
- db_data:/var/lib/postgresql/data # - db_data:/var/lib/postgresql/data
healthcheck: # healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"] # test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"]
interval: 10s # interval: 10s
timeout: 5s # timeout: 5s
retries: 10 # retries: 10
pgadmin: # pgadmin:
image: dpage/pgadmin4 # image: dpage/pgadmin4
container_name: pgadmin # container_name: pgadmin
environment: # environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com} # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password} # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password}
ports: # ports:
- "8081:80" # - "8081:80"
volumes: # volumes:
- pgadmin_data:/var/lib/pgadmin # - pgadmin_data:/var/lib/pgadmin
depends_on: # depends_on:
- db # - db
volumes: volumes:
db_data: sqlite_data:
composer_cache: # composer_cache:
pgadmin_data: # pgadmin_data:

View File

@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name localhost; # host header (not used locally) server_name _; # host header (not used locally)
root /var/www/html/public; # Symfony's public/ dir (front controller) root /var/www/html/public; # Symfony's public/ dir (front controller)
location / { location / {
@@ -11,7 +11,7 @@ server {
location ~ \.php$ { location ~ \.php$ {
# Forward PHP requests to php-fpm service # Forward PHP requests to php-fpm service
include fastcgi_params; include fastcgi_params;
fastcgi_pass php:9000; fastcgi_pass tonehaus:9000;
# Use resolved path to avoid path traversal issues # Use resolved path to avoid path traversal issues
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_param DOCUMENT_ROOT $realpath_root;

View File

@@ -8,6 +8,21 @@ require_app_secret() {
fi fi
} }
install_runtime() {
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
return
fi
echo "Installing Composer dependencies..."
su-exec www-data composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress
}
install_runtime
if [ -f bin/console ]; then if [ -f bin/console ]; then
require_app_secret require_app_secret
fi fi

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205134500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add genres JSON column to albums for advanced search filters';
}
public function up(Schema $schema): void
{
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
return;
}
if ($this->isSqlite()) {
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
return;
}
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite cannot drop columns without rebuilding the table; leave as-is.
return;
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
$this->addSql('ALTER TABLE albums DROP genres');
}
}
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);
}
}

View File

@@ -72,6 +72,7 @@ class SeedDemoAlbumsCommand extends Command
$album->setLocalId($localId); $album->setLocalId($localId);
$album->setName($this->generateAlbumName()); $album->setName($this->generateAlbumName());
$album->setArtists($this->generateArtists()); $album->setArtists($this->generateArtists());
$album->setGenres($this->generateGenres());
$album->setReleaseDate($this->generateReleaseDate()); $album->setReleaseDate($this->generateReleaseDate());
$album->setTotalTracks(random_int(6, 16)); $album->setTotalTracks(random_int(6, 16));
$album->setCoverUrl($this->generateCoverUrl($localId)); $album->setCoverUrl($this->generateCoverUrl($localId));
@@ -136,6 +137,19 @@ class SeedDemoAlbumsCommand extends Command
return sprintf('%04d-%02d-%02d', $year, $month, $day); return sprintf('%04d-%02d-%02d', $year, $month, $day);
} }
/**
* @return list<string>
*/
private function generateGenres(): array
{
$count = random_int(1, 3);
$genres = [];
for ($i = 0; $i < $count; $i++) {
$genres[] = self::GENRES[random_int(0, count(self::GENRES) - 1)];
}
return array_values(array_unique($genres));
}
private function generateCoverUrl(string $seed): string private function generateCoverUrl(string $seed): string
{ {
return sprintf('https://picsum.photos/seed/%s/640/640', $seed); return sprintf('https://picsum.photos/seed/%s/640/640', $seed);

View File

@@ -9,7 +9,7 @@ use App\Repository\AlbumRepository;
use App\Service\ImageStorage; use App\Service\ImageStorage;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -83,20 +83,18 @@ class AccountController extends AbstractController
$form = $this->createForm(ProfileFormType::class, $user); $form = $this->createForm(ProfileFormType::class, $user);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted()) {
$newPassword = (string) $form->get('newPassword')->getData(); $newPassword = (string) $form->get('newPassword')->getData();
if ($newPassword !== '') { if ($newPassword !== '') {
$current = (string) $form->get('currentPassword')->getData(); $current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) { if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); $form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/profile.html.twig', [ } else {
'form' => $form->createView(),
'profileImage' => $user->getProfileImagePath(),
]);
}
$user->setPassword($hasher->hashPassword($user, $newPassword)); $user->setPassword($hasher->hashPassword($user, $newPassword));
} }
}
if ($form->isValid()) {
$upload = $form->get('profileImage')->getData(); $upload = $form->get('profileImage')->getData();
if ($upload instanceof UploadedFile) { if ($upload instanceof UploadedFile) {
$images->remove($user->getProfileImagePath()); $images->remove($user->getProfileImagePath());
@@ -107,6 +105,7 @@ class AccountController extends AbstractController
$this->addFlash('success', 'Profile updated.'); $this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_profile'); return $this->redirectToRoute('account_profile');
} }
}
return $this->render('account/profile.html.twig', [ return $this->render('account/profile.html.twig', [
'form' => $form->createView(), 'form' => $form->createView(),

View File

@@ -5,8 +5,10 @@ namespace App\Controller\Admin;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Service\SpotifyMetadataRefresher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -48,4 +50,24 @@ class DashboardController extends AbstractController
'recentAlbums' => $recentAlbums, 'recentAlbums' => $recentAlbums,
]); ]);
} }
#[Route('/admin/dashboard/refresh-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])]
public function refreshSpotify(
Request $request,
SpotifyMetadataRefresher $refresher
): Response {
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$updated = $refresher->refreshAllSpotifyAlbums();
if ($updated === 0) {
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
} else {
$this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated));
}
return $this->redirectToRoute('admin_dashboard');
}
} }

View File

@@ -4,12 +4,14 @@ namespace App\Controller\Admin;
use App\Form\SiteSettingsType; use App\Form\SiteSettingsType;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
use App\Service\CatalogResetService;
use App\Service\ConsoleCommandRunner;
use App\Service\RegistrationToggle; use App\Service\RegistrationToggle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* SettingsController lets admins adjust key integration settings. * SettingsController lets admins adjust key integration settings.
@@ -17,6 +19,47 @@ use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController class SettingsController extends AbstractController
{ {
private const DEMO_COMMANDS = [
'users' => [
'command' => 'app:seed-demo-users',
'label' => 'Demo users',
'description' => 'Creates demo accounts with randomized emails.',
'fields' => [
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'],
],
],
'albums' => [
'command' => 'app:seed-demo-albums',
'label' => 'Demo albums',
'description' => 'Creates user albums with randomized metadata.',
'fields' => [
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40],
['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true],
],
],
'reviews' => [
'command' => 'app:seed-demo-reviews',
'label' => 'Demo reviews',
'description' => 'Adds sample reviews for existing albums.',
'fields' => [
['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1],
['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3],
['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'],
],
],
'avatars' => [
'command' => 'app:seed-user-avatars',
'label' => 'Profile pictures',
'description' => 'Assigns generated avatars to users (skips existing).',
'fields' => [
['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'],
['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'],
],
],
];
/** /**
* Displays and persists Spotify credential settings. * Displays and persists Spotify credential settings.
*/ */
@@ -46,6 +89,80 @@ class SettingsController extends AbstractController
'form' => $form->createView(), 'form' => $form->createView(),
'registrationImmutable' => $registrationOverride !== null, 'registrationImmutable' => $registrationOverride !== null,
'registrationOverrideValue' => $registrationOverride, 'registrationOverrideValue' => $registrationOverride,
'demoCommands' => self::DEMO_COMMANDS,
]); ]);
} }
#[Route('/admin/settings/reset-catalog', name: 'admin_settings_reset_catalog', methods: ['POST'])]
public function resetCatalog(Request $request, CatalogResetService $resetService): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('admin_settings_reset_catalog', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$result = $resetService->reset();
$this->addFlash('success', sprintf(
'Reset catalog: deleted %d reviews and %d albums.',
$result['reviews'],
$result['albums']
));
return $this->redirectToRoute('admin_settings');
}
#[Route('/admin/settings/generate-demo/{type}', name: 'admin_settings_generate_demo', methods: ['POST'])]
public function generateDemo(
string $type,
Request $request,
ConsoleCommandRunner $runner
): Response {
$config = self::DEMO_COMMANDS[$type] ?? null;
if ($config === null) {
throw $this->createNotFoundException('Unknown demo data type.');
}
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('admin_settings_generate_' . $type, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
try {
$options = $this->buildCommandOptions($config, $request);
$runner->run($config['command'], $options);
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
} catch (\Throwable $e) {
$this->addFlash('danger', sprintf(
'%s failed: %s',
$config['label'],
$e->getMessage()
));
}
return $this->redirectToRoute('admin_settings');
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
private function buildCommandOptions(array $config, Request $request): array
{
$options = [];
foreach (($config['fields'] ?? []) as $field) {
$name = (string) $field['name'];
$type = $field['type'] ?? 'text';
$value = $request->request->get($name);
if ($type === 'checkbox') {
if ($value) {
$options['--' . $name] = true;
}
continue;
}
if ($value === null || $value === '') {
continue;
}
$options['--' . $name] = $value;
}
return $options;
}
} }

View File

@@ -8,7 +8,7 @@ use App\Form\AdminUserType;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

View File

@@ -14,6 +14,7 @@ use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService; use App\Service\AlbumSearchService;
use App\Service\ImageStorage; use App\Service\ImageStorage;
use App\Service\SpotifyClient; use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -21,7 +22,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* AlbumController orchestrates search, CRUD, and review entry on albums. * AlbumController orchestrates search, CRUD, and review entry on albums.
@@ -31,6 +32,7 @@ class AlbumController extends AbstractController
public function __construct( public function __construct(
private readonly ImageStorage $imageStorage, private readonly ImageStorage $imageStorage,
private readonly AlbumSearchService $albumSearch, private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver,
private readonly int $searchLimit = 20 private readonly int $searchLimit = 20
) { ) {
} }
@@ -48,6 +50,7 @@ class AlbumController extends AbstractController
'query' => $criteria->query, 'query' => $criteria->query,
'album' => $criteria->albumName, 'album' => $criteria->albumName,
'artist' => $criteria->artist, 'artist' => $criteria->artist,
'genre' => $criteria->getGenre(),
'year_from' => $criteria->yearFrom ?? '', 'year_from' => $criteria->yearFrom ?? '',
'year_to' => $criteria->yearTo ?? '', 'year_to' => $criteria->yearTo ?? '',
'albums' => $result->albums, 'albums' => $result->albums,
@@ -69,7 +72,7 @@ class AlbumController extends AbstractController
$form = $this->createForm(AlbumType::class, $album); $form = $this->createForm(AlbumType::class, $album);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAlbumFormData($album, $form); $this->normalizeAlbumFormData($album);
$user = $this->getUser(); $user = $this->getUser();
if ($user instanceof User) { if ($user instanceof User) {
$album->setCreatedBy($user); $album->setCreatedBy($user);
@@ -256,10 +259,9 @@ class AlbumController extends AbstractController
$this->ensureCanManageAlbum($album); $this->ensureCanManageAlbum($album);
$form = $this->createForm(AlbumType::class, $album); $form = $this->createForm(AlbumType::class, $album);
$form->get('artistsCsv')->setData(implode(', ', $album->getArtists()));
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAlbumFormData($album, $form); $this->normalizeAlbumFormData($album);
$this->handleAlbumCoverUpload($album, $form); $this->handleAlbumCoverUpload($album, $form);
$em->flush(); $em->flush();
$this->addFlash('success', 'Album updated.'); $this->addFlash('success', 'Album updated.');
@@ -314,26 +316,11 @@ class AlbumController extends AbstractController
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId(); return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
} }
/** private function normalizeAlbumFormData(Album $album): void
* Applies normalized metadata from the album form.
*/
private function applyAlbumFormData(Album $album, FormInterface $form): void
{ {
$album->setArtists($this->parseArtistsCsv((string) $form->get('artistsCsv')->getData()));
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
} }
/**
* Splits the artists CSV input into a normalized list.
*
* @return list<string>
*/
private function parseArtistsCsv(string $csv): array
{
$parts = array_map(static fn($s) => trim((string) $s), explode(',', $csv));
return array_values(array_filter($parts, static fn($s) => $s !== ''));
}
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
{ {
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) { if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
@@ -351,7 +338,13 @@ class AlbumController extends AbstractController
*/ */
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
{ {
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); // Bring genres along when we persist Spotify albums so templates can display them immediately.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumId = (string) ($spotifyAlbum['id'] ?? '');
$album = $albumRepo->upsertFromSpotifyAlbum(
$spotifyAlbum,
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
);
$tracks = $spotifyAlbum['tracks']['items'] ?? []; $tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) { if (is_array($tracks) && $tracks !== []) {
$trackRepo->replaceAlbumTracks($album, $tracks); $trackRepo->replaceAlbumTracks($album, $tracks);
@@ -381,7 +374,10 @@ class AlbumController extends AbstractController
if ($spotifyAlbum === null) { if ($spotifyAlbum === null) {
return false; return false;
} }
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); // Rehydrate genres during syncs as well, in case Spotify has updated the metadata.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumGenres = $genresMap[$spotifyId] ?? [];
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres);
$tracks = $spotifyAlbum['tracks']['items'] ?? []; $tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (!is_array($tracks) || $tracks === []) { if (!is_array($tracks) || $tracks === []) {
return false; return false;

View File

@@ -6,14 +6,14 @@ use App\Entity\Review;
use App\Form\ReviewType; use App\Form\ReviewType;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
* ReviewController funnels CRUD flows through album pages and simple routes. * ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow.
*/ */
#[Route('/reviews')] #[Route('/reviews')]
class ReviewController extends AbstractController class ReviewController extends AbstractController
@@ -28,7 +28,7 @@ class ReviewController extends AbstractController
} }
/** /**
* Redirects users to the album flow when starting a review. * Nudges users back to the album flow so new reviews always start from a specific album.
*/ */
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])] #[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
@@ -43,7 +43,7 @@ class ReviewController extends AbstractController
} }
/** /**
* Shows a standalone review page. * Shows a read-only page for a single review.
*/ */
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] #[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
public function show(Review $review): Response public function show(Review $review): Response
@@ -54,7 +54,7 @@ class ReviewController extends AbstractController
} }
/** /**
* Handles review form edits with authorization. * Handles review edits by running the voter, binding the form, and saving valid updates.
*/ */
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])] #[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
@@ -78,7 +78,7 @@ class ReviewController extends AbstractController
} }
/** /**
* Deletes a review when the CSRF token and permission check pass. * Deletes a review after both the CSRF token and voter checks pass.
*/ */
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])] #[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]

View File

@@ -5,8 +5,8 @@ namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* AdminUserData transports user creation input from the admin form. * AdminUserData captures the fields used when an admin creates a user manually.
* This is a Data Transfer Object to avoid direct entity manipulation. * Using a DTO keeps validation separate from the User entity and avoids side effects.
* Used to allow user creation in the user management panel without invalidating active token. * Used to allow user creation in the user management panel without invalidating active token.
* (This took too long to figure out) * (This took too long to figure out)
*/ */

View File

@@ -12,6 +12,7 @@ final class AlbumSearchCriteria
public readonly string $query; public readonly string $query;
public readonly string $albumName; public readonly string $albumName;
public readonly string $artist; public readonly string $artist;
public readonly string $genre;
public readonly ?int $yearFrom; public readonly ?int $yearFrom;
public readonly ?int $yearTo; public readonly ?int $yearTo;
public readonly string $source; public readonly string $source;
@@ -21,6 +22,7 @@ final class AlbumSearchCriteria
string $query, string $query,
string $albumName, string $albumName,
string $artist, string $artist,
string $genre,
?int $yearFrom, ?int $yearFrom,
?int $yearTo, ?int $yearTo,
string $source, string $source,
@@ -29,6 +31,7 @@ final class AlbumSearchCriteria
$this->query = $query; $this->query = $query;
$this->albumName = $albumName; $this->albumName = $albumName;
$this->artist = $artist; $this->artist = $artist;
$this->genre = $genre;
$this->yearFrom = $yearFrom; $this->yearFrom = $yearFrom;
$this->yearTo = $yearTo; $this->yearTo = $yearTo;
$this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all'; $this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
@@ -44,6 +47,7 @@ final class AlbumSearchCriteria
query: trim((string) $request->query->get('q', '')), query: trim((string) $request->query->get('q', '')),
albumName: trim($request->query->getString('album', '')), albumName: trim($request->query->getString('album', '')),
artist: trim($request->query->getString('artist', '')), artist: trim($request->query->getString('artist', '')),
genre: trim($request->query->getString('genre', '')),
yearFrom: self::normalizeYear($request->query->get('year_from')), yearFrom: self::normalizeYear($request->query->get('year_from')),
yearTo: self::normalizeYear($request->query->get('year_to')), yearTo: self::normalizeYear($request->query->get('year_to')),
source: self::normalizeSource($request->query->getString('source', 'all')), source: self::normalizeSource($request->query->getString('source', 'all')),
@@ -61,6 +65,11 @@ final class AlbumSearchCriteria
return $this->source === 'all' || $this->source === 'user'; return $this->source === 'all' || $this->source === 'user';
} }
public function getGenre(): string
{
return $this->genre;
}
private static function normalizeYear(mixed $value): ?int private static function normalizeYear(mixed $value): ?int
{ {
if ($value === null) { if ($value === null) {

View File

@@ -3,7 +3,9 @@
namespace App\Dto; namespace App\Dto;
/** /**
* AlbumSearchResult encapsulates merged payloads for presentation layers. * AlbumSearchResult is the value object returned by AlbumSearchService.
* It keeps the original criteria alongside the merged album payloads,
* per-album review stats, and a list of Spotify IDs stored locally.
*/ */
final class AlbumSearchResult final class AlbumSearchResult
{ {

View File

@@ -49,6 +49,12 @@ class Album
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $artists = []; private array $artists = [];
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $genres = [];
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD // Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
#[ORM\Column(type: 'string', length: 20, nullable: true)] #[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null; private ?string $releaseDate = null;
@@ -188,6 +194,27 @@ class Album
$this->artists = array_values($artists); $this->artists = array_values($artists);
} }
/**
* @return list<string>
*/
public function getGenres(): array
{
return $this->genres;
}
/**
* @param list<string> $genres
*/
public function setGenres(array $genres): void
{
$normalized = array_map(
static fn($genre) => trim((string) $genre),
$genres
);
$filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== ''));
$this->genres = $filtered;
}
/** /**
* Returns the stored release date string. * Returns the stored release date string.
*/ */
@@ -324,12 +351,14 @@ class Album
$external = 'https://open.spotify.com/album/' . $this->spotifyId; $external = 'https://open.spotify.com/album/' . $this->spotifyId;
} }
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId; $publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
$genres = array_slice($this->genres, 0, 5);
return [ return [
'id' => $publicId, 'id' => $publicId,
'name' => $this->name, 'name' => $this->name,
'images' => $images, 'images' => $images,
'artists' => $artists, 'artists' => $artists,
'genres' => $genres,
'release_date' => $this->releaseDate, 'release_date' => $this->releaseDate,
'total_tracks' => $this->totalTracks, 'total_tracks' => $this->totalTracks,
'external_urls' => [ 'spotify' => $external ], 'external_urls' => [ 'spotify' => $external ],

View File

@@ -133,6 +133,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** /**
* Removes any sensitive transient data (no-op here). * Removes any sensitive transient data (no-op here).
*/ */
#[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')]
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
// no-op // no-op

View File

@@ -6,9 +6,10 @@ use App\Entity\Album;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -28,6 +29,12 @@ class AlbumType extends AbstractType
'label' => 'Artists (comma-separated)', 'label' => 'Artists (comma-separated)',
'constraints' => [new Assert\NotBlank()], 'constraints' => [new Assert\NotBlank()],
]) ])
->add('genresCsv', TextType::class, [
'mapped' => false,
'required' => false,
'label' => 'Genres (comma-separated)',
'help' => 'Optional: e.g. Dream pop, Shoegaze',
])
->add('releaseDate', TextType::class, [ ->add('releaseDate', TextType::class, [
'required' => false, 'required' => false,
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD', 'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
@@ -46,6 +53,34 @@ class AlbumType extends AbstractType
'required' => false, 'required' => false,
'label' => 'External link', 'label' => 'External link',
]); ]);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$album = $event->getData();
if (!$album instanceof Album) {
return;
}
$form = $event->getForm();
if ($form->has('artistsCsv')) {
$form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists()));
}
if ($form->has('genresCsv')) {
$form->get('genresCsv')->setData($this->implodeCsv($album->getGenres()));
}
});
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$album = $event->getData();
if (!$album instanceof Album) {
return;
}
$form = $event->getForm();
if ($form->has('artistsCsv')) {
$album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData()));
}
if ($form->has('genresCsv')) {
$album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData()));
}
});
} }
/** /**
@@ -57,6 +92,37 @@ class AlbumType extends AbstractType
'data_class' => Album::class, 'data_class' => Album::class,
]); ]);
} }
/**
* Converts a comma-separated string into a normalized, de-duplicated list.
*
* @return list<string>
*/
private function splitCsv(string $input): array
{
if ($input === '') {
return [];
}
$normalized = str_replace(["\n", "\r", ';'], ',', $input);
$parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized));
$filtered = array_values(array_filter($parts, static fn(string $value) => $value !== ''));
return array_values(array_unique($filtered));
}
/**
* Joins a list back into a user-friendly CSV string for display.
*
* @param list<string> $items
*/
private function implodeCsv(array $items): string
{
if ($items === []) {
return '';
}
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
}
} }

View File

@@ -62,12 +62,36 @@ class AlbumRepository extends ServiceEntityRepository
return $out; return $out;
} }
/**
* @return list<string>
*/
public function findAllSpotifyIds(): array
{
$rows = $this->createQueryBuilder('a')
->select('a.spotifyId')
->where('a.source = :src')
->andWhere('a.spotifyId IS NOT NULL')
->setParameter('src', 'spotify')
->getQuery()
->getScalarResult();
$ids = [];
foreach ($rows as $row) {
$sid = (string) ($row['spotifyId'] ?? '');
if ($sid !== '') {
$ids[] = $sid;
}
}
return array_values(array_unique($ids));
}
/** /**
* Filters user albums by optional metadata. * Filters user albums by optional metadata.
* *
* @return list<Album> * @return list<Album>
*/ */
public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array public function searchUserAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
{ {
$qb = $this->createQueryBuilder('a') $qb = $this->createQueryBuilder('a')
->where('a.source = :src') ->where('a.source = :src')
@@ -91,7 +115,7 @@ class AlbumRepository extends ServiceEntityRepository
} }
$results = $qb->getQuery()->getResult(); $results = $qb->getQuery()->getResult();
$artistNeedle = $artist ?? $freeText; $artistNeedle = $artist ?? $freeText;
return $this->filterByArtistAndLimit($results, $artistNeedle, $limit); return $this->filterByArtistAndGenreAndLimit($results, $artistNeedle, $genre, $limit);
} }
/** /**
@@ -99,7 +123,7 @@ class AlbumRepository extends ServiceEntityRepository
* *
* @return list<Album> * @return list<Album>
*/ */
public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array public function searchSpotifyAlbums(?string $freeText, ?string $albumName, ?string $artist, ?string $genre, int $yearFrom, int $yearTo, int $limit = 20): array
{ {
$qb = $this->createQueryBuilder('a') $qb = $this->createQueryBuilder('a')
->where('a.source = :src') ->where('a.source = :src')
@@ -123,7 +147,7 @@ class AlbumRepository extends ServiceEntityRepository
} }
} }
$results = $qb->getQuery()->getResult(); $results = $qb->getQuery()->getResult();
return $this->filterByArtistAndLimit($results, $artist ?? $freeText, $limit); return $this->filterByArtistAndGenreAndLimit($results, $artist ?? $freeText, $genre, $limit);
} }
/** /**
@@ -131,11 +155,16 @@ class AlbumRepository extends ServiceEntityRepository
* *
* @param array<string,mixed> $spotifyAlbum * @param array<string,mixed> $spotifyAlbum
*/ */
public function upsertFromSpotifyAlbum(array $spotifyAlbum): Album /**
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups).
*/
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album
{ {
$spotifyId = (string) ($spotifyAlbum['id'] ?? ''); $spotifyId = (string) ($spotifyAlbum['id'] ?? '');
$name = (string) ($spotifyAlbum['name'] ?? ''); $name = (string) ($spotifyAlbum['name'] ?? '');
$artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? []))); $artists = array_values(array_map(static fn($a) => (string) ($a['name'] ?? ''), (array) ($spotifyAlbum['artists'] ?? [])));
$rawGenres = $resolvedGenres !== [] ? $resolvedGenres : (array) ($spotifyAlbum['genres'] ?? []);
$genres = array_values(array_filter(array_map(static fn($g) => trim((string) $g), $rawGenres), static fn($g) => $g !== ''));
$releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null; $releaseDate = isset($spotifyAlbum['release_date']) ? (string) $spotifyAlbum['release_date'] : null;
$totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0); $totalTracks = (int) ($spotifyAlbum['total_tracks'] ?? 0);
@@ -157,6 +186,7 @@ class AlbumRepository extends ServiceEntityRepository
$album->setSpotifyId($spotifyId); $album->setSpotifyId($spotifyId);
$album->setName($name); $album->setName($name);
$album->setArtists($artists); $album->setArtists($artists);
$album->setGenres($genres);
$album->setReleaseDate($releaseDate); $album->setReleaseDate($releaseDate);
$album->setTotalTracks($totalTracks); $album->setTotalTracks($totalTracks);
$album->setCoverUrl($coverUrl); $album->setCoverUrl($coverUrl);
@@ -166,19 +196,48 @@ class AlbumRepository extends ServiceEntityRepository
} }
/** /**
* Applies artist/genre substring filters and the requested limit in the environment.
*
* @param list<Album> $albums * @param list<Album> $albums
* @return list<Album> * @return list<Album>
*/ */
private function filterByArtistAndLimit(array $albums, ?string $needle, int $limit): array private function filterByArtistAndGenreAndLimit(array $albums, ?string $artistNeedle, ?string $genreNeedle, int $limit): array
{ {
if ($needle === null || trim($needle) === '') { $filtered = $albums;
$appliedFilter = false;
$normalizedArtist = $this->normalizeNeedle($artistNeedle);
$normalizedGenre = $this->normalizeNeedle($genreNeedle);
if ($normalizedArtist !== null) {
$filtered = $this->filterByNeedle($filtered, $normalizedArtist, static fn(Album $album) => $album->getArtists(), $limit);
$appliedFilter = true;
}
if ($normalizedGenre !== null) {
$filtered = $this->filterByNeedle($filtered, $normalizedGenre, static fn(Album $album) => $album->getGenres(), $limit);
$appliedFilter = true;
}
if (!$appliedFilter) {
return array_slice($albums, 0, $limit); return array_slice($albums, 0, $limit);
} }
$needle = mb_strtolower(trim($needle));
return array_slice($filtered, 0, $limit);
}
/**
* Filters albums by matching a lowercase needle against extracted values.
*
* @param callable(Album):list<string> $valueExtractor
* @param list<Album> $albums
* @return list<Album>
*/
private function filterByNeedle(array $albums, string $needle, callable $valueExtractor, int $limit): array
{
$filtered = []; $filtered = [];
foreach ($albums as $album) { foreach ($albums as $album) {
foreach ($album->getArtists() as $artist) { $haystack = $valueExtractor($album);
if (str_contains(mb_strtolower($artist), $needle)) { foreach ($haystack as $value) {
if (str_contains(mb_strtolower($value), $needle)) {
$filtered[] = $album; $filtered[] = $album;
break; break;
} }
@@ -187,11 +246,20 @@ class AlbumRepository extends ServiceEntityRepository
break; break;
} }
} }
if ($filtered === []) {
return array_slice($albums, 0, $limit);
}
return $filtered; return $filtered;
} }
private function normalizeNeedle(?string $needle): ?string
{
if ($needle === null) {
return null;
}
$trimmed = trim($needle);
if ($trimmed === '') {
return null;
}
return mb_strtolower($trimmed);
}
} }

View File

@@ -28,13 +28,14 @@ class AlbumTrackRepository extends ServiceEntityRepository
{ {
$em = $this->getEntityManager(); $em = $this->getEntityManager();
foreach ($album->getTracks()->toArray() as $existing) { // Remove existing rows with a single query so unique constraints don't conflict during reinsert.
if ($existing instanceof AlbumTrack) { $em->createQuery('DELETE FROM App\Entity\AlbumTrack t WHERE t.album = :album')
$album->removeTrack($existing); ->setParameter('album', $album)
} ->execute();
} $album->getTracks()->clear();
$position = 1; $position = 1;
$occupied = [];
foreach ($trackPayloads as $payload) { foreach ($trackPayloads as $payload) {
$name = trim((string) ($payload['name'] ?? '')); $name = trim((string) ($payload['name'] ?? ''));
if ($name === '') { if ($name === '') {
@@ -44,8 +45,12 @@ class AlbumTrackRepository extends ServiceEntityRepository
$track = new AlbumTrack(); $track = new AlbumTrack();
$track->setAlbum($album); $track->setAlbum($album);
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null)); $track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null));
$track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1)); $disc = $this->normalizePositiveInt($payload['disc_number'] ?? 1);
$track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position)); $trackNumber = $this->normalizePositiveInt($payload['track_number'] ?? $position);
// Some Spotify payloads reuse the same track index (remixes, duplicates, etc.); bump forward until unique.
$trackNumber = $this->ensureUniqueTrackNumber($occupied, $disc, $trackNumber);
$track->setDiscNumber($disc);
$track->setTrackNumber($trackNumber);
$track->setName($name); $track->setName($name);
$track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0))); $track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0)));
$track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null)); $track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null));
@@ -70,6 +75,18 @@ class AlbumTrackRepository extends ServiceEntityRepository
$int = (int) $value; $int = (int) $value;
return $int > 0 ? $int : 1; return $int > 0 ? $int : 1;
} }
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
{
// Track which disc/track slots have already been claimed in this upsert run.
$discMap = $occupied[$disc] ?? [];
while (isset($discMap[$track])) {
$track++;
}
$discMap[$track] = true;
$occupied[$disc] = $discMap;
return $track;
}
} }

View File

@@ -7,11 +7,13 @@ use App\Dto\AlbumSearchResult;
use App\Entity\Album; use App\Entity\Album;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
* AlbumSearchService composes Spotify and user albums into reusable payloads. * AlbumSearchService pulls albums from Spotify and the local catalog,
* adds review stats, and hands the UI one uniform structure to render.
*/ */
class AlbumSearchService class AlbumSearchService
{ {
@@ -21,20 +23,30 @@ class AlbumSearchService
private readonly ReviewRepository $reviewRepository, private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) { ) {
} }
/**
* Runs the end-to-end search flow: figures out which sources to query, merges the payloads,
* and returns review stats plus saved IDs so templates get everything in one package.
*/
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{ {
$spotifyQuery = $this->buildSpotifyQuery($criteria); $spotifyQuery = $this->buildSpotifyQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery); $hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
// Spotify only gets pinged when callers explicitly enable it and we actually have
// something to ask for (bare "all" requests would otherwise waste API calls).
$shouldQuerySpotify = $criteria->useSpotify()
&& ($spotifyQuery !== '' || $criteria->getGenre() !== '' || $criteria->source === 'spotify');
$stats = []; $stats = [];
$savedIds = []; $savedIds = [];
$spotifyPayloads = []; $spotifyPayloads = [];
$userPayloads = []; $userPayloads = [];
if ($criteria->useSpotify() && $spotifyQuery !== '') { if ($shouldQuerySpotify) {
// Try to reuse cached Spotify albums and only hit the API if we still need more.
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery); $spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
$spotifyPayloads = $spotifyData['payloads']; $spotifyPayloads = $spotifyData['payloads'];
$stats = $this->mergeStats($stats, $spotifyData['stats']); $stats = $this->mergeStats($stats, $spotifyData['stats']);
@@ -42,6 +54,7 @@ class AlbumSearchService
} }
if ($criteria->useUser() && $hasUserFilters) { if ($criteria->useUser() && $hasUserFilters) {
// Skip the user query unless at least one meaningful filter is present.
$userData = $this->resolveUserAlbums($criteria); $userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads']; $userPayloads = $userData['payloads'];
$stats = $this->mergeStats($stats, $userData['stats']); $stats = $this->mergeStats($stats, $userData['stats']);
@@ -52,6 +65,9 @@ class AlbumSearchService
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds); return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
} }
/**
* Turns structured filters into Spotify's free-form query syntax.
*/
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
{ {
$parts = []; $parts = [];
@@ -77,34 +93,48 @@ class AlbumSearchService
return implode(' ', $parts); return implode(' ', $parts);
} }
/**
* Quick gate to tell if it's worth running the user catalog query.
*/
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
{ {
if ($criteria->source === 'user') {
return true;
}
return $spotifyQuery !== '' return $spotifyQuery !== ''
|| $criteria->albumName !== '' || $criteria->albumName !== ''
|| $criteria->artist !== '' || $criteria->artist !== ''
|| $criteria->getGenre() !== ''
|| $criteria->yearFrom !== null || $criteria->yearFrom !== null
|| $criteria->yearTo !== null; || $criteria->yearTo !== null;
} }
/** /**
* Looks up cached Spotify albums and, if needed, tops them off with fresh API data.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/ */
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
{ {
$stored = $this->albumRepository->searchSpotifyAlbums( $stored = $this->albumRepository->searchSpotifyAlbums(
$spotifyQuery, $criteria->query,
$criteria->albumName, $criteria->albumName,
$criteria->artist, $criteria->artist,
$criteria->getGenre(),
$criteria->yearFrom ?? 0, $criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $criteria->limit
); );
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored); $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->getGenre());
$storedIds = $this->collectSpotifyIds($stored); $storedIds = $this->collectSpotifyIds($stored);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : []; $stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds; $savedIds = $storedIds;
if (count($stored) >= $criteria->limit) { $shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit;
if (!$shouldFetchFromSpotify) {
return [ return [
'payloads' => array_slice($storedPayloads, 0, $criteria->limit), 'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
'stats' => $stats, 'stats' => $stats,
@@ -112,8 +142,10 @@ class AlbumSearchService
]; ];
} }
// Mix cached payloads with just enough fresh API data to satisfy the limit.
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads); $apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit); $filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre());
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']); $stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']); $savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
@@ -121,6 +153,8 @@ class AlbumSearchService
} }
/** /**
* Calls Spotify search/albums endpoints, syncs what we find, and returns normalized payloads, stats, and IDs.
*
* @param array<int,array<mixed>> $storedPayloads * @param array<int,array<mixed>> $storedPayloads
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/ */
@@ -149,9 +183,17 @@ class AlbumSearchService
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]); $this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
} }
// Resolve genres up-front (either from album payloads or artist fallbacks) so we store richer data.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albumsPayload);
$upserted = 0; $upserted = 0;
foreach ($albumsPayload as $payload) { foreach ($albumsPayload as $payload) {
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload); // Persist each album so the next search can reuse it without another API call.
$albumId = (string) ($payload['id'] ?? '');
$this->albumRepository->upsertFromSpotifyAlbum(
(array) $payload,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
$upserted++; $upserted++;
} }
$this->em->flush(); $this->em->flush();
@@ -175,6 +217,8 @@ class AlbumSearchService
} }
/** /**
* Searches user-created albums and attaches review aggregates keyed to each payload.
*
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>} * @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/ */
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
@@ -183,6 +227,7 @@ class AlbumSearchService
$criteria->query, $criteria->query,
$criteria->albumName, $criteria->albumName,
$criteria->artist, $criteria->artist,
$criteria->getGenre(),
$criteria->yearFrom ?? 0, $criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0, $criteria->yearTo ?? 0,
$criteria->limit $criteria->limit
@@ -202,6 +247,8 @@ class AlbumSearchService
} }
/** /**
* Re-keys entity-based stats to the local IDs that templates actually use.
*
* @param list<Album> $userAlbums * @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats * @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}> * @return array<string,array{count:int,avg:float}>
@@ -219,6 +266,13 @@ class AlbumSearchService
return $mapped; return $mapped;
} }
/**
* Chooses which payload list to return based on the requested source preference.
*
* @param array<int,array<mixed>> $userPayloads
* @param array<int,array<mixed>> $spotifyPayloads
* @return array<int,array<mixed>>
*/
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
{ {
if ($source === 'user') { if ($source === 'user') {
@@ -231,6 +285,8 @@ class AlbumSearchService
} }
/** /**
* Collects Spotify IDs from hydrated album entities while skipping blanks.
*
* @param list<Album> $albums * @param list<Album> $albums
* @return list<string> * @return list<string>
*/ */
@@ -247,6 +303,8 @@ class AlbumSearchService
} }
/** /**
* Pulls distinct Spotify IDs out of the raw search payload to reduce follow-up calls.
*
* @param array<int,mixed> $searchItems * @param array<int,mixed> $searchItems
* @return list<string> * @return list<string>
*/ */
@@ -262,6 +320,13 @@ class AlbumSearchService
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
/**
* Overwrites stats with the newest aggregates from the update set.
*
* @param array<string,array{count:int,avg:float}> $current
* @param array<string,array{count:int,avg:float}> $updates
* @return array<string,array{count:int,avg:float}>
*/
private function mergeStats(array $current, array $updates): array private function mergeStats(array $current, array $updates): array
{ {
foreach ($updates as $key => $value) { foreach ($updates as $key => $value) {
@@ -270,6 +335,13 @@ class AlbumSearchService
return $current; return $current;
} }
/**
* Adds newly saved Spotify IDs while tossing blanks and duplicates.
*
* @param list<string> $current
* @param list<string> $updates
* @return list<string>
*/
private function mergeSavedIds(array $current, array $updates): array private function mergeSavedIds(array $current, array $updates): array
{ {
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== '')); $merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
@@ -277,6 +349,8 @@ class AlbumSearchService
} }
/** /**
* Combines payload lists without duplicates, preferring primary items and honoring the limit.
*
* @param array<int,array<mixed>> $primary * @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary * @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>> * @return array<int,array<mixed>>
@@ -299,6 +373,7 @@ class AlbumSearchService
if ($id !== null && isset($seen[$id])) { if ($id !== null && isset($seen[$id])) {
continue; continue;
} }
// Fill the remainder of the list with secondary payloads that have not already been emitted.
$merged[] = $payload; $merged[] = $payload;
if ($id !== null) { if ($id !== null) {
$seen[$id] = true; $seen[$id] = true;
@@ -309,5 +384,30 @@ class AlbumSearchService
} }
return array_slice($merged, 0, $limit); return array_slice($merged, 0, $limit);
} }
/**
* @param array<int,array<mixed>> $payloads
* @return array<int,array<mixed>>
*/
private function filterPayloadsByGenre(array $payloads, string $genreNeedle): array
{
$genreNeedle = trim($genreNeedle);
if ($genreNeedle === '') {
return $payloads;
}
$needle = mb_strtolower($genreNeedle);
return array_values(array_filter($payloads, static function(array $payload) use ($needle): bool {
$genres = $payload['genres'] ?? [];
if (!is_array($genres)) {
return false;
}
foreach ($genres as $genre) {
if (str_contains(mb_strtolower((string) $genre), $needle)) {
return true;
}
}
return false;
}));
}
} }

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Service;
use App\Repository\AlbumRepository;
use Doctrine\ORM\EntityManagerInterface;
class CatalogResetService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AlbumRepository $albumRepository,
) {
}
/**
* @return array{albums:int,reviews:int}
*/
public function reset(): array
{
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
$albums = $this->albumRepository->findAll();
$albumCount = count($albums);
foreach ($albums as $album) {
$this->entityManager->remove($album);
}
$this->entityManager->flush();
return [
'albums' => $albumCount,
'reviews' => $deletedReviews,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Service;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* ConsoleCommandRunner executes Symfony console commands from HTTP contexts.
*/
class ConsoleCommandRunner
{
public function __construct(private readonly KernelInterface $kernel)
{
}
/**
* @param array<string,mixed> $options
*/
public function run(string $commandName, array $options = []): string
{
$application = new Application($this->kernel);
$application->setAutoExit(false);
$input = new ArrayInput(array_merge(['command' => $commandName], $options));
$output = new BufferedOutput();
$exitCode = $application->run($input, $output);
if ($exitCode !== Command::SUCCESS) {
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
}
return trim($output->fetch());
}
}

View File

@@ -6,6 +6,10 @@ use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\String\Slugger\SluggerInterface;
/**
* ImageStorage handles moving uploaded images under /public/uploads,
* making sure directories exist and returning web-ready paths.
*/
class ImageStorage class ImageStorage
{ {
private Filesystem $fs; private Filesystem $fs;
@@ -17,16 +21,25 @@ class ImageStorage
$this->fs = new Filesystem(); $this->fs = new Filesystem();
} }
/**
* Saves a profile avatar and returns the path the front end can render.
*/
public function storeProfileImage(UploadedFile $file): string public function storeProfileImage(UploadedFile $file): string
{ {
return $this->store($file, 'avatars'); return $this->store($file, 'avatars');
} }
/**
* Saves an album cover and returns the path the front end can render.
*/
public function storeAlbumCover(UploadedFile $file): string public function storeAlbumCover(UploadedFile $file): string
{ {
return $this->store($file, 'album_covers'); return $this->store($file, 'album_covers');
} }
/**
* Removes a stored image when the provided web path points to a file.
*/
public function remove(?string $webPath): void public function remove(?string $webPath): void
{ {
if ($webPath === null || $webPath === '') { if ($webPath === null || $webPath === '') {
@@ -38,6 +51,12 @@ class ImageStorage
} }
} }
/**
* Moves the uploaded file into the requested uploads directory and returns its web path.
*
* @param UploadedFile $file Uploaded Symfony file object.
* @param string $subDirectory Subdirectory under /public/uploads.
*/
private function store(UploadedFile $file, string $subDirectory): string private function store(UploadedFile $file, string $subDirectory): string
{ {
$targetDir = $this->projectDir . '/public/uploads/' . $subDirectory; $targetDir = $this->projectDir . '/public/uploads/' . $subDirectory;
@@ -48,6 +67,7 @@ class ImageStorage
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME); $originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = $this->slugger->slug($originalName ?: 'image'); $safeName = $this->slugger->slug($originalName ?: 'image');
$extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin'; $extension = $file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin';
// The uniqid suffix avoids collisions when users upload files with identical names.
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension); $filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), $extension);
$file->move($targetDir, $filename); $file->move($targetDir, $filename);

View File

@@ -5,7 +5,7 @@ namespace App\Service;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
/** /**
* RegistrationToggle centralizes the logic around the registration switch. * RegistrationToggle decides whether sign-ups are allowed, respecting env overrides and DB settings.
*/ */
final class RegistrationToggle final class RegistrationToggle
{ {
@@ -17,7 +17,7 @@ final class RegistrationToggle
} }
/** /**
* Returns the environment-provided override, or null when unset. * Returns the environment override when present, otherwise null.
*/ */
public function envOverride(): ?bool public function envOverride(): ?bool
{ {
@@ -25,7 +25,7 @@ final class RegistrationToggle
} }
/** /**
* Resolves whether registration should currently be enabled. * Tells callers whether registration is currently enabled.
*/ */
public function isEnabled(): bool public function isEnabled(): bool
{ {
@@ -37,13 +37,16 @@ final class RegistrationToggle
} }
/** /**
* Persists a new database-backed toggle value. * Saves the given toggle value to the settings store.
*/ */
public function persist(bool $enabled): void public function persist(bool $enabled): void
{ {
$this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0'); $this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0');
} }
/**
* Normalizes APP_ALLOW_REGISTRATION from the environment into a bool or null.
*/
private function detectEnvOverride(): ?bool private function detectEnvOverride(): ?bool
{ {
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null; $raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;

View File

@@ -119,6 +119,7 @@ class SpotifyClient
$limit = 50; $limit = 50;
$offset = 0; $offset = 0;
do { do {
// Spotify returns tracks in pages of 50, so iterate until there are no further pages.
$page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset); $page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset);
if ($page === null) { if ($page === null) {
break; break;
@@ -156,6 +157,31 @@ class SpotifyClient
} }
} }
/**
* Fetch multiple artists to gather genre information.
*
* @param list<string> $artistIds
* @return array<mixed>|null
*/
public function getArtists(array $artistIds): ?array
{
if ($artistIds === []) { return []; }
$accessToken = $this->getAccessToken();
if ($accessToken === null) { return null; }
$url = 'https://api.spotify.com/v1/artists';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'ids' => implode(',', $artistIds) ],
];
try {
return $this->sendRequest('GET', $url, $options, 1800);
} catch (\Throwable) {
return null;
}
}
/** /**
* Centralized request helper with lightweight caching. * Centralized request helper with lightweight caching.
* *
@@ -164,31 +190,26 @@ class SpotifyClient
*/ */
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0): array
{ {
$cacheKey = null; $request = function () use ($method, $url, $options): array {
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') { $response = $this->httpClient->request($method, $url, $options);
return $response->toArray(false);
};
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
if ($shouldCache) {
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? [])); $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
// placeholder; we'll set item value explicitly below on miss $item->expiresAfter($cacheTtlSeconds);
$item->expiresAfter(1); return $request();
return null;
}); });
if (is_array($cached) && !empty($cached)) {
return $cached;
}
} }
$response = $this->httpClient->request($method, $url, $options); return $request();
$data = $response->toArray(false);
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
$item->expiresAfter($cacheTtlSeconds);
return $data;
});
}
return $data;
} }
/** /**
* Requests one paginated track list page for an album using the provided OAuth token.
*
* @return array<mixed>|null * @return array<mixed>|null
*/ */
private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Service;
/**
* SpotifyGenreResolver fills in album genres using the Spotify artists endpoint as a fallback.
*/
class SpotifyGenreResolver
{
public function __construct(private readonly SpotifyClient $spotify)
{
}
/**
* @param list<array<string,mixed>> $albums
* @return array<string,list<string>> Map of album ID => genres list
*/
public function resolveGenresForAlbums(array $albums): array
{
$albumGenres = [];
$artistIds = [];
foreach ($albums as $album) {
$albumId = (string) ($album['id'] ?? '');
if ($albumId === '') {
continue;
}
$genres = $this->normalizeGenres((array) ($album['genres'] ?? []));
if ($genres !== []) {
$albumGenres[$albumId] = $genres;
continue;
}
// Collect artist IDs so we can fetch their genres in one pass rather than per album.
foreach ((array) ($album['artists'] ?? []) as $artist) {
$artistId = (string) ($artist['id'] ?? '');
if ($artistId !== '') {
$artistIds[$artistId] = true;
}
}
}
if ($artistIds === []) {
return $albumGenres;
}
$artistGenres = $this->fetchArtistsGenres(array_keys($artistIds));
if ($artistGenres === []) {
return $albumGenres;
}
foreach ($albums as $album) {
$albumId = (string) ($album['id'] ?? '');
if ($albumId === '' || isset($albumGenres[$albumId])) {
continue;
}
$combined = [];
foreach ((array) ($album['artists'] ?? []) as $artist) {
$artistId = (string) ($artist['id'] ?? '');
if ($artistId !== '' && isset($artistGenres[$artistId])) {
// Merge artist genres; duplicates removed later.
$combined = array_merge($combined, $artistGenres[$artistId]);
}
}
if ($combined !== []) {
$albumGenres[$albumId] = array_values(array_unique($combined));
}
}
return $albumGenres;
}
/**
* @param list<string> $artistIds
* @return array<string,list<string>>
*/
private function fetchArtistsGenres(array $artistIds): array
{
$genres = [];
foreach (array_chunk($artistIds, 50) as $chunk) {
// Spotify allows up to 50 artist IDs per request; batching keeps calls minimal.
$payload = $this->spotify->getArtists($chunk);
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
foreach ($artists as $artist) {
$id = (string) ($artist['id'] ?? '');
if ($id === '') {
continue;
}
$genres[$id] = $this->normalizeGenres((array) ($artist['genres'] ?? []));
}
}
return $genres;
}
/**
* @param array<int,mixed> $values
* @return list<string>
*/
private function normalizeGenres(array $values): array
{
return array_values(array_filter(array_map(
static fn($genre) => trim((string) $genre),
$values
), static fn($genre) => $genre !== ''));
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Service;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* SpotifyMetadataRefresher bulk-updates stored Spotify albums using batched API calls.
*/
class SpotifyMetadataRefresher
{
private const BATCH_SIZE = 20;
public function __construct(
private readonly SpotifyClient $spotify,
private readonly AlbumRepository $albumRepository,
private readonly AlbumTrackRepository $trackRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly SpotifyGenreResolver $genreResolver,
) {
}
/**
* Refreshes all saved Spotify albums and returns the number of payloads that were re-synced.
*/
public function refreshAllSpotifyAlbums(): int
{
$spotifyIds = $this->albumRepository->findAllSpotifyIds();
if ($spotifyIds === []) {
return 0;
}
$updated = 0;
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $chunk) {
$payload = $this->spotify->getAlbums($chunk);
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
if ($albums === []) {
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($chunk)]);
continue;
}
// Share the same genre resolution logic used during search so existing rows gain genre data too.
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
foreach ($albums as $albumData) {
try {
$albumId = (string) ($albumData['id'] ?? '');
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
(array) $albumData,
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
);
if ($albumId !== '' && $albumEntity !== null) {
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumData);
if ($tracks !== []) {
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
$albumEntity->setTotalTracks(count($tracks));
}
}
$updated++;
} catch (\Throwable $e) {
$this->logger->error('Failed to upsert Spotify album', [
'error' => $e->getMessage(),
'album' => $albumData['id'] ?? null,
]);
}
}
$this->em->flush();
}
return $updated;
}
/**
* @return list<array<string,mixed>>
*/
private function resolveTrackPayloads(string $albumId, array $albumPayload): array
{
$tracks = (array) ($albumPayload['tracks']['items'] ?? []);
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
if ($total > count($tracks)) {
$full = $this->spotify->getAlbumTracks($albumId);
if ($full !== []) {
return $full;
}
}
return $tracks;
}
}

View File

@@ -5,7 +5,7 @@
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %} {% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %} {% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
<div class="card"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div> <div>
@@ -34,6 +34,62 @@
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
</div> </div>
<div class="card mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Maintenance</h2>
<form method="post" action="{{ path('admin_settings_reset_catalog') }}" onsubmit="return confirm('Delete all albums, tracks, and reviews? Users remain untouched.');">
<input type="hidden" name="_token" value="{{ csrf_token('admin_settings_reset_catalog') }}">
<button class="btn btn-outline-danger" type="submit">Reset album & review data</button>
<div class="form-text mt-1">Deletes all albums (and tracks) plus reviews. Users and settings stay intact.</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h2 class="h6 mb-3">Generate demo data</h2>
<div class="vstack gap-4">
{% for key, command in demoCommands %}
<form class="p-3 border rounded" method="post" action="{{ path('admin_settings_generate_demo', {type: key}) }}">
<div class="row gy-3 align-items-center">
<div class="col-12 col-xl-4">
<div class="fw-semibold">{{ command.label }}</div>
<div class="text-secondary small">{{ command.description }}</div>
<div class="text-secondary small">Command: <code>php bin/console {{ command.command }}</code></div>
</div>
<div class="col-12 col-xl-8">
<div class="row g-3 align-items-end">
{% for field in command.fields %}
{% if field.type != 'checkbox' %}
<div class="col-auto">
<label class="form-label small mb-1" for="{{ key }}_{{ field.name }}">{{ field.label }}</label>
<input class="form-control form-control-sm" id="{{ key }}_{{ field.name }}" type="{{ field.type }}" name="{{ field.name }}" placeholder="{{ field.placeholder|default('') }}" value="{{ field.default|default('') }}">
</div>
{% endif %}
{% endfor %}
</div>
<div class="d-flex flex-wrap gap-3 mt-3">
{% for field in command.fields %}
{% if field.type == 'checkbox' %}
<div class="form-check form-switch" style="min-width: 220px;">
<input class="form-check-input" type="checkbox" name="{{ field.name }}" id="{{ key }}_{{ field.name }}" {% if field.default is defined and field.default %}checked{% endif %}>
<label class="form-check-label" for="{{ key }}_{{ field.name }}">{{ field.label }}</label>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<div class="text-end mt-3">
<input type="hidden" name="_token" value="{{ csrf_token('admin_settings_generate_' ~ key) }}">
<button class="btn btn-outline-primary btn-sm" type="submit">Run</button>
</div>
</form>
{% endfor %}
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,15 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Site Dashboard{% endblock %} {% block title %}Site Dashboard{% endblock %}
{% block body %} {% block body %}
<h1 class="h4 mb-3">Site dashboard</h1> <div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<h1 class="h4 mb-0">Site dashboard</h1>
<form method="post" action="{{ path('admin_dashboard_refresh_spotify') }}" class="ms-auto">
<input type="hidden" name="_token" value="{{ csrf_token('dashboard_refresh_spotify') }}">
<button class="btn btn-outline-success" type="submit">
Refresh Spotify metadata
</button>
</form>
</div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-sm-4"> <div class="col-sm-4">

View File

@@ -5,6 +5,7 @@
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div> <div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>

View File

@@ -5,6 +5,7 @@
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div> <div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.genresCsv) }}{{ form_widget(form.genresCsv, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
<div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div> <div>{{ form_label(form.coverUpload) }}{{ form_widget(form.coverUpload, {attr: {class: 'form-control'}}) }}</div>

View File

@@ -6,9 +6,10 @@
{% set artist_value = artist|default('') %} {% set artist_value = artist|default('') %}
{% set year_from_value = year_from|default('') %} {% set year_from_value = year_from|default('') %}
{% set year_to_value = year_to|default('') %} {% set year_to_value = year_to|default('') %}
{% set genre_value = genre|default('') %}
{% set source_value = source|default('all') %} {% set source_value = source|default('all') %}
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} {% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %} {% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (genre_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
{% set landing_view = not has_search %} {% set landing_view = not has_search %}
{% if landing_view %} {% if landing_view %}
@@ -52,6 +53,9 @@
<div class="col-sm-4"> <div class="col-sm-4">
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" /> <input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
</div> </div>
<div class="col-sm-4">
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
</div>
<div class="col-sm-2"> <div class="col-sm-2">
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" /> <input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
</div> </div>
@@ -84,6 +88,9 @@
<div class="col-sm-4"> <div class="col-sm-4">
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" /> <input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
</div> </div>
<div class="col-sm-4">
<input class="form-control" type="text" name="genre" value="{{ genre_value }}" placeholder="Genre" />
</div>
<div class="col-sm-2"> <div class="col-sm-2">
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" /> <input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" placeholder="Year from" min="1900" max="2100" />
</div> </div>
@@ -109,7 +116,12 @@
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5> <h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p> <p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
<p class="card-text text-secondary">Released {{ album.release_date }}{{ album.total_tracks }} tracks</p> <p class="card-text text-secondary">
Released {{ album.release_date }}{{ album.total_tracks }} tracks
{% if album.genres is defined and album.genres is not empty %}
<br><small>Genre: {{ album.genres|join(', ') }}</small>
{% endif %}
</p>
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p> <p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
<div class="mt-auto"> <div class="mt-auto">
@@ -133,7 +145,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% elseif query or album or artist or year_from or year_to %} {% elseif query or album or artist or genre or year_from or year_to %}
<p>No albums found.</p> <p>No albums found.</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -24,6 +24,9 @@
</small> </small>
{% endif %} {% endif %}
</p> </p>
{% if album.genres is defined and album.genres is not empty %}
<p class="text-secondary mb-2">Genres: {{ album.genres|join(', ') }}</p>
{% endif %}
<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>