From 6d8fe814b0891a4744f9d25ef4eeac3846b8087b Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Wed, 8 Jul 2026 14:46:48 +0200 Subject: [PATCH] update: autosearch for musics --- .env.example | 8 +++ README.md | 17 +++++- public/css/style.css | 61 +++++++++++++++++++ public/index.html | 23 ++++--- public/js/nav.js | 5 +- public/js/repertoire.js | 124 ++++++++++++++++++++++++++++++++++++++ public/js/suggestions.js | 10 +++ public/suggestions.html | 9 ++- src/config.js | 7 +++ src/lib/musicSearch.js | 93 ++++++++++++++++++++++++++++ src/routes/index.js | 2 + src/routes/musicSearch.js | 33 ++++++++++ 12 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 src/lib/musicSearch.js create mode 100644 src/routes/musicSearch.js diff --git a/.env.example b/.env.example index 20331f4..c779814 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,11 @@ APP_DOMAIN=octane.dandrove.com # Only used by docker-compose.dev.yml (local testing without Traefik) APP_PORT=3000 + +# Optional — auto-fill YouTube/Spotify links when adding a song from the +# title/artist autocomplete (which itself needs no key, it uses Apple's free +# iTunes Search API). Leave blank to skip auto-fill and enter links by hand; +# see README "Recherche automatique de morceaux" for how to obtain these. +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= +YOUTUBE_API_KEY= diff --git a/README.md b/README.md index fec1ae2..78eb684 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ erDiagram | Page | Accès | Description | |---|---|---| -| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument | +| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) | | `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement | | `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel | | `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés | @@ -254,6 +254,21 @@ Un bouton clair/sombre dans la barre de navigation permet de forcer un thème (m Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périmètre exact (au-delà de la modération des suggestions) reste ouvert et pourra évoluer. Il n'y a pas de gestion des utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion). +## Recherche automatique de morceaux + +En tapant un titre dans le formulaire "Ajouter un morceau" du répertoire, une liste de suggestions apparaît (titre, artiste, pochette), basée sur l'[iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) d'Apple — **gratuite, sans clé, sans inscription**, donc ça fonctionne dès le premier déploiement sans rien configurer. + +En sélectionnant une suggestion, l'app tente aussi de retrouver automatiquement les liens **YouTube** et **Spotify** correspondants. Ça, en revanche, nécessite des identifiants (facultatifs) : + +| Variable | Obtenir | Sans elle | +|---|---|---| +| `YOUTUBE_API_KEY` | [Google Cloud Console](https://console.cloud.google.com/) → activer "YouTube Data API v3" → créer une clé API (quota gratuit largement suffisant pour un groupe) | Le champ lien YouTube reste vide, saisie manuelle | +| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` | [Spotify for Developers](https://developer.spotify.com/dashboard) → créer une app → Client ID/Secret (flux "Client Credentials", pas de compte utilisateur impliqué) | Le champ lien Spotify reste vide, saisie manuelle | + +Si aucune des deux n'est configurée, l'autocomplete titre/artiste marche quand même — seuls les liens ne se remplissent pas tout seuls. Vous pouvez ajouter ces clés à tout moment dans `.env` sans changement de code, juste un redémarrage du conteneur `app`. + +Si la recherche ne trouve rien (morceau trop obscur, faute de frappe...), rien ne bloque : titre, artiste et liens restent modifiables à la main comme avant. + ## Prérequis - Docker + Docker Compose diff --git a/public/css/style.css b/public/css/style.css index 810cb4e..c76c0d0 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -289,6 +289,17 @@ h3 { font-size: 1rem; margin: 0 0 0.5rem; } margin: 0 0 1.5rem; } +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin: 2.25rem 0 0.75rem; +} + +.section-header h2 { margin: 0; } + /* ---------- Cards / grids ---------- */ .card-grid { @@ -491,6 +502,56 @@ form.stacked-form label { gap: 0.3rem; } +/* ---------- Title autocomplete (add song) ---------- */ + +.autocomplete-wrapper { position: relative; } + +.autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + max-height: 260px; + overflow-y: auto; + z-index: 30; +} + +.autocomplete-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 0.6rem; + cursor: pointer; + font-size: 0.85rem; +} + +.autocomplete-item:hover, +.autocomplete-item.active { + background: var(--surface-alt); +} + +.autocomplete-item img, +.autocomplete-thumb-placeholder { + width: 32px; + height: 32px; + border-radius: 6px; + object-fit: cover; + flex: 0 0 auto; +} + +.autocomplete-thumb-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-alt); + color: var(--muted); +} + input, select, textarea { font: inherit; padding: 0.5rem 0.65rem; diff --git a/public/index.html b/public/index.html index d4fcf61..c173057 100644 --- a/public/index.html +++ b/public/index.html @@ -16,19 +16,28 @@

Les morceaux travaillés par le groupe, avec liens d'écoute et tutos par instrument.

-
-

Ajouter un morceau

+
+

Morceaux travaillés

+ +
+ + -

Morceaux travaillés

diff --git a/public/js/nav.js b/public/js/nav.js index 1b4159a..86ad822 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -10,10 +10,13 @@ function applyTheme(theme) { updateThemeToggleIcon(); } +const SUN_ICON = ``; +const MOON_ICON = ``; + function updateThemeToggleIcon() { const btn = document.getElementById('theme-toggle'); if (!btn) return; - btn.textContent = currentTheme() === 'dark' ? '☀️' : '🌙'; + btn.innerHTML = currentTheme() === 'dark' ? SUN_ICON : MOON_ICON; } async function initNav(activePage) { diff --git a/public/js/repertoire.js b/public/js/repertoire.js index bba395c..7b68784 100644 --- a/public/js/repertoire.js +++ b/public/js/repertoire.js @@ -231,12 +231,20 @@ async function onAddSong(e) { try { await api.post('/api/songs', { title, artist, notes, youtubeUrl, spotifyUrl }); form.reset(); + document.getElementById('song-links-status').textContent = ''; + closeAutocompleteDropdown(); + setAddSongPanelOpen(false); await loadSongs(); } catch (err) { showError(err.message); } } +function setAddSongPanelOpen(open) { + document.getElementById('add-song-panel').style.display = open ? 'block' : 'none'; + document.getElementById('toggle-add-song').textContent = open ? 'Annuler' : '+ Ajouter un morceau'; +} + async function onSaveSong(e) { e.preventDefault(); const form = e.target; @@ -269,9 +277,125 @@ function showError(message) { document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; } +let autocompleteTimer = null; +let autocompleteCandidates = []; +let autocompleteHighlight = -1; + +function candidateItemTemplate(c, index) { + const img = c.artworkUrl + ? `` + : ``; + return ` +
+ ${img} +
+
${escapeHtml(c.title)}
+
${escapeHtml(c.artist)}
+
+
+ `; +} + +function renderAutocompleteDropdown() { + const dropdown = document.getElementById('song-title-dropdown'); + if (!autocompleteCandidates.length) { + dropdown.style.display = 'none'; + dropdown.innerHTML = ''; + return; + } + dropdown.innerHTML = autocompleteCandidates.map(candidateItemTemplate).join(''); + dropdown.style.display = 'block'; + dropdown.querySelectorAll('.autocomplete-item').forEach((el) => { + el.addEventListener('mousedown', (e) => { + e.preventDefault(); + selectCandidate(autocompleteCandidates[parseInt(el.dataset.index, 10)]); + }); + }); +} + +function closeAutocompleteDropdown() { + autocompleteCandidates = []; + autocompleteHighlight = -1; + renderAutocompleteDropdown(); +} + +async function selectCandidate(candidate) { + document.getElementById('song-title-input').value = candidate.title; + document.getElementById('song-artist-input').value = candidate.artist; + closeAutocompleteDropdown(); + + const statusEl = document.getElementById('song-links-status'); + statusEl.textContent = 'Recherche des liens YouTube / Spotify…'; + try { + const links = await api.get( + `/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}` + ); + const youtubeInput = document.getElementById('song-youtube-input'); + const spotifyInput = document.getElementById('song-spotify-input'); + if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl; + if (links.spotifyUrl && !spotifyInput.value) spotifyInput.value = links.spotifyUrl; + + if (links.youtubeUrl && links.spotifyUrl) statusEl.textContent = 'Liens YouTube et Spotify trouvés automatiquement.'; + else if (links.youtubeUrl) statusEl.textContent = 'Lien YouTube trouvé automatiquement. Aucun lien Spotify trouvé — à saisir manuellement si besoin.'; + else if (links.spotifyUrl) statusEl.textContent = 'Lien Spotify trouvé automatiquement. Aucun lien YouTube trouvé — à saisir manuellement si besoin.'; + else statusEl.textContent = 'Aucun lien trouvé automatiquement — vous pouvez les saisir manuellement.'; + } catch (err) { + statusEl.textContent = ''; + } +} + +function initTitleAutocomplete() { + const input = document.getElementById('song-title-input'); + + input.addEventListener('input', () => { + const query = input.value.trim(); + clearTimeout(autocompleteTimer); + if (query.length < 2) { + closeAutocompleteDropdown(); + return; + } + autocompleteTimer = setTimeout(async () => { + try { + autocompleteCandidates = await api.get(`/api/music-search?q=${encodeURIComponent(query)}`); + autocompleteHighlight = -1; + renderAutocompleteDropdown(); + } catch (err) { + closeAutocompleteDropdown(); + } + }, 300); + }); + + input.addEventListener('keydown', (e) => { + if (!autocompleteCandidates.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + autocompleteHighlight = Math.min(autocompleteHighlight + 1, autocompleteCandidates.length - 1); + renderAutocompleteDropdown(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + autocompleteHighlight = Math.max(autocompleteHighlight - 1, 0); + renderAutocompleteDropdown(); + } else if (e.key === 'Enter' && autocompleteHighlight >= 0) { + e.preventDefault(); + selectCandidate(autocompleteCandidates[autocompleteHighlight]); + } else if (e.key === 'Escape') { + closeAutocompleteDropdown(); + } + }); + + input.addEventListener('blur', () => { + setTimeout(closeAutocompleteDropdown, 150); + }); +} + (async function init() { me = await initNav('repertoire'); await loadInstruments(); document.getElementById('add-song-form').addEventListener('submit', onAddSong); + document.getElementById('toggle-add-song').addEventListener('click', () => { + const isOpen = document.getElementById('add-song-panel').style.display !== 'none'; + setAddSongPanelOpen(!isOpen); + }); + initTitleAutocomplete(); await loadSongs(); })(); diff --git a/public/js/suggestions.js b/public/js/suggestions.js index 2930855..f7f654a 100644 --- a/public/js/suggestions.js +++ b/public/js/suggestions.js @@ -192,12 +192,18 @@ async function onAddSuggestion(e) { try { await api.post('/api/suggestions', { title, artist, youtubeUrl, description }); form.reset(); + setAddSuggestionPanelOpen(false); await loadSuggestions(); } catch (err) { showError(err.message); } } +function setAddSuggestionPanelOpen(open) { + document.getElementById('add-suggestion-panel').style.display = open ? 'block' : 'none'; + document.getElementById('toggle-add-suggestion').textContent = open ? 'Annuler' : '+ Proposer un morceau'; +} + function showError(message) { document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; } @@ -205,5 +211,9 @@ function showError(message) { (async function init() { me = await initNav('suggestions'); document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion); + document.getElementById('toggle-add-suggestion').addEventListener('click', () => { + const isOpen = document.getElementById('add-suggestion-panel').style.display !== 'none'; + setAddSuggestionPanelOpen(!isOpen); + }); await loadSuggestions(); })(); diff --git a/public/suggestions.html b/public/suggestions.html index 6767324..280e5b6 100644 --- a/public/suggestions.html +++ b/public/suggestions.html @@ -16,8 +16,12 @@

Proposez un morceau et votez sur les suggestions des autres — chaque vote est nominatif.

-
-

Proposer un morceau

+
+

Toutes les suggestions

+ +
+ + -

Toutes les suggestions

diff --git a/src/config.js b/src/config.js index 63903de..db06874 100644 --- a/src/config.js +++ b/src/config.js @@ -42,4 +42,11 @@ module.exports = { process.env.POST_LOGOUT_REDIRECT_URI || (process.env.OIDC_REDIRECT_URI ? new URL('/', process.env.OIDC_REDIRECT_URI).href : undefined), adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins', + // All optional: the "add a song" autocomplete works with none of these set + // (title/artist suggestions come from Apple's free, key-less iTunes Search + // API). Without Spotify/YouTube credentials, the matching links are just + // left blank for manual entry instead of being auto-filled. + spotifyClientId: process.env.SPOTIFY_CLIENT_ID || null, + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || null, + youtubeApiKey: process.env.YOUTUBE_API_KEY || null, }; diff --git a/src/lib/musicSearch.js b/src/lib/musicSearch.js new file mode 100644 index 0000000..2ab40a1 --- /dev/null +++ b/src/lib/musicSearch.js @@ -0,0 +1,93 @@ +const config = require('../config'); + +let spotifyToken = null; // { accessToken, expiresAt } + +async function searchCandidates(query) { + const url = new URL('https://itunes.apple.com/search'); + url.searchParams.set('term', query); + url.searchParams.set('entity', 'song'); + url.searchParams.set('limit', '8'); + + const res = await fetch(url); + if (!res.ok) throw new Error(`iTunes search failed: ${res.status}`); + const data = await res.json(); + + return (data.results || []).map((r) => ({ + title: r.trackName, + artist: r.artistName, + artworkUrl: r.artworkUrl100 || r.artworkUrl60 || null, + })); +} + +async function getSpotifyToken() { + if (spotifyToken && spotifyToken.expiresAt > Date.now()) { + return spotifyToken.accessToken; + } + const basic = Buffer.from(`${config.spotifyClientId}:${config.spotifyClientSecret}`).toString('base64'); + const res = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + if (!res.ok) throw new Error(`Spotify token request failed: ${res.status}`); + const data = await res.json(); + spotifyToken = { + accessToken: data.access_token, + expiresAt: Date.now() + (data.expires_in - 60) * 1000, + }; + return spotifyToken.accessToken; +} + +async function findSpotifyUrl(title, artist) { + if (!config.spotifyClientId || !config.spotifyClientSecret) return null; + try { + const token = await getSpotifyToken(); + const url = new URL('https://api.spotify.com/v1/search'); + url.searchParams.set('q', `track:${title} artist:${artist}`); + url.searchParams.set('type', 'track'); + url.searchParams.set('limit', '1'); + + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`Spotify search failed: ${res.status}`); + const data = await res.json(); + const track = data.tracks?.items?.[0]; + return track?.external_urls?.spotify || null; + } catch (err) { + console.warn('[musicSearch] Spotify lookup failed:', err.message); + return null; + } +} + +async function findYoutubeUrl(title, artist) { + if (!config.youtubeApiKey) return null; + try { + const url = new URL('https://www.googleapis.com/youtube/v3/search'); + url.searchParams.set('part', 'snippet'); + url.searchParams.set('q', `${title} ${artist}`); + url.searchParams.set('type', 'video'); + url.searchParams.set('maxResults', '1'); + url.searchParams.set('key', config.youtubeApiKey); + + const res = await fetch(url); + if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); + const data = await res.json(); + const videoId = data.items?.[0]?.id?.videoId; + return videoId ? `https://www.youtube.com/watch?v=${videoId}` : null; + } catch (err) { + console.warn('[musicSearch] YouTube lookup failed:', err.message); + return null; + } +} + +async function findLinks(title, artist) { + const [spotifyUrl, youtubeUrl] = await Promise.all([ + findSpotifyUrl(title, artist), + findYoutubeUrl(title, artist), + ]); + return { spotifyUrl, youtubeUrl }; +} + +module.exports = { searchCandidates, findLinks }; diff --git a/src/routes/index.js b/src/routes/index.js index bf26db9..1917581 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -4,6 +4,7 @@ const instrumentsRoutes = require('./instruments'); const songsRoutes = require('./songs'); const suggestionsRoutes = require('./suggestions'); const setlistsRoutes = require('./setlists'); +const musicSearchRoutes = require('./musicSearch'); const router = express.Router(); @@ -12,5 +13,6 @@ router.use('/instruments', instrumentsRoutes); router.use('/songs', songsRoutes); router.use('/suggestions', suggestionsRoutes); router.use('/setlists', setlistsRoutes); +router.use('/music-search', musicSearchRoutes); module.exports = router; diff --git a/src/routes/musicSearch.js b/src/routes/musicSearch.js new file mode 100644 index 0000000..a13ce06 --- /dev/null +++ b/src/routes/musicSearch.js @@ -0,0 +1,33 @@ +const express = require('express'); +const musicSearch = require('../lib/musicSearch'); +const asyncHandler = require('../lib/asyncHandler'); + +const router = express.Router(); + +router.get( + '/', + asyncHandler(async (req, res) => { + const q = (req.query.q || '').trim(); + if (q.length < 2) return res.json([]); + try { + const candidates = await musicSearch.searchCandidates(q); + res.json(candidates); + } catch (err) { + console.warn('[musicSearch] candidate search failed:', err.message); + res.json([]); + } + }) +); + +router.get( + '/links', + asyncHandler(async (req, res) => { + const title = (req.query.title || '').trim(); + const artist = (req.query.artist || '').trim(); + if (!title || !artist) return res.status(400).json({ error: 'title_and_artist_required' }); + const links = await musicSearch.findLinks(title, artist); + res.json(links); + }) +); + +module.exports = router;