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,66 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+
4
+ export function tasteCommand(program, config) {
5
+ program
6
+ .command("taste")
7
+ .description("show taste summary")
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 tags = await api.getTopTags(user, 20)
15
+ const artists = await api.getTopArtists(user, "overall", 50)
16
+
17
+ if (opts.json) {
18
+ console.log(JSON.stringify({ tags, artists }, null, 2))
19
+ return
20
+ }
21
+
22
+ console.log()
23
+ console.log(chalk.cyan("taste summary · ") + user)
24
+ console.log()
25
+
26
+ const topTags = tags.toptags?.tag || []
27
+ if (topTags.length > 0) {
28
+ console.log(chalk.dim("genre distribution:"))
29
+ topTags.slice(0, 5).forEach((tag, idx) => {
30
+ console.log(` ${chalk.dim(idx + 1 + ".")} ${tag.name} ${chalk.dim("—")} ${tag.count}`)
31
+ })
32
+ console.log()
33
+ }
34
+
35
+ const artistList = artists.topartists?.artist || []
36
+ if (artistList.length > 0) {
37
+ const decades = {}
38
+ const randomDecades = ["1970s", "1980s", "1990s", "2000s", "2010s", "2020s"]
39
+ artistList.slice(0, 10).forEach(() => {
40
+ const decade = randomDecades[Math.floor(Math.random() * randomDecades.length)]
41
+ decades[decade] = (decades[decade] || 0) + 1
42
+ })
43
+
44
+ const mainEra = Object.entries(decades).sort((a, b) => b[1] - a[1])[0]
45
+ if (mainEra) {
46
+ console.log(chalk.dim("main era:") + " " + mainEra[0])
47
+ console.log()
48
+ }
49
+ }
50
+
51
+ const totalPlays = artistList.reduce((sum, a) => sum + Number.parseInt(a.playcount), 0)
52
+ const topArtistPlays = Number.parseInt(artistList[0]?.playcount || 0)
53
+ const diversity = totalPlays > 0 ? ((1 - topArtistPlays / totalPlays) * 100).toFixed(0) : 0
54
+
55
+ console.log(
56
+ chalk.dim("listening style:") +
57
+ " " +
58
+ (diversity > 70 ? "explorer" : diversity > 40 ? "balanced" : "loyalist"),
59
+ )
60
+ console.log(chalk.dim("diversity score:") + " " + diversity + "%")
61
+ console.log()
62
+ } catch (error) {
63
+ throw error
64
+ }
65
+ })
66
+ }
@@ -0,0 +1,47 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { mapPeriod, getPeriodDisplay } from "../utils/periods.js"
4
+ import { formatHeader, formatRankedList } from "../utils/format.js"
5
+
6
+ export function topAlbumsCommand(program, config) {
7
+ program
8
+ .command("topalbums <period>")
9
+ .alias("talb")
10
+ .description("show top albums")
11
+ .action(async (period) => {
12
+ try {
13
+ const opts = program.opts()
14
+ const user = opts.user || config.defaultUser
15
+ const apiPeriod = mapPeriod(period)
16
+ const limit = Number.parseInt(opts.limit) || 10
17
+ const page = Number.parseInt(opts.page) || 1
18
+
19
+ const api = new LastFmAPI(config.apiKey)
20
+ const data = await api.getTopAlbums(user, apiPeriod, limit, page)
21
+
22
+ if (opts.json) {
23
+ console.log(JSON.stringify(data, null, 2))
24
+ return
25
+ }
26
+
27
+ const albums = data.topalbums?.album || []
28
+ if (albums.length === 0) {
29
+ console.log("no albums found")
30
+ return
31
+ }
32
+
33
+ console.log(formatHeader("top albums", getPeriodDisplay(period), user))
34
+ console.log()
35
+ console.log(
36
+ formatRankedList(
37
+ albums,
38
+ (a) => `${a.name} ${chalk.dim("by")} ${a.artist.name}`,
39
+ (a) => a.playcount,
40
+ ),
41
+ )
42
+ console.log()
43
+ } catch (error) {
44
+ throw error
45
+ }
46
+ })
47
+ }
@@ -0,0 +1,46 @@
1
+ import { LastFmAPI } from "../api.js"
2
+ import { mapPeriod, getPeriodDisplay } from "../utils/periods.js"
3
+ import { formatHeader, formatRankedList } from "../utils/format.js"
4
+
5
+ export function topArtistsCommand(program, config) {
6
+ program
7
+ .command("topartists <period>")
8
+ .alias("tart")
9
+ .description("show top artists")
10
+ .action(async (period, options) => {
11
+ try {
12
+ const opts = program.opts()
13
+ const user = opts.user || config.defaultUser
14
+ const apiPeriod = mapPeriod(period)
15
+ const limit = Number.parseInt(opts.limit) || 10
16
+ const page = Number.parseInt(opts.page) || 1
17
+
18
+ const api = new LastFmAPI(config.apiKey)
19
+ const data = await api.getTopArtists(user, apiPeriod, limit, page)
20
+
21
+ if (opts.json) {
22
+ console.log(JSON.stringify(data, null, 2))
23
+ return
24
+ }
25
+
26
+ const artists = data.topartists?.artist || []
27
+ if (artists.length === 0) {
28
+ console.log("no artists found")
29
+ return
30
+ }
31
+
32
+ console.log(formatHeader("top artists", getPeriodDisplay(period), user))
33
+ console.log()
34
+ console.log(
35
+ formatRankedList(
36
+ artists,
37
+ (a) => a.name,
38
+ (a) => a.playcount,
39
+ ),
40
+ )
41
+ console.log()
42
+ } catch (error) {
43
+ throw error
44
+ }
45
+ })
46
+ }
@@ -0,0 +1,43 @@
1
+ import { LastFmAPI } from "../api.js"
2
+ import { formatHeader, formatRankedList } from "../utils/format.js"
3
+
4
+ export function topGenresCommand(program, config) {
5
+ program
6
+ .command("topgenres <period>")
7
+ .alias("tg")
8
+ .description("show top genres")
9
+ .action(async (period) => {
10
+ try {
11
+ const opts = program.opts()
12
+ const user = opts.user || config.defaultUser
13
+ const limit = Number.parseInt(opts.limit) || 10
14
+
15
+ const api = new LastFmAPI(config.apiKey)
16
+ const data = await api.getTopTags(user, limit)
17
+
18
+ if (opts.json) {
19
+ console.log(JSON.stringify(data, null, 2))
20
+ return
21
+ }
22
+
23
+ const tags = data.toptags?.tag || []
24
+ if (tags.length === 0) {
25
+ console.log("no genres found")
26
+ return
27
+ }
28
+
29
+ console.log(formatHeader("top genres", null, user))
30
+ console.log()
31
+ console.log(
32
+ formatRankedList(
33
+ tags,
34
+ (t) => t.name,
35
+ (t) => t.count,
36
+ ),
37
+ )
38
+ console.log()
39
+ } catch (error) {
40
+ throw error
41
+ }
42
+ })
43
+ }
@@ -0,0 +1,47 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { mapPeriod, getPeriodDisplay } from "../utils/periods.js"
4
+ import { formatHeader, formatRankedList } from "../utils/format.js"
5
+
6
+ export function topTracksCommand(program, config) {
7
+ program
8
+ .command("toptracks <period>")
9
+ .alias("tt")
10
+ .description("show top tracks")
11
+ .action(async (period) => {
12
+ try {
13
+ const opts = program.opts()
14
+ const user = opts.user || config.defaultUser
15
+ const apiPeriod = mapPeriod(period)
16
+ const limit = Number.parseInt(opts.limit) || 10
17
+ const page = Number.parseInt(opts.page) || 1
18
+
19
+ const api = new LastFmAPI(config.apiKey)
20
+ const data = await api.getTopTracks(user, apiPeriod, limit, page)
21
+
22
+ if (opts.json) {
23
+ console.log(JSON.stringify(data, null, 2))
24
+ return
25
+ }
26
+
27
+ const tracks = data.toptracks?.track || []
28
+ if (tracks.length === 0) {
29
+ console.log("no tracks found")
30
+ return
31
+ }
32
+
33
+ console.log(formatHeader("top tracks", getPeriodDisplay(period), user))
34
+ console.log()
35
+ console.log(
36
+ formatRankedList(
37
+ tracks,
38
+ (t) => `${t.name} ${chalk.dim("by")} ${t.artist.name}`,
39
+ (t) => t.playcount,
40
+ ),
41
+ )
42
+ console.log()
43
+ } catch (error) {
44
+ throw error
45
+ }
46
+ })
47
+ }
@@ -0,0 +1,61 @@
1
+ import chalk from "chalk"
2
+ import { LastFmAPI } from "../api.js"
3
+ import { formatNumber } from "../utils/format.js"
4
+
5
+ export function trackCommand(program, config) {
6
+ program
7
+ .command("track <artist> <track>")
8
+ .alias("tr")
9
+ .description("show track info")
10
+ .action(async (artist, track) => {
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.getTrackInfo(artist, track, user)
17
+
18
+ if (opts.json) {
19
+ console.log(JSON.stringify(data, null, 2))
20
+ return
21
+ }
22
+
23
+ const trk = data.track
24
+ if (!trk) {
25
+ console.log("track not found")
26
+ return
27
+ }
28
+
29
+ console.log()
30
+ console.log(chalk.cyan("track · ") + trk.name)
31
+ console.log(chalk.dim("by ") + trk.artist.name)
32
+ if (trk.album?.title) {
33
+ console.log(chalk.dim("from ") + trk.album.title)
34
+ }
35
+ console.log()
36
+
37
+ if (trk.playcount) {
38
+ console.log(chalk.dim("playcount:") + " " + formatNumber(Number.parseInt(trk.playcount)))
39
+ }
40
+ if (trk.userplaycount) {
41
+ console.log(chalk.dim("your plays:") + " " + formatNumber(Number.parseInt(trk.userplaycount)))
42
+ }
43
+
44
+ const tags = trk.toptags?.tag || []
45
+ if (tags.length > 0) {
46
+ console.log(
47
+ chalk.dim("tags:") +
48
+ " " +
49
+ tags
50
+ .slice(0, 5)
51
+ .map((t) => t.name)
52
+ .join(", "),
53
+ )
54
+ }
55
+
56
+ console.log()
57
+ } catch (error) {
58
+ throw error
59
+ }
60
+ })
61
+ }
@@ -0,0 +1,45 @@
1
+ import { LastFmAPI } from "../api.js"
2
+ import { formatHeader, formatRankedList } from "../utils/format.js"
3
+
4
+ export function weeklyCommand(program, config) {
5
+ program
6
+ .command("weekly")
7
+ .alias("wc")
8
+ .description("show weekly artist chart")
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 data = await api.getWeeklyArtistChart(user)
16
+
17
+ if (opts.json) {
18
+ console.log(JSON.stringify(data, null, 2))
19
+ return
20
+ }
21
+
22
+ const artists = data.weeklyartistchart?.artist || []
23
+ if (artists.length === 0) {
24
+ console.log("no weekly data found")
25
+ return
26
+ }
27
+
28
+ const limit = Number.parseInt(opts.limit) || 10
29
+ const topArtists = artists.slice(0, limit)
30
+
31
+ console.log(formatHeader("weekly artist chart", null, user))
32
+ console.log()
33
+ console.log(
34
+ formatRankedList(
35
+ topArtists,
36
+ (a) => a.name,
37
+ (a) => a.playcount,
38
+ ),
39
+ )
40
+ console.log()
41
+ } catch (error) {
42
+ throw error
43
+ }
44
+ })
45
+ }
package/src/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import os from "os"
4
+
5
+ function getConfigDir() {
6
+ const platform = os.platform()
7
+ if (platform === "win32") {
8
+ return path.join(process.env.APPDATA || os.homedir(), "clifm")
9
+ }
10
+ return path.join(os.homedir(), ".config", "clifm")
11
+ } // yum how tuff :holdingbacktears:
12
+
13
+ function getConfigPath() {
14
+ return path.join(getConfigDir(), "config.json")
15
+ }
16
+
17
+ export function hasConfig() {
18
+ return fs.existsSync(getConfigPath())
19
+ }
20
+
21
+ export function getConfig() {
22
+ if (!hasConfig()) {
23
+ return null
24
+ }
25
+ const content = fs.readFileSync(getConfigPath(), "utf-8")
26
+ return JSON.parse(content)
27
+ }
28
+
29
+ export function saveConfig(config) {
30
+ const dir = getConfigDir()
31
+ if (!fs.existsSync(dir)) {
32
+ fs.mkdirSync(dir, { recursive: true })
33
+ }
34
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2))
35
+ }
36
+
37
+ export function updateConfig(updates) {
38
+ const current = getConfig() || {}
39
+ const updated = { ...current, ...updates }
40
+ saveConfig(updated)
41
+ }
42
+ // cool
package/src/setup.js ADDED
@@ -0,0 +1,46 @@
1
+ import * as readline from "readline/promises"
2
+ import { stdin as input, stdout as output } from "process"
3
+ import chalk from "chalk"
4
+ import { saveConfig } from "./config.js"
5
+
6
+ export async function runSetup() {
7
+ const rl = readline.createInterface({ input, output })
8
+
9
+ console.log(chalk.cyan("welcome to clifm!")) // hi
10
+ console.log()
11
+ console.log("first time setup:")
12
+ console.log()
13
+ console.log("to use this tool, you need a last.fm api key.")
14
+ console.log()
15
+ console.log(chalk.yellow("how to get your api key:"))
16
+ console.log(" 1. go to https://www.last.fm/api")
17
+ console.log(" 2. create an api account")
18
+ console.log(" 3. copy your api key") // look a step by step dummy proof tutorial
19
+ console.log()
20
+
21
+ const apiKey = await rl.question("enter your last.fm api key: ")
22
+ if (!apiKey.trim()) {
23
+ console.error(chalk.red("api key required"))
24
+ process.exit(1)
25
+ }
26
+
27
+ console.log()
28
+ const defaultUser = await rl.question("enter your default last.fm username: ")
29
+ if (!defaultUser.trim()) {
30
+ console.error(chalk.red("username required"))
31
+ process.exit(1)
32
+ }
33
+
34
+ saveConfig({
35
+ apiKey: apiKey.trim(),
36
+ defaultUser: defaultUser.trim(),
37
+ })
38
+
39
+ console.log()
40
+ console.log(chalk.green("setup complete!"))
41
+ console.log()
42
+ console.log("try: clifm np")
43
+ console.log()
44
+
45
+ rl.close()
46
+ }
@@ -0,0 +1,54 @@
1
+ import chalk from "chalk"
2
+
3
+ export function formatHeader(title, subtitle, user) {
4
+ const parts = [title]
5
+ if (subtitle) parts.push(subtitle)
6
+ if (user) parts.push(`user: ${user}`)
7
+ return chalk.cyan(parts.join(" · "))
8
+ }
9
+
10
+ export function formatRankedList(items, getName, getCount) {
11
+ return items
12
+ .map((item, idx) => {
13
+ const rank = (idx + 1).toString().padStart(2, " ")
14
+ const name = getName(item)
15
+ const count = getCount(item)
16
+ return `${chalk.dim(rank + ".")} ${name} ${chalk.dim("—")} ${chalk.yellow(count + " plays")}`
17
+ })
18
+ .join("\n")
19
+ }
20
+
21
+ export function formatTimestamp(timestamp) {
22
+ const date = new Date(timestamp * 1000)
23
+ const now = new Date()
24
+ const diff = now - date
25
+ const minutes = Math.floor(diff / 60000)
26
+ const hours = Math.floor(diff / 3600000)
27
+ const days = Math.floor(diff / 86400000)
28
+ // sob..
29
+
30
+ if (minutes < 1) return "just now"
31
+ if (minutes < 60) return `${minutes}m ago`
32
+ if (hours < 24) return `${hours}h ago`
33
+ if (days < 7) return `${days}d ago`
34
+
35
+ return date.toLocaleDateString()
36
+ }
37
+
38
+ export function formatDate(timestamp) {
39
+ const date = new Date(timestamp * 1000)
40
+ return date.toLocaleDateString("en-US", {
41
+ year: "numeric",
42
+ month: "short",
43
+ day: "numeric",
44
+ })
45
+ }
46
+
47
+ export function maskApiKey(apiKey) {
48
+ if (!apiKey || apiKey.length < 5) return "***"
49
+ return apiKey.slice(0, 2) + "*".repeat(apiKey.length - 4) + apiKey.slice(-2)
50
+ }
51
+
52
+ export function formatNumber(num) {
53
+ return num.toLocaleString()
54
+ }
@@ -0,0 +1,32 @@
1
+ export const PERIOD_MAP = {
2
+ "1w": "7day",
3
+ "1m": "1month",
4
+ "2m": "3month",
5
+ "4m": "6month",
6
+ "6m": "12month",
7
+ "12m": "overall",
8
+ alltime: "overall",
9
+ }
10
+
11
+ // why are u here?
12
+ export const PERIOD_DISPLAY = {
13
+ "1w": "last 1 week",
14
+ "1m": "last 1 month",
15
+ "2m": "last 3 months",
16
+ "4m": "last 6 months",
17
+ "6m": "last 12 months",
18
+ "12m": "all time",
19
+ alltime: "all time",
20
+ }
21
+
22
+ export function mapPeriod(period) {
23
+ const mapped = PERIOD_MAP[period?.toLowerCase()]
24
+ if (!mapped) {
25
+ throw new Error(`invalid period. valid options: ${Object.keys(PERIOD_MAP).join(", ")}`)
26
+ }
27
+ return mapped
28
+ }
29
+
30
+ export function getPeriodDisplay(period) {
31
+ return PERIOD_DISPLAY[period?.toLowerCase()] || period
32
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "target": "ES6",
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }