commit 05112e3d6ee55a865fe851ab76eb8b44f9cbfffc Author: Jane Petrovna Date: Wed Dec 1 21:36:11 2021 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cda65cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/* +node_modules/ +pnpm-lock.yaml +.env diff --git a/commands/api.js b/commands/api.js new file mode 100644 index 0000000..14b9b47 --- /dev/null +++ b/commands/api.js @@ -0,0 +1,12 @@ +import { callApi } from "../lib/lastfm.js" + +const api = (username, method, args, options) => { + callApi(method, { + username: username, + ...JSON.parse(args) + }).then((data) => { + console.log(data) + }) +} + +export default api \ No newline at end of file diff --git a/commands/process.js b/commands/process.js new file mode 100644 index 0000000..9274319 --- /dev/null +++ b/commands/process.js @@ -0,0 +1,115 @@ +import { loadAllData, mkSubDir, saveData } from "../lib/dataStore.js" + +const process = async (username, options) => { + let data = {} + let processed = { + tracks: {}, + listenTime: { + track: {}, + album: {}, + artist: {}, + sortedTracks: [], + sortedAlbums: [], + sortedArtists: [] + } + } + if (options.tracks) { + data.tracks = loadAllData(username, 'tracks') + + // track-only data + for (let page in data.tracks) { + console.log(page, data.tracks[page]) + if (!data.tracks[page].toptracks) continue; + let currentPage = data.tracks[page].toptracks.track + // console.log(currentPage) + for (let track of currentPage) { + console.log('processing track', track.name) + processed.tracks[track.mbid] = { + playcount: track.playcount, + name: track.name, + artist: track.artist.name + } + if (track.duration && parseInt(track.duration)) { + console.log('track has duration', track.duration) + let listenTime = parseInt(track.duration) * parseInt(track.playcount) + console.log('track listen time is', listenTime) + processed.listenTime.track[track.mbid] = { + seconds: listenTime, + minutes: listenTime / 60 + } + + console.log(track.mbid, processed.listenTime.track[track.mbid]) + let key = '' + if (track.artist.mbid) { key = track.artist.mbid } + else { key = 'N-' + track.artist.name } + + if (!processed.listenTime.artist[key]) { + processed.listenTime.artist[key] = { + seconds: listenTime, + minutes: listenTime / 60, + name: track.artist.name + } + } + else { + processed.listenTime.artist[key].seconds += listenTime + processed.listenTime.artist[key].minutes = + processed.listenTime.artist[key].seconds / 60 + } + } + else { + processed.listenTime.track[track.mbid] = { + playcount: track.playcount + } + } + } + } + + for (let track in processed.listenTime.tracks) { + processed.listenTime.sortedTracks.push({ + mbid: track, + ...processed.listenTime.track[track] + }) + } + + processed.listenTime.sortedTracks.sort( + (a, b) => { + return b.seconds - a.seconds + } + ) + + for (let artist in processed.listenTime.artist) { + processed.listenTime.sortedArtists.push( + { + mbid: artist, + ...processed.listenTime.artist[artist] + } + ) + } + + processed.listenTime.sortedArtists.sort( + (a, b) => { + return b.seconds - a.seconds + } + ) + } + if (options.albums) { + data.albums = loadAllData(username, 'albums') + for (let page in data.albums) { + let currentPage = data.albums[page].topalbums.album + } + } + + if (options.artists) { + data.artists = loadAllData(username, 'artists') + for (let page in data.artists) { + let currentPage = data.artists[page].topartists.artist + } + } + console.log('saving processed data') + mkSubDir(username, 'processed') + saveData(username, 'processed/gross', processed) + saveData(username, 'processed/tracks', processed.tracks) + saveData(username, 'processed/listenTime', processed.listenTime) +} + +export default process \ No newline at end of file diff --git a/commands/retrieve.js b/commands/retrieve.js new file mode 100644 index 0000000..1764625 --- /dev/null +++ b/commands/retrieve.js @@ -0,0 +1,105 @@ +import { callApi } from '../lib/lastfm.js' +import { hasData, mkSubDir, saveData } from '../lib/dataStore.js' + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +const retrieve = async (username, options) => { + callApi("user.getinfo", { user: username }).then((data) => { + saveData(username, "basic_info", data) + }) + if (!options.basic) { + await retrieveTracks(username, options) + await retrieveAlbums(username, options) + await retrieveArtists(username, options) + } +} + +const retrieveTracks = async (username, options) => { + let period = '12month' + if (options.full) { + period = 'overall' + } + let pages = 0; + mkSubDir(username, 'tracks') + if (hasData(username, "tracks/tracks-1")) { + console.log('already have tracks'); + return; + } + console.log('downloading page 1 of ???') + let data = await callApi("user.getTopTracks", { + user: username + }) + pages = data.toptracks['@attr'].totalPages + saveData(username, "tracks/tracks-1", data) + for (let i = 2; i <= pages; i++) { + await sleep(500) + console.log(`downloading page ${i} of ${pages}`) + let data = await callApi("user.getTopTracks", { + user: username, + page: i + }) + saveData(username, `tracks/tracks-${i}`, data) + } +} + +const retrieveAlbums = async (username, options) => { + let period = '12month' + if (options.full) { + period = 'overall' + } + let pages = 0; + mkSubDir(username, 'albums') + if (hasData(username, "albums/albums-1")) { + console.log('already have albums'); + return; + } + console.log('downloading page 1 of ???') + let data = await callApi("user.getTopAlbums", { + user: username + }) + pages = data.topalbums['@attr'].totalPages + saveData(username, "albums/albums-1", data) + for (let i = 2; i <= pages; i++) { + await sleep(500) + console.log(`downloading page ${i} of ${pages}`) + let data = await callApi("user.getTopAlbums", { + user: username, + page: i + }) + saveData(username, `albums/albums-${i}`, data) + } +} + +const retrieveArtists = async (username, options) => { + let period = '12month' + if (options.full) { + period = 'overall' + } + let pages = 0; + mkSubDir(username, 'artists') + if (hasData(username, "artists/artists-1")) { + console.log('already have artists'); + return; + } + console.log('downloading page 1 of ???') + let data = await callApi("user.getTopArtists", { + user: username + }) + pages = data.topartists['@attr'].totalPages + saveData(username, "artists/artists-1", data) + for (let i = 2; i <= pages; i++) { + await sleep(500) + console.log(`downloading page ${i} of ${pages}`) + let data = await callApi("user.getTopArtists", { + user: username, + page: i + }) + saveData(username, `artists/artists-${i}`, data) + } +} + +export default retrieve \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..231d8ef --- /dev/null +++ b/index.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import cli from 'commander' +import retrieve from './commands/retrieve.js' +import api from './commands/api.js' +import process from './commands/process.js' +import dotenv from 'dotenv' + +dotenv.config() + +cli.description("Last.fm Wrapper") +cli.name("wrapper") +cli.addHelpCommand(false) +cli.helpOption(true) + +cli.command("retrieve") +.argument("username", "The username for which to download data.") +.option("-b, --basic", "Download only basic user-data") +.option("-f, --full", "Download listening data for previous years.") +.action(retrieve) + +cli.command('process') +.argument('username', "") +.option("-t, --tracks", "") +.option("-al, --albums", "") +.option("-ar, --artists", "") +.action(process) + +cli.command("api") +.argument("username", "") +.argument("method", "") +.argument("args", "") +.action(api) + +// must ALWAYS be at the bottom +cli.parse(process.argv) \ No newline at end of file diff --git a/lib/dataStore.js b/lib/dataStore.js new file mode 100644 index 0000000..3af8e3b --- /dev/null +++ b/lib/dataStore.js @@ -0,0 +1,44 @@ +import 'fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs' + +export const mkSubDir = (username, subdir) => { + if (!existsSync(`./data/${username}`)) { + mkdirSync(`./data/${username}`) + } + if (!existsSync(`./data/${username}/${subdir}`)) { + mkdirSync(`./data/${username}/${subdir}`) + } +} + +export const hasData = (username, dataFile) => { + return existsSync(`./data/${username}/${dataFile}.json`) +} + +export const saveData = (username, dataFile, object) => { + if (!existsSync('./data')) { + mkdirSync('./data') + } + if (!existsSync(`./data/${username}`)) { + mkdirSync(`./data/${username}`) + } + // console.log(dataFile, object, JSON.stringify(object)) + writeFileSync(`./data/${username}/${dataFile}.json`, JSON.stringify(object)) +} + +export const loadData = (username, dataFile) => { + let data = undefined; + if (existsSync(`./data/${username}/${dataFile}.json`)) { + data = JSON.parse(readFileSync(`./data/${username}/${dataFile}.json`)) + } + return data; +} + +export const loadAllData = (username, subdir) => { + let files = readdirSync(`./data/${username}/${subdir}`) + let data = [] + for (let file of files) { + let parsed = JSON.parse(readFileSync(`./data/${username}/${subdir}/${file}`)) + data.push(parsed) + } + return data +} diff --git a/lib/lastfm.js b/lib/lastfm.js new file mode 100644 index 0000000..5e5aca1 --- /dev/null +++ b/lib/lastfm.js @@ -0,0 +1,15 @@ +import fetch from 'node-fetch' + +const API_URL = "http://ws.audioscrobbler.com/2.0/" + +export const callApi = async (method, params = {}) => { + + let url = `${API_URL}?method=${method}&api_key=${process.env.API_KEY}&format=json` + if (params != undefined) { + for (let [key, value] of Object.entries(params)) { + url += `&${key}=${value}` + } + } + let result = await fetch(url) + return result.json(); +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8a66ef --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "lastfm-wrapped", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node index.js" + }, + "bin": { + "wrapper": "index.js" + }, + "author": "", + "license": "ISC", + "dependencies": { + "commander": "^8.3.0", + "dotenv": "^10.0.0", + "node-fetch": "^3.1.0" + } +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..40f7088 --- /dev/null +++ b/run.sh @@ -0,0 +1,3 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i real-interpreter -p nodejs nodePackages.pnpm +pnpm start diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..66facdb --- /dev/null +++ b/shell.nix @@ -0,0 +1,5 @@ +{ pkgs ? import {} }: + pkgs.mkShell { + # nativeBuildInputs is usually what you want -- tools you need to run + nativeBuildInputs = [ pkgs.nodejs pkgs.nodePackages.pnpm ]; +}