documentation and env changes
This commit is contained in:
25
.env.example
25
.env.example
@@ -1,17 +1,12 @@
|
|||||||
SPOTIFY_CLIENT_ID=
|
# Uncomment to override stored setting.
|
||||||
SPOTIFY_CLIENT_SECRET=
|
#SPOTIFY_CLIENT_ID=
|
||||||
APP_ENV=dev
|
#SPOTIFY_CLIENT_SECRET=
|
||||||
APP_SECRET=changeme # Arbitrary secret. Ideally a long random string.
|
|
||||||
APP_ALLOW_REGISTRATION=1 #
|
|
||||||
DEFAULT_URI=http://localhost:8000 # Should match external URI of application.
|
|
||||||
DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite
|
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
|
||||||
#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite
|
|
||||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits.
|
|
||||||
|
|
||||||
# POSTGRES_DB=
|
APP_ENV=prod
|
||||||
# POSTGRES_USER=
|
APP_SECRET=changeme
|
||||||
# POSTGRES_PASSWORD=
|
# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
|
||||||
|
DEFAULT_URI=http://localhost:8085
|
||||||
|
ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by Spotify.
|
||||||
|
|
||||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
|
||||||
PGADMIN_DEFAULT_PASSWORD=password
|
# DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||||
|
|||||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -12,6 +12,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
@@ -70,6 +71,8 @@ jobs:
|
|||||||
name: Build production image
|
name: Build production image
|
||||||
needs: php-tests
|
needs: php-tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -100,3 +103,19 @@ jobs:
|
|||||||
- name: Smoke-test entrypoint & migrations
|
- name: Smoke-test entrypoint & migrations
|
||||||
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
|
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Tag latest image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
|
||||||
|
|
||||||
|
- name: Push latest image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: docker push $IMAGE_NAME:latest
|
||||||
|
|
||||||
|
|||||||
10
.idea/musicratings.iml
generated
10
.idea/musicratings.iml
generated
@@ -24,10 +24,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
||||||
@@ -38,7 +34,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
||||||
@@ -69,13 +64,11 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
||||||
@@ -87,7 +80,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||||
@@ -120,14 +112,12 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
|
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
10
.idea/php.xml
generated
10
.idea/php.xml
generated
@@ -12,11 +12,9 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="PhpIncludePathManager">
|
<component name="PhpIncludePathManager">
|
||||||
<include_path>
|
<include_path>
|
||||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||||
@@ -31,11 +29,8 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||||
@@ -43,7 +38,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||||
@@ -88,7 +82,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
||||||
@@ -112,7 +105,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||||
@@ -120,7 +112,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||||
@@ -129,7 +120,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||||
|
|||||||
139
README.md
139
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Tonehaus — Music Ratings
|
# Tonehaus — Music Ratings (Symfony 7)
|
||||||
|
|
||||||
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
Discover albums via Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||||
|
|
||||||
## Quick start
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
1) Start the stack
|
1) Start the stack
|
||||||
|
|
||||||
@@ -10,109 +10,66 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Create the database schema
|
2) Open the app
|
||||||
|
|
||||||
|
- App URL: `http://localhost:8085`
|
||||||
|
- Health: `http://localhost:8085/healthz`
|
||||||
|
|
||||||
|
3) Create your first admin
|
||||||
|
|
||||||
|
- Sign Up through Tonehaus
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Promote an admin (to access Site Settings)
|
4) Configure Spotify
|
||||||
|
|
||||||
|
- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or
|
||||||
|
- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
|
||||||
|
5) (Optional) Seed demo data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
```
|
```
|
||||||
|
|
||||||
4) Configure Spotify API credentials (admin only)
|
Notes:
|
||||||
|
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
|
||||||
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
|
- To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
|
||||||
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
|
|
||||||
|
|
||||||
5) Visit `http://localhost:8000` to search for albums.
|
|
||||||
|
|
||||||
6) (Optional) Seed demo data
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console app:seed-demo-users --count=50
|
|
||||||
docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users
|
|
||||||
docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production container (immutable)
|
|
||||||
|
|
||||||
The repository ships with a single-container production target that bundles PHP-FPM, Nginx, your code, and a self-contained SQLite database. The build bakes in the `APP_ENV=prod` flag so production-only Symfony config is used automatically, and no bind mounts are required at runtime.
|
|
||||||
|
|
||||||
1. Build the image (uses `docker/php/Dockerfile`'s `prod` stage):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build \
|
|
||||||
--target=prod \
|
|
||||||
-t tonehaus-app:latest \
|
|
||||||
-f docker/php/Dockerfile \
|
|
||||||
.
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Run the container (listens on port 8080 inside the container):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name tonehaus \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-e APP_ENV=prod \
|
|
||||||
-e APP_SECRET=change_me \
|
|
||||||
-e SPOTIFY_CLIENT_ID=your_client_id \
|
|
||||||
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
|
|
||||||
tonehaus-app:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
- The runtime defaults to `DATABASE_DRIVER=sqlite` and stores the database file inside the image at `var/data/database.sqlite`. On each boot the entrypoint runs Doctrine migrations (safe to re-run) so the schema stays current while the container filesystem remains immutable from the host's perspective.
|
|
||||||
- To point at Postgres (or any external database), override `DATABASE_DRIVER` and `DATABASE_URL` at `docker run` time and optionally disable auto-migration with `RUN_MIGRATIONS_ON_START=0`.
|
|
||||||
- Health endpoint: `GET /healthz` on the published port (example: `curl http://localhost:8080/healthz`).
|
|
||||||
- The entrypoint now also performs Symfony cache clear/warmup on startup, which requires `APP_SECRET` to be set; the container exits with an error if it is missing so misconfigured deployments are caught immediately.
|
|
||||||
|
|
||||||
3. Rebuild/redeploy by re-running the `docker build` command; no manual steps or bind mounts are involved.
|
|
||||||
|
|
||||||
## Continuous integration
|
|
||||||
|
|
||||||
- `.github/workflows/ci.yml` runs on pushes and pull requests targeting `main` or `prod`.
|
|
||||||
- Job 1 installs Composer deps, prepares a SQLite database, runs Doctrine migrations, and executes the PHPUnit suite under PHP 8.2 so functional regressions are caught early.
|
|
||||||
- Job 2 builds the production Docker image (`docker/php/Dockerfile` prod stage), checks that key Symfony artifacts (e.g., `public/index.php`, `bin/console`) are present, ensures `APP_ENV=prod` is baked in, and smoke-tests the `/entrypoint.sh` startup path.
|
|
||||||
- The resulting artifact mirrors the immutable container described above, so a green CI run guarantees the repo can be deployed anywhere via `docker run`.
|
|
||||||
- Self-hosted runners can use `.gitea/workflows/ci.yml`, which mirrors the GitHub workflow but also supports optional registry pushes after the image passes the same verification steps.
|
|
||||||
|
|
||||||
## Database driver
|
|
||||||
|
|
||||||
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
|
||||||
- Set `DATABASE_DRIVER=sqlite` to run against a self-contained SQLite file stored at `var/data/database.sqlite`.
|
|
||||||
- When `DATABASE_DRIVER=sqlite`, the `DATABASE_URL` env var is ignored. Doctrine will automatically create and use the SQLite file; override the default location with `DATABASE_SQLITE_PATH` if needed.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
|
- Spotify search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count)
|
||||||
- Album page with details, reviews list, and inline new review (logged in)
|
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
|
||||||
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
|
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
||||||
- Role-based access: authors manage their own reviews, admins can manage any
|
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
||||||
- Admin Site Settings to manage Spotify credentials in DB
|
- Admin Site Settings: manage Spotify credentials and public registration toggle
|
||||||
- User Dashboard to update profile and change password (requires current password)
|
- User Dashboard: profile updates and password change
|
||||||
- Light/Dark theme toggle in Settings (cookie-backed)
|
- Light/Dark theme toggle (cookie-backed)
|
||||||
- Bootstrap UI
|
|
||||||
|
|
||||||
## Rate limiting & caching
|
## Documentation
|
||||||
|
|
||||||
- Server-side Client Credentials; access tokens are cached.
|
- Setup and configuration: `docs/setup.md`
|
||||||
|
- Feature overview: `docs/features.md`
|
||||||
|
- Authentication and users: `docs/auth-and-users.md`
|
||||||
|
- Spotify integration: `docs/spotify-integration.md`
|
||||||
|
- Reviews and albums: `docs/reviews-and-albums.md`
|
||||||
|
- Admin & site settings: `docs/admin-and-settings.md`
|
||||||
|
- Troubleshooting: `docs/troubleshooting.md`
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Deployment: `docs/deployment.md`
|
||||||
|
|
||||||
## Docs
|
## Environment overview
|
||||||
|
|
||||||
See `/docs` for how-tos and deeper notes:
|
- `APP_ENV` (dev|prod), `APP_SECRET` (required)
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
- Setup and configuration: `docs/01-setup.md`
|
- `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
|
||||||
- Features and UX: `docs/02-features.md`
|
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
|
||||||
- Authentication and users: `docs/03-auth-and-users.md`
|
- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
|
||||||
- Spotify integration: `docs/04-spotify-integration.md`
|
- `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
|
||||||
- Reviews and albums: `docs/05-reviews-and-albums.md`
|
|
||||||
- Admin & site settings: `docs/06-admin-and-settings.md`
|
|
||||||
- Troubleshooting: `docs/07-troubleshooting.md`
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic Doctrine DBAL configuration.
|
||||||
|
*
|
||||||
|
* This file complements `config/packages/doctrine.yaml`, not replacing it!:
|
||||||
|
* - YAML handles ORM mappings, naming strategy, caches, and env-specific tweaks.
|
||||||
|
* - This PHP config focuses on DBAL and runtime driver selection.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Chooses the database driver from `DATABASE_DRIVER` (`postgres` or `sqlite`).
|
||||||
|
* - For Postgres:
|
||||||
|
* - Uses `DATABASE_URL` (e.g. `postgresql://user:pass@host:5432/dbname`).
|
||||||
|
* - Pins `serverVersion` (currently `16`) to avoid auto-detection issues.
|
||||||
|
* - For SQLite:
|
||||||
|
* - Uses `DATABASE_SQLITE_PATH` when provided.
|
||||||
|
* - Otherwise, defaults to `<projectDir>/var/data/database.sqlite`, creating the
|
||||||
|
* directory and file if they do not already exist. (Recommended)
|
||||||
|
*
|
||||||
|
* This split keeps the mapping/caching config in YAML while allowing
|
||||||
|
* DBAL to adapt between Docker/postgres and local sqlite setups.
|
||||||
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Symfony\Component\Filesystem\Filesystem;
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
@@ -7,6 +28,7 @@ use Symfony\Config\DoctrineConfig;
|
|||||||
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
|
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
|
||||||
|
|
||||||
return static function (DoctrineConfig $doctrine): void {
|
return static function (DoctrineConfig $doctrine): void {
|
||||||
|
// Normalize DATABASE_DRIVER and validate allowed values up front.
|
||||||
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
|
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
|
||||||
$supportedDrivers = ['postgres', 'sqlite'];
|
$supportedDrivers = ['postgres', 'sqlite'];
|
||||||
|
|
||||||
@@ -18,6 +40,7 @@ return static function (DoctrineConfig $doctrine): void {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure the default DBAL connection.
|
||||||
$dbal = $doctrine->dbal();
|
$dbal = $doctrine->dbal();
|
||||||
$dbal->defaultConnection('default');
|
$dbal->defaultConnection('default');
|
||||||
|
|
||||||
@@ -26,12 +49,14 @@ return static function (DoctrineConfig $doctrine): void {
|
|||||||
$connection->useSavepoints(true);
|
$connection->useSavepoints(true);
|
||||||
|
|
||||||
if ('sqlite' === $driver) {
|
if ('sqlite' === $driver) {
|
||||||
|
// SQLite: use a file-backed database by default.
|
||||||
$connection->driver('pdo_sqlite');
|
$connection->driver('pdo_sqlite');
|
||||||
|
|
||||||
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
||||||
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
||||||
|
|
||||||
if ($hasCustomPath) {
|
if ($hasCustomPath) {
|
||||||
|
// Allow explicit database path via env overrides.
|
||||||
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||||
} else {
|
} else {
|
||||||
$projectDir = dirname(__DIR__, 2);
|
$projectDir = dirname(__DIR__, 2);
|
||||||
@@ -50,7 +75,9 @@ return static function (DoctrineConfig $doctrine): void {
|
|||||||
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Postgres (or other server-based driver) via DATABASE_URL.
|
||||||
$connection->url('%env(resolve:DATABASE_URL)%');
|
$connection->url('%env(resolve:DATABASE_URL)%');
|
||||||
|
// Keep the server version explicit so Doctrine does not need network calls to detect it.
|
||||||
$connection->serverVersion('16');
|
$connection->serverVersion('16');
|
||||||
$connection->charset('utf8');
|
$connection->charset('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ services:
|
|||||||
|
|
||||||
App\Service\SpotifyClient:
|
App\Service\SpotifyClient:
|
||||||
arguments:
|
arguments:
|
||||||
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
|
$clientId: '%env(default::SPOTIFY_CLIENT_ID)%'
|
||||||
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
|
$clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
|
||||||
|
|
||||||
App\Service\ImageStorage:
|
App\Service\UploadStorage:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$storageRoot: '%kernel.project_dir%/public/uploads'
|
||||||
|
$publicPrefix: '/uploads'
|
||||||
|
|
||||||
App\Controller\AlbumController:
|
App\Controller\AlbumController:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -1,56 +1,14 @@
|
|||||||
## Docker Compose stack for Symfony app
|
|
||||||
## - php: PHP-FPM container that runs Symfony and Composer
|
|
||||||
## - nginx: Frontend web server proxying PHP to php-fpm
|
|
||||||
## - db: Postgres database for Doctrine
|
|
||||||
## - pgadmin: Optional UI to inspect the database
|
|
||||||
services:
|
services:
|
||||||
# php:
|
|
||||||
# # Build multi-stage image defined in docker/php/Dockerfile
|
|
||||||
# build:
|
|
||||||
# context: .
|
|
||||||
# dockerfile: docker/php/Dockerfile
|
|
||||||
# target: dev
|
|
||||||
# args:
|
|
||||||
# - APP_ENV=dev
|
|
||||||
# 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:
|
tonehaus:
|
||||||
image: tonehaus/tonehaus:dev-arm64
|
# image: tonehaus/tonehaus:dev-arm64
|
||||||
|
image: git.ntbx.io/boris/tonehaus:latest
|
||||||
container_name: tonehaus
|
container_name: tonehaus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./.env:/var/www/html/.env:ro
|
- uploads:/var/www/html/public/uploads
|
||||||
- sqlite_data:/var/www/html/var/data
|
- sqlite_data:/var/www/html/var/data
|
||||||
ports:
|
ports:
|
||||||
- "8085:8080"
|
- "8085:80"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -58,56 +16,7 @@ services:
|
|||||||
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:
|
|
||||||
# image: postgres:16-alpine
|
|
||||||
# container_name: postgres
|
|
||||||
# restart: unless-stopped
|
|
||||||
# environment:
|
|
||||||
# POSTGRES_DB: ${POSTGRES_DB:-symfony}
|
|
||||||
# POSTGRES_USER: ${POSTGRES_USER:-symfony}
|
|
||||||
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
|
|
||||||
# ports:
|
|
||||||
# - 5432:5432
|
|
||||||
# volumes:
|
|
||||||
# - db_data:/var/lib/postgresql/data
|
|
||||||
# healthcheck:
|
|
||||||
# test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"]
|
|
||||||
# interval: 10s
|
|
||||||
# timeout: 5s
|
|
||||||
# retries: 10
|
|
||||||
|
|
||||||
# pgadmin:
|
|
||||||
# image: dpage/pgadmin4
|
|
||||||
# container_name: pgadmin
|
|
||||||
# environment:
|
|
||||||
# PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com}
|
|
||||||
# PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password}
|
|
||||||
# ports:
|
|
||||||
# - "8081:80"
|
|
||||||
# volumes:
|
|
||||||
# - pgadmin_data:/var/lib/pgadmin
|
|
||||||
# depends_on:
|
|
||||||
# - db
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sqlite_data:
|
sqlite_data:
|
||||||
# composer_cache:
|
uploads:
|
||||||
# pgadmin_data:
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /var/www/html/public;
|
root /var/www/html/public;
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Setup
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- Docker + Docker Compose
|
|
||||||
- Spotify Developer account (for a Client ID/Secret)
|
|
||||||
|
|
||||||
## Start services
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
```
|
|
||||||
|
|
||||||
### Switching database drivers
|
|
||||||
- `DATABASE_DRIVER=postgres` (default) continues to use the Postgres 16 service from `docker-compose.yml` and reads credentials from `DATABASE_URL`.
|
|
||||||
- `DATABASE_DRIVER=sqlite` runs Doctrine against a local SQLite file at `var/data/database.sqlite`. `DATABASE_URL` is ignored; override the SQLite file path with `DATABASE_SQLITE_PATH` if desired.
|
|
||||||
|
|
||||||
## Admin user
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Moderator (optional)
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console app:promote-moderator mod@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Spotify credentials
|
|
||||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. (Stored in DB)
|
|
||||||
- Fallback to env vars:
|
|
||||||
```bash
|
|
||||||
export SPOTIFY_CLIENT_ID=your_client_id
|
|
||||||
export SPOTIFY_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional feature flags
|
|
||||||
- Disable public registration by setting an env variable before starting Symfony:
|
|
||||||
```bash
|
|
||||||
export APP_ALLOW_REGISTRATION=0 # set to 1 (default) to re-enable
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Features
|
|
||||||
|
|
||||||
- Spotify album search with Advanced filters (album, artist, year range)
|
|
||||||
- Album page with details, list of reviews, and inline new review
|
|
||||||
- Review rating slider (1–10) with live badge
|
|
||||||
- Per-album aggregates: average rating and total review count
|
|
||||||
- Auth modal (Login/Sign up) with remember-me cookie
|
|
||||||
- Role-based access (user, moderator, admin) with protected admin routes
|
|
||||||
- Admin Site Settings to manage Spotify credentials
|
|
||||||
- Moderator/Admin dashboard with latest activity snapshots
|
|
||||||
- User management table (create/delete accounts, promote/demote moderators)
|
|
||||||
- User Dashboard for profile changes (email, display name, password)
|
|
||||||
- Light/Dark theme toggle (cookie-backed)
|
|
||||||
- Bootstrap UI
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Authentication & Users
|
|
||||||
|
|
||||||
## Modal auth
|
|
||||||
- Login and registration happen in a Bootstrap modal.
|
|
||||||
- AJAX submits keep users on the same page; state updates after reload.
|
|
||||||
- Remember-me cookie keeps users logged in across sessions.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
- `ROLE_USER`: default for registered users.
|
|
||||||
- `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings.
|
|
||||||
- `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access.
|
|
||||||
|
|
||||||
### Demo accounts
|
|
||||||
- Generate placeholder accounts locally with `php bin/console app:seed-demo-users --count=50` (default password: `password`).
|
|
||||||
- Emails use the pattern `demo+<token>@example.com`, making them easy to spot in the admin UI.
|
|
||||||
- Give existing accounts avatars with `php bin/console app:seed-user-avatars`; pass `--overwrite` to refresh everyone or tweak `--style` to try other DiceBear sets.
|
|
||||||
|
|
||||||
### Access flow
|
|
||||||
- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically.
|
|
||||||
- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy.
|
|
||||||
- Admin-only actions (site settings, moderator toggling, deleting other admins) are additionally guarded in controllers/templates to avoid accidental misuse.
|
|
||||||
|
|
||||||
### User management UI
|
|
||||||
- `/admin/users` (moderator+) lists every account along with album/review counts.
|
|
||||||
- Moderators can create new accounts (without affecting their own login session.. ).
|
|
||||||
- Delete buttons are disabled (with tooltip hints) for protected rows such as the current user or any admin.
|
|
||||||
- Admins see a Promote/Demote toggle: promoting grants `ROLE_MODERATOR`; demoting removes that role unless the target is an admin (admins always outrank moderators).
|
|
||||||
- Admins can disable public registration from `/admin/settings`; when disabled, the “Sign up” button in the auth modal is replaced with a tooltip explaining that registration is closed, but `/admin/users` remains fully functional.
|
|
||||||
- Registration can also be enforced via `APP_ALLOW_REGISTRATION=0/1` in the environment; the DB setting syncs on each Symfony boot, so flips take effect after the next restart.
|
|
||||||
|
|
||||||
## Password changes
|
|
||||||
- On `/profile`, users can change email/display name.
|
|
||||||
- To set a new password, the current password must be provided.
|
|
||||||
|
|
||||||
## Logout
|
|
||||||
- `/logout` (link in user menu).
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Spotify Integration
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
- Prefer configuring via `/admin/settings` (stored in DB).
|
|
||||||
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
|
|
||||||
|
|
||||||
## API client
|
|
||||||
- `src/Service/SpotifyClient.php`
|
|
||||||
- Client Credentials token fetch (cached)
|
|
||||||
- `searchAlbums(q, limit)`
|
|
||||||
- `getAlbum(id)` / `getAlbums([ids])`
|
|
||||||
- `getAlbumWithTracks(id)` fetches metadata plus a hydrated tracklist
|
|
||||||
- `getAlbumTracks(id)` provides the raw paginated track payload when needed
|
|
||||||
|
|
||||||
## Advanced search
|
|
||||||
- The search page builds Spotify fielded queries:
|
|
||||||
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
|
||||||
- Optional free-text added to the query
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Reviews & Albums
|
|
||||||
|
|
||||||
## Album page
|
|
||||||
- Shows album artwork, metadata, average rating and review count.
|
|
||||||
- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
|
|
||||||
- Lists reviews newest-first.
|
|
||||||
- Logged-in users can submit a review inline.
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
- Anyone can view.
|
|
||||||
- Authors can edit/delete their own reviews.
|
|
||||||
- Moderators and admins can edit/delete any review or user-created album.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
|
||||||
|
|
||||||
## Demo data
|
|
||||||
- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately.
|
|
||||||
- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity.
|
|
||||||
- Use `--only-empty` when you want to focus on albums that currently have no reviews.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Troubleshooting
|
|
||||||
|
|
||||||
## Cannot find template or routes
|
|
||||||
- Clear cache: `docker compose exec php php bin/console cache:clear`
|
|
||||||
- List routes: `docker compose exec php php bin/console debug:router`
|
|
||||||
|
|
||||||
## Missing vendors
|
|
||||||
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
|
|
||||||
|
|
||||||
## .env not read in container
|
|
||||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
|
||||||
|
|
||||||
## Login modal shows blank
|
|
||||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
|
||||||
|
|
||||||
## Hitting admin routes redirects to home
|
|
||||||
- Expected when not logged in or lacking the required role.
|
|
||||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
|
||||||
- Use the console commands in `06-admin-and-settings.md` to grant roles.
|
|
||||||
|
|
||||||
@@ -42,4 +42,7 @@ docker compose exec php php bin/console app:promote-moderator user@example.com
|
|||||||
- `/settings` provides a dark/light mode toggle.
|
- `/settings` provides a dark/light mode toggle.
|
||||||
- Preference saved in a cookie; applied via `data-bs-theme`.
|
- Preference saved in a cookie; applied via `data-bs-theme`.
|
||||||
|
|
||||||
|
## Useful tips
|
||||||
|
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
|
||||||
|
- Changing Spotify credentials in settings is effective immediately; no restart is required.
|
||||||
|
- Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
|
||||||
90
docs/architecture.md
Normal file
90
docs/architecture.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
|
||||||
|
|
||||||
|
## Naming & reusability standards (PHP)
|
||||||
|
|
||||||
|
- **Classes**
|
||||||
|
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
|
||||||
|
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `SpotifyClient`, `SpotifyMetadataRefresher`).
|
||||||
|
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
|
||||||
|
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
|
||||||
|
|
||||||
|
- **Methods**
|
||||||
|
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllSpotifyAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
|
||||||
|
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isSpotifyConfigured()`).
|
||||||
|
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
|
||||||
|
|
||||||
|
- **Variables & parameters**
|
||||||
|
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$spotifyAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
|
||||||
|
- Booleans read naturally (`$isEnabled`, `$shouldQuerySpotify`, `$needsSync`).
|
||||||
|
- Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`).
|
||||||
|
|
||||||
|
- **Files & namespaces**
|
||||||
|
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
|
||||||
|
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
|
||||||
|
|
||||||
|
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
|
||||||
|
|
||||||
|
## High-level flow
|
||||||
|
1. Visitors search for albums (Spotify) and view an album page
|
||||||
|
2. Logged‑in users can write, edit, and delete reviews
|
||||||
|
3. Moderators and admins can moderate content and manage users
|
||||||
|
4. Admins configure site settings (Spotify credentials, registration toggle)
|
||||||
|
|
||||||
|
## Layers & components
|
||||||
|
|
||||||
|
### Controllers (`src/Controller/*`)
|
||||||
|
- `AlbumController` — search, album detail, inline review creation
|
||||||
|
- `ReviewController` — view, edit, and delete reviews
|
||||||
|
- `AccountController` — profile, password, and user settings pages
|
||||||
|
- `Admin/*` — site dashboard, user management, and settings
|
||||||
|
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
|
||||||
|
|
||||||
|
### Entities (`src/Entity/*`)
|
||||||
|
- `User` — authentication principal and roles
|
||||||
|
- `Album`, `AlbumTrack` — normalized album metadata and track list
|
||||||
|
- `Review` — user‑authored review with rating and timestamps
|
||||||
|
- `Setting` — key/value store for site configuration (e.g., Spotify credentials)
|
||||||
|
|
||||||
|
### Repositories (`src/Repository/*`)
|
||||||
|
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
|
||||||
|
|
||||||
|
### Forms (`src/Form/*`)
|
||||||
|
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
|
||||||
|
- Leverage Symfony validation constraints for robust server‑side validation
|
||||||
|
|
||||||
|
### Services (`src/Service/*`)
|
||||||
|
- `SpotifyClient` — Client Credentials token management (cached) and API calls
|
||||||
|
- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data
|
||||||
|
- `CatalogResetService` — admin action to reset/sync catalog state safely
|
||||||
|
- `ImageStorage` — avatar uploads and related image handling
|
||||||
|
- `RegistrationToggle` — DB‑backed registration flag with env override
|
||||||
|
|
||||||
|
### Security (`config/packages/security.yaml`, `src/Security/*`)
|
||||||
|
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
|
||||||
|
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
|
||||||
|
- Access control for `/admin/*` enforced via routes and controllers
|
||||||
|
|
||||||
|
### Views (`templates/*`)
|
||||||
|
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
|
||||||
|
- Auth modal in `templates/_partials/auth_modal.html.twig`
|
||||||
|
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
|
||||||
|
|
||||||
|
### DTOs (`src/Dto/*`)
|
||||||
|
- Simple data transfer objects for admin tables and search results
|
||||||
|
|
||||||
|
## Data & persistence
|
||||||
|
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
|
||||||
|
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
|
||||||
|
|
||||||
|
## Error handling & UX
|
||||||
|
- 404 for missing albums
|
||||||
|
- Flash messages for success/error on actions
|
||||||
|
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
|
||||||
|
|
||||||
|
## Testing & tooling
|
||||||
|
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
|
||||||
|
- Web Profiler enabled in dev
|
||||||
|
|
||||||
|
|
||||||
48
docs/auth-and-users.md
Normal file
48
docs/auth-and-users.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Authentication & Users
|
||||||
|
|
||||||
|
## Login & Registration (modal)
|
||||||
|
- Login and sign‑up are handled in a Bootstrap modal.
|
||||||
|
- AJAX submits keep users on the page; a successful login refreshes state.
|
||||||
|
- Remember‑me cookie keeps users logged in across sessions.
|
||||||
|
|
||||||
|
## Roles & Permissions
|
||||||
|
- `ROLE_USER` — default for registered users
|
||||||
|
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
|
||||||
|
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
|
||||||
|
|
||||||
|
Promotion (from your host):
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access flow
|
||||||
|
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
|
||||||
|
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
|
||||||
|
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
|
||||||
|
|
||||||
|
## Public registration toggle
|
||||||
|
- Toggle in UI: `/admin/settings` (stored in DB)
|
||||||
|
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
|
||||||
|
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
|
||||||
|
|
||||||
|
## User management (moderator+)
|
||||||
|
- `/admin/users` lists accounts with album/review counts and actions:
|
||||||
|
- Create accounts inline (does not affect the current session)
|
||||||
|
- Delete users (guards prevent deleting self or administrators)
|
||||||
|
- Admins can Promote/Demote Moderator on non‑admins
|
||||||
|
|
||||||
|
## Profiles & Passwords
|
||||||
|
- `/account/profile`: update email and display name
|
||||||
|
- `/account/password`: change password (requires current password)
|
||||||
|
|
||||||
|
## Demo accounts & avatars
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logout
|
||||||
|
- Link in the user menu calls `/logout` (handled by Symfony security).
|
||||||
|
|
||||||
|
|
||||||
70
docs/deployment.md
Normal file
70
docs/deployment.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
|
||||||
|
|
||||||
|
## Build (locally)
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--target=prod \
|
||||||
|
-t tonehaus-app:latest \
|
||||||
|
-f docker/php/Dockerfile \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name tonehaus \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e APP_ENV=prod \
|
||||||
|
-e APP_SECRET=change_me \
|
||||||
|
-e SPOTIFY_CLIENT_ID=your_client_id \
|
||||||
|
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
|
||||||
|
tonehaus-app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
|
||||||
|
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
|
||||||
|
- Cache warmup is executed on boot; `APP_SECRET` is required
|
||||||
|
|
||||||
|
## Persistence options
|
||||||
|
### SQLite (default)
|
||||||
|
- Data file at `var/data/database.sqlite`
|
||||||
|
- Use a volume for durability:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-v tonehaus_sqlite:/var/www/html/var/data \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Postgres
|
||||||
|
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
|
||||||
|
```
|
||||||
|
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
|
||||||
|
```bash
|
||||||
|
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
- `APP_ENV` (`prod` recommended in production)
|
||||||
|
- `APP_SECRET` (required; random string)
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
- `APP_ALLOW_REGISTRATION` (env override for public registration)
|
||||||
|
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
|
||||||
|
- `DATABASE_URL` (when using Postgres)
|
||||||
|
- `DATABASE_SQLITE_PATH` (optional)
|
||||||
|
- `RUN_MIGRATIONS_ON_START` (default `1`)
|
||||||
|
|
||||||
|
## Reverse proxy / TLS
|
||||||
|
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
|
||||||
|
- Terminate TLS at the proxy and forward to the container’s port 8080
|
||||||
|
- Ensure proxy sends `X-Forwarded-*` headers
|
||||||
|
|
||||||
|
## Zero‑downtime tips
|
||||||
|
- Build then run a new container alongside the old one, switch traffic at the proxy
|
||||||
|
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
|
||||||
|
|
||||||
|
|
||||||
31
docs/features.md
Normal file
31
docs/features.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
## Albums & Reviews
|
||||||
|
- Spotify album search with advanced filters (album, artist, year range)
|
||||||
|
- Album page: cover art, metadata, full tracklist (when available)
|
||||||
|
- Reviews list (newest first) and inline new review form (logged-in)
|
||||||
|
- Rating slider (1–10) with live badge
|
||||||
|
- Per‑album aggregates: average rating and total review count
|
||||||
|
|
||||||
|
## Authentication & Users
|
||||||
|
- Bootstrap auth modal for login/sign-up with AJAX submits
|
||||||
|
- Remember‑me cookie keeps users signed in
|
||||||
|
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
|
||||||
|
- Profile: update email, display name, and password (requires current password)
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
- Dashboard: latest reviews/albums and key counts (moderator+)
|
||||||
|
- Users: create/delete users, promote/demote moderators (admin constraints)
|
||||||
|
- Settings: manage Spotify credentials, toggle public registration (admin)
|
||||||
|
|
||||||
|
## Design & UX
|
||||||
|
- Responsive Bootstrap UI
|
||||||
|
- Light/Dark theme toggle (cookie-backed)
|
||||||
|
- CSRF protection on forms
|
||||||
|
- Access control via role hierarchy and security voters
|
||||||
|
|
||||||
|
## Screenshots (placeholders)
|
||||||
|
- Search page — `docs/img/search.png` (optional)
|
||||||
|
- Album page — `docs/img/album.png` (optional)
|
||||||
|
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
|
||||||
|
|
||||||
31
docs/reviews-and-albums.md
Normal file
31
docs/reviews-and-albums.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Reviews & Albums
|
||||||
|
|
||||||
|
## Album page
|
||||||
|
- Artwork, metadata, average rating, and review count
|
||||||
|
- Full Spotify tracklist when available
|
||||||
|
- Reviews list (newest first)
|
||||||
|
- Inline new review form for logged‑in users
|
||||||
|
|
||||||
|
## Writing a review
|
||||||
|
- Rating slider from 1–10
|
||||||
|
- Title (max 160 chars) and body (20–5000 chars)
|
||||||
|
- Server-side validation provides inline errors on failure
|
||||||
|
- Successful submissions persist, flash a success message, and reload the album page
|
||||||
|
|
||||||
|
## Editing & deleting reviews
|
||||||
|
- Authors can edit/delete their own reviews
|
||||||
|
- Moderators/Admins can edit/delete any review
|
||||||
|
- CSRF protection is required for deletion
|
||||||
|
|
||||||
|
## Aggregates
|
||||||
|
- The album page computes:
|
||||||
|
- Total number of reviews for the album
|
||||||
|
- Average rating rounded to one decimal
|
||||||
|
|
||||||
|
## Demo data
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
|
```
|
||||||
|
- Use `--only-empty` to focus on albums that currently have no reviews.
|
||||||
|
|
||||||
63
docs/setup.md
Normal file
63
docs/setup.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Spotify Developer account (Client ID/Secret)
|
||||||
|
- A unique `APP_SECRET` value in your environment (for prod builds)
|
||||||
|
|
||||||
|
## 1) Start the stack
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App: `http://localhost:8085`
|
||||||
|
Health: `http://localhost:8085/healthz`
|
||||||
|
|
||||||
|
## 2) Create an admin
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Configure Spotify
|
||||||
|
- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB)
|
||||||
|
- Env fallback (in `.env` or your shell):
|
||||||
|
```bash
|
||||||
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
|
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) (Optional) Seed demo data
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database drivers
|
||||||
|
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
|
||||||
|
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
|
||||||
|
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
|
||||||
|
```
|
||||||
|
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
- `APP_ENV=dev|prod`
|
||||||
|
- `APP_SECRET=<random_string>`
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
|
||||||
|
- `DATABASE_DRIVER=sqlite|postgres`
|
||||||
|
- `DATABASE_SQLITE_PATH` (optional)
|
||||||
|
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
```bash
|
||||||
|
# Symfony cache
|
||||||
|
docker compose exec tonehaus php bin/console cache:clear
|
||||||
|
|
||||||
|
# Inspect routes
|
||||||
|
docker compose exec tonehaus php bin/console debug:router
|
||||||
|
|
||||||
|
# Promote moderator
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||||
|
```
|
||||||
30
docs/spotify-integration.md
Normal file
30
docs/spotify-integration.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Spotify Integration
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
- Preferred: Manage in `/admin/settings` (persisted in DB; no restart required)
|
||||||
|
- Env fallback: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
- `src/Service/SpotifyClient.php`
|
||||||
|
- Client Credentials token fetch with caching
|
||||||
|
- `searchAlbums(q, limit)` — album search endpoint
|
||||||
|
- `getAlbum(id)` / `getAlbums([ids])` — metadata fetch
|
||||||
|
- `getAlbumWithTracks(id)` — metadata + hydrated tracklist
|
||||||
|
- `getAlbumTracks(id)` — raw paginated tracks (when needed)
|
||||||
|
|
||||||
|
### Caching & Rate Limits
|
||||||
|
- Access tokens are cached until expiry to avoid unnecessary auth calls.
|
||||||
|
- Downstream requests should be mindful of Spotify rate limits; user actions are debounced in the UI and server calls are focused on album/track data needed by the current page.
|
||||||
|
|
||||||
|
## Advanced search syntax
|
||||||
|
- Fielded queries are composed as:
|
||||||
|
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
||||||
|
- Optional free text is appended to the query
|
||||||
|
- Examples:
|
||||||
|
- `album:"in rainbows" artist:"radiohead"`
|
||||||
|
- `year:1999-2004 post rock`
|
||||||
|
|
||||||
|
## Admin settings
|
||||||
|
- Update credentials in `/admin/settings`
|
||||||
|
- Settings are stored in the database; `APP_ENV` reload or container restart is not required
|
||||||
|
|
||||||
46
docs/troubleshooting.md
Normal file
46
docs/troubleshooting.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Cannot find template or routes
|
||||||
|
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
|
||||||
|
- List routes: `docker compose exec tonehaus php bin/console debug:router`
|
||||||
|
|
||||||
|
## Missing vendors
|
||||||
|
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
|
||||||
|
|
||||||
|
## .env not read in container
|
||||||
|
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||||
|
|
||||||
|
## Login modal shows blank
|
||||||
|
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||||
|
|
||||||
|
## Hitting admin routes redirects to home
|
||||||
|
- Expected when not logged in or lacking the required role.
|
||||||
|
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||||
|
- Use the console commands in `admin-and-settings.md` to grant roles.
|
||||||
|
|
||||||
|
## SQLite file permissions
|
||||||
|
- The default SQLite path is `var/data/database.sqlite`.
|
||||||
|
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
|
||||||
|
|
||||||
|
## Postgres connection issues
|
||||||
|
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
|
||||||
|
- Example URL:
|
||||||
|
```
|
||||||
|
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spotify errors
|
||||||
|
- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`.
|
||||||
|
- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container.
|
||||||
|
|
||||||
|
## ARM64 Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--target prod \
|
||||||
|
-t tonehaus/tonehaus:dev-arm64 \
|
||||||
|
-f docker/php/Dockerfile \
|
||||||
|
. \
|
||||||
|
--load
|
||||||
|
```
|
||||||
@@ -16,7 +16,10 @@ class PromoteAdminCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* Stores injected dependencies for later use.
|
* Stores injected dependencies for later use.
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,7 @@ class PromoteAdminCommand extends Command
|
|||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$email = (string) $input->getArgument('email');
|
$email = (string) $input->getArgument('email');
|
||||||
$user = $this->users->findOneByEmail($email);
|
$user = $this->userRepository->findOneByEmail($email);
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
@@ -45,7 +48,7 @@ class PromoteAdminCommand extends Command
|
|||||||
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
||||||
$roles[] = 'ROLE_ADMIN';
|
$roles[] = 'ROLE_ADMIN';
|
||||||
$user->setRoles($roles);
|
$user->setRoles($roles);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class PromoteModeratorCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* Stores dependencies for the console handler.
|
* Stores dependencies for the console handler.
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -35,7 +38,7 @@ class PromoteModeratorCommand extends Command
|
|||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$email = (string) $input->getArgument('email');
|
$email = (string) $input->getArgument('email');
|
||||||
$user = $this->users->findOneByEmail($email);
|
$user = $this->userRepository->findOneByEmail($email);
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
@@ -45,7 +48,7 @@ class PromoteModeratorCommand extends Command
|
|||||||
if (!in_array('ROLE_MODERATOR', $roles, true)) {
|
if (!in_array('ROLE_MODERATOR', $roles, true)) {
|
||||||
$roles[] = 'ROLE_MODERATOR';
|
$roles[] = 'ROLE_MODERATOR';
|
||||||
$user->setRoles($roles);
|
$user->setRoles($roles);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
name: 'app:seed-demo-albums',
|
name: 'app:seed-demo-albums',
|
||||||
description: 'Create demo albums with randomized metadata for local development.'
|
description: 'Create demo albums with randomized metadata for local development.'
|
||||||
)]
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with synthetic user-sourced albums.
|
||||||
|
*
|
||||||
|
* - Always marked as "user" source with a unique localId.
|
||||||
|
* - Include randomized names, artists, genres, release dates, and cover URLs.
|
||||||
|
* - Optionally link to existing users as creators when --attach-users is set.
|
||||||
|
*/
|
||||||
class SeedDemoAlbumsCommand extends Command
|
class SeedDemoAlbumsCommand extends Command
|
||||||
{
|
{
|
||||||
private const GENRES = [
|
private const GENRES = [
|
||||||
@@ -59,11 +66,14 @@ class SeedDemoAlbumsCommand extends Command
|
|||||||
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
// Track generated localIds so we never attempt to persist obvious duplicates.
|
||||||
$seenLocalIds = [];
|
$seenLocalIds = [];
|
||||||
|
|
||||||
while ($created < $count) {
|
while ($created < $count) {
|
||||||
|
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
|
||||||
$localId = $this->generateLocalId();
|
$localId = $this->generateLocalId();
|
||||||
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
||||||
|
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
name: 'app:seed-demo-reviews',
|
name: 'app:seed-demo-reviews',
|
||||||
description: 'Generate demo reviews across existing albums.'
|
description: 'Generate demo reviews across existing albums.'
|
||||||
)]
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with demo reviews attached to existing albums and users.
|
||||||
|
*
|
||||||
|
* Controls:
|
||||||
|
* - --cover-percent: roughly what percentage of albums receive reviews.
|
||||||
|
* - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
|
||||||
|
* - --only-empty: restricts seeding to albums that currently have no reviews.
|
||||||
|
*
|
||||||
|
* The command avoids:
|
||||||
|
* - Creating multiple reviews from the same user on a single album.
|
||||||
|
* - Touching albums/users when there is no suitable data to seed.
|
||||||
|
*/
|
||||||
class SeedDemoReviewsCommand extends Command
|
class SeedDemoReviewsCommand extends Command
|
||||||
{
|
{
|
||||||
private const SUBJECTS = [
|
private const SUBJECTS = [
|
||||||
@@ -59,6 +71,7 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
// Pull all albums/users once up front so downstream helpers filter as needed.
|
||||||
$albums = $this->albumRepository->findAll();
|
$albums = $this->albumRepository->findAll();
|
||||||
$users = $this->userRepository->findAll();
|
$users = $this->userRepository->findAll();
|
||||||
|
|
||||||
@@ -67,14 +80,17 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
|
||||||
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
||||||
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
||||||
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
||||||
|
|
||||||
|
// Apply coverage and "only empty" filters before creating any Review entities. (filter)
|
||||||
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
||||||
$onlyEmpty = (bool) $input->getOption('only-empty');
|
$onlyEmpty = (bool) $input->getOption('only-empty');
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
// Count how many albums actually received new reviews for clearer operator feedback. (count)
|
||||||
$processedAlbums = 0;
|
$processedAlbums = 0;
|
||||||
foreach ($selectedAlbums as $album) {
|
foreach ($selectedAlbums as $album) {
|
||||||
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
||||||
@@ -107,6 +123,7 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
return $albums;
|
return $albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Randomly sample albums until the requested coverage threshold is met.
|
||||||
$selected = [];
|
$selected = [];
|
||||||
foreach ($albums as $album) {
|
foreach ($albums as $album) {
|
||||||
if (random_int(1, 100) <= $coverPercent) {
|
if (random_int(1, 100) <= $coverPercent) {
|
||||||
@@ -114,6 +131,7 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we always seed at least one album when any albums exist.
|
||||||
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,17 +142,22 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
{
|
{
|
||||||
$created = 0;
|
$created = 0;
|
||||||
$existingAuthors = $this->fetchExistingAuthors($album);
|
$existingAuthors = $this->fetchExistingAuthors($album);
|
||||||
|
// Filter out users who have already reviewed this album so we only ever
|
||||||
|
// create one review per (album, author) pair.
|
||||||
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
||||||
|
|
||||||
if ($availableUsers === []) {
|
if ($availableUsers === []) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limit requested reviews to the number of eligible authors, then randomly
|
||||||
|
// choose a stable subset for this run.
|
||||||
$targetReviews = min($targetReviews, count($availableUsers));
|
$targetReviews = min($targetReviews, count($availableUsers));
|
||||||
shuffle($availableUsers);
|
shuffle($availableUsers);
|
||||||
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
||||||
|
|
||||||
foreach ($selectedUsers as $user) {
|
foreach ($selectedUsers as $user) {
|
||||||
|
// Prevent duplicate reviews per author by only iterating over filtered unique users.
|
||||||
$review = new Review();
|
$review = new Review();
|
||||||
$review->setAlbum($album);
|
$review->setAlbum($album);
|
||||||
$review->setAuthor($user);
|
$review->setAuthor($user);
|
||||||
@@ -154,6 +177,8 @@ class SeedDemoReviewsCommand extends Command
|
|||||||
*/
|
*/
|
||||||
private function fetchExistingAuthors(Album $album): array
|
private function fetchExistingAuthors(Album $album): array
|
||||||
{
|
{
|
||||||
|
// Fetch all distinct author IDs that have already reviewed this album so we
|
||||||
|
// can cheaply check for duplicates in PHP without loading full Review objects.
|
||||||
$qb = $this->entityManager->createQueryBuilder()
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
->select('IDENTITY(r.author) AS authorId')
|
->select('IDENTITY(r.author) AS authorId')
|
||||||
->from(Review::class, 'r')
|
->from(Review::class, 'r')
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|||||||
name: 'app:seed-demo-users',
|
name: 'app:seed-demo-users',
|
||||||
description: 'Create demo users with random emails and display names.'
|
description: 'Create demo users with random emails and display names.'
|
||||||
)]
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with demo users for local development and testing.
|
||||||
|
*
|
||||||
|
* - Generates unique, non-conflicting demo email addresses.
|
||||||
|
* - Assigns a predictable default password (overridable via --password).
|
||||||
|
* - Creates users with a single ROLE_USER role.
|
||||||
|
*/
|
||||||
class SeedDemoUsersCommand extends Command
|
class SeedDemoUsersCommand extends Command
|
||||||
{
|
{
|
||||||
private const FIRST_NAMES = [
|
private const FIRST_NAMES = [
|
||||||
@@ -54,11 +61,15 @@ class SeedDemoUsersCommand extends Command
|
|||||||
$plainPassword = (string) $input->getOption('password');
|
$plainPassword = (string) $input->getOption('password');
|
||||||
|
|
||||||
$created = 0;
|
$created = 0;
|
||||||
|
// Track generated emails so we never attempt to persist obvious duplicates.
|
||||||
$seenEmails = [];
|
$seenEmails = [];
|
||||||
|
|
||||||
while ($created < $count) {
|
while ($created < $count) {
|
||||||
|
// Keep generating new tokens until we find an email that is unique
|
||||||
|
// for both this run and the existing database.
|
||||||
$email = $this->generateEmail();
|
$email = $this->generateEmail();
|
||||||
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
||||||
|
// Collisions are rare but possible because we only randomize 8 hex chars; try again.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
name: 'app:seed-user-avatars',
|
name: 'app:seed-user-avatars',
|
||||||
description: 'Assign generated profile images to existing users.'
|
description: 'Assign generated profile images to existing users.'
|
||||||
)]
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds or refreshes user profile images using the DiceBear avatar API.
|
||||||
|
*
|
||||||
|
* - Skips users that already have an image unless --overwrite is provided.
|
||||||
|
* - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
|
||||||
|
* - Does not download or cache the avatars locally; URLs are stored directly.
|
||||||
|
*/
|
||||||
class SeedUserAvatarsCommand extends Command
|
class SeedUserAvatarsCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -54,6 +61,7 @@ class SeedUserAvatarsCommand extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!$overwrite && $user->getProfileImagePath()) {
|
if (!$overwrite && $user->getProfileImagePath()) {
|
||||||
|
// Respect existing uploads unless the operator explicitly allows clobbering them.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
||||||
@@ -73,7 +81,11 @@ class SeedUserAvatarsCommand extends Command
|
|||||||
|
|
||||||
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
||||||
{
|
{
|
||||||
|
// Use a stable identifier (display name when present, email as fallback)
|
||||||
|
// so the same user is always mapped to the same avatar for a given prefix.
|
||||||
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
||||||
|
// Combine prefix, identifier, and primary key into a deterministic hash
|
||||||
|
// and trim it to a shorter seed value accepted by DiceBear.
|
||||||
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
||||||
|
|
||||||
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Entity\User;
|
|||||||
use App\Form\ProfileFormType;
|
use App\Form\ProfileFormType;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Repository\AlbumRepository;
|
use App\Repository\AlbumRepository;
|
||||||
use App\Service\ImageStorage;
|
use App\Service\UploadStorage;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
@@ -76,7 +76,7 @@ class AccountController extends AbstractController
|
|||||||
* Allows users to update profile details and avatar.
|
* Allows users to update profile details and avatar.
|
||||||
*/
|
*/
|
||||||
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
||||||
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, ImageStorage $images): Response
|
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
@@ -90,6 +90,7 @@ class AccountController extends AbstractController
|
|||||||
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.'));
|
||||||
} else {
|
} else {
|
||||||
|
// Allow password updates inside the same form submission instead of forcing a separate flow.
|
||||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,8 +98,8 @@ class AccountController extends AbstractController
|
|||||||
if ($form->isValid()) {
|
if ($form->isValid()) {
|
||||||
$upload = $form->get('profileImage')->getData();
|
$upload = $form->get('profileImage')->getData();
|
||||||
if ($upload instanceof UploadedFile) {
|
if ($upload instanceof UploadedFile) {
|
||||||
$images->remove($user->getProfileImagePath());
|
$uploadStorage->remove($user->getProfileImagePath());
|
||||||
$user->setProfileImagePath($images->storeProfileImage($upload));
|
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
|
||||||
}
|
}
|
||||||
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class DashboardController extends AbstractController
|
|||||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
||||||
{
|
{
|
||||||
|
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
|
||||||
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
||||||
->select('COUNT(r.id)')
|
->select('COUNT(r.id)')
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
@@ -36,6 +37,7 @@ class DashboardController extends AbstractController
|
|||||||
->select('COUNT(u.id)')
|
->select('COUNT(u.id)')
|
||||||
->getQuery()->getSingleScalarResult();
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
// Latest rows are pulled separately so the dashboard can show concrete activity.
|
||||||
$recentReviews = $reviews->findLatest(50);
|
$recentReviews = $reviews->findLatest(50);
|
||||||
$recentAlbums = $albums->createQueryBuilder('a')
|
$recentAlbums = $albums->createQueryBuilder('a')
|
||||||
->orderBy('a.createdAt', 'DESC')
|
->orderBy('a.createdAt', 'DESC')
|
||||||
@@ -61,6 +63,7 @@ class DashboardController extends AbstractController
|
|||||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
|
||||||
$updated = $refresher->refreshAllSpotifyAlbums();
|
$updated = $refresher->refreshAllSpotifyAlbums();
|
||||||
if ($updated === 0) {
|
if ($updated === 0) {
|
||||||
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
|
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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\CatalogResetService;
|
||||||
use App\Service\ConsoleCommandRunner;
|
use App\Service\CommandRunner;
|
||||||
use App\Service\RegistrationToggle;
|
use App\Service\RegistrationToggle;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -19,6 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
class SettingsController extends AbstractController
|
class SettingsController extends AbstractController
|
||||||
{
|
{
|
||||||
|
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
|
||||||
private const DEMO_COMMANDS = [
|
private const DEMO_COMMANDS = [
|
||||||
'users' => [
|
'users' => [
|
||||||
'command' => 'app:seed-demo-users',
|
'command' => 'app:seed-demo-users',
|
||||||
@@ -69,7 +70,7 @@ class SettingsController extends AbstractController
|
|||||||
$form = $this->createForm(SiteSettingsType::class);
|
$form = $this->createForm(SiteSettingsType::class);
|
||||||
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
||||||
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
||||||
$registrationOverride = $registrationToggle->envOverride();
|
$registrationOverride = $registrationToggle->getEnvOverride();
|
||||||
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
|
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
|
||||||
|
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
@@ -77,6 +78,7 @@ class SettingsController extends AbstractController
|
|||||||
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
||||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
||||||
if ($registrationOverride === null) {
|
if ($registrationOverride === null) {
|
||||||
|
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
|
||||||
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
|
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
|
||||||
} else {
|
} else {
|
||||||
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
|
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
|
||||||
@@ -101,7 +103,7 @@ class SettingsController extends AbstractController
|
|||||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $resetService->reset();
|
$result = $resetService->resetCatalog();
|
||||||
$this->addFlash('success', sprintf(
|
$this->addFlash('success', sprintf(
|
||||||
'Reset catalog: deleted %d reviews and %d albums.',
|
'Reset catalog: deleted %d reviews and %d albums.',
|
||||||
$result['reviews'],
|
$result['reviews'],
|
||||||
@@ -115,7 +117,7 @@ class SettingsController extends AbstractController
|
|||||||
public function generateDemo(
|
public function generateDemo(
|
||||||
string $type,
|
string $type,
|
||||||
Request $request,
|
Request $request,
|
||||||
ConsoleCommandRunner $runner
|
CommandRunner $runner
|
||||||
): Response {
|
): Response {
|
||||||
$config = self::DEMO_COMMANDS[$type] ?? null;
|
$config = self::DEMO_COMMANDS[$type] ?? null;
|
||||||
if ($config === null) {
|
if ($config === null) {
|
||||||
@@ -128,7 +130,7 @@ class SettingsController extends AbstractController
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$options = $this->buildCommandOptions($config, $request);
|
$options = $this->buildCommandOptions($config, $request);
|
||||||
$runner->run($config['command'], $options);
|
$runner->runConsoleCommand($config['command'], $options);
|
||||||
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
|
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->addFlash('danger', sprintf(
|
$this->addFlash('danger', sprintf(
|
||||||
@@ -154,6 +156,7 @@ class SettingsController extends AbstractController
|
|||||||
$value = $request->request->get($name);
|
$value = $request->request->get($name);
|
||||||
if ($type === 'checkbox') {
|
if ($type === 'checkbox') {
|
||||||
if ($value) {
|
if ($value) {
|
||||||
|
// Symfony console options expect "--flag" style boolean toggles.
|
||||||
$options['--' . $name] = true;
|
$options['--' . $name] = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
class UserController extends AbstractController
|
class UserController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly UserPasswordHasherInterface $hasher,
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +37,14 @@ class UserController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
// Form collects only high-level metadata; everything else is defaulted here.
|
||||||
$plainPassword = (string) $form->get('plainPassword')->getData();
|
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||||
$newUser = new User();
|
$newUser = new User();
|
||||||
$newUser->setEmail($formData->email);
|
$newUser->setEmail($formData->email);
|
||||||
$newUser->setDisplayName($formData->displayName);
|
$newUser->setDisplayName($formData->displayName);
|
||||||
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
|
$newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
|
||||||
$this->em->persist($newUser);
|
$this->entityManager->persist($newUser);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
$this->addFlash('success', 'User account created.');
|
$this->addFlash('success', 'User account created.');
|
||||||
return $this->redirectToRoute('admin_users');
|
return $this->redirectToRoute('admin_users');
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ class UserController extends AbstractController
|
|||||||
/** @var User|null $current */
|
/** @var User|null $current */
|
||||||
$current = $this->getUser();
|
$current = $this->getUser();
|
||||||
if ($current && $target->getId() === $current->getId()) {
|
if ($current && $target->getId() === $current->getId()) {
|
||||||
|
// Protect against accidental lockouts by blocking self-deletes.
|
||||||
$this->addFlash('danger', 'You cannot delete your own account.');
|
$this->addFlash('danger', 'You cannot delete your own account.');
|
||||||
return $this->redirectToRoute('admin_users');
|
return $this->redirectToRoute('admin_users');
|
||||||
}
|
}
|
||||||
@@ -76,8 +78,8 @@ class UserController extends AbstractController
|
|||||||
return $this->redirectToRoute('admin_users');
|
return $this->redirectToRoute('admin_users');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->em->remove($target);
|
$this->entityManager->remove($target);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
$this->addFlash('success', 'User deleted.');
|
$this->addFlash('success', 'User deleted.');
|
||||||
|
|
||||||
return $this->redirectToRoute('admin_users');
|
return $this->redirectToRoute('admin_users');
|
||||||
@@ -102,14 +104,15 @@ class UserController extends AbstractController
|
|||||||
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
|
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
|
||||||
|
|
||||||
if ($isModerator) {
|
if ($isModerator) {
|
||||||
|
// Toggle-style UX: hitting the endpoint again demotes the moderator.
|
||||||
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
||||||
$target->setRoles($filtered);
|
$target->setRoles($filtered);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
$this->addFlash('success', 'Moderator privileges removed.');
|
$this->addFlash('success', 'Moderator privileges removed.');
|
||||||
} else {
|
} else {
|
||||||
$roles[] = 'ROLE_MODERATOR';
|
$roles[] = 'ROLE_MODERATOR';
|
||||||
$target->setRoles(array_values(array_unique($roles)));
|
$target->setRoles(array_values(array_unique($roles)));
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
$this->addFlash('success', 'User promoted to moderator.');
|
$this->addFlash('success', 'User promoted to moderator.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use App\Repository\AlbumRepository;
|
|||||||
use App\Repository\AlbumTrackRepository;
|
use App\Repository\AlbumTrackRepository;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
use App\Service\AlbumSearchService;
|
use App\Service\AlbumSearchService;
|
||||||
use App\Service\ImageStorage;
|
use App\Service\UploadStorage;
|
||||||
use App\Service\SpotifyClient;
|
use App\Service\SpotifyClient;
|
||||||
use App\Service\SpotifyGenreResolver;
|
use App\Service\SpotifyGenreResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -30,7 +30,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class AlbumController extends AbstractController
|
class AlbumController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ImageStorage $imageStorage,
|
private readonly UploadStorage $uploadStorage,
|
||||||
private readonly AlbumSearchService $albumSearch,
|
private readonly AlbumSearchService $albumSearch,
|
||||||
private readonly SpotifyGenreResolver $genreResolver,
|
private readonly SpotifyGenreResolver $genreResolver,
|
||||||
private readonly int $searchLimit = 20
|
private readonly int $searchLimit = 20
|
||||||
@@ -50,13 +50,14 @@ 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(),
|
'genre' => $criteria->genre,
|
||||||
'year_from' => $criteria->yearFrom ?? '',
|
'year_from' => $criteria->yearFrom ?? '',
|
||||||
'year_to' => $criteria->yearTo ?? '',
|
'year_to' => $criteria->yearTo ?? '',
|
||||||
'albums' => $result->albums,
|
'albums' => $result->albums,
|
||||||
'stats' => $result->stats,
|
'stats' => $result->stats,
|
||||||
'savedIds' => $result->savedIds,
|
'savedIds' => $result->savedIds,
|
||||||
'source' => $criteria->source,
|
'source' => $criteria->source,
|
||||||
|
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ class AlbumController extends AbstractController
|
|||||||
$albumEntity = $this->findAlbum($id, $albumRepo);
|
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||||
$isSaved = $albumEntity !== null;
|
$isSaved = $albumEntity !== null;
|
||||||
if (!$albumEntity) {
|
if (!$albumEntity) {
|
||||||
|
// Album has never been saved locally, so hydrate it via Spotify before rendering.
|
||||||
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||||
if ($spotifyAlbum === null) {
|
if ($spotifyAlbum === null) {
|
||||||
throw $this->createNotFoundException('Album not found');
|
throw $this->createNotFoundException('Album not found');
|
||||||
@@ -106,6 +108,7 @@ class AlbumController extends AbstractController
|
|||||||
$em->flush();
|
$em->flush();
|
||||||
} else {
|
} else {
|
||||||
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||||
|
// Track sync mutated the entity: persist before we build template arrays.
|
||||||
$em->flush();
|
$em->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +196,7 @@ class AlbumController extends AbstractController
|
|||||||
if ($album) {
|
if ($album) {
|
||||||
$this->ensureCanManageAlbum($album);
|
$this->ensureCanManageAlbum($album);
|
||||||
if ($album->getSource() === 'user') {
|
if ($album->getSource() === 'user') {
|
||||||
$this->imageStorage->remove($album->getCoverImagePath());
|
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||||
}
|
}
|
||||||
$em->remove($album);
|
$em->remove($album);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
@@ -238,6 +241,7 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
// Fallback: attempt to parse
|
// Fallback: attempt to parse
|
||||||
try {
|
try {
|
||||||
|
// Trust PHP's parser only as a last resort (it accepts many human formats).
|
||||||
$dt = new \DateTimeImmutable($s);
|
$dt = new \DateTimeImmutable($s);
|
||||||
return $dt->format('Y-m-d');
|
return $dt->format('Y-m-d');
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -328,8 +332,8 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
$file = $form->get('coverUpload')->getData();
|
$file = $form->get('coverUpload')->getData();
|
||||||
if ($file instanceof UploadedFile) {
|
if ($file instanceof UploadedFile) {
|
||||||
$this->imageStorage->remove($album->getCoverImagePath());
|
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||||
$album->setCoverImagePath($this->imageStorage->storeAlbumCover($file));
|
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +369,7 @@ class AlbumController extends AbstractController
|
|||||||
$storedCount = $album->getTracks()->count();
|
$storedCount = $album->getTracks()->count();
|
||||||
$needsSync = $storedCount === 0;
|
$needsSync = $storedCount === 0;
|
||||||
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
||||||
|
// Spotify track counts do not match what we have stored; re-sync to avoid stale data.
|
||||||
$needsSync = true;
|
$needsSync = true;
|
||||||
}
|
}
|
||||||
if (!$needsSync) {
|
if (!$needsSync) {
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ namespace App\Dto;
|
|||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminUserData captures the fields used when an admin creates a user manually.
|
* AdminUserData carries the lightweight fields needed when admins create or edit
|
||||||
* Using a DTO keeps validation separate from the User entity and avoids side effects.
|
* users from the back office without touching the `User` entity directly.
|
||||||
* 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)
|
||||||
|
|
||||||
*/
|
*/
|
||||||
class AdminUserData
|
class AdminUserData
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Email address for the managed user.
|
||||||
|
*/
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
#[Assert\Email]
|
#[Assert\Email]
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional public display name.
|
||||||
|
*/
|
||||||
#[Assert\Length(max: 120)]
|
#[Assert\Length(max: 120)]
|
||||||
public ?string $displayName = null;
|
public ?string $displayName = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,28 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
*/
|
*/
|
||||||
final class AlbumSearchCriteria
|
final class AlbumSearchCriteria
|
||||||
{
|
{
|
||||||
|
/** Free-form query that mixes album, artist, and keyword matches. */
|
||||||
public readonly string $query;
|
public readonly string $query;
|
||||||
|
|
||||||
|
/** Explicit album title filter supplied via the advanced panel. */
|
||||||
public readonly string $albumName;
|
public readonly string $albumName;
|
||||||
|
|
||||||
|
/** Explicit artist filter supplied via the advanced panel. */
|
||||||
public readonly string $artist;
|
public readonly string $artist;
|
||||||
|
|
||||||
|
/** Genre substring to match within stored Spotify/user genres. */
|
||||||
public readonly string $genre;
|
public readonly string $genre;
|
||||||
|
|
||||||
|
/** Lower bound (inclusive) of the release year filter, if any. */
|
||||||
public readonly ?int $yearFrom;
|
public readonly ?int $yearFrom;
|
||||||
|
|
||||||
|
/** Upper bound (inclusive) of the release year filter, if any. */
|
||||||
public readonly ?int $yearTo;
|
public readonly ?int $yearTo;
|
||||||
|
|
||||||
|
/** Requested source scope: `all`, `spotify`, or `user`. */
|
||||||
public readonly string $source;
|
public readonly string $source;
|
||||||
|
|
||||||
|
/** Maximum number of results the search should return. */
|
||||||
public readonly int $limit;
|
public readonly int $limit;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -55,21 +70,22 @@ final class AlbumSearchCriteria
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function useSpotify(): bool
|
/**
|
||||||
|
* Determines whether the search should include Spotify-sourced albums.
|
||||||
|
*/
|
||||||
|
public function shouldUseSpotify(): bool
|
||||||
{
|
{
|
||||||
return $this->source === 'all' || $this->source === 'spotify';
|
return $this->source === 'all' || $this->source === 'spotify';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function useUser(): bool
|
/**
|
||||||
|
* Determines whether the search should include user-created albums.
|
||||||
|
*/
|
||||||
|
public function shouldUseUserCatalog(): bool
|
||||||
{
|
{
|
||||||
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) {
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ final class AlbumSearchResult
|
|||||||
* @param array<int,string> $savedIds
|
* @param array<int,string> $savedIds
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
/** Filters that produced this result set. */
|
||||||
public readonly AlbumSearchCriteria $criteria,
|
public readonly AlbumSearchCriteria $criteria,
|
||||||
|
/** Album payloads ready for Twig rendering. */
|
||||||
public readonly array $albums,
|
public readonly array $albums,
|
||||||
|
/** Per-album review aggregates keyed by album ID. */
|
||||||
public readonly array $stats,
|
public readonly array $stats,
|
||||||
|
/** List of Spotify IDs saved locally for quick lookup. */
|
||||||
public readonly array $savedIds
|
public readonly array $savedIds
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
*/
|
*/
|
||||||
class AdminUserType extends AbstractType
|
class AdminUserType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Declares the admin-only account fields plus password confirmation.
|
||||||
|
*/
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -46,6 +49,9 @@ class AdminUserType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the AdminUserData DTO as the underlying data object.
|
||||||
|
*/
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
|
||||||
|
*/
|
||||||
class AlbumType extends AbstractType
|
class AlbumType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +57,7 @@ class AlbumType extends AbstractType
|
|||||||
'label' => 'External link',
|
'label' => 'External link',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Seed the CSV helper fields with existing entity values before rendering.
|
||||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||||
$album = $event->getData();
|
$album = $event->getData();
|
||||||
if (!$album instanceof Album) {
|
if (!$album instanceof Album) {
|
||||||
@@ -68,6 +72,7 @@ class AlbumType extends AbstractType
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert the CSV helper fields back into normalized arrays when saving.
|
||||||
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
||||||
$album = $event->getData();
|
$album = $event->getData();
|
||||||
if (!$album instanceof Album) {
|
if (!$album instanceof Album) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProfileFormType lets authenticated users edit their account details and password.
|
||||||
|
*/
|
||||||
class ProfileFormType extends AbstractType
|
class ProfileFormType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrationFormType defines the public signup form and its validation rules.
|
||||||
|
*/
|
||||||
class RegistrationFormType extends AbstractType
|
class RegistrationFormType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReviewType captures the fields needed to author or edit a review.
|
||||||
|
*/
|
||||||
class ReviewType extends AbstractType
|
class ReviewType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
|
|||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
|
||||||
|
*/
|
||||||
class SiteSettingsType extends AbstractType
|
class SiteSettingsType extends AbstractType
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
/**
|
/**
|
||||||
* Wires the repository to Doctrine's registry.
|
* Wires the repository to Doctrine's registry.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Provides the Doctrine registry so we can build query builders on demand.
|
||||||
|
*/
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, Album::class);
|
parent::__construct($registry, Album::class);
|
||||||
@@ -63,6 +66,8 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Returns all stored Spotify album IDs so background jobs can iterate over them.
|
||||||
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public function findAllSpotifyIds(): array
|
public function findAllSpotifyIds(): array
|
||||||
@@ -153,10 +158,8 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
/**
|
/**
|
||||||
* Upserts data from a Spotify album payload and keeps DB entities in sync.
|
* Upserts data from a Spotify album payload and keeps DB entities in sync.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $spotifyAlbum
|
* @param array<string,mixed> $spotifyAlbum Raw Spotify album payload.
|
||||||
*/
|
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups).
|
||||||
/**
|
|
||||||
* @param list<string> $resolvedGenres Optional, precomputed genres (typically from artist lookups).
|
|
||||||
*/
|
*/
|
||||||
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album
|
public function upsertFromSpotifyAlbum(array $spotifyAlbum, array $resolvedGenres = []): Album
|
||||||
{
|
{
|
||||||
@@ -249,6 +252,9 @@ class AlbumRepository extends ServiceEntityRepository
|
|||||||
return $filtered;
|
return $filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a filter needle to lowercase so comparisons stay consistent.
|
||||||
|
*/
|
||||||
private function normalizeNeedle(?string $needle): ?string
|
private function normalizeNeedle(?string $needle): ?string
|
||||||
{
|
{
|
||||||
if ($needle === null) {
|
if ($needle === null) {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
*/
|
*/
|
||||||
class AlbumTrackRepository extends ServiceEntityRepository
|
class AlbumTrackRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Registers the repository with Doctrine's manager registry.
|
||||||
|
*/
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, AlbumTrack::class);
|
parent::__construct($registry, AlbumTrack::class);
|
||||||
@@ -61,6 +64,9 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims user/Spotify data to a nullable string, collapsing empty values to null.
|
||||||
|
*/
|
||||||
private function stringOrNull(mixed $value): ?string
|
private function stringOrNull(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
@@ -70,12 +76,20 @@ class AlbumTrackRepository extends ServiceEntityRepository
|
|||||||
return $string === '' ? null : $string;
|
return $string === '' ? null : $string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures a positive integer (defaults to 1) for disc/track numbers.
|
||||||
|
*/
|
||||||
private function normalizePositiveInt(mixed $value): int
|
private function normalizePositiveInt(mixed $value): int
|
||||||
{
|
{
|
||||||
$int = (int) $value;
|
$int = (int) $value;
|
||||||
return $int > 0 ? $int : 1;
|
return $int > 0 ? $int : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps disc/track combinations unique within the upsert operation.
|
||||||
|
*
|
||||||
|
* @param array<int,array<int,bool>> $occupied
|
||||||
|
*/
|
||||||
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
|
private function ensureUniqueTrackNumber(array &$occupied, int $disc, int $track): int
|
||||||
{
|
{
|
||||||
// Track which disc/track slots have already been claimed in this upsert run.
|
// Track which disc/track slots have already been claimed in this upsert run.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class SettingRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a setting value falling back to the supplied default.
|
* Returns a setting value, falling back to the caller's default when missing.
|
||||||
*/
|
*/
|
||||||
public function getValue(string $name, ?string $default = null): ?string
|
public function getValue(string $name, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ class SettingRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the supplied configuration value.
|
* Persists or updates the supplied configuration value.
|
||||||
*/
|
*/
|
||||||
public function setValue(string $name, ?string $value): void
|
public function setValue(string $name, ?string $value): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReviewVoter grants edit/delete access to review owners or admins.
|
* ReviewVoter determines whether the authenticated user may edit or delete a review.
|
||||||
|
* Moderators/admins always pass; otherwise the review author must match the current user.
|
||||||
*/
|
*/
|
||||||
class ReviewVoter extends Voter
|
class ReviewVoter extends Voter
|
||||||
{
|
{
|
||||||
@@ -24,7 +25,7 @@ class ReviewVoter extends Voter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grants access to admins or the review author.
|
* Evaluates the permission for the given attribute/subject pair.
|
||||||
*/
|
*/
|
||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
@@ -40,6 +41,7 @@ class ReviewVoter extends Voter
|
|||||||
|
|
||||||
/** @var Review $review */
|
/** @var Review $review */
|
||||||
$review = $subject;
|
$review = $subject;
|
||||||
|
// Only the author may edit/delete their own review.
|
||||||
return $review->getAuthor()?->getId() === $user->getId();
|
return $review->getAuthor()?->getId() === $user->getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ use Psr\Log\LoggerInterface;
|
|||||||
class AlbumSearchService
|
class AlbumSearchService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SpotifyClient $spotify,
|
private readonly SpotifyClient $spotifyClient,
|
||||||
private readonly AlbumRepository $albumRepository,
|
private readonly AlbumRepository $albumRepository,
|
||||||
private readonly ReviewRepository $reviewRepository,
|
private readonly ReviewRepository $reviewRepository,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly SpotifyGenreResolver $genreResolver,
|
private readonly SpotifyGenreResolver $genreResolver,
|
||||||
) {
|
) {
|
||||||
@@ -33,12 +33,12 @@ class AlbumSearchService
|
|||||||
*/
|
*/
|
||||||
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
||||||
{
|
{
|
||||||
$spotifyQuery = $this->buildSpotifyQuery($criteria);
|
$spotifyQuery = $this->buildSpotifySearchQuery($criteria);
|
||||||
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
|
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
|
||||||
// Spotify only gets pinged when callers explicitly enable it and we actually have
|
// 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).
|
// something to ask for (bare "all" requests would otherwise waste API calls).
|
||||||
$shouldQuerySpotify = $criteria->useSpotify()
|
$shouldQuerySpotify = $criteria->shouldUseSpotify()
|
||||||
&& ($spotifyQuery !== '' || $criteria->getGenre() !== '' || $criteria->source === 'spotify');
|
&& ($spotifyQuery !== '' || $criteria->genre !== '' || $criteria->source === 'spotify');
|
||||||
|
|
||||||
$stats = [];
|
$stats = [];
|
||||||
$savedIds = [];
|
$savedIds = [];
|
||||||
@@ -53,7 +53,7 @@ class AlbumSearchService
|
|||||||
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($criteria->useUser() && $hasUserFilters) {
|
if ($criteria->shouldUseUserCatalog() && $hasUserFilters) {
|
||||||
// Skip the user query unless at least one meaningful filter is present.
|
// 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'];
|
||||||
@@ -65,10 +65,18 @@ class AlbumSearchService
|
|||||||
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether Spotify credentials are configured.
|
||||||
|
*/
|
||||||
|
public function isSpotifyConfigured(): bool
|
||||||
|
{
|
||||||
|
return $this->spotifyClient->isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns structured filters into Spotify's free-form query syntax.
|
* Turns structured filters into Spotify's free-form query syntax.
|
||||||
*/
|
*/
|
||||||
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
|
private function buildSpotifySearchQuery(AlbumSearchCriteria $criteria): string
|
||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
if ($criteria->albumName !== '') {
|
if ($criteria->albumName !== '') {
|
||||||
@@ -105,7 +113,7 @@ class AlbumSearchService
|
|||||||
return $spotifyQuery !== ''
|
return $spotifyQuery !== ''
|
||||||
|| $criteria->albumName !== ''
|
|| $criteria->albumName !== ''
|
||||||
|| $criteria->artist !== ''
|
|| $criteria->artist !== ''
|
||||||
|| $criteria->getGenre() !== ''
|
|| $criteria->genre !== ''
|
||||||
|| $criteria->yearFrom !== null
|
|| $criteria->yearFrom !== null
|
||||||
|| $criteria->yearTo !== null;
|
|| $criteria->yearTo !== null;
|
||||||
}
|
}
|
||||||
@@ -117,22 +125,22 @@ class AlbumSearchService
|
|||||||
*/
|
*/
|
||||||
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
|
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
|
||||||
{
|
{
|
||||||
$stored = $this->albumRepository->searchSpotifyAlbums(
|
$storedSpotifyAlbums = $this->albumRepository->searchSpotifyAlbums(
|
||||||
$criteria->query,
|
$criteria->query,
|
||||||
$criteria->albumName,
|
$criteria->albumName,
|
||||||
$criteria->artist,
|
$criteria->artist,
|
||||||
$criteria->getGenre(),
|
$criteria->genre,
|
||||||
$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(), $storedSpotifyAlbums);
|
||||||
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->getGenre());
|
$storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre);
|
||||||
$storedIds = $this->collectSpotifyIds($stored);
|
$storedIds = $this->collectSpotifyIds($storedSpotifyAlbums);
|
||||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||||
$savedIds = $storedIds;
|
$savedIds = $storedIds;
|
||||||
|
|
||||||
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($stored) < $criteria->limit;
|
$shouldFetchFromSpotify = $spotifyQuery !== '' && count($storedSpotifyAlbums) < $criteria->limit;
|
||||||
|
|
||||||
if (!$shouldFetchFromSpotify) {
|
if (!$shouldFetchFromSpotify) {
|
||||||
return [
|
return [
|
||||||
@@ -143,8 +151,9 @@ class AlbumSearchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mix cached payloads with just enough fresh API data to satisfy the limit.
|
// Mix cached payloads with just enough fresh API data to satisfy the limit.
|
||||||
|
// This has the consequence of preferring cached data, but reduces API calls.
|
||||||
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
|
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
|
||||||
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->getGenre());
|
$filteredApiPayloads = $this->filterPayloadsByGenre($apiPayloads['payloads'], $criteria->genre);
|
||||||
$payloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit);
|
$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']);
|
||||||
@@ -160,7 +169,7 @@ class AlbumSearchService
|
|||||||
*/
|
*/
|
||||||
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
|
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
|
||||||
{
|
{
|
||||||
$result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
|
$result = $this->spotifyClient->searchAlbums($spotifyQuery, $criteria->limit);
|
||||||
$searchItems = $result['albums']['items'] ?? [];
|
$searchItems = $result['albums']['items'] ?? [];
|
||||||
$this->logger->info('Album search results received', [
|
$this->logger->info('Album search results received', [
|
||||||
'query' => $spotifyQuery,
|
'query' => $spotifyQuery,
|
||||||
@@ -176,7 +185,7 @@ class AlbumSearchService
|
|||||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
$full = $this->spotify->getAlbums($ids);
|
$full = $this->spotifyClient->getAlbums($ids);
|
||||||
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
||||||
if ($albumsPayload === [] && $searchItems !== []) {
|
if ($albumsPayload === [] && $searchItems !== []) {
|
||||||
$albumsPayload = $searchItems;
|
$albumsPayload = $searchItems;
|
||||||
@@ -196,7 +205,7 @@ class AlbumSearchService
|
|||||||
);
|
);
|
||||||
$upserted++;
|
$upserted++;
|
||||||
}
|
}
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
||||||
|
|
||||||
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
|
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
|
||||||
@@ -227,7 +236,7 @@ class AlbumSearchService
|
|||||||
$criteria->query,
|
$criteria->query,
|
||||||
$criteria->albumName,
|
$criteria->albumName,
|
||||||
$criteria->artist,
|
$criteria->artist,
|
||||||
$criteria->getGenre(),
|
$criteria->genre,
|
||||||
$criteria->yearFrom ?? 0,
|
$criteria->yearFrom ?? 0,
|
||||||
$criteria->yearTo ?? 0,
|
$criteria->yearTo ?? 0,
|
||||||
$criteria->limit
|
$criteria->limit
|
||||||
@@ -260,6 +269,7 @@ class AlbumSearchService
|
|||||||
$entityId = (int) $album->getId();
|
$entityId = (int) $album->getId();
|
||||||
$localId = (string) $album->getLocalId();
|
$localId = (string) $album->getLocalId();
|
||||||
if ($localId !== '' && isset($userStats[$entityId])) {
|
if ($localId !== '' && isset($userStats[$entityId])) {
|
||||||
|
// Templates never see entity IDs, so stats must be re-keyed to the user-facing local IDs.
|
||||||
$mapped[$localId] = $userStats[$entityId];
|
$mapped[$localId] = $userStats[$entityId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,7 +383,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.
|
// Secondary payloads often duplicate the primary list; skip anything we've already emitted.
|
||||||
$merged[] = $payload;
|
$merged[] = $payload;
|
||||||
if ($id !== null) {
|
if ($id !== null) {
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
|
|||||||
@@ -14,15 +14,18 @@ class CatalogResetService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Deletes all reviews and albums from the catalog and returns summary counts.
|
||||||
|
*
|
||||||
* @return array{albums:int,reviews:int}
|
* @return array{albums:int,reviews:int}
|
||||||
*/
|
*/
|
||||||
public function reset(): array
|
public function resetCatalog(): array
|
||||||
{
|
{
|
||||||
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
|
$deletedReviews = $this->entityManager->createQuery('DELETE FROM App\Entity\Review r')->execute();
|
||||||
|
|
||||||
$albums = $this->albumRepository->findAll();
|
$albums = $this->albumRepository->findAll();
|
||||||
$albumCount = count($albums);
|
$albumCount = count($albums);
|
||||||
foreach ($albums as $album) {
|
foreach ($albums as $album) {
|
||||||
|
// Remove entities one-by-one so Doctrine cascades delete related tracks/reviews as configured.
|
||||||
$this->entityManager->remove($album);
|
$this->entityManager->remove($album);
|
||||||
}
|
}
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ use Symfony\Component\Console\Output\BufferedOutput;
|
|||||||
use Symfony\Component\HttpKernel\KernelInterface;
|
use Symfony\Component\HttpKernel\KernelInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConsoleCommandRunner executes Symfony console commands from HTTP contexts.
|
* CommandRunner executes Symfony console commands from non-CLI contexts
|
||||||
|
* and returns their buffered output.
|
||||||
*/
|
*/
|
||||||
class ConsoleCommandRunner
|
class CommandRunner
|
||||||
{
|
{
|
||||||
public function __construct(private readonly KernelInterface $kernel)
|
public function __construct(private readonly KernelInterface $kernel)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Executes a Symfony console command and returns its buffered output.
|
||||||
|
*
|
||||||
* @param array<string,mixed> $options
|
* @param array<string,mixed> $options
|
||||||
*/
|
*/
|
||||||
public function run(string $commandName, array $options = []): string
|
public function runConsoleCommand(string $commandName, array $options = []): string
|
||||||
{
|
{
|
||||||
$application = new Application($this->kernel);
|
$application = new Application($this->kernel);
|
||||||
$application->setAutoExit(false);
|
$application->setAutoExit(false);
|
||||||
@@ -30,6 +33,7 @@ class ConsoleCommandRunner
|
|||||||
|
|
||||||
$exitCode = $application->run($input, $output);
|
$exitCode = $application->run($input, $output);
|
||||||
if ($exitCode !== Command::SUCCESS) {
|
if ($exitCode !== Command::SUCCESS) {
|
||||||
|
// Surface the underlying command failure so the caller can surface the message in UI.
|
||||||
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
|
throw new \RuntimeException(sprintf('Command "%s" exited with status %d', $commandName, $exitCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use Symfony\Component\Filesystem\Filesystem;
|
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
||||||
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
|
|
||||||
{
|
|
||||||
private Filesystem $fs;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $projectDir,
|
|
||||||
private readonly SluggerInterface $slugger
|
|
||||||
) {
|
|
||||||
$this->fs = new Filesystem();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves a profile avatar and returns the path the front end can render.
|
|
||||||
*/
|
|
||||||
public function storeProfileImage(UploadedFile $file): string
|
|
||||||
{
|
|
||||||
return $this->store($file, 'avatars');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves an album cover and returns the path the front end can render.
|
|
||||||
*/
|
|
||||||
public function storeAlbumCover(UploadedFile $file): string
|
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
if ($webPath === null || $webPath === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$path = $this->projectDir . '/public' . $webPath;
|
|
||||||
if ($this->fs->exists($path)) {
|
|
||||||
$this->fs->remove($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
$targetDir = $this->projectDir . '/public/uploads/' . $subDirectory;
|
|
||||||
if (!$this->fs->exists($targetDir)) {
|
|
||||||
$this->fs->mkdir($targetDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
|
||||||
$safeName = $this->slugger->slug($originalName ?: 'image');
|
|
||||||
$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);
|
|
||||||
|
|
||||||
$file->move($targetDir, $filename);
|
|
||||||
|
|
||||||
return '/uploads/' . $subDirectory . '/' . $filename;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ final class RegistrationToggle
|
|||||||
/**
|
/**
|
||||||
* Returns the environment override when present, otherwise null.
|
* Returns the environment override when present, otherwise null.
|
||||||
*/
|
*/
|
||||||
public function envOverride(): ?bool
|
public function getEnvOverride(): ?bool
|
||||||
{
|
{
|
||||||
return $this->envOverride;
|
return $this->envOverride;
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ final class RegistrationToggle
|
|||||||
*/
|
*/
|
||||||
private function detectEnvOverride(): ?bool
|
private function detectEnvOverride(): ?bool
|
||||||
{
|
{
|
||||||
|
// Symfony loads env vars into both $_ENV and $_SERVER; prefer $_ENV for consistency.
|
||||||
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
|
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
|
||||||
if ($raw === null) {
|
if ($raw === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ class SpotifyClient
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
HttpClientInterface $httpClient,
|
HttpClientInterface $httpClient,
|
||||||
CacheInterface $cache,
|
CacheInterface $cache,
|
||||||
string $clientId,
|
?string $clientId,
|
||||||
string $clientSecret,
|
?string $clientSecret,
|
||||||
SettingRepository $settings
|
SettingRepository $settings
|
||||||
) {
|
) {
|
||||||
$this->httpClient = $httpClient;
|
$this->httpClient = $httpClient;
|
||||||
@@ -129,6 +129,8 @@ class SpotifyClient
|
|||||||
$offset += $limit;
|
$offset += $limit;
|
||||||
$total = isset($page['total']) ? (int) $page['total'] : null;
|
$total = isset($page['total']) ? (int) $page['total'] : null;
|
||||||
$hasNext = isset($page['next']) && $page['next'] !== null;
|
$hasNext = isset($page['next']) && $page['next'] !== null;
|
||||||
|
// Guard against Spotify omitting total by relying on the "next" cursor.
|
||||||
|
// Ensures album requests stop when Spotify has no more pages.
|
||||||
} while ($hasNext && ($total === null || $offset < $total));
|
} while ($hasNext && ($total === null || $offset < $total));
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
@@ -197,6 +199,7 @@ class SpotifyClient
|
|||||||
|
|
||||||
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
|
$shouldCache = $cacheTtlSeconds > 0 && strtoupper($method) === 'GET';
|
||||||
if ($shouldCache) {
|
if ($shouldCache) {
|
||||||
|
// Cache fingerprint mixes URL and query only; headers are static (Bearer token).
|
||||||
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
|
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
|
||||||
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
|
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($cacheTtlSeconds, $request) {
|
||||||
$item->expiresAfter($cacheTtlSeconds);
|
$item->expiresAfter($cacheTtlSeconds);
|
||||||
@@ -268,12 +271,22 @@ class SpotifyClient
|
|||||||
});
|
});
|
||||||
|
|
||||||
if ($token === null) {
|
if ($token === null) {
|
||||||
// Remove failed entries so the next request retries instead of serving cached nulls.
|
// Nuke cached nulls so the next request retries instead of reusing the failure sentinel.
|
||||||
$this->cache->delete($cacheKey);
|
$this->cache->delete($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when credentials are available from DB or environment.
|
||||||
|
*/
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
|
||||||
|
$clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
|
||||||
|
return $clientId !== '' && $clientSecret !== '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace App\Service;
|
|||||||
*/
|
*/
|
||||||
class SpotifyGenreResolver
|
class SpotifyGenreResolver
|
||||||
{
|
{
|
||||||
public function __construct(private readonly SpotifyClient $spotify)
|
public function __construct(private readonly SpotifyClient $spotifyClient)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +80,9 @@ class SpotifyGenreResolver
|
|||||||
private function fetchArtistsGenres(array $artistIds): array
|
private function fetchArtistsGenres(array $artistIds): array
|
||||||
{
|
{
|
||||||
$genres = [];
|
$genres = [];
|
||||||
foreach (array_chunk($artistIds, 50) as $chunk) {
|
foreach (array_chunk($artistIds, 50) as $artistIdChunk) {
|
||||||
// Spotify allows up to 50 artist IDs per request; batching keeps calls minimal.
|
// Spotify allows up to 50 artist IDs per request; batching keeps calls minimal.
|
||||||
$payload = $this->spotify->getArtists($chunk);
|
$payload = $this->spotifyClient->getArtists($artistIdChunk);
|
||||||
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
|
$artists = is_array($payload) ? ((array) ($payload['artists'] ?? [])) : [];
|
||||||
foreach ($artists as $artist) {
|
foreach ($artists as $artist) {
|
||||||
$id = (string) ($artist['id'] ?? '');
|
$id = (string) ($artist['id'] ?? '');
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ class SpotifyMetadataRefresher
|
|||||||
private const BATCH_SIZE = 20;
|
private const BATCH_SIZE = 20;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SpotifyClient $spotify,
|
private readonly SpotifyClient $spotifyClient,
|
||||||
private readonly AlbumRepository $albumRepository,
|
private readonly AlbumRepository $albumRepository,
|
||||||
private readonly AlbumTrackRepository $trackRepository,
|
private readonly AlbumTrackRepository $trackRepository,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly SpotifyGenreResolver $genreResolver,
|
private readonly SpotifyGenreResolver $genreResolver,
|
||||||
) {
|
) {
|
||||||
@@ -34,45 +34,46 @@ class SpotifyMetadataRefresher
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$updated = 0;
|
$updatedAlbumCount = 0;
|
||||||
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $chunk) {
|
foreach (array_chunk($spotifyIds, self::BATCH_SIZE) as $albumIdChunk) {
|
||||||
$payload = $this->spotify->getAlbums($chunk);
|
$payload = $this->spotifyClient->getAlbums($albumIdChunk);
|
||||||
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
|
$albums = is_array($payload) ? ((array) ($payload['albums'] ?? [])) : [];
|
||||||
if ($albums === []) {
|
if ($albums === []) {
|
||||||
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($chunk)]);
|
$this->logger->warning('Spotify getAlbums returned no payloads for batch', ['count' => count($albumIdChunk)]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Share the same genre resolution logic used during search so existing rows gain genre data too.
|
// Share the same genre resolution logic used during search so existing rows gain genre data too.
|
||||||
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
|
$genresByAlbum = $this->genreResolver->resolveGenresForAlbums($albums);
|
||||||
|
|
||||||
foreach ($albums as $albumData) {
|
foreach ($albums as $albumPayload) {
|
||||||
try {
|
try {
|
||||||
$albumId = (string) ($albumData['id'] ?? '');
|
$albumId = (string) ($albumPayload['id'] ?? '');
|
||||||
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
|
$albumEntity = $this->albumRepository->upsertFromSpotifyAlbum(
|
||||||
(array) $albumData,
|
(array) $albumPayload,
|
||||||
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
$albumId !== '' ? ($genresByAlbum[$albumId] ?? []) : []
|
||||||
);
|
);
|
||||||
if ($albumId !== '' && $albumEntity !== null) {
|
if ($albumId !== '' && $albumEntity !== null) {
|
||||||
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumData);
|
$tracks = $this->resolveTrackPayloads($albumId, (array) $albumPayload);
|
||||||
if ($tracks !== []) {
|
if ($tracks !== []) {
|
||||||
|
// Replace tracks wholesale to simplify diffs (instead of diffing rows).
|
||||||
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
|
$this->trackRepository->replaceAlbumTracks($albumEntity, $tracks);
|
||||||
$albumEntity->setTotalTracks(count($tracks));
|
$albumEntity->setTotalTracks(count($tracks));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$updated++;
|
$updatedAlbumCount++;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('Failed to upsert Spotify album', [
|
$this->logger->error('Failed to upsert Spotify album', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'album' => $albumData['id'] ?? null,
|
'album' => $albumPayload['id'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $updated;
|
return $updatedAlbumCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,9 +85,9 @@ class SpotifyMetadataRefresher
|
|||||||
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
|
$total = (int) ($albumPayload['tracks']['total'] ?? 0);
|
||||||
|
|
||||||
if ($total > count($tracks)) {
|
if ($total > count($tracks)) {
|
||||||
$full = $this->spotify->getAlbumTracks($albumId);
|
$fullTrackPayloads = $this->spotifyClient->getAlbumTracks($albumId);
|
||||||
if ($full !== []) {
|
if ($fullTrackPayloads !== []) {
|
||||||
return $full;
|
return $fullTrackPayloads;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
src/Service/UploadStorage.php
Normal file
117
src/Service/UploadStorage.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UploadStorage handles moving uploaded files into a stable storage root
|
||||||
|
* and returns web-ready paths for use in templates.
|
||||||
|
*
|
||||||
|
* By default this stores under "<projectDir>/public/uploads" inside the
|
||||||
|
* container (i.e. "/var/www/html/var/data/uploads").
|
||||||
|
*/
|
||||||
|
class UploadStorage
|
||||||
|
{
|
||||||
|
private Filesystem $filesystem;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $storageRoot,
|
||||||
|
private readonly string $publicPrefix,
|
||||||
|
private readonly SluggerInterface $slugger,
|
||||||
|
) {
|
||||||
|
$this->filesystem = new Filesystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a profile avatar and returns the path the front end can render.
|
||||||
|
*/
|
||||||
|
public function storeProfileImage(UploadedFile $file): string
|
||||||
|
{
|
||||||
|
return $this->storeInNamespace($file, 'avatars');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an album cover and returns the path the front end can render.
|
||||||
|
*/
|
||||||
|
public function storeAlbumCover(UploadedFile $file): string
|
||||||
|
{
|
||||||
|
return $this->storeInNamespace($file, 'album_covers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a stored file when the provided web path points to a file
|
||||||
|
* managed under the configured storage root.
|
||||||
|
*/
|
||||||
|
public function remove(?string $webPath): void
|
||||||
|
{
|
||||||
|
if ($webPath === null || $webPath === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$absolutePath = $this->resolveAbsolutePathFromWebPath($webPath);
|
||||||
|
if ($absolutePath === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filesystem->exists($absolutePath)) {
|
||||||
|
$this->filesystem->remove($absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the uploaded file into the requested uploads namespace and returns its web path.
|
||||||
|
*
|
||||||
|
* @param UploadedFile $file Uploaded Symfony file object.
|
||||||
|
* @param string $namespace Logical namespace under the storage root (e.g. "avatars").
|
||||||
|
*/
|
||||||
|
private function storeInNamespace(UploadedFile $file, string $namespace): string
|
||||||
|
{
|
||||||
|
$namespace = trim($namespace, '/');
|
||||||
|
|
||||||
|
$targetDir = rtrim($this->storageRoot, '/') . '/' . $namespace;
|
||||||
|
if (!$this->filesystem->exists($targetDir)) {
|
||||||
|
$this->filesystem->mkdir($targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||||
|
$safeName = $this->slugger->slug($originalName ?: 'file');
|
||||||
|
$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);
|
||||||
|
|
||||||
|
$file->move($targetDir, $filename);
|
||||||
|
|
||||||
|
$publicPrefix = '/' . ltrim($this->publicPrefix, '/');
|
||||||
|
|
||||||
|
return sprintf('%s/%s/%s', rtrim($publicPrefix, '/'), $namespace, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a stored web path back into an absolute filesystem path
|
||||||
|
* under the storage root, or null when it is outside the managed prefix.
|
||||||
|
*/
|
||||||
|
private function resolveAbsolutePathFromWebPath(string $webPath): ?string
|
||||||
|
{
|
||||||
|
$normalizedPath = '/' . ltrim($webPath, '/');
|
||||||
|
$normalizedPrefix = '/' . ltrim($this->publicPrefix, '/');
|
||||||
|
|
||||||
|
// Only strip the prefix when the path starts with our configured public prefix.
|
||||||
|
if (str_starts_with($normalizedPath, $normalizedPrefix)) {
|
||||||
|
$relative = ltrim(substr($normalizedPath, strlen($normalizedPrefix)), '/');
|
||||||
|
} else {
|
||||||
|
// Fallback: treat the incoming path as already relative to the storage root.
|
||||||
|
$relative = ltrim($webPath, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($relative === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($this->storageRoot, '/') . '/' . $relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +28,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
||||||
|
{% if source_value != 'user' and spotifyConfigured is defined and not spotifyConfigured %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Spotify is not configured yet. Results will only include user-created albums.
|
||||||
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
|
<a class="alert-link" href="{{ path('admin_settings') }}">Enter Spotify credentials</a>.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
||||||
{% if landing_view %}
|
{% if landing_view %}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user