clifm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bun.lock +38 -0
- package/components.json +21 -0
- package/index.js +38 -0
- package/next.config.mjs +11 -0
- package/package.json +33 -0
- package/postcss.config.mjs +8 -0
- package/src/api.js +91 -0
- package/src/commands/album.js +67 -0
- package/src/commands/artist.js +63 -0
- package/src/commands/calendar.js +47 -0
- package/src/commands/compare.js +56 -0
- package/src/commands/config.js +23 -0
- package/src/commands/discovery.js +50 -0
- package/src/commands/doctor.js +53 -0
- package/src/commands/eras.js +60 -0
- package/src/commands/index.js +53 -0
- package/src/commands/library.js +39 -0
- package/src/commands/loved.js +44 -0
- package/src/commands/milestones.js +61 -0
- package/src/commands/nowplaying.js +49 -0
- package/src/commands/profile.js +65 -0
- package/src/commands/recent.js +48 -0
- package/src/commands/recommend.js +56 -0
- package/src/commands/set.js +25 -0
- package/src/commands/stats.js +56 -0
- package/src/commands/streaks.js +63 -0
- package/src/commands/taste.js +66 -0
- package/src/commands/topalbums.js +47 -0
- package/src/commands/topartists.js +46 -0
- package/src/commands/topgenres.js +43 -0
- package/src/commands/toptracks.js +47 -0
- package/src/commands/track.js +61 -0
- package/src/commands/weekly.js +45 -0
- package/src/config.js +42 -0
- package/src/setup.js +46 -0
- package/src/utils/format.js +54 -0
- package/src/utils/periods.js +32 -0
- package/tsconfig.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# clifm
|
|
2
|
+
|
|
3
|
+
last.fm cli client
|
|
4
|
+
|
|
5
|
+
## installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g clifm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
clifm np
|
|
15
|
+
clifm tart 1m
|
|
16
|
+
clifm profile
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## first run
|
|
20
|
+
|
|
21
|
+
on first run, clifm will prompt you for:
|
|
22
|
+
- last.fm api key (get from https://www.last.fm/api)
|
|
23
|
+
- default username
|
|
24
|
+
|
|
25
|
+
## commands
|
|
26
|
+
|
|
27
|
+
- `clifm topartists <period>` or `clifm tart <period>` - top artists
|
|
28
|
+
- `clifm toptracks <period>` or `clifm tt <period>` - top tracks
|
|
29
|
+
- `clifm topalbums <period>` or `clifm talb <period>` - top albums
|
|
30
|
+
- `clifm topgenres <period>` or `clifm tg <period>` - top genres
|
|
31
|
+
- `clifm nowplaying` or `clifm np` - now playing or last track
|
|
32
|
+
- `clifm profile` or `clifm pr` - user profile
|
|
33
|
+
- `clifm recent` or `clifm rt` - recent tracks
|
|
34
|
+
- `clifm loved` or `clifm lt` - loved tracks
|
|
35
|
+
- `clifm artist <name>` or `clifm ar <name>` - artist info
|
|
36
|
+
- `clifm album <artist> <album>` or `clifm alb <artist> <album>` - album info
|
|
37
|
+
- `clifm track <artist> <track>` or `clifm tr <artist> <track>` - track info
|
|
38
|
+
- `clifm weekly` or `clifm wc` - weekly chart
|
|
39
|
+
- `clifm taste` - taste summary
|
|
40
|
+
- `clifm streaks` - listening streaks
|
|
41
|
+
- `clifm compare <user1> <user2>` or `clifm cmp <user1> <user2>` - compare users
|
|
42
|
+
- `clifm recommend` or `clifm rec` - recommendations
|
|
43
|
+
- `clifm discovery` or `clifm disc` - discovery artists
|
|
44
|
+
- `clifm stats` - stats overview
|
|
45
|
+
- `clifm calendar` - scrobbles per weekday
|
|
46
|
+
- `clifm eras` - dominant eras
|
|
47
|
+
- `clifm library` - library size
|
|
48
|
+
- `clifm milestones` - listening milestones
|
|
49
|
+
- `clifm set defaultuser <username>` - set default user
|
|
50
|
+
- `clifm config` - show config
|
|
51
|
+
- `clifm doctor` - check health
|
|
52
|
+
|
|
53
|
+
## periods
|
|
54
|
+
|
|
55
|
+
- `1w` - last week
|
|
56
|
+
- `1m` - last month
|
|
57
|
+
- `2m` - last 3 months
|
|
58
|
+
- `4m` - last 6 months
|
|
59
|
+
- `6m` - last 12 months
|
|
60
|
+
- `12m` - all time
|
|
61
|
+
- `alltime` - all time
|
|
62
|
+
|
|
63
|
+
## global options
|
|
64
|
+
|
|
65
|
+
- `-u, --user <username>` - override default user
|
|
66
|
+
- `--json` - output raw json
|
|
67
|
+
- `--no-color` - disable colors
|
|
68
|
+
- `--limit <number>` - limit list output
|
|
69
|
+
- `--page <number>` - pagination
|
|
70
|
+
|
|
71
|
+
## license
|
|
72
|
+
|
|
73
|
+
MIT
|
package/bun.lock
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "clifm",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"chalk": "^5.3.0",
|
|
9
|
+
"commander": "^12.1.0",
|
|
10
|
+
"node-fetch": "^3.3.2",
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/node": "^25.0.3",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"packages": {
|
|
18
|
+
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
|
19
|
+
|
|
20
|
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
21
|
+
|
|
22
|
+
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
|
23
|
+
|
|
24
|
+
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
|
25
|
+
|
|
26
|
+
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
|
27
|
+
|
|
28
|
+
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
|
29
|
+
|
|
30
|
+
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
|
31
|
+
|
|
32
|
+
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
|
33
|
+
|
|
34
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
35
|
+
|
|
36
|
+
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
|
37
|
+
}
|
|
38
|
+
}
|
package/components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"aliases": {
|
|
14
|
+
"components": "@/components",
|
|
15
|
+
"utils": "@/lib/utils",
|
|
16
|
+
"ui": "@/components/ui",
|
|
17
|
+
"lib": "@/lib",
|
|
18
|
+
"hooks": "@/hooks"
|
|
19
|
+
},
|
|
20
|
+
"iconLibrary": "lucide"
|
|
21
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander"
|
|
4
|
+
import chalk from "chalk"
|
|
5
|
+
import { getConfig, hasConfig } from "./src/config.js"
|
|
6
|
+
import { runSetup } from "./src/setup.js"
|
|
7
|
+
import { registerCommands } from "./src/commands/index.js"
|
|
8
|
+
|
|
9
|
+
const program = new Command()
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
try {
|
|
13
|
+
if (!hasConfig()) {
|
|
14
|
+
await runSetup()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const config = getConfig()
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name("clifm")
|
|
21
|
+
.description("last.fm cli client")
|
|
22
|
+
.version("1.0.0")
|
|
23
|
+
.option("-u, --user <username>", "override default user")
|
|
24
|
+
.option("--json", "output raw json")
|
|
25
|
+
.option("--no-color", "disable colors")
|
|
26
|
+
.option("--limit <number>", "limit list output", "10")
|
|
27
|
+
.option("--page <number>", "page number", "1")
|
|
28
|
+
|
|
29
|
+
registerCommands(program, config)
|
|
30
|
+
|
|
31
|
+
await program.parseAsync(process.argv)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(chalk.red(error.message || "unknown error"))
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main()
|
package/next.config.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clifm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "last.fm cli client",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lastfm",
|
|
7
|
+
"last.fm",
|
|
8
|
+
"cli",
|
|
9
|
+
"music",
|
|
10
|
+
"scrobble"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Avery <av@skillissue.lol>",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "index.js",
|
|
16
|
+
"bin": {
|
|
17
|
+
"clifm": "./index.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node index.js --help"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"commander": "^12.1.0",
|
|
25
|
+
"node-fetch": "^3.3.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.0.3"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fetch from "node-fetch"
|
|
2
|
+
|
|
3
|
+
const BASE_URL = "https://ws.audioscrobbler.com/2.0/"
|
|
4
|
+
|
|
5
|
+
export class LastFmAPI {
|
|
6
|
+
constructor(apiKey) {
|
|
7
|
+
this.apiKey = apiKey
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async call(method, params = {}) {
|
|
11
|
+
const url = new URL(BASE_URL)
|
|
12
|
+
url.searchParams.set("method", method)
|
|
13
|
+
url.searchParams.set("api_key", this.apiKey)
|
|
14
|
+
url.searchParams.set("format", "json")
|
|
15
|
+
|
|
16
|
+
for (const [key, value] of Object.entries(params)) {
|
|
17
|
+
if (value !== undefined && value !== null) {
|
|
18
|
+
url.searchParams.set(key, value.toString())
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(url.toString())
|
|
24
|
+
const data = await response.json()
|
|
25
|
+
|
|
26
|
+
if (data.error) {
|
|
27
|
+
throw new Error(data.message || "last.fm api error")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return data
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.message.includes("last.fm")) {
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
throw new Error("failed to connect to last.fm") // generic as fuck
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getUserInfo(user) {
|
|
40
|
+
return this.call("user.getInfo", { user })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getTopArtists(user, period, limit = 10, page = 1) {
|
|
44
|
+
return this.call("user.getTopArtists", { user, period, limit, page })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getTopTracks(user, period, limit = 10, page = 1) {
|
|
48
|
+
return this.call("user.getTopTracks", { user, period, limit, page })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getTopAlbums(user, period, limit = 10, page = 1) {
|
|
52
|
+
return this.call("user.getTopAlbums", { user, period, limit, page })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getTopTags(user, limit = 10) {
|
|
56
|
+
return this.call("user.getTopTags", { user, limit })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getRecentTracks(user, limit = 10, page = 1) {
|
|
60
|
+
return this.call("user.getRecentTracks", { user, limit, page })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getLovedTracks(user, limit = 10, page = 1) {
|
|
64
|
+
return this.call("user.getLovedTracks", { user, limit, page })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getArtistInfo(artist, user) {
|
|
68
|
+
return this.call("artist.getInfo", { artist, user })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getAlbumInfo(artist, album, user) {
|
|
72
|
+
return this.call("album.getInfo", { artist, album, user })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getTrackInfo(artist, track, user) {
|
|
76
|
+
return this.call("track.getInfo", { artist, track, user })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getWeeklyArtistChart(user) {
|
|
80
|
+
return this.call("user.getWeeklyArtistChart", { user })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// geez these could have been done with a better approach (above & below)
|
|
84
|
+
async getSimilarArtists(artist, limit = 10) {
|
|
85
|
+
return this.call("artist.getSimilar", { artist, limit })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async getArtistTopTags(artist) {
|
|
89
|
+
return this.call("artist.getTopTags", { artist })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { LastFmAPI } from "../api.js"
|
|
3
|
+
import { formatNumber } from "../utils/format.js"
|
|
4
|
+
|
|
5
|
+
export function albumCommand(program, config) {
|
|
6
|
+
program
|
|
7
|
+
.command("album <artist> <album>")
|
|
8
|
+
.alias("alb")
|
|
9
|
+
.description("show album info")
|
|
10
|
+
.action(async (artist, album) => {
|
|
11
|
+
try {
|
|
12
|
+
const opts = program.opts()
|
|
13
|
+
const user = opts.user || config.defaultUser
|
|
14
|
+
|
|
15
|
+
const api = new LastFmAPI(config.apiKey)
|
|
16
|
+
const data = await api.getAlbumInfo(artist, album, user)
|
|
17
|
+
|
|
18
|
+
if (opts.json) {
|
|
19
|
+
console.log(JSON.stringify(data, null, 2))
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const alb = data.album
|
|
24
|
+
if (!alb) {
|
|
25
|
+
console.log("album not found")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log()
|
|
30
|
+
console.log(chalk.cyan("album · ") + alb.name)
|
|
31
|
+
console.log(chalk.dim("by ") + alb.artist)
|
|
32
|
+
console.log()
|
|
33
|
+
|
|
34
|
+
if (alb.playcount) {
|
|
35
|
+
console.log(chalk.dim("playcount:") + " " + formatNumber(Number.parseInt(alb.playcount)))
|
|
36
|
+
}
|
|
37
|
+
if (alb.userplaycount) {
|
|
38
|
+
console.log(chalk.dim("your plays:") + " " + formatNumber(Number.parseInt(alb.userplaycount)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tags = alb.tags?.tag || []
|
|
42
|
+
if (tags.length > 0) {
|
|
43
|
+
console.log(
|
|
44
|
+
chalk.dim("tags:") +
|
|
45
|
+
" " +
|
|
46
|
+
tags
|
|
47
|
+
.slice(0, 5)
|
|
48
|
+
.map((t) => t.name)
|
|
49
|
+
.join(", "),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tracks = alb.tracks?.track || []
|
|
54
|
+
if (tracks.length > 0) {
|
|
55
|
+
console.log()
|
|
56
|
+
console.log(chalk.dim("tracks:"))
|
|
57
|
+
tracks.slice(0, 10).forEach((t, idx) => {
|
|
58
|
+
console.log(` ${chalk.dim(idx + 1 + ".")} ${t.name}`)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log()
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { LastFmAPI } from "../api.js"
|
|
3
|
+
import { formatNumber } from "../utils/format.js"
|
|
4
|
+
|
|
5
|
+
export function artistCommand(program, config) {
|
|
6
|
+
program
|
|
7
|
+
.command("artist <name>")
|
|
8
|
+
.alias("ar")
|
|
9
|
+
.description("show artist info")
|
|
10
|
+
.action(async (name) => {
|
|
11
|
+
try {
|
|
12
|
+
const opts = program.opts()
|
|
13
|
+
const user = opts.user || config.defaultUser
|
|
14
|
+
|
|
15
|
+
const api = new LastFmAPI(config.apiKey)
|
|
16
|
+
const data = await api.getArtistInfo(name, user)
|
|
17
|
+
|
|
18
|
+
if (opts.json) {
|
|
19
|
+
console.log(JSON.stringify(data, null, 2))
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const artist = data.artist
|
|
24
|
+
if (!artist) {
|
|
25
|
+
console.log("artist not found")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log()
|
|
30
|
+
console.log(chalk.cyan("artist · ") + artist.name)
|
|
31
|
+
console.log()
|
|
32
|
+
|
|
33
|
+
if (artist.stats?.playcount) {
|
|
34
|
+
console.log(chalk.dim("playcount:") + " " + formatNumber(Number.parseInt(artist.stats.playcount)))
|
|
35
|
+
}
|
|
36
|
+
if (artist.stats?.userplaycount) {
|
|
37
|
+
console.log(chalk.dim("your plays:") + " " + formatNumber(Number.parseInt(artist.stats.userplaycount)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tags = artist.tags?.tag || []
|
|
41
|
+
if (tags.length > 0) {
|
|
42
|
+
console.log(
|
|
43
|
+
chalk.dim("tags:") +
|
|
44
|
+
" " +
|
|
45
|
+
tags
|
|
46
|
+
.slice(0, 5)
|
|
47
|
+
.map((t) => t.name)
|
|
48
|
+
.join(", "),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (artist.bio?.summary) {
|
|
53
|
+
console.log()
|
|
54
|
+
const summary = artist.bio.summary.replace(/<[^>]*>/g, "").split("\n")[0]
|
|
55
|
+
console.log(summary.substring(0, 300) + (summary.length > 300 ? "..." : ""))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log()
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { LastFmAPI } from "../api.js"
|
|
3
|
+
|
|
4
|
+
export function calendarCommand(program, config) {
|
|
5
|
+
program
|
|
6
|
+
.command("calendar")
|
|
7
|
+
.description("show scrobbles per weekday")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
try {
|
|
10
|
+
const opts = program.opts()
|
|
11
|
+
const user = opts.user || config.defaultUser
|
|
12
|
+
|
|
13
|
+
const api = new LastFmAPI(config.apiKey)
|
|
14
|
+
const recent = await api.getRecentTracks(user, 200)
|
|
15
|
+
|
|
16
|
+
if (opts.json) {
|
|
17
|
+
console.log(JSON.stringify(recent, null, 2))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tracks = recent.recenttracks?.track || []
|
|
22
|
+
const weekdays = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
|
|
23
|
+
const counts = new Array(7).fill(0)
|
|
24
|
+
|
|
25
|
+
tracks.forEach((track) => {
|
|
26
|
+
if (track.date?.uts) {
|
|
27
|
+
const date = new Date(track.date.uts * 1000)
|
|
28
|
+
counts[date.getDay()]++
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
console.log()
|
|
33
|
+
console.log(chalk.cyan("calendar · ") + user)
|
|
34
|
+
console.log()
|
|
35
|
+
|
|
36
|
+
weekdays.forEach((day, idx) => {
|
|
37
|
+
const count = counts[idx]
|
|
38
|
+
const bar = "█".repeat(Math.floor(count / 2))
|
|
39
|
+
console.log(`${day.padEnd(10)} ${chalk.yellow(bar)} ${count}`)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
console.log()
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { LastFmAPI } from "../api.js"
|
|
3
|
+
|
|
4
|
+
export function compareCommand(program, config) {
|
|
5
|
+
program
|
|
6
|
+
.command("compare <user1> <user2>")
|
|
7
|
+
.alias("cmp")
|
|
8
|
+
.description("compare two users")
|
|
9
|
+
.action(async (user1, user2) => {
|
|
10
|
+
try {
|
|
11
|
+
const opts = program.opts()
|
|
12
|
+
|
|
13
|
+
const api = new LastFmAPI(config.apiKey)
|
|
14
|
+
const artists1 = await api.getTopArtists(user1, "overall", 50)
|
|
15
|
+
const artists2 = await api.getTopArtists(user2, "overall", 50)
|
|
16
|
+
|
|
17
|
+
if (opts.json) {
|
|
18
|
+
console.log(JSON.stringify({ user1: artists1, user2: artists2 }, null, 2))
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const list1 = artists1.topartists?.artist || []
|
|
23
|
+
const list2 = artists2.topartists?.artist || []
|
|
24
|
+
|
|
25
|
+
if (list1.length === 0 || list2.length === 0) {
|
|
26
|
+
console.log("insufficient data to compare")
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const names1 = new Set(list1.map((a) => a.name.toLowerCase()))
|
|
31
|
+
const names2 = new Set(list2.map((a) => a.name.toLowerCase()))
|
|
32
|
+
|
|
33
|
+
const shared = list1.filter((a) => names2.has(a.name.toLowerCase()))
|
|
34
|
+
const compatibility = ((shared.length / Math.min(list1.length, list2.length)) * 100).toFixed(0)
|
|
35
|
+
|
|
36
|
+
console.log()
|
|
37
|
+
console.log(chalk.cyan("compare · ") + user1 + chalk.dim(" vs ") + user2)
|
|
38
|
+
console.log()
|
|
39
|
+
console.log(chalk.dim("compatibility:") + " " + compatibility + "%")
|
|
40
|
+
console.log()
|
|
41
|
+
|
|
42
|
+
if (shared.length > 0) {
|
|
43
|
+
console.log(chalk.dim("shared artists:"))
|
|
44
|
+
shared.slice(0, 10).forEach((artist, idx) => {
|
|
45
|
+
console.log(` ${chalk.dim(idx + 1 + ".")} ${artist.name}`)
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
console.log("no shared artists in top 50")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log()
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { getConfig } from "../config.js"
|
|
3
|
+
import { maskApiKey } from "../utils/format.js"
|
|
4
|
+
|
|
5
|
+
export function configShowCommand(program, config) {
|
|
6
|
+
program
|
|
7
|
+
.command("config")
|
|
8
|
+
.description("show config")
|
|
9
|
+
.action(() => {
|
|
10
|
+
try {
|
|
11
|
+
const cfg = getConfig()
|
|
12
|
+
|
|
13
|
+
console.log()
|
|
14
|
+
console.log(chalk.cyan("config"))
|
|
15
|
+
console.log()
|
|
16
|
+
console.log(chalk.dim("api key:") + " " + maskApiKey(cfg.apiKey))
|
|
17
|
+
console.log(chalk.dim("default user:") + " " + cfg.defaultUser)
|
|
18
|
+
console.log()
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { LastFmAPI } from "../api.js"
|
|
3
|
+
|
|
4
|
+
export function discoveryCommand(program, config) {
|
|
5
|
+
program
|
|
6
|
+
.command("discovery")
|
|
7
|
+
.alias("disc")
|
|
8
|
+
.description("show discovery artists")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const opts = program.opts()
|
|
12
|
+
const user = opts.user || config.defaultUser
|
|
13
|
+
|
|
14
|
+
const api = new LastFmAPI(config.apiKey)
|
|
15
|
+
const artists = await api.getTopArtists(user, "overall", 50)
|
|
16
|
+
|
|
17
|
+
if (opts.json) {
|
|
18
|
+
console.log(JSON.stringify(artists, null, 2))
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const list = artists.topartists?.artist || []
|
|
23
|
+
if (list.length === 0) {
|
|
24
|
+
console.log("no data available")
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const discoveries = list
|
|
29
|
+
.map((a, idx) => ({ ...a, rank: idx + 1, playcount: Number.parseInt(a.playcount) }))
|
|
30
|
+
.filter((a) => a.playcount < 50 && a.rank < 30)
|
|
31
|
+
.sort((a, b) => a.rank - b.rank)
|
|
32
|
+
|
|
33
|
+
console.log()
|
|
34
|
+
console.log(chalk.cyan("discoveries · ") + user)
|
|
35
|
+
console.log()
|
|
36
|
+
|
|
37
|
+
if (discoveries.length === 0) {
|
|
38
|
+
console.log("no discoveries found")
|
|
39
|
+
} else {
|
|
40
|
+
discoveries.slice(0, 10).forEach((artist, idx) => {
|
|
41
|
+
console.log(`${chalk.dim(idx + 1 + ".")} ${artist.name} ${chalk.dim("—")} ${artist.playcount} plays`)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log()
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { getConfig, hasConfig } from "../config.js"
|
|
3
|
+
import { LastFmAPI } from "../api.js"
|
|
4
|
+
|
|
5
|
+
export function doctorCommand(program, config) {
|
|
6
|
+
program
|
|
7
|
+
.command("doctor")
|
|
8
|
+
.description("check system health")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
try {
|
|
11
|
+
console.log()
|
|
12
|
+
console.log(chalk.cyan("running diagnostics..."))
|
|
13
|
+
console.log()
|
|
14
|
+
|
|
15
|
+
if (!hasConfig()) {
|
|
16
|
+
console.log(chalk.red("✗") + " config file missing")
|
|
17
|
+
console.log()
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(chalk.green("✓") + " config file found")
|
|
22
|
+
|
|
23
|
+
const cfg = getConfig()
|
|
24
|
+
|
|
25
|
+
if (!cfg.apiKey) {
|
|
26
|
+
console.log(chalk.red("✗") + " api key missing")
|
|
27
|
+
} else {
|
|
28
|
+
console.log(chalk.green("✓") + " api key configured")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!cfg.defaultUser) {
|
|
32
|
+
console.log(chalk.red("✗") + " default user missing")
|
|
33
|
+
} else {
|
|
34
|
+
console.log(chalk.green("✓") + " default user configured")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const api = new LastFmAPI(cfg.apiKey)
|
|
39
|
+
await api.getUserInfo(cfg.defaultUser)
|
|
40
|
+
console.log(chalk.green("✓") + " last.fm api connection successful")
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.log(chalk.red("✗") + " last.fm api connection failed")
|
|
43
|
+
console.log(chalk.dim(" " + error.message))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log()
|
|
47
|
+
console.log(chalk.green("diagnostics complete"))
|
|
48
|
+
console.log()
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|