+ */
+ private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
+ {
+ $cacheKey = null;
+ if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
+ $cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
+ $cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
+ // placeholder; we'll set item value explicitly below on miss
+ $item->expiresAfter(1);
+ return null;
+ });
+ if (is_array($cached) && !empty($cached)) {
+ return $cached;
+ }
+ }
+
+ $this->throttle($sensitive);
+
+ $attempts = 0;
+ while (true) {
+ ++$attempts;
+ $response = $this->httpClient->request($method, $url, $options);
+ $status = $response->getStatusCode();
+
+ if ($status === 429) {
+ $retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1);
+ $retryAfter = max(1, min(30, $retryAfter));
+ sleep($retryAfter);
+ if ($attempts < 3) { continue; }
+ }
+
+ $data = $response->toArray(false);
+ if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
+ $this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
+ $item->expiresAfter($cacheTtlSeconds);
+ return $data;
+ });
+ }
+ return $data;
+ }
+ }
+
+ private function throttle(bool $sensitive): void
+ {
+ $windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate';
+ $max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests;
+ $now = time();
+ $entry = $this->cache->get($windowKey, function($item) use ($now) {
+ $item->expiresAfter($this->rateWindowSeconds);
+ return ['start' => $now, 'count' => 0];
+ });
+ if (!is_array($entry) || !isset($entry['start'], $entry['count'])) {
+ $entry = ['start' => $now, 'count' => 0];
+ }
+ $start = (int) $entry['start'];
+ $count = (int) $entry['count'];
+ $elapsed = $now - $start;
+ if ($elapsed >= $this->rateWindowSeconds) {
+ $start = $now; $count = 0;
+ }
+ if ($count >= $max) {
+ $sleep = max(1, $this->rateWindowSeconds - $elapsed);
+ sleep($sleep);
+ $start = time(); $count = 0;
+ }
+ $count++;
+ $newEntry = ['start' => $start, 'count' => $count];
+ $this->cache->get($windowKey, function($item) use ($newEntry) {
+ $item->expiresAfter($this->rateWindowSeconds);
+ return $newEntry;
+ });
+ }
+
+ private function getAccessToken(): ?string
+ {
+ return $this->cache->get('spotify_client_credentials_token', function ($item) {
+ // Default to 1 hour, will adjust based on response
+ $item->expiresAfter(3500);
+
+ $clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
+ $clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
+ if ($clientId === '' || $clientSecret === '') {
+ return null;
+ }
+
+ $response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [
+ 'headers' => [
+ 'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret),
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ ],
+ 'body' => 'grant_type=client_credentials',
+ ]);
+
+ $data = $response->toArray(false);
+
+ if (!isset($data['access_token'])) {
+ return null;
+ }
+
+ if (isset($data['expires_in']) && is_int($data['expires_in'])) {
+ $ttl = max(60, $data['expires_in'] - 60);
+ $item->expiresAfter($ttl);
+ }
+
+ return $data['access_token'];
+ });
+ }
+}
+
+
diff --git a/templates/.DS_Store b/templates/.DS_Store
new file mode 100644
index 0000000..4632d4e
Binary files /dev/null and b/templates/.DS_Store differ
diff --git a/templates/_partials/auth_modal.html.twig b/templates/_partials/auth_modal.html.twig
new file mode 100644
index 0000000..6d65cd6
--- /dev/null
+++ b/templates/_partials/auth_modal.html.twig
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/templates/_partials/navbar.html.twig b/templates/_partials/navbar.html.twig
new file mode 100644
index 0000000..34948bb
--- /dev/null
+++ b/templates/_partials/navbar.html.twig
@@ -0,0 +1,40 @@
+
+
+
diff --git a/templates/account/dashboard.html.twig b/templates/account/dashboard.html.twig
new file mode 100644
index 0000000..bcd9669
--- /dev/null
+++ b/templates/account/dashboard.html.twig
@@ -0,0 +1,17 @@
+{% extends 'base.html.twig' %}
+{% block title %}Dashboard{% endblock %}
+{% block body %}
+ Your profile
+ {% for msg in app.flashes('success') %}{{ msg }}
{% endfor %}
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
+ {{ form_label(form.email) }}{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}{{ form_errors(form.email) }}
+ {{ form_label(form.displayName) }}{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}{{ form_errors(form.displayName) }}
+ {{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}
+ {{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}
+ {{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}
+
+ {{ form_end(form) }}
+{% endblock %}
+
+
diff --git a/templates/account/settings.html.twig b/templates/account/settings.html.twig
new file mode 100644
index 0000000..30f9459
--- /dev/null
+++ b/templates/account/settings.html.twig
@@ -0,0 +1,35 @@
+{% extends 'base.html.twig' %}
+{% block title %}Settings{% endblock %}
+{% block body %}
+ Settings
+
+
+
+
Appearance
+
+
+
+
+
Your choice is saved in a cookie.
+
+
+
+
+{% endblock %}
+
+
diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig
new file mode 100644
index 0000000..8761527
--- /dev/null
+++ b/templates/admin/settings.html.twig
@@ -0,0 +1,26 @@
+{% extends 'base.html.twig' %}
+{% block title %}Site Settings{% endblock %}
+{% block body %}
+ Site Settings
+ {% for msg in app.flashes('success') %}{{ msg }}
{% endfor %}
+
+
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
+
+ {{ form_label(form.SPOTIFY_CLIENT_ID) }}
+ {{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }}
+
+
+ {{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
+ {{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
+
+
+
+
+ {{ form_end(form) }}
+
+
+{% endblock %}
+
+
diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig
new file mode 100644
index 0000000..e2b282d
--- /dev/null
+++ b/templates/album/search.html.twig
@@ -0,0 +1,68 @@
+{% extends 'base.html.twig' %}
+{% block title %}Album Search{% endblock %}
+{% block body %}
+ Search Albums
+
+
+ {% if query is empty and (album is empty) and (artist is empty) and (year_from is empty) and (year_to is empty) %}
+ Tip: Use the Advanced search to filter by album, artist, or year range.
+ {% endif %}
+
+ {% if albums is defined and albums|length > 0 %}
+
+ {% for album in albums %}
+
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %}
+ {% if image %}
+
+
+
+ {% endif %}
+
+
+
{{ album.artists|map(a => a.name)|join(', ') }}
+
Released {{ album.release_date }} • {{ album.total_tracks }} tracks
+ {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
+
User score: {{ s.avg }}/10 ({{ s.count }})
+
+
+
+
+ {% endfor %}
+
+ {% elseif query or album or artist or year_from or year_to %}
+ No albums found.
+ {% endif %}
+{% endblock %}
+
+
diff --git a/templates/album/show.html.twig b/templates/album/show.html.twig
new file mode 100644
index 0000000..2683162
--- /dev/null
+++ b/templates/album/show.html.twig
@@ -0,0 +1,68 @@
+{% extends 'base.html.twig' %}
+{% block title %}{{ album.name }} — Reviews{% endblock %}
+{% block body %}
+
+
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %}
+ {% if image %}
+

+ {% endif %}
+
+
{{ album.name }}
+
{{ album.artists|map(a => a.name)|join(', ') }}
+
Released {{ album.release_date }} • {{ album.total_tracks }} tracks
+
User score: {{ avg }}/10 ({{ count }})
+
Open in Spotify
+
+
+
+
+
+
Reviews
+
+
+ {% for r in reviews %}
+
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}
+
{{ r.content|u.truncate(300, '…', false) }}
+
Read more
+
+
+ {% else %}
+
No reviews yet for this album.
+ {% endfor %}
+
+
+ {% if app.user %}
+
+
+
Leave a review
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
+
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 6}}) }}{{ form_errors(form.content) }}
+
+ {{ form_label(form.rating) }}
+
+ {{ form_widget(form.rating, {attr: {class: 'form-range', min:1, max:10, step:1, list:'rating-ticks', oninput:'document.getElementById("rating-value").textContent=this.value;'}}) }}
+ {{ form.rating.vars.value ?? 5 }}
+
+
+ {{ form_errors(form.rating) }}
+
+
+ {{ form_end(form) }}
+
+
+ {% else %}
+
Sign in to leave a review.
+ {% endif %}
+
+
+{% endblock %}
+
+
diff --git a/templates/base.html.twig b/templates/base.html.twig
new file mode 100644
index 0000000..6d86eef
--- /dev/null
+++ b/templates/base.html.twig
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {% block title %}Music Ratings{% endblock %}
+
+
+
+ {% include '_partials/navbar.html.twig' %}
+
+ {% block body %}{% endblock %}
+
+
+
+ {% include '_partials/auth_modal.html.twig' %}
+
+
+
+
diff --git a/templates/review/edit.html.twig b/templates/review/edit.html.twig
new file mode 100644
index 0000000..ef74d9f
--- /dev/null
+++ b/templates/review/edit.html.twig
@@ -0,0 +1,17 @@
+{% extends 'base.html.twig' %}
+{% block title %}Edit Review{% endblock %}
+{% block body %}
+ Edit review
+ {{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
+ {{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+ {{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+ {{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+
+ {{ form_end(form) }}
+
+ Back to review
+{% endblock %}
+
+
diff --git a/templates/review/index.html.twig b/templates/review/index.html.twig
new file mode 100644
index 0000000..e65efb9
--- /dev/null
+++ b/templates/review/index.html.twig
@@ -0,0 +1,29 @@
+{% extends 'base.html.twig' %}
+{% block title %}Album Reviews{% endblock %}
+{% block body %}
+
+
Album reviews
+ {% if app.user %}
+
New review
+ {% endif %}
+
+
+
+ {% for r in reviews %}
+
+
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
{{ r.albumName }} — {{ r.albumArtist }}
+
{{ r.content|u.truncate(220, '…', false) }}
+
Read more
+
+
+
+ {% else %}
+
No reviews yet.
+ {% endfor %}
+
+{% endblock %}
+
+
diff --git a/templates/review/new.html.twig b/templates/review/new.html.twig
new file mode 100644
index 0000000..7c2aaee
--- /dev/null
+++ b/templates/review/new.html.twig
@@ -0,0 +1,30 @@
+{% extends 'base.html.twig' %}
+{% block title %}New Review{% endblock %}
+{% block body %}
+ Write a review
+
+
+
+
+
+
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
+ {{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+ {{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+ {{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+
+ {{ form_end(form) }}
+
+{% endblock %}
+
+
diff --git a/templates/review/show.html.twig b/templates/review/show.html.twig
new file mode 100644
index 0000000..2955f8b
--- /dev/null
+++ b/templates/review/show.html.twig
@@ -0,0 +1,22 @@
+{% extends 'base.html.twig' %}
+{% block title %}{{ review.title }}{% endblock %}
+{% block body %}
+ ← Back
+ {{ review.title }} (Rating {{ review.rating }}/10)
+ {{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})
+
+ {{ review.content|nl2br }}
+
+
+ {% if is_granted('REVIEW_EDIT', review) %}
+
+ {% endif %}
+{% endblock %}
+
+
diff --git a/var/.DS_Store b/var/.DS_Store
new file mode 100644
index 0000000..b06c0d5
Binary files /dev/null and b/var/.DS_Store differ