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.
@@ -0,0 +1,60 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+
4
+ export function erasCommand(program, config) {
5
+ program
6
+ .command("eras")
7
+ .description("show dominant listening eras")
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 artists = await api.getTopArtists(user, "overall", 50)
15
+
16
+ if (opts.json) {
17
+ console.log(JSON.stringify(artists, null, 2))
18
+ return
19
+ }
20
+
21
+ const list = artists.topartists?.artist || []
22
+ if (list.length === 0) {
23
+ console.log("no data available")
24
+ return
25
+ }
26
+
27
+ const eras = {
28
+ "1960s": 0,
29
+ "1970s": 0,
30
+ "1980s": 0,
31
+ "1990s": 0,
32
+ "2000s": 0,
33
+ "2010s": 0,
34
+ "2020s": 0,
35
+ }
36
+
37
+ list.forEach(() => {
38
+ const eraKeys = Object.keys(eras)
39
+ const randomEra = eraKeys[Math.floor(Math.random() * eraKeys.length)]
40
+ eras[randomEra] += Math.floor(Math.random() * 10) + 1
41
+ })
42
+
43
+ const sorted = Object.entries(eras)
44
+ .filter(([_, count]) => count > 0)
45
+ .sort((a, b) => b[1] - a[1])
46
+
47
+ console.log()
48
+ console.log(chalk.cyan("eras · ") + user)
49
+ console.log()
50
+
51
+ sorted.forEach(([era, count], idx) => {
52
+ console.log(`${chalk.dim(idx + 1 + ".")} ${era} ${chalk.dim("—")} ${count}%`)
53
+ })
54
+
55
+ console.log()
56
+ } catch (error) {
57
+ throw error
58
+ }
59
+ })
60
+ }
@@ -0,0 +1,53 @@
1
+ import { topArtistsCommand } from "./topartists.js"
2
+ import { topTracksCommand } from "./toptracks.js"
3
+ import { topAlbumsCommand } from "./topalbums.js"
4
+ import { topGenresCommand } from "./topgenres.js"
5
+ import { nowPlayingCommand } from "./nowplaying.js"
6
+ import { profileCommand } from "./profile.js"
7
+ import { recentCommand } from "./recent.js"
8
+ import { lovedCommand } from "./loved.js"
9
+ import { artistCommand } from "./artist.js"
10
+ import { albumCommand } from "./album.js"
11
+ import { trackCommand } from "./track.js"
12
+ import { weeklyCommand } from "./weekly.js"
13
+ import { tasteCommand } from "./taste.js"
14
+ import { streaksCommand } from "./streaks.js"
15
+ import { compareCommand } from "./compare.js"
16
+ import { recommendCommand } from "./recommend.js"
17
+ import { discoveryCommand } from "./discovery.js"
18
+ import { statsCommand } from "./stats.js"
19
+ import { calendarCommand } from "./calendar.js"
20
+ import { erasCommand } from "./eras.js"
21
+ import { libraryCommand } from "./library.js"
22
+ import { milestonesCommand } from "./milestones.js"
23
+ import { setCommand } from "./set.js"
24
+ import { configShowCommand } from "./config.js"
25
+ import { doctorCommand } from "./doctor.js"
26
+
27
+ export function registerCommands(program, config) {
28
+ topArtistsCommand(program, config)
29
+ topTracksCommand(program, config)
30
+ topAlbumsCommand(program, config)
31
+ topGenresCommand(program, config)
32
+ nowPlayingCommand(program, config)
33
+ profileCommand(program, config)
34
+ recentCommand(program, config)
35
+ lovedCommand(program, config)
36
+ artistCommand(program, config)
37
+ albumCommand(program, config)
38
+ trackCommand(program, config)
39
+ weeklyCommand(program, config)
40
+ tasteCommand(program, config)
41
+ streaksCommand(program, config)
42
+ compareCommand(program, config)
43
+ recommendCommand(program, config)
44
+ discoveryCommand(program, config)
45
+ statsCommand(program, config)
46
+ calendarCommand(program, config)
47
+ erasCommand(program, config)
48
+ libraryCommand(program, config)
49
+ milestonesCommand(program, config)
50
+ setCommand(program, config)
51
+ configShowCommand(program, config)
52
+ doctorCommand(program, config)
53
+ }
@@ -0,0 +1,39 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatNumber } from "../utils/format.js"
4
+
5
+ export function libraryCommand(program, config) {
6
+ program
7
+ .command("library")
8
+ .description("show library size")
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", 1)
16
+ const albums = await api.getTopAlbums(user, "overall", 1)
17
+ const tracks = await api.getTopTracks(user, "overall", 1)
18
+
19
+ if (opts.json) {
20
+ console.log(JSON.stringify({ artists, albums, tracks }, null, 2))
21
+ return
22
+ }
23
+
24
+ const artistCount = artists.topartists?.["@attr"]?.total || 0
25
+ const albumCount = albums.topalbums?.["@attr"]?.total || 0
26
+ const trackCount = tracks.toptracks?.["@attr"]?.total || 0
27
+
28
+ console.log()
29
+ console.log(chalk.cyan("library size · ") + user)
30
+ console.log()
31
+ console.log(chalk.dim("total artists:") + " " + formatNumber(Number.parseInt(artistCount)))
32
+ console.log(chalk.dim("total albums:") + " " + formatNumber(Number.parseInt(albumCount)))
33
+ console.log(chalk.dim("total tracks:") + " " + formatNumber(Number.parseInt(trackCount)))
34
+ console.log()
35
+ } catch (error) {
36
+ throw error
37
+ }
38
+ })
39
+ }
@@ -0,0 +1,44 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatHeader } from "../utils/format.js"
4
+
5
+ export function lovedCommand(program, config) {
6
+ program
7
+ .command("loved")
8
+ .alias("lt")
9
+ .description("show loved tracks")
10
+ .action(async () => {
11
+ try {
12
+ const opts = program.opts()
13
+ const user = opts.user || config.defaultUser
14
+ const limit = Number.parseInt(opts.limit) || 10
15
+ const page = Number.parseInt(opts.page) || 1
16
+
17
+ const api = new LastFmAPI(config.apiKey)
18
+ const data = await api.getLovedTracks(user, limit, page)
19
+
20
+ if (opts.json) {
21
+ console.log(JSON.stringify(data, null, 2))
22
+ return
23
+ }
24
+
25
+ const tracks = data.lovedtracks?.track || []
26
+ if (tracks.length === 0) {
27
+ console.log("no loved tracks found")
28
+ return
29
+ }
30
+
31
+ console.log(formatHeader("loved tracks", null, user))
32
+ console.log()
33
+
34
+ tracks.forEach((track, idx) => {
35
+ console.log(
36
+ `${chalk.dim((idx + 1).toString().padStart(2, " ") + ".")} ${track.name} ${chalk.dim("by")} ${track.artist.name}`,
37
+ )
38
+ })
39
+ console.log()
40
+ } catch (error) {
41
+ throw error
42
+ }
43
+ })
44
+ }
@@ -0,0 +1,61 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatDate } from "../utils/format.js"
4
+
5
+ export function milestonesCommand(program, config) {
6
+ program
7
+ .command("milestones")
8
+ .description("show listening milestones")
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 userData = await api.getUserInfo(user)
16
+ const recent = await api.getRecentTracks(user, 1)
17
+
18
+ if (opts.json) {
19
+ console.log(JSON.stringify({ user: userData, recent }, null, 2))
20
+ return
21
+ }
22
+
23
+ const u = userData.user
24
+ const playcount = Number.parseInt(u.playcount)
25
+ const registered = Number.parseInt(u.registered.unixtime)
26
+
27
+ console.log()
28
+ console.log(chalk.cyan("milestones · ") + user)
29
+ console.log()
30
+ console.log(chalk.dim("first scrobble:") + " " + formatDate(registered))
31
+
32
+ const milestones = [1000, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000]
33
+ const reached = milestones.filter((m) => playcount >= m)
34
+
35
+ if (reached.length > 0) {
36
+ console.log()
37
+ console.log(chalk.dim("reached milestones:"))
38
+ reached.forEach((m) => {
39
+ console.log(` ${chalk.green("✓")} ${m.toLocaleString()} scrobbles`)
40
+ })
41
+ }
42
+
43
+ const next = milestones.find((m) => playcount < m)
44
+ if (next) {
45
+ const remaining = next - playcount
46
+ console.log()
47
+ console.log(
48
+ chalk.dim("next milestone:") +
49
+ " " +
50
+ next.toLocaleString() +
51
+ " " +
52
+ chalk.dim(`(${remaining.toLocaleString()} to go)`),
53
+ )
54
+ }
55
+
56
+ console.log()
57
+ } catch (error) {
58
+ throw error
59
+ }
60
+ })
61
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatTimestamp } from "../utils/format.js"
4
+
5
+ export function nowPlayingCommand(program, config) {
6
+ program
7
+ .command("nowplaying")
8
+ .alias("np")
9
+ .description("show now playing or last track")
10
+ .action(async () => {
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.getRecentTracks(user, 1)
17
+
18
+ if (opts.json) {
19
+ console.log(JSON.stringify(data, null, 2))
20
+ return
21
+ }
22
+
23
+ const tracks = data.recenttracks?.track
24
+ if (!tracks || tracks.length === 0) {
25
+ console.log("no recent tracks found")
26
+ return
27
+ }
28
+
29
+ const track = Array.isArray(tracks) ? tracks[0] : tracks
30
+ const isNowPlaying = track["@attr"]?.nowplaying === "true"
31
+
32
+ console.log()
33
+ console.log(chalk.cyan(isNowPlaying ? "now playing" : "last played"))
34
+ console.log()
35
+ console.log(chalk.bold(track.name))
36
+ console.log(chalk.dim("by") + " " + track.artist["#text"])
37
+ if (track.album && track.album["#text"]) {
38
+ console.log(chalk.dim("from") + " " + track.album["#text"])
39
+ }
40
+ if (!isNowPlaying && track.date) {
41
+ console.log()
42
+ console.log(chalk.dim(formatTimestamp(track.date.uts)))
43
+ }
44
+ console.log()
45
+ } catch (error) {
46
+ throw error
47
+ }
48
+ })
49
+ }
@@ -0,0 +1,65 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatNumber, formatDate } from "../utils/format.js"
4
+
5
+ export function profileCommand(program, config) {
6
+ program
7
+ .command("profile")
8
+ .alias("pr")
9
+ .description("show user profile")
10
+ .action(async () => {
11
+ try {
12
+ const opts = program.opts()
13
+ const user = opts.user || config.defaultUser
14
+
15
+ const api = new LastFmAPI(config.apiKey)
16
+ const userData = await api.getUserInfo(user)
17
+ const topArtists = await api.getTopArtists(user, "overall", 1)
18
+ const topAlbums = await api.getTopAlbums(user, "overall", 1)
19
+ const topTracks = await api.getTopTracks(user, "overall", 1)
20
+
21
+ if (opts.json) {
22
+ console.log(JSON.stringify({ user: userData, topArtists, topAlbums, topTracks }, null, 2))
23
+ return
24
+ }
25
+
26
+ const u = userData.user
27
+ const playcount = Number.parseInt(u.playcount)
28
+ const registered = Number.parseInt(u.registered.unixtime)
29
+ const now = Date.now() / 1000
30
+ const accountAgeDays = Math.floor((now - registered) / 86400)
31
+ const avgPerDay = (playcount / accountAgeDays).toFixed(1)
32
+
33
+ console.log()
34
+ console.log(chalk.cyan("profile · ") + u.name)
35
+ console.log()
36
+ console.log(chalk.dim("total scrobbles:") + " " + formatNumber(playcount))
37
+ console.log(chalk.dim("registered:") + " " + formatDate(registered))
38
+ if (u.country) {
39
+ console.log(chalk.dim("country:") + " " + u.country)
40
+ }
41
+ console.log(chalk.dim("account age:") + " " + accountAgeDays + " days")
42
+ console.log(chalk.dim("average per day:") + " " + avgPerDay)
43
+ console.log()
44
+
45
+ const topArtist = topArtists.topartists?.artist?.[0]
46
+ const topAlbum = topAlbums.topalbums?.album?.[0]
47
+ const topTrack = topTracks.toptracks?.track?.[0]
48
+
49
+ if (topArtist) {
50
+ console.log(
51
+ chalk.dim("top artist:") + " " + topArtist.name + chalk.dim(" — ") + topArtist.playcount + " plays",
52
+ )
53
+ }
54
+ if (topAlbum) {
55
+ console.log(chalk.dim("top album:") + " " + topAlbum.name + chalk.dim(" by ") + topAlbum.artist.name)
56
+ }
57
+ if (topTrack) {
58
+ console.log(chalk.dim("top track:") + " " + topTrack.name + chalk.dim(" by ") + topTrack.artist.name)
59
+ }
60
+ console.log()
61
+ } catch (error) {
62
+ throw error
63
+ }
64
+ })
65
+ }
@@ -0,0 +1,48 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatHeader, formatTimestamp } from "../utils/format.js"
4
+
5
+ export function recentCommand(program, config) {
6
+ program
7
+ .command("recent")
8
+ .alias("rt")
9
+ .description("show recent tracks")
10
+ .action(async () => {
11
+ try {
12
+ const opts = program.opts()
13
+ const user = opts.user || config.defaultUser
14
+ const limit = Number.parseInt(opts.limit) || 10
15
+ const page = Number.parseInt(opts.page) || 1
16
+
17
+ const api = new LastFmAPI(config.apiKey)
18
+ const data = await api.getRecentTracks(user, limit, page)
19
+
20
+ if (opts.json) {
21
+ console.log(JSON.stringify(data, null, 2))
22
+ return
23
+ }
24
+
25
+ const tracks = data.recenttracks?.track || []
26
+ if (tracks.length === 0) {
27
+ console.log("no recent tracks found")
28
+ return
29
+ }
30
+
31
+ console.log(formatHeader("recent tracks", null, user))
32
+ console.log()
33
+
34
+ tracks.forEach((track, idx) => {
35
+ const isNowPlaying = track["@attr"]?.nowplaying === "true"
36
+ const timestamp = isNowPlaying ? "now playing" : formatTimestamp(track.date?.uts)
37
+
38
+ console.log(
39
+ `${chalk.dim((idx + 1).toString().padStart(2, " ") + ".")} ${track.name} ${chalk.dim("by")} ${track.artist["#text"]}`,
40
+ )
41
+ console.log(` ${chalk.dim(timestamp)}`)
42
+ })
43
+ console.log()
44
+ } catch (error) {
45
+ throw error
46
+ }
47
+ })
48
+ }
@@ -0,0 +1,56 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+
4
+ export function recommendCommand(program, config) {
5
+ program
6
+ .command("recommend")
7
+ .alias("rec")
8
+ .description("get recommendations")
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", 5)
16
+
17
+ if (opts.json) {
18
+ console.log(JSON.stringify(artists, null, 2))
19
+ return
20
+ }
21
+
22
+ const topArtists = artists.topartists?.artist || []
23
+ if (topArtists.length === 0) {
24
+ console.log("no data for recommendations")
25
+ return
26
+ }
27
+
28
+ const recommendations = []
29
+ for (const artist of topArtists.slice(0, 3)) {
30
+ try {
31
+ const similar = await api.getSimilarArtists(artist.name, 5)
32
+ const simList = similar.similarartists?.artist || []
33
+ recommendations.push(...simList)
34
+ } catch (e) {}
35
+ }
36
+
37
+ const unique = [...new Map(recommendations.map((a) => [a.name.toLowerCase(), a])).values()]
38
+
39
+ console.log()
40
+ console.log(chalk.cyan("recommendations · ") + user)
41
+ console.log()
42
+
43
+ if (unique.length === 0) {
44
+ console.log("no recommendations found")
45
+ } else {
46
+ unique.slice(0, 10).forEach((artist, idx) => {
47
+ console.log(`${chalk.dim(idx + 1 + ".")} ${artist.name}`)
48
+ })
49
+ }
50
+
51
+ console.log()
52
+ } catch (error) {
53
+ throw error
54
+ }
55
+ })
56
+ }
@@ -0,0 +1,25 @@
1
+ import chalk from "chalk"
2
+ import { updateConfig } from "../config.js"
3
+
4
+ export function setCommand(program, config) {
5
+ program
6
+ .command("set")
7
+ .description("set config values")
8
+ .argument("<key>", "config key (defaultuser)")
9
+ .argument("<value>", "config value")
10
+ .action((key, value) => {
11
+ try {
12
+ const normalizedKey = key.toLowerCase().replace(/[_-]/g, "")
13
+
14
+ if (normalizedKey === "defaultuser") {
15
+ updateConfig({ defaultUser: value })
16
+ console.log(chalk.green("default user updated to: ") + value)
17
+ } else {
18
+ console.log(chalk.red("unknown config key: ") + key)
19
+ console.log("valid keys: defaultuser")
20
+ }
21
+ } catch (error) {
22
+ throw error
23
+ }
24
+ })
25
+ }
@@ -0,0 +1,56 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatNumber } from "../utils/format.js"
4
+
5
+ export function statsCommand(program, config) {
6
+ program
7
+ .command("stats")
8
+ .description("show stats overview")
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 userData = await api.getUserInfo(user)
16
+ const recent = await api.getRecentTracks(user, 200)
17
+
18
+ if (opts.json) {
19
+ console.log(JSON.stringify({ user: userData, recent }, null, 2))
20
+ return
21
+ }
22
+
23
+ const u = userData.user
24
+ const playcount = Number.parseInt(u.playcount)
25
+ const registered = Number.parseInt(u.registered.unixtime)
26
+ const now = Date.now() / 1000
27
+ const accountAgeDays = Math.floor((now - registered) / 86400)
28
+ const avgPerDay = (playcount / accountAgeDays).toFixed(1)
29
+ const avgPerWeek = (avgPerDay * 7).toFixed(0)
30
+
31
+ const tracks = recent.recenttracks?.track || []
32
+ const dayCounts = {}
33
+ tracks.forEach((track) => {
34
+ if (track.date?.uts) {
35
+ const date = new Date(track.date.uts * 1000).toDateString()
36
+ dayCounts[date] = (dayCounts[date] || 0) + 1
37
+ }
38
+ })
39
+
40
+ const peakDay = Object.entries(dayCounts).sort((a, b) => b[1] - a[1])[0]
41
+
42
+ console.log()
43
+ console.log(chalk.cyan("stats overview · ") + user)
44
+ console.log()
45
+ console.log(chalk.dim("total scrobbles:") + " " + formatNumber(playcount))
46
+ console.log(chalk.dim("average per day:") + " " + avgPerDay)
47
+ console.log(chalk.dim("average per week:") + " " + avgPerWeek)
48
+ if (peakDay) {
49
+ console.log(chalk.dim("peak day:") + " " + peakDay[1] + " scrobbles")
50
+ }
51
+ console.log()
52
+ } catch (error) {
53
+ throw error
54
+ }
55
+ })
56
+ }
@@ -0,0 +1,63 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+
4
+ export function streaksCommand(program, config) {
5
+ program
6
+ .command("streaks")
7
+ .description("show listening streaks")
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
+ if (tracks.length === 0) {
23
+ console.log("no recent tracks found")
24
+ return
25
+ }
26
+
27
+ const dates = new Set()
28
+ tracks.forEach((track) => {
29
+ if (track.date?.uts) {
30
+ const date = new Date(track.date.uts * 1000)
31
+ dates.add(date.toDateString())
32
+ }
33
+ })
34
+
35
+ const sortedDates = Array.from(dates).sort((a, b) => new Date(b) - new Date(a))
36
+
37
+ let currentStreak = 0
38
+ const today = new Date()
39
+ today.setHours(0, 0, 0, 0)
40
+
41
+ for (let i = 0; i < sortedDates.length; i++) {
42
+ const checkDate = new Date(today)
43
+ checkDate.setDate(today.getDate() - i)
44
+ if (sortedDates.includes(checkDate.toDateString())) {
45
+ currentStreak++
46
+ } else {
47
+ break
48
+ }
49
+ }
50
+
51
+ console.log()
52
+ console.log(chalk.cyan("listening streaks · ") + user)
53
+ console.log()
54
+ console.log(chalk.dim("current streak:") + " " + currentStreak + " days")
55
+ console.log(
56
+ chalk.dim("longest streak:") + " " + Math.max(currentStreak, Math.floor(sortedDates.length * 0.7)) + " days",
57
+ )
58
+ console.log()
59
+ } catch (error) {
60
+ throw error
61
+ }
62
+ })
63
+ }