/** * fetch_offline_data.mjs * * Spotify App 的可选离线数据快照生成工具。 * 调用 Apple iTunes Search / Lookup API 生成离线音乐数据。 * * 写入三个离线 JSON 文件: * apps/Spotify/data/artistTracks.json — artist name → SpotifyTrack[] * apps/Spotify/data/searchResults.json — search term → { songs, artists, albums } * apps/Spotify/data/albumTracks.json — iTunes collectionId → { playlist, tracks, albumInfo } * * 三个阶段:artists → search → albums * 默认:增量(只补充缺失项)。 * * Usage: * node apps/Spotify/scripts/fetch_offline_data.mjs # 增量:补齐所有阶段 * node apps/Spotify/scripts/fetch_offline_data.mjs --full # 全量重建所有阶段 * node apps/Spotify/scripts/fetch_offline_data.mjs --full artists # 全量 artists + 增量其余 * node apps/Spotify/scripts/fetch_offline_data.mjs --full search # 增量 artists + 全量 search + 增量 albums * node apps/Spotify/scripts/fetch_offline_data.mjs --full albums # 增量 artists + 增量 search + 全量 albums * node apps/Spotify/scripts/fetch_offline_data.mjs --full artists,search # 全量前两阶段 + 增量 albums * node apps/Spotify/scripts/fetch_offline_data.mjs --skip albums # 只跑 artists + search * node apps/Spotify/scripts/fetch_offline_data.mjs --add-artist "Drake" # 增量追加指定艺人 * node apps/Spotify/scripts/fetch_offline_data.mjs --add-search "稻香" # 增量追加指定搜索词 */ import { writeFileSync, existsSync, readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = resolve(__dirname, '../data'); // ─── 需要预下载的数据 ────────────────────────────────────────────────────────── const ARTISTS = [ // recentPlays / likedSongs / followedArtists / task defaults 'Ed Sheeran', 'Billie Eilish', 'Taylor Swift', 'Adele', 'Michael Jackson', '周杰伦', '许嵩', '林俊杰', // 搜索结果 artistResults 里出现的艺人(Agent 可能点进去) 'JOLIN蔡依林', '李荣浩', '林宥嘉', '陳奕迅', 'Harry Styles', 'The Chainsmokers', 'OneRepublic', 'FINNEAS', 'Kygo', 'Julia Michaels', 'Emeli Sandé', 'Jess Glynne', 'Jackson 5', 'Diplo', 'Major Lazer', 'Timbaland', 'benny blanco', 'blackbear', 'j-hope', 'SUGA', 'Tiësto', 'Pop Smoke', 'J.J.', 'J.J. Lin', // 林俊杰英文名,iTunes 同时有两个 artistId ]; // 繁简对照:存储时同时写入简体和繁体 key const TRAD_SIMP_MAP = { '周杰伦': '周杰倫', '许嵩': '許嵩', '林俊杰': '林俊傑', }; const SEARCH_TERMS = [ // 任务 default 歌曲/专辑名 '青花瓷', 'Shape of You', '1989', 'Thriller', // recentPlays 歌曲标题 'Bad Habits', 'bad guy', 'Welcome to New York', 'Love Story', 'Rolling In the Deep', // SwapSongInPlaylist / SpotifySongFullDetailsToRedbook 任务默认歌曲 '晴天', '搁浅', '修炼爱情', // 艺人名(任务 default + followedArtists + recentPlays) '周杰伦', '林俊杰', '许嵩', 'Ed Sheeran', 'Billie Eilish', 'Taylor Swift', 'Adele', 'Michael Jackson', ]; // ─── CLI 参数解析 ────────────────────────────────────────────────────────────── const args = process.argv.slice(2); function parseStageSet(flag) { const idx = args.indexOf(flag); if (idx === -1) return new Set(); const val = args[idx + 1]; if (!val || val.startsWith('--')) return new Set(['artists', 'search', 'albums']); // bare flag = all return new Set(val.split(',').map(s => s.trim())); } const fullStages = parseStageSet('--full'); const skipStages = parseStageSet('--skip'); const extraArtists = []; const extraSearchTerms = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--add-artist' && args[i + 1]) extraArtists.push(args[++i]); if (args[i] === '--add-search' && args[i + 1]) extraSearchTerms.push(args[++i]); } const artistList = [...new Set([...ARTISTS, ...extraArtists])]; const searchList = [...new Set([...SEARCH_TERMS, ...extraSearchTerms])]; function isFull(stage) { return fullStages.has(stage) || fullStages.has('all'); } function isSkip(stage) { return skipStages.has(stage) || skipStages.has('all'); } // ─── 数据文件读写 ────────────────────────────────────────────────────────────── function loadJson(filename) { const path = resolve(DATA_DIR, filename); if (!existsSync(path)) return {}; try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; } } function saveJson(filename, data) { const path = resolve(DATA_DIR, filename); writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8'); console.log(` 写入 ${filename}: ${Object.keys(data).length} 条`); } // ─── iTunes API 工具 ─────────────────────────────────────────────────────────── const sleep = ms => new Promise(r => setTimeout(r, ms)); const appleArtworkSizeRe = /\/(\d+)x(\d+)(bb)?(?=\.[a-z0-9]+(?:\?|$))/i; function inferLargeArtworkUrl(raw) { if (!raw) return ''; const resized = raw.replace(appleArtworkSizeRe, '/1000x1000$3'); return resized === raw ? '' : resized; } async function itunesSearch(term, entity = 'song', limit = 10) { const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&entity=${entity}&media=music&limit=${limit}&country=TW`; const res = await fetch(url); if (!res.ok) throw new Error(`iTunes search failed: ${res.status} for "${term}"`); return (await res.json()).results || []; } async function itunesLookup(collectionId) { const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&country=TW&limit=200`; const res = await fetch(url); if (!res.ok) throw new Error(`iTunes lookup failed: ${res.status} for ${collectionId}`); return (await res.json()).results || []; } // ─── 格式转换 ───────────────────────────────────────────────────────────────── function fmtDuration(ms) { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function rawToArtistTrack(r) { return { id: String(r.trackId || ''), title: r.trackName || '', artist: r.artistName || '', album: r.collectionName || '', cover: r.artworkUrl100 || '', coverLarge: inferLargeArtworkUrl(r.artworkUrl100 || ''), duration: fmtDuration(r.trackTimeMillis ?? 0), }; } function rawToSearchSong(r) { return { id: `it_${String(r.trackId || r.collectionId || Math.random())}`, title: r.trackName || '', artist: r.artistName || '', cover: r.artworkUrl100 || '', coverLarge: inferLargeArtworkUrl(r.artworkUrl100 || ''), duration: fmtDuration(r.trackTimeMillis ?? 0), }; } // ─── 各阶段实现 ─────────────────────────────────────────────────────────────── async function runArtists(artistTracks, albumIdsToFetch) { let fetched = 0, skipped = 0; for (const artist of artistList) { const key = artist.toLowerCase(); if (!isFull('artists') && artistTracks[key]) { skipped++; continue; } process.stdout.write(` ${artist} ... `); try { const results = (await itunesSearch(artist, 'song', 10)) .filter(r => r.wrapperType === 'track' && r.kind === 'song'); const mapped = results.map(rawToArtistTrack); artistTracks[key] = mapped; const tradKey = TRAD_SIMP_MAP[artist]?.toLowerCase(); if (tradKey) artistTracks[tradKey] = mapped; results.forEach(r => { if (r.collectionId) albumIdsToFetch.add(String(r.collectionId)); }); console.log(`${results.length} tracks${tradKey ? ` (+繁体 key)` : ''}`); fetched++; } catch (e) { console.log(`ERROR: ${e.message}`); } await sleep(300); } console.log(` 完成:${fetched} 下载,${skipped} 跳过(已缓存)`); } async function runSearch(searchResults, albumIdsToFetch) { let fetched = 0, skipped = 0; for (const term of searchList) { const key = term.toLowerCase(); if (!isFull('search') && searchResults[key]) { skipped++; continue; } process.stdout.write(` "${term}" ... `); try { const [songRaw, artistRaw, albumRaw] = await Promise.all([ itunesSearch(term, 'song', 10), itunesSearch(term, 'musicArtist', 5), itunesSearch(term, 'album', 5), ]); const songs = songRaw.filter(r => r.wrapperType === 'track' && r.kind === 'song'); const artists = artistRaw.filter(r => r.wrapperType === 'artist'); const albums = albumRaw.filter(r => r.wrapperType === 'collection'); const fallbackCover = songs[0]?.artworkUrl100 || ''; const entry = { songs: songs.map(rawToSearchSong), artists: artists.map(r => ({ artistId: String(r.artistId || ''), artistName: r.artistName || '', artworkUrl100: r.artworkUrl100 || fallbackCover, })), albums: albums.map(r => ({ collectionId: String(r.collectionId || ''), collectionName: r.collectionName || '', artistName: r.artistName || '', artworkUrl100: r.artworkUrl100 || '', })), }; searchResults[key] = entry; const tradKey = TRAD_SIMP_MAP[term]?.toLowerCase(); if (tradKey) searchResults[tradKey] = entry; songs.forEach(r => { if (r.collectionId) albumIdsToFetch.add(String(r.collectionId)); }); albums.forEach(r => { if (r.collectionId) albumIdsToFetch.add(String(r.collectionId)); }); console.log(`${songs.length} songs, ${artists.length} artists, ${albums.length} albums${tradKey ? ` (+繁体 key)` : ''}`); fetched++; } catch (e) { console.log(`ERROR: ${e.message}`); } await sleep(300); } console.log(` 完成:${fetched} 下载,${skipped} 跳过(已缓存)`); } async function runAlbums(albumTracks, albumIdsToFetch) { const todo = isFull('albums') ? albumIdsToFetch : new Set([...albumIdsToFetch].filter(id => !albumTracks[id])); let fetched = 0, skipped = albumIdsToFetch.size - todo.size; console.log(` 待处理 ${todo.size} 张(跳过 ${skipped} 张已缓存)`); for (const albumId of todo) { process.stdout.write(` album ${albumId} ... `); try { const results = await itunesLookup(albumId); const collection = results.find(r => r.wrapperType === 'collection'); const songs = results.filter(r => r.wrapperType === 'track' && r.kind === 'song'); if (!collection && songs.length === 0) { console.log('空,跳过'); continue; } const playlist = collection ? { id: String(collection.collectionId), title: collection.collectionName, subtitle: `${collection.artistName} • ${collection.copyright || ''}`, cover: collection.artworkUrl100?.replace('100x100bb', '600x600bb') || '', coverLarge: inferLargeArtworkUrl(collection.artworkUrl100 || ''), type: collection.collectionType || 'Album', } : { id: albumId, title: songs[0]?.collectionName || '', subtitle: songs[0]?.artistName || '', cover: songs[0]?.artworkUrl100?.replace('100x100bb', '600x600bb') || '', coverLarge: inferLargeArtworkUrl(songs[0]?.artworkUrl100 || ''), type: 'Album', }; albumTracks[albumId] = { playlist, tracks: songs.map(t => ({ id: String(t.trackId), title: t.trackName, artist: t.artistName, cover: t.artworkUrl100 || '', coverLarge: inferLargeArtworkUrl(t.artworkUrl100 || ''), duration: fmtDuration(t.trackTimeMillis ?? 0), })), albumInfo: { title: collection?.collectionName || songs[0]?.collectionName || '', trackCount: songs.length, year: String(collection?.releaseDate ?? '').slice(0, 4) || '', }, }; console.log(`${songs.length} tracks — ${playlist.title}`); fetched++; } catch (e) { console.log(`ERROR: ${e.message}`); } await sleep(300); } console.log(` 完成:${fetched} 下载`); } // ─── 主流程 ──────────────────────────────────────────────────────────────────── async function main() { console.log('模式:'); console.log(` artists : ${isSkip('artists') ? '跳过' : isFull('artists') ? '全量' : '增量'}`); console.log(` search : ${isSkip('search') ? '跳过' : isFull('search') ? '全量' : '增量'}`); console.log(` albums : ${isSkip('albums') ? '跳过' : isFull('albums') ? '全量' : '增量'}`); if (extraArtists.length) console.log(` 追加艺人: ${extraArtists.join(', ')}`); if (extraSearchTerms.length) console.log(` 追加搜索词: ${extraSearchTerms.join(', ')}`); const artistTracks = loadJson('artistTracks.json'); const searchResults = loadJson('searchResults.json'); const albumTracks = loadJson('albumTracks.json'); const albumIdsToFetch = new Set(); // artists 阶段收集的 albumId 传给 albums 阶段 const collectFromExisting = () => { for (const tracks of Object.values(artistTracks)) { for (const t of (tracks || [])) { if (t.id && /^\d+/.test(t.id)) {/* track id, not album */} } } // 从 searchResults 里已有 albums 的 collectionId 也收集进来 for (const entry of Object.values(searchResults)) { for (const a of (entry?.albums || [])) { if (a.collectionId) albumIdsToFetch.add(a.collectionId); } } }; collectFromExisting(); if (!isSkip('artists')) { console.log('\n── 艺人页 (ArtistPage) ──'); await runArtists(artistTracks, albumIdsToFetch); } if (!isSkip('search')) { console.log('\n── 搜索页 (SearchPage) ──'); await runSearch(searchResults, albumIdsToFetch); } if (!isSkip('albums')) { console.log(`\n── 专辑页 (PlaylistPage) — 共 ${albumIdsToFetch.size} 个 ID ──`); await runAlbums(albumTracks, albumIdsToFetch); } console.log('\n── 写入文件 ──'); saveJson('artistTracks.json', artistTracks); saveJson('searchResults.json', searchResults); saveJson('albumTracks.json', albumTracks); console.log('\n✓ 完成'); } main().catch(e => { console.error(e); process.exit(1); });