mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: autosearch for musics
This commit is contained in:
parent
9426b73940
commit
6d8fe814b0
12 changed files with 380 additions and 12 deletions
|
|
@ -36,3 +36,11 @@ APP_DOMAIN=octane.dandrove.com
|
||||||
|
|
||||||
# Only used by docker-compose.dev.yml (local testing without Traefik)
|
# Only used by docker-compose.dev.yml (local testing without Traefik)
|
||||||
APP_PORT=3000
|
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=
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -237,7 +237,7 @@ erDiagram
|
||||||
|
|
||||||
| Page | Accès | Description |
|
| 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 |
|
| `/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 |
|
| `/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 |
|
| `/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).
|
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
|
## Prérequis
|
||||||
|
|
||||||
- Docker + Docker Compose
|
- Docker + Docker Compose
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,17 @@ h3 { font-size: 1rem; margin: 0 0 0.5rem; }
|
||||||
margin: 0 0 1.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 ---------- */
|
/* ---------- Cards / grids ---------- */
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
|
|
@ -491,6 +502,56 @@ form.stacked-form label {
|
||||||
gap: 0.3rem;
|
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 {
|
input, select, textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,28 @@
|
||||||
<p class="page-subtitle">Les morceaux travaillés par le groupe, avec liens d'écoute et tutos par instrument.</p>
|
<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 id="error"></div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="section-header">
|
||||||
<h2 style="margin-top:0">Ajouter un morceau</h2>
|
<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">
|
<form id="add-song-form" class="stacked-form">
|
||||||
<label>Titre <input name="title" required></label>
|
<div class="autocomplete-wrapper">
|
||||||
<label>Artiste <input name="artist" required></label>
|
<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>Notes <input name="notes"></label>
|
||||||
<label>Lien YouTube <input name="youtubeUrl" type="url" placeholder="https://youtube.com/watch?v=..."></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" type="url" placeholder="https://open.spotify.com/track/..."></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>
|
<button type="submit">Ajouter au répertoire</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Morceaux travaillés</h2>
|
|
||||||
<div id="songs-list" class="card-grid"></div>
|
<div id="songs-list" class="card-grid"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,13 @@ function applyTheme(theme) {
|
||||||
updateThemeToggleIcon();
|
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() {
|
function updateThemeToggleIcon() {
|
||||||
const btn = document.getElementById('theme-toggle');
|
const btn = document.getElementById('theme-toggle');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.textContent = currentTheme() === 'dark' ? '☀️' : '🌙';
|
btn.innerHTML = currentTheme() === 'dark' ? SUN_ICON : MOON_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initNav(activePage) {
|
async function initNav(activePage) {
|
||||||
|
|
|
||||||
|
|
@ -231,12 +231,20 @@ async function onAddSong(e) {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/songs', { title, artist, notes, youtubeUrl, spotifyUrl });
|
await api.post('/api/songs', { title, artist, notes, youtubeUrl, spotifyUrl });
|
||||||
form.reset();
|
form.reset();
|
||||||
|
document.getElementById('song-links-status').textContent = '';
|
||||||
|
closeAutocompleteDropdown();
|
||||||
|
setAddSongPanelOpen(false);
|
||||||
await loadSongs();
|
await loadSongs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
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) {
|
async function onSaveSong(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
|
|
@ -269,9 +277,125 @@ function showError(message) {
|
||||||
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
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">♫</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() {
|
(async function init() {
|
||||||
me = await initNav('repertoire');
|
me = await initNav('repertoire');
|
||||||
await loadInstruments();
|
await loadInstruments();
|
||||||
document.getElementById('add-song-form').addEventListener('submit', onAddSong);
|
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();
|
await loadSongs();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -192,12 +192,18 @@ async function onAddSuggestion(e) {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/suggestions', { title, artist, youtubeUrl, description });
|
await api.post('/api/suggestions', { title, artist, youtubeUrl, description });
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setAddSuggestionPanelOpen(false);
|
||||||
await loadSuggestions();
|
await loadSuggestions();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
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) {
|
function showError(message) {
|
||||||
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -205,5 +211,9 @@ function showError(message) {
|
||||||
(async function init() {
|
(async function init() {
|
||||||
me = await initNav('suggestions');
|
me = await initNav('suggestions');
|
||||||
document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion);
|
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();
|
await loadSuggestions();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,12 @@
|
||||||
<p class="page-subtitle">Proposez un morceau et votez sur les suggestions des autres — chaque vote est nominatif.</p>
|
<p class="page-subtitle">Proposez un morceau et votez sur les suggestions des autres — chaque vote est nominatif.</p>
|
||||||
<div id="error"></div>
|
<div id="error"></div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="section-header">
|
||||||
<h2 style="margin-top:0">Proposer un morceau</h2>
|
<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">
|
<form id="add-suggestion-form" class="stacked-form">
|
||||||
<label>Titre <input name="title" required></label>
|
<label>Titre <input name="title" required></label>
|
||||||
<label>Artiste <input name="artist"></label>
|
<label>Artiste <input name="artist"></label>
|
||||||
|
|
@ -27,7 +31,6 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Toutes les suggestions</h2>
|
|
||||||
<div id="suggestions-list" class="card-grid"></div>
|
<div id="suggestions-list" class="card-grid"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,11 @@ module.exports = {
|
||||||
process.env.POST_LOGOUT_REDIRECT_URI ||
|
process.env.POST_LOGOUT_REDIRECT_URI ||
|
||||||
(process.env.OIDC_REDIRECT_URI ? new URL('/', process.env.OIDC_REDIRECT_URI).href : undefined),
|
(process.env.OIDC_REDIRECT_URI ? new URL('/', process.env.OIDC_REDIRECT_URI).href : undefined),
|
||||||
adminGroupName: process.env.ADMIN_GROUP_NAME || 'octane-admins',
|
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
93
src/lib/musicSearch.js
Normal 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 };
|
||||||
|
|
@ -4,6 +4,7 @@ const instrumentsRoutes = require('./instruments');
|
||||||
const songsRoutes = require('./songs');
|
const songsRoutes = require('./songs');
|
||||||
const suggestionsRoutes = require('./suggestions');
|
const suggestionsRoutes = require('./suggestions');
|
||||||
const setlistsRoutes = require('./setlists');
|
const setlistsRoutes = require('./setlists');
|
||||||
|
const musicSearchRoutes = require('./musicSearch');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -12,5 +13,6 @@ router.use('/instruments', instrumentsRoutes);
|
||||||
router.use('/songs', songsRoutes);
|
router.use('/songs', songsRoutes);
|
||||||
router.use('/suggestions', suggestionsRoutes);
|
router.use('/suggestions', suggestionsRoutes);
|
||||||
router.use('/setlists', setlistsRoutes);
|
router.use('/setlists', setlistsRoutes);
|
||||||
|
router.use('/music-search', musicSearchRoutes);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
33
src/routes/musicSearch.js
Normal file
33
src/routes/musicSearch.js
Normal 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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue