update: autosearch for musics

This commit is contained in:
Nathan FONTEYNE 2026-07-08 14:46:48 +02:00
parent 9426b73940
commit 6d8fe814b0
12 changed files with 380 additions and 12 deletions

View file

@ -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=

View file

@ -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

View file

@ -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;

View file

@ -16,19 +16,28 @@
<p class="page-subtitle">Les morceaux travaillés par le groupe, avec liens d'écoute et tutos par instrument.</p>
<div id="error"></div>
<div class="panel">
<h2 style="margin-top:0">Ajouter un morceau</h2>
<div class="section-header">
<h2 style="margin:0">Morceaux travaillés</h2>
<button type="button" class="secondary" id="toggle-add-song">+ Ajouter un morceau</button>
</div>
<div class="panel" id="add-song-panel" style="display:none">
<form id="add-song-form" class="stacked-form">
<label>Titre <input name="title" required></label>
<label>Artiste <input name="artist" required></label>
<div class="autocomplete-wrapper">
<label>Titre
<input name="title" id="song-title-input" autocomplete="off" required>
</label>
<div class="autocomplete-dropdown" id="song-title-dropdown" style="display:none"></div>
</div>
<label>Artiste <input name="artist" id="song-artist-input" required></label>
<label>Notes <input name="notes"></label>
<label>Lien YouTube <input name="youtubeUrl" type="url" placeholder="https://youtube.com/watch?v=..."></label>
<label>Lien Spotify <input name="spotifyUrl" type="url" placeholder="https://open.spotify.com/track/..."></label>
<label>Lien YouTube <input name="youtubeUrl" id="song-youtube-input" type="url" placeholder="https://youtube.com/watch?v=..."></label>
<label>Lien Spotify <input name="spotifyUrl" id="song-spotify-input" type="url" placeholder="https://open.spotify.com/track/..."></label>
<p class="note" id="song-links-status"></p>
<button type="submit">Ajouter au répertoire</button>
</form>
</div>
<h2>Morceaux travaillés</h2>
<div id="songs-list" class="card-grid"></div>
</main>

View file

@ -10,10 +10,13 @@ function applyTheme(theme) {
updateThemeToggleIcon();
}
const SUN_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>`;
const MOON_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;
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) {

View file

@ -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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
let autocompleteTimer = null;
let autocompleteCandidates = [];
let autocompleteHighlight = -1;
function candidateItemTemplate(c, index) {
const img = c.artworkUrl
? `<img src="${escapeHtml(c.artworkUrl)}" alt="">`
: `<span class="autocomplete-thumb-placeholder">&#9835;</span>`;
return `
<div class="autocomplete-item${index === autocompleteHighlight ? ' active' : ''}" data-index="${index}">
${img}
<div>
<div>${escapeHtml(c.title)}</div>
<div class="card-subtitle">${escapeHtml(c.artist)}</div>
</div>
</div>
`;
}
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();
})();

View file

@ -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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
@ -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();
})();

View file

@ -16,8 +16,12 @@
<p class="page-subtitle">Proposez un morceau et votez sur les suggestions des autres — chaque vote est nominatif.</p>
<div id="error"></div>
<div class="panel">
<h2 style="margin-top:0">Proposer un morceau</h2>
<div class="section-header">
<h2 style="margin:0">Toutes les suggestions</h2>
<button type="button" class="secondary" id="toggle-add-suggestion">+ Proposer un morceau</button>
</div>
<div class="panel" id="add-suggestion-panel" style="display:none">
<form id="add-suggestion-form" class="stacked-form">
<label>Titre <input name="title" required></label>
<label>Artiste <input name="artist"></label>
@ -27,7 +31,6 @@
</form>
</div>
<h2>Toutes les suggestions</h2>
<div id="suggestions-list" class="card-grid"></div>
</main>

View file

@ -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,
};

93
src/lib/musicSearch.js Normal file
View file

@ -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 };

View file

@ -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;

33
src/routes/musicSearch.js Normal file
View file

@ -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;