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

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