its 7am i havent slept i have no idea
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal 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/
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
migrations/Version20251205134500.php
Normal file
58
migrations/Version20251205134500.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,29 +83,28 @@ 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(),
|
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||||
'profileImage' => $user->getProfileImagePath(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$upload = $form->get('profileImage')->getData();
|
if ($form->isValid()) {
|
||||||
if ($upload instanceof UploadedFile) {
|
$upload = $form->get('profileImage')->getData();
|
||||||
$images->remove($user->getProfileImagePath());
|
if ($upload instanceof UploadedFile) {
|
||||||
$user->setProfileImagePath($images->storeProfileImage($upload));
|
$images->remove($user->getProfileImagePath());
|
||||||
}
|
$user->setProfileImagePath($images->storeProfileImage($upload));
|
||||||
|
}
|
||||||
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$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', [
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')]
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
src/Service/CatalogResetService.php
Normal file
37
src/Service/CatalogResetService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
40
src/Service/ConsoleCommandRunner.php
Normal file
40
src/Service/ConsoleCommandRunner.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
111
src/Service/SpotifyGenreResolver.php
Normal file
111
src/Service/SpotifyGenreResolver.php
Normal 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 !== ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
97
src/Service/SpotifyMetadataRefresher.php
Normal file
97
src/Service/SpotifyMetadataRefresher.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user