initial commit
This commit is contained in:
commit
05112e3d6e
10 changed files with 359 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
data/*
|
||||||
|
node_modules/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
.env
|
12
commands/api.js
Normal file
12
commands/api.js
Normal file
|
@ -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
|
115
commands/process.js
Normal file
115
commands/process.js
Normal file
|
@ -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
|
105
commands/retrieve.js
Normal file
105
commands/retrieve.js
Normal file
|
@ -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
|
35
index.js
Normal file
35
index.js
Normal file
|
@ -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)
|
44
lib/dataStore.js
Normal file
44
lib/dataStore.js
Normal file
|
@ -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
|
||||||
|
}
|
15
lib/lastfm.js
Normal file
15
lib/lastfm.js
Normal file
|
@ -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();
|
||||||
|
}
|
21
package.json
Normal file
21
package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
3
run.sh
Executable file
3
run.sh
Executable file
|
@ -0,0 +1,3 @@
|
||||||
|
#! /usr/bin/env nix-shell
|
||||||
|
#! nix-shell -i real-interpreter -p nodejs nodePackages.pnpm
|
||||||
|
pnpm start
|
5
shell.nix
Normal file
5
shell.nix
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{ pkgs ? import <nixpkgs> {} }:
|
||||||
|
pkgs.mkShell {
|
||||||
|
# nativeBuildInputs is usually what you want -- tools you need to run
|
||||||
|
nativeBuildInputs = [ pkgs.nodejs pkgs.nodePackages.pnpm ];
|
||||||
|
}
|
Loading…
Reference in a new issue