All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing, spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has - in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed - /api/spotify/connect + callback (owner only), /trackers/music public stats - scripts: music-poll, music-import-lastfm (resumable), music-import-history - deploy/music-collector: optional host timer with its own role
70 lines
2.7 KiB
TypeScript
70 lines
2.7 KiB
TypeScript
// Import a Spotify data export into music.plays (source 'spotify', origin 'export').
|
|
//
|
|
// bun scripts/music-import-history.ts <file-or-dir> [...] [--min-ms 30000] [--dry-run]
|
|
//
|
|
// Accepts the Extended Streaming History (Streaming_History_Audio_*.json, older
|
|
// endsong_*.json) and the short account-data export (StreamingHistory_music_*.json).
|
|
// Podcasts/audiobooks and plays under --min-ms are skipped. Safe to re-run: a play already
|
|
// stored (same artist+title within a minute, also from the live poller) is not added twice.
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { DEFAULT_MIN_MS, parseHistoryEntry, type HistoryPlay } from '../src/lib/music/history';
|
|
import { insertPlay, newCache, upsertTrack } from '../src/lib/music/store.server';
|
|
import { arg, musicDb } from './music-db';
|
|
|
|
const minMs = Number(arg('min-ms') ?? DEFAULT_MIN_MS);
|
|
const dryRun = process.argv.includes('--dry-run');
|
|
const skip = new Set(['--min-ms', arg('min-ms')]);
|
|
const inputs = process.argv.slice(2).filter((a) => !a.startsWith('--') && !skip.has(a));
|
|
if (!inputs.length) throw new Error('Usage: bun scripts/music-import-history.ts <file-or-dir> ...');
|
|
|
|
const NAME = /^(Streaming_History_Audio_.*|endsong_?\d*|StreamingHistory(_music)?_?\d*)\.json$/i;
|
|
function files(path: string): string[] {
|
|
if (statSync(path).isDirectory())
|
|
return readdirSync(path).flatMap((name) => {
|
|
const child = join(path, name);
|
|
return statSync(child).isDirectory() ? files(child) : NAME.test(name) ? [child] : [];
|
|
});
|
|
return [path];
|
|
}
|
|
|
|
const plays: HistoryPlay[] = [];
|
|
let entries = 0;
|
|
for (const file of inputs.flatMap(files)) {
|
|
const data = JSON.parse(readFileSync(file, 'utf8')) as unknown[];
|
|
if (!Array.isArray(data)) continue;
|
|
entries += data.length;
|
|
for (const entry of data) {
|
|
const play = parseHistoryEntry(entry, minMs);
|
|
if (play) plays.push(play);
|
|
}
|
|
console.log(`${file}: ${data.length} entries`);
|
|
}
|
|
plays.sort((a, b) => a.playedAt.getTime() - b.playedAt.getTime());
|
|
console.log(`${plays.length} music plays of ${minMs / 1000}s or more out of ${entries} entries.`);
|
|
if (dryRun || !plays.length) process.exit(0);
|
|
|
|
const sql = musicDb();
|
|
try {
|
|
const cache = newCache();
|
|
let stored = 0;
|
|
for (const [i, play] of plays.entries()) {
|
|
const trackId = await upsertTrack(sql, play.track, false, cache);
|
|
if (
|
|
await insertPlay(sql, {
|
|
trackId,
|
|
playedAt: play.playedAt,
|
|
source: 'spotify',
|
|
origin: 'export',
|
|
msPlayed: play.msPlayed,
|
|
platform: play.platform
|
|
})
|
|
)
|
|
stored++;
|
|
if ((i + 1) % 5000 === 0) console.log(` ${i + 1}/${plays.length} (${stored} new)`);
|
|
}
|
|
console.log(`Done: ${stored} new plays stored.`);
|
|
} finally {
|
|
await sql.end();
|
|
}
|