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