├── tsconfig.eslint.json ├── tsconfig.build.json ├── test ├── env.ts ├── schemas │ ├── geo.schema.ts │ ├── album.schema.ts │ ├── chart.schema.ts │ ├── track.schema.ts │ ├── tag.schema.ts │ ├── artist.schema.ts │ └── user.schema.ts └── tests │ ├── chart.test.ts │ ├── geo.test.ts │ ├── tag.test.ts │ ├── album.test.ts │ ├── track.test.ts │ ├── artist.test.ts │ └── user.test.ts ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc.json ├── .editorconfig ├── vitest.config.ts ├── src ├── params │ ├── geo.params.ts │ ├── index.ts │ ├── chart.params.ts │ ├── album.params.ts │ ├── track.params.ts │ ├── tag.params.ts │ ├── artist.params.ts │ └── user.params.ts ├── utils │ ├── error.ts │ ├── convert.ts │ └── caster.ts ├── constants.ts ├── base.ts ├── typings │ ├── geo.type.ts │ ├── chart.type.ts │ ├── album.type.ts │ ├── tag.type.ts │ ├── track.type.ts │ ├── artist.type.ts │ ├── user.type.ts │ └── index.ts ├── responses │ ├── geo.response.ts │ ├── chart.response.ts │ ├── album.response.ts │ ├── tag.response.ts │ ├── track.response.ts │ ├── index.ts │ ├── artist.response.ts │ └── user.response.ts ├── index.ts ├── request.ts └── classes │ ├── geo.class.ts │ ├── chart.class.ts │ ├── album.class.ts │ ├── track.class.ts │ ├── tag.class.ts │ ├── artist.class.ts │ └── user.class.ts ├── .eslintrc.json ├── .prettierignore ├── .changeset ├── config.json └── README.md ├── .eslintignore ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── CHANGELOG.md ├── package.json ├── README.md └── public └── logo.svg /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["./src/local.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /test/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const envSchema = z.object({ 4 | LASTFM_TOKEN: z.string(), 5 | }); 6 | 7 | export const env = envSchema.parse(process.env); 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "editorconfig.editorconfig", "esbenp.prettier-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always", 3 | "printWidth": 120, 4 | "quoteProps": "preserve", 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = false 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | setupFiles: 'dotenv/config', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/params/geo.params.ts: -------------------------------------------------------------------------------- 1 | export interface GeoGetTopArtistsParams { 2 | country: string; 3 | limit?: number; 4 | page?: number; 5 | } 6 | 7 | export interface GeoGetTopTracksParams { 8 | country: string; 9 | limit?: number; 10 | page?: number; 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "clarity/typescript", 3 | "parserOptions": { 4 | "project": "./tsconfig.eslint.json" 5 | }, 6 | "rules": { 7 | "import/extensions": "off", 8 | "import/no-useless-path-segments": "off", 9 | "@typescript-eslint/no-unnecessary-condition": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/params/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@params/album.params.js'; 2 | export * from '@params/artist.params.js'; 3 | export * from '@params/chart.params.js'; 4 | export * from '@params/geo.params.js'; 5 | export * from '@params/tag.params.js'; 6 | export * from '@params/track.params.js'; 7 | export * from '@params/user.params.js'; 8 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | interface ErrorResponse { 2 | error: number; 3 | message: string; 4 | } 5 | 6 | export default class LastFMError extends Error { 7 | error: number; 8 | 9 | constructor(public response: ErrorResponse) { 10 | super(response.message); 11 | this.error = response.error; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/params/chart.params.ts: -------------------------------------------------------------------------------- 1 | export interface ChartGetTopArtistParams { 2 | limit?: number; 3 | page?: number; 4 | } 5 | 6 | export interface ChartGetTopTagsParams { 7 | limit?: number; 8 | page?: number; 9 | } 10 | 11 | export interface ChartGetTopTracksParams { 12 | limit?: number; 13 | page?: number; 14 | } 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .output/ 4 | 5 | # dependencies 6 | node_modules/ 7 | .yarn 8 | 9 | # logs 10 | logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # OS-specific files 21 | .DS_Store 22 | desktop.ini -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | const PACKAGE_VERSION = '1.7.2'; 2 | 3 | export const BASE_URL = 'https://ws.audioscrobbler.com/2.0'; 4 | export const USER_AGENT = `simple-fm (v${PACKAGE_VERSION}) - a simple & lightweight Last.fm library in TypeScript (https://github.com/solelychloe/simple-fm)`; 5 | 6 | export const IMAGE_SIZES = ['extralarge', 'large', 'medium', 'small']; 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .output/ 4 | 5 | # dependencies 6 | node_modules/ 7 | .yarn 8 | 9 | # logs 10 | logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # OS-specific files 21 | .DS_Store 22 | desktop.ini 23 | 24 | .eslintrc.json -------------------------------------------------------------------------------- /src/params/album.params.ts: -------------------------------------------------------------------------------- 1 | export interface AlbumGetInfoParams { 2 | artist: string; 3 | album: string; 4 | username?: string; 5 | } 6 | 7 | export interface AlbumGetTopTagsParams { 8 | artist: string; 9 | album: string; 10 | } 11 | 12 | export interface AlbumSearchParams { 13 | album: string; 14 | limit?: number; 15 | page?: number; 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .output/ 4 | 5 | # dependencies 6 | node_modules/ 7 | .yarn/cache 8 | .yarn/install-state.gz 9 | 10 | # logs 11 | logs 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # environment variables 18 | .env 19 | .env.production 20 | 21 | # OS-specific files 22 | .DS_Store 23 | desktop.ini 24 | 25 | /package/ 26 | /package.tgz 27 | 28 | /src/local.ts -------------------------------------------------------------------------------- /src/params/track.params.ts: -------------------------------------------------------------------------------- 1 | export interface TrackGetInfoParams { 2 | artist: string; 3 | track: string; 4 | username?: string; 5 | } 6 | 7 | export interface TrackGetSimilarParams { 8 | artist: string; 9 | track: string; 10 | limit?: number; 11 | } 12 | 13 | export interface TrackGetTopTagsParams { 14 | artist: string; 15 | track: string; 16 | } 17 | 18 | export interface TrackSearchParams { 19 | track: string; 20 | limit?: number; 21 | page?: number; 22 | } 23 | -------------------------------------------------------------------------------- /test/schemas/geo.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { GeoGetTopArtistsType, GeoGetTopTracksType } from '../../src/typings/geo.type.js'; 4 | 5 | export const GeoGetTopArtistsSchema = z.array( 6 | z.object({}) as ZodObject 7 | ); 8 | 9 | export const GeoGetTopTracksSchema = z.array( 10 | z.object({}) as ZodObject 11 | ); 12 | -------------------------------------------------------------------------------- /src/params/tag.params.ts: -------------------------------------------------------------------------------- 1 | export interface TagGetInfoParams { 2 | tag: string; 3 | } 4 | 5 | export interface TagGetTopAlbumsParams { 6 | tag: string; 7 | limit?: number; 8 | page?: number; 9 | } 10 | 11 | export interface TagGetTopArtistsParams { 12 | tag: string; 13 | limit?: number; 14 | page?: number; 15 | } 16 | 17 | export interface TagGetTopTracksParams { 18 | tag: string; 19 | limit?: number; 20 | page?: number; 21 | } 22 | 23 | export interface TagGetWeeklyChartListParams { 24 | tag: string; 25 | } 26 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { USER_AGENT } from './constants.js'; 2 | 3 | import { LastFMRequest, LastFMArgument } from '~/request.js'; 4 | 5 | export default class Base { 6 | protected key: string; 7 | protected userAgent: string; 8 | 9 | constructor(key: string, userAgent?: string) { 10 | this.key = key; 11 | this.userAgent = userAgent ?? USER_AGENT; 12 | } 13 | 14 | protected async sendRequest(params: LastFMArgument): Promise { 15 | const response = await new LastFMRequest(this.key, this.userAgent, params).execute(); 16 | 17 | return response as Promise; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "strict": true, 5 | "moduleResolution": "nodenext", 6 | "esModuleInterop": true, 7 | "module": "NodeNext", 8 | "target": "ESNext", 9 | "rootDir": "./src", 10 | "outDir": "./dist", 11 | "baseUrl": ".", 12 | "paths": { 13 | "~/*": ["src/*"], 14 | "@classes/*": ["src/classes/*"], 15 | "@params/*": ["src/params/*"], 16 | "@responses/*": ["src/responses/*"], 17 | "@typings/*": ["src/typings/*"], 18 | "@utils/*": ["src/utils/*"] 19 | } 20 | }, 21 | "include": ["./src"] 22 | } 23 | -------------------------------------------------------------------------------- /src/params/artist.params.ts: -------------------------------------------------------------------------------- 1 | export interface ArtistGetInfoParams { 2 | artist: string; 3 | username?: string; 4 | } 5 | 6 | export interface ArtistGetSimilarParams { 7 | artist: string; 8 | limit?: number; 9 | } 10 | 11 | export interface ArtistGetTopAlbumsParams { 12 | artist: string; 13 | limit?: number; 14 | page?: number; 15 | } 16 | 17 | export interface ArtistGetTopTagsParams { 18 | artist: string; 19 | } 20 | 21 | export interface ArtistGetTopTracksParams { 22 | artist: string; 23 | limit?: number; 24 | page?: number; 25 | } 26 | 27 | export interface ArtistSearchParams { 28 | artist: string; 29 | limit?: number; 30 | page?: number; 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | }, 7 | "eslint.validate": ["javascript", "typescript"], 8 | "[javascript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[jsonc]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[markdown]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/schemas/album.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { AlbumGetInfoType, AlbumGetTopTagsType, AlbumSearchType } from '../../src/typings/album.type.js'; 4 | 5 | export const AlbumGetInfoSchema = z.object({}) as ZodObject< 6 | ZodRawShape, 7 | UnknownKeysParam, 8 | ZodTypeAny, 9 | AlbumGetInfoType 10 | >; 11 | 12 | export const AlbumGetTopTagsSchema = z.array( 13 | z.object({}) as ZodObject 14 | ); 15 | 16 | export const AlbumSearchSchema = z.array( 17 | z.object({}) as ZodObject 18 | ); 19 | -------------------------------------------------------------------------------- /test/schemas/chart.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { ChartGetTopArtistsType, ChartGetTopTagsType, ChartGetTopTracksType } from '../../src/typings/chart.type.js'; 4 | 5 | export const ChartGetTopArtistsSchema = z.array( 6 | z.object({}) as ZodObject 7 | ); 8 | 9 | export const ChartGetTopTagsSchema = z.array( 10 | z.object({}) as ZodObject 11 | ); 12 | 13 | export const ChartGetTopTracksSchema = z.array( 14 | z.object({}) as ZodObject 15 | ); 16 | -------------------------------------------------------------------------------- /src/typings/geo.type.ts: -------------------------------------------------------------------------------- 1 | import type { ArtistType, TrackType, SearchMeta } from '@typings/index.js'; 2 | 3 | export declare interface GeoGetTopArtistsType { 4 | search: SearchMeta & { 5 | country: string; 6 | }; 7 | artists: Array< 8 | ArtistType & { 9 | mbid: string | undefined; 10 | listeners: number; 11 | } 12 | >; 13 | } 14 | 15 | export declare interface GeoGetTopTracksType { 16 | search: SearchMeta & { 17 | country: string; 18 | }; 19 | tracks: Array< 20 | TrackType & { 21 | rank: number; 22 | mbid: string | undefined; 23 | duration: number; 24 | listeners: number; 25 | artist: { 26 | mbid: string | undefined; 27 | }; 28 | } 29 | >; 30 | } 31 | -------------------------------------------------------------------------------- /src/responses/geo.response.ts: -------------------------------------------------------------------------------- 1 | import type { ArtistResponse, AttrResponse, TrackResponse } from '@responses/index.js'; 2 | 3 | export declare interface GeoGetTopArtistsResponse { 4 | topartists: { 5 | artist: Array< 6 | ArtistResponse & { 7 | mbid: string; 8 | listeners: string; 9 | } 10 | >; 11 | '@attr': AttrResponse & { country: string }; 12 | }; 13 | } 14 | 15 | export declare interface GeoGetTopTracksResponse { 16 | tracks: { 17 | track: Array< 18 | TrackResponse & { 19 | duration: string; 20 | listeners: string; 21 | artist: ArtistResponse & { 22 | mbid: string; 23 | }; 24 | '@attr': { 25 | rank: string; 26 | }; 27 | } 28 | >; 29 | '@attr': AttrResponse & { country: string }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /test/schemas/track.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { 4 | TrackGetInfoType, 5 | TrackGetSimilarType, 6 | TrackGetTopTagsType, 7 | TrackSearchType, 8 | } from '../../src/typings/track.type.js'; 9 | 10 | export const TrackGetInfoSchema = z.object({}) as ZodObject< 11 | ZodRawShape, 12 | UnknownKeysParam, 13 | ZodTypeAny, 14 | TrackGetInfoType 15 | >; 16 | 17 | export const TrackGetSimilarSchema = z.array( 18 | z.object({}) as ZodObject 19 | ); 20 | 21 | export const TrackGetTopTagsSchema = z.array( 22 | z.object({}) as ZodObject 23 | ); 24 | 25 | export const TrackSearchSchema = z.array( 26 | z.object({}) as ZodObject 27 | ); 28 | -------------------------------------------------------------------------------- /src/typings/chart.type.ts: -------------------------------------------------------------------------------- 1 | import type { ArtistType, TagType, TrackType, SearchMeta } from '@typings/index.js'; 2 | 3 | export declare interface ChartGetTopArtistsType { 4 | search: SearchMeta; 5 | artists: Array< 6 | ArtistType & { 7 | mbid: string | undefined; 8 | stats: { 9 | scrobbles: number; 10 | listeners: number; 11 | }; 12 | } 13 | >; 14 | } 15 | 16 | export declare interface ChartGetTopTagsType { 17 | search: SearchMeta; 18 | tags: Array< 19 | TagType & { 20 | stats: { 21 | count: number; 22 | reach: number; 23 | }; 24 | } 25 | >; 26 | } 27 | 28 | export declare interface ChartGetTopTracksType { 29 | search: SearchMeta; 30 | tracks: Array< 31 | TrackType & { 32 | mbid: string | undefined; 33 | stats: { 34 | scrobbles: number; 35 | listeners: number; 36 | }; 37 | } 38 | >; 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | zlib License 2 | 3 | (C) 2023 Chloe Arciniega 4 | 5 | This software is provided 'as-is', without any express or implied 6 | warranty. In no event will the authors be held liable for any damages 7 | arising from the use of this software. 8 | 9 | Permission is granted to anyone to use this software for any purpose, 10 | including commercial applications, and to alter it and redistribute it 11 | freely, subject to the following restrictions: 12 | 13 | 1. The origin of this software must not be misrepresented; you must not 14 | claim that you wrote the original software. If you use this software 15 | in a product, an acknowledgment in the product documentation would be 16 | appreciated but is not required. 17 | 2. Altered source versions must be plainly marked as such, and must not be 18 | misrepresented as being the original software. 19 | 3. This notice may not be removed or altered from any source distribution. -------------------------------------------------------------------------------- /src/responses/chart.response.ts: -------------------------------------------------------------------------------- 1 | import type { ArtistResponse, AttrResponse, TagResponse, TrackResponse } from '@responses/index.js'; 2 | 3 | export declare interface ChartGetTopArtistsResponse { 4 | artists: { 5 | artist: Array< 6 | ArtistResponse & { 7 | mbid: string; 8 | playcount: string; 9 | listeners: string; 10 | } 11 | >; 12 | '@attr': AttrResponse; 13 | }; 14 | } 15 | 16 | export declare interface ChartGetTopTagsResponse { 17 | tags: { 18 | tag: Array< 19 | TagResponse & { 20 | taggings: string; 21 | url: string; 22 | } 23 | >; 24 | '@attr': AttrResponse; 25 | }; 26 | } 27 | 28 | export declare interface ChartGetTopTracksResponse { 29 | tracks: { 30 | track: Array< 31 | TrackResponse & { 32 | playcount: string; 33 | listeners: string; 34 | artist: ArtistResponse & { 35 | mbid: string; 36 | }; 37 | } 38 | >; 39 | '@attr': AttrResponse; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /test/schemas/tag.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { 4 | TagGetInfoType, 5 | TagGetTopAlbumsType, 6 | TagGetTopArtistsType, 7 | TagGetTopTracksType, 8 | TagGetWeeklyChartListType, 9 | } from '../../src/typings/tag.type.js'; 10 | 11 | export const TagGetInfoSchema = z.object({}) as ZodObject; 12 | 13 | export const TagGetTopAlbumsSchema = z.array( 14 | z.object({}) as ZodObject 15 | ); 16 | 17 | export const TagGetTopArtistsSchema = z.array( 18 | z.object({}) as ZodObject 19 | ); 20 | 21 | export const TagGetTopTracksSchema = z.array( 22 | z.object({}) as ZodObject 23 | ); 24 | 25 | export const TagGetWeeklyChartListSchema = z.array( 26 | z.object({}) as ZodObject 27 | ); 28 | -------------------------------------------------------------------------------- /src/params/user.params.ts: -------------------------------------------------------------------------------- 1 | export interface UserGetInfoParams { 2 | username: string; 3 | } 4 | 5 | export interface UserGetFriendsParams { 6 | username: string; 7 | limit?: number; 8 | page?: number; 9 | } 10 | 11 | export interface UserGetLovedTracksParams { 12 | username: string; 13 | limit?: number; 14 | page?: number; 15 | } 16 | 17 | export interface UserGetPersonalTagsParams { 18 | username: string; 19 | tag: string; 20 | taggingtype: 'album' | 'artist' | 'track'; 21 | } 22 | 23 | export interface UserGetRecentTracksParams { 24 | username: string; 25 | limit?: number; 26 | page?: number; 27 | } 28 | 29 | export interface UserGetTopAlbumsParams { 30 | username: string; 31 | limit?: number; 32 | page?: number; 33 | } 34 | 35 | export interface UserGetTopArtistsParams { 36 | username: string; 37 | limit?: number; 38 | page?: number; 39 | } 40 | 41 | export interface UserGetTopTagsParams { 42 | username: string; 43 | limit?: number; 44 | } 45 | 46 | export interface UserGetTopTracksParams { 47 | username: string; 48 | limit?: number; 49 | page?: number; 50 | } 51 | -------------------------------------------------------------------------------- /test/schemas/artist.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { 4 | ArtistGetInfoType, 5 | ArtistGetSimilarType, 6 | ArtistGetTopAlbumsType, 7 | ArtistGetTopTagsType, 8 | ArtistGetTopTracksType, 9 | } from '../../src/typings/artist.type.js'; 10 | 11 | export const ArtistGetInfoSchema = z.object({}) as ZodObject< 12 | ZodRawShape, 13 | UnknownKeysParam, 14 | ZodTypeAny, 15 | ArtistGetInfoType 16 | >; 17 | 18 | export const ArtistGetSimilarSchema = z.array( 19 | z.object({}) as ZodObject 20 | ); 21 | 22 | export const ArtistGetTopAlbumsSchema = z.array( 23 | z.object({}) as ZodObject 24 | ); 25 | 26 | export const ArtistGetTopTagsSchema = z.array( 27 | z.object({}) as ZodObject 28 | ); 29 | 30 | export const ArtistGetTopTracksSchema = z.array( 31 | z.object({}) as ZodObject 32 | ); 33 | -------------------------------------------------------------------------------- /test/tests/chart.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import { env } from '../env.js'; 5 | import { ChartGetTopArtistsSchema, ChartGetTopTagsSchema, ChartGetTopTracksSchema } from '../schemas/chart.schema.js'; 6 | 7 | const client = new simpleFM(env.LASTFM_TOKEN); 8 | 9 | describe('Chart', () => { 10 | describe('getTopArtists', () => { 11 | it('Should return top artists', async () => { 12 | const data = await client.chart.getTopArtists(); 13 | 14 | expect(() => ChartGetTopArtistsSchema.parse(data.artists)).not.toThrow(); 15 | }); 16 | }); 17 | 18 | describe('getTopTags', () => { 19 | it('Should return top tags', async () => { 20 | const data = await client.chart.getTopTags(); 21 | expect(() => ChartGetTopTagsSchema.parse(data.tags)).not.toThrow(); 22 | }); 23 | }); 24 | 25 | describe('getTopTracks', () => { 26 | it('Should return top tracks', async () => { 27 | const data = await client.chart.getTopTracks(); 28 | 29 | expect(() => ChartGetTopTracksSchema.parse(data.tracks)).not.toThrow(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Install Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | 19 | - uses: pnpm/action-setup@v2 20 | name: Install pnpm 21 | with: 22 | version: 8 23 | run_install: false 24 | 25 | - name: Get pnpm store directory 26 | shell: bash 27 | run: | 28 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 29 | 30 | - uses: actions/cache@v3 31 | name: Setup pnpm cache 32 | with: 33 | path: ${{ env.STORE_PATH }} 34 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pnpm-store- 37 | 38 | - run: | 39 | touch .env 40 | echo LASTFM_TOKEN=${{ secrets.LASTFM_TOKEN }} >> .env 41 | 42 | - run: pnpm install --frozen-lockfile 43 | - run: pnpm test 44 | - run: pnpm build 45 | -------------------------------------------------------------------------------- /src/typings/album.type.ts: -------------------------------------------------------------------------------- 1 | import type { ObjectArray } from '@responses/index.js'; 2 | import type { AlbumType, ImageType, SearchMeta, TagType, TrackType } from '@typings/index.js'; 3 | 4 | export declare interface AlbumGetInfoType { 5 | name: string; 6 | artist: { 7 | name: string; 8 | url: string | undefined; 9 | }; 10 | mbid: string | undefined; 11 | stats: { 12 | scrobbles: number; 13 | listeners: number; 14 | }; 15 | userStats: { 16 | userPlayCount: number | undefined; 17 | }; 18 | tags: ObjectArray; 19 | tracks: ObjectArray< 20 | Omit & { 21 | rank: number; 22 | duration: number; 23 | } 24 | >; 25 | url: string; 26 | image: ImageType[] | undefined; 27 | } 28 | 29 | export declare interface AlbumGetTopTagsType extends Omit { 30 | artist: { 31 | name: string; 32 | url: string | undefined; 33 | }; 34 | tags: Array< 35 | TagType & { 36 | count: number; 37 | } 38 | >; 39 | } 40 | 41 | export declare interface AlbumSearchType { 42 | search: SearchMeta & { 43 | query: string; 44 | }; 45 | albums: Array< 46 | AlbumType & { 47 | mbid: string | undefined; 48 | } 49 | >; 50 | } 51 | -------------------------------------------------------------------------------- /src/typings/tag.type.ts: -------------------------------------------------------------------------------- 1 | import type { AlbumType, ArtistType, SearchMeta, TagType, TrackType } from '@typings/index.js'; 2 | 3 | export declare interface TagGetInfoType extends TagType { 4 | description: string; 5 | stats: { 6 | count: number; 7 | reach: number; 8 | }; 9 | } 10 | 11 | export declare interface TagGetTopAlbumsType { 12 | search: SearchMeta & { 13 | tag: string; 14 | }; 15 | albums: Array< 16 | AlbumType & { 17 | rank: number; 18 | mbid: string | undefined; 19 | artist: { 20 | mbid: string | undefined; 21 | }; 22 | } 23 | >; 24 | } 25 | 26 | export declare interface TagGetTopArtistsType { 27 | search: SearchMeta & { 28 | tag: string; 29 | }; 30 | artists: Array< 31 | ArtistType & { 32 | rank: number; 33 | } 34 | >; 35 | } 36 | 37 | export declare interface TagGetTopTracksType { 38 | search: SearchMeta & { 39 | tag: string; 40 | }; 41 | tracks: Array< 42 | TrackType & { 43 | rank: number; 44 | duration: number; 45 | } 46 | >; 47 | } 48 | 49 | export declare interface TagGetWeeklyChartListType { 50 | search: { 51 | tag: string; 52 | }; 53 | positions: Array<{ 54 | from: Date; 55 | to: Date; 56 | }>; 57 | } 58 | -------------------------------------------------------------------------------- /src/responses/album.response.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AlbumResponse, 3 | ArtistResponse, 4 | ImageResponse, 5 | ObjectArray, 6 | OpenSearchResponse, 7 | TagResponse, 8 | TrackResponse, 9 | } from '@responses/index.js'; 10 | 11 | export declare interface AlbumGetInfoResponse { 12 | album: AlbumResponse & { 13 | tags: { 14 | tag: ObjectArray< 15 | TagResponse & { 16 | url: string; 17 | } 18 | >; 19 | }; 20 | artist: string; 21 | listeners: string; 22 | playcount: string; 23 | userplaycount?: number; 24 | tracks: { 25 | track: ObjectArray< 26 | TrackResponse & { 27 | duration: string; 28 | '@attr': { 29 | rank: number; 30 | }; 31 | artist: ArtistResponse; 32 | } 33 | >; 34 | }; 35 | url: string; 36 | image?: ImageResponse[]; 37 | }; 38 | } 39 | 40 | export declare interface AlbumGetTopTagsResponse { 41 | toptags: { 42 | tag: Array< 43 | TagResponse & { 44 | count: number; 45 | url: string; 46 | } 47 | >; 48 | '@attr': { 49 | artist: string; 50 | album: string; 51 | }; 52 | }; 53 | } 54 | 55 | export declare interface AlbumSearchResponse { 56 | results: OpenSearchResponse & { 57 | 'opensearch:Query': { 58 | searchTerms: string; 59 | }; 60 | albummatches: { 61 | album: Array< 62 | AlbumResponse & { 63 | artist: string; 64 | } 65 | >; 66 | }; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/convert.ts: -------------------------------------------------------------------------------- 1 | import { IMAGE_SIZES } from '~/constants.js'; 2 | 3 | import type { ImageResponse } from '@responses/index.js'; 4 | import type { ImageType } from '@typings/index.js'; 5 | 6 | const convertURL = (url?: string) => encodeURIComponent(url ?? '').replaceAll(/%20/g, '+'); 7 | 8 | type LastFmURLType = 'album' | 'artist' | 'tag' | 'track'; 9 | 10 | interface LastFmURLParams { 11 | type: T; 12 | value: string; 13 | track?: T extends 'track' ? string : never; 14 | album?: T extends 'album' ? string : never; 15 | } 16 | 17 | export const convertImageSizes = (images?: ImageResponse[]) => { 18 | if (!images) return undefined; 19 | 20 | const data = images 21 | .filter((image) => image['#text'] && IMAGE_SIZES.includes(image.size)) 22 | .map( 23 | (image): ImageType => ({ 24 | size: image.size, 25 | url: image['#text'], 26 | }) 27 | ); 28 | 29 | return data; 30 | }; 31 | 32 | export const createLastFmURL = (params: LastFmURLParams) => { 33 | switch (params.type) { 34 | case 'album': 35 | return `https://www.last.fm/music/${convertURL(params.value)}/_/${convertURL(params.album)}`; 36 | case 'artist': 37 | return `https://www.last.fm/music/${convertURL(params.value)}`; 38 | case 'track': 39 | return `https://www.last.fm/music/${convertURL(params.value)}/_/${convertURL(params.track)}`; 40 | case 'tag': 41 | return `https://www.last.fm/tag/${convertURL(params.value)}`; 42 | default: 43 | return undefined; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/typings/track.type.ts: -------------------------------------------------------------------------------- 1 | import type { AlbumType, SearchMeta, TagType, TrackType } from '@typings/index.js'; 2 | 3 | export declare interface TrackGetInfoType { 4 | name: string; 5 | mbid: string | undefined; 6 | duration?: number; 7 | stats: { 8 | scrobbles: number; 9 | listeners: number; 10 | }; 11 | userStats: { 12 | userPlayCount?: number; 13 | userLoved?: boolean; 14 | }; 15 | artist: { 16 | name: string; 17 | mbid: string | undefined; 18 | url: string; 19 | }; 20 | album: 21 | | Partial< 22 | Omit & { 23 | position: number; 24 | mbid: string | undefined; 25 | } 26 | > 27 | | undefined; 28 | tags?: object[]; 29 | url: string; 30 | } 31 | 32 | export declare interface TrackGetSimilarType { 33 | name: string; 34 | artist: { 35 | name: string; 36 | url?: string; 37 | }; 38 | url?: string; 39 | tracks: Array< 40 | TrackType & { 41 | match: number; 42 | duration: number; 43 | scrobbles: number; 44 | } 45 | >; 46 | } 47 | 48 | export declare interface TrackGetTopTagsType { 49 | name: string; 50 | artist: { 51 | name: string; 52 | url: string | undefined; 53 | }; 54 | url: string | undefined; 55 | tags: Array< 56 | TagType & { 57 | count: number; 58 | } 59 | >; 60 | } 61 | 62 | export declare interface TrackSearchType { 63 | search: SearchMeta & { 64 | query: string; 65 | }; 66 | tracks: Array< 67 | TrackType & { 68 | mbid: string | undefined; 69 | listeners: number; 70 | } 71 | >; 72 | } 73 | -------------------------------------------------------------------------------- /src/responses/tag.response.ts: -------------------------------------------------------------------------------- 1 | import type { AlbumResponse, ArtistResponse, AttrResponse, TagResponse, TrackResponse } from '@responses/index.js'; 2 | 3 | export declare interface TagGetInfoResponse { 4 | tag: TagResponse & { 5 | total: number; 6 | wiki: { 7 | summary: string; 8 | content: string; 9 | }; 10 | }; 11 | } 12 | 13 | export declare interface TagGetTopAlbumsResponse { 14 | albums: { 15 | album: Array< 16 | AlbumResponse & { 17 | artist: ArtistResponse & { 18 | mbid: string; 19 | }; 20 | '@attr': { 21 | rank: number; 22 | }; 23 | } 24 | >; 25 | '@attr': AttrResponse & { tag: string }; 26 | }; 27 | } 28 | 29 | export declare interface TagGetTopArtistsResponse { 30 | topartists: { 31 | artist: Array< 32 | ArtistResponse & { 33 | mbid: string; 34 | '@attr': { 35 | rank: number; 36 | }; 37 | } 38 | >; 39 | '@attr': AttrResponse & { tag: string }; 40 | }; 41 | } 42 | 43 | export declare interface TagGetTopTracksResponse { 44 | tracks: { 45 | track: Array< 46 | TrackResponse & { 47 | duration: string; 48 | artist: ArtistResponse & { 49 | mbid: string; 50 | }; 51 | '@attr': { 52 | rank: string; 53 | }; 54 | } 55 | >; 56 | '@attr': AttrResponse & { tag: string }; 57 | }; 58 | } 59 | 60 | export declare interface TagGetWeeklyChartListResponse { 61 | weeklychartlist: { 62 | chart: Array<{ 63 | from: string; 64 | to: string; 65 | }>; 66 | '@attr': { 67 | tag: string; 68 | }; 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { USER_AGENT } from './constants.js'; 2 | 3 | import Album from '@classes/album.class.js'; 4 | import Artist from '@classes/artist.class.js'; 5 | import Chart from '@classes/chart.class.js'; 6 | import Geo from '@classes/geo.class.js'; 7 | import Tag from '@classes/tag.class.js'; 8 | import Track from '@classes/track.class.js'; 9 | import User from '@classes/user.class.js'; 10 | import LastFMError from '@utils/error.js'; 11 | 12 | export default class SimpleFMClient { 13 | readonly album: Album; 14 | readonly artist: Artist; 15 | readonly chart: Chart; 16 | readonly geo: Geo; 17 | readonly tag: Tag; 18 | readonly track: Track; 19 | readonly user: User; 20 | 21 | constructor( 22 | private readonly key: string, 23 | private readonly options: { 24 | userAgent?: string; 25 | } = {} 26 | ) { 27 | this.validateApiKey(); 28 | 29 | options.userAgent ??= USER_AGENT; 30 | 31 | this.album = this.createService(Album); 32 | this.artist = this.createService(Artist); 33 | this.chart = this.createService(Chart); 34 | this.geo = this.createService(Geo); 35 | this.tag = this.createService(Tag); 36 | this.track = this.createService(Track); 37 | this.user = this.createService(User); 38 | } 39 | 40 | private createService(ServiceClass: new (key: string, userAgent?: string) => T): T { 41 | return new ServiceClass(this.key, this.options.userAgent); 42 | } 43 | 44 | private validateApiKey() { 45 | if (!this.key) 46 | throw new LastFMError({ 47 | message: 'A Last.fm API key is required. Get one here: https://www.last.fm/api/account/create', 48 | error: 6, 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @solely/simple-fm 2 | 3 | ## 1.7.2 4 | 5 | ### Patch Changes 6 | 7 | - 38d2200: Remove position from AlbumType 8 | - 891cd60: Add back the name property to albums in user.getRecentTracks 9 | 10 | ## 1.7.1 11 | 12 | ### Patch Changes 13 | 14 | - 65d0a7c: Use a constants file & make user agent version static. Should fix issues in environments like Cloudflare Workers. 15 | - 0a05734: Clean up condition check for userPlayCount in artist.getInfo 16 | - 6ecb4b9: Fix nowPlaying condition check 17 | 18 | ## 1.7.0 19 | 20 | ### Minor Changes 21 | 22 | - aaee298: Use casters for type helpers & improve types 23 | 24 | ### Patch Changes 25 | 26 | - 0513ddc: Make userAgent versioning optional behind an option 27 | - 2ccf3e6: change node version requirement back to 18 28 | 29 | ## 1.6.4 30 | 31 | ### Patch Changes 32 | 33 | - ffd2125: Use LastFMError for FetchError 34 | - 439c491: Add type safety for package 35 | - b131fc8: Create a separate function for initializing methods 36 | 37 | ## 1.6.3 38 | 39 | ### Patch Changes 40 | 41 | - ffa9047: Use a synchronous call for readFile 42 | 43 | ## 1.6.2 44 | 45 | ### Patch Changes 46 | 47 | - d27fb25: Fix behavior of dateAdded with a track that's currently playing. 48 | 49 | ## 1.6.1 50 | 51 | ### Patch Changes 52 | 53 | - bce6394: Remove redundant "node:" module specifiers 54 | 55 | ## 1.6.0 56 | 57 | ### Minor Changes 58 | 59 | - bbbee3d: Lots of internal changes made (types, etc). Fixed some annoying bugs with some methods. 60 | 61 | ## 1.5.5 62 | 63 | ### Patch Changes 64 | 65 | - eee79df: Add stats property to user.getInfo 66 | - 2d4a73e: Clean up import paths 67 | - 31acc38: Update deps 68 | 69 | ## 1.5.4 70 | 71 | ### Patch Changes 72 | 73 | - 968f34b: Add Changesets to handle versioning and changelogs 74 | -------------------------------------------------------------------------------- /test/tests/geo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import LastFMError from '../../src/utils/error.js'; 5 | import { env } from '../env.js'; 6 | import { GeoGetTopArtistsSchema, GeoGetTopTracksSchema } from '../schemas/geo.schema.js'; 7 | 8 | const client = new simpleFM(env.LASTFM_TOKEN); 9 | 10 | describe('Geo', () => { 11 | describe('getTopArtists', () => { 12 | it('Should return top artists from a country', async () => { 13 | const data = await client.geo.getTopArtists({ country: 'Canada' }); 14 | 15 | expect(() => GeoGetTopArtistsSchema.parse(data.artists)).not.toThrow(); 16 | }); 17 | 18 | it('Should error when the country is invalid', async () => { 19 | try { 20 | const data = await client.geo.getTopArtists({ country: 'Texas' }); 21 | 22 | expect(() => GeoGetTopArtistsSchema.parse(data)).toThrow(); 23 | } catch (err) { 24 | if (err instanceof LastFMError) expect(err.message).toEqual('country param invalid'); 25 | } 26 | }); 27 | }); 28 | 29 | describe('getTopTracks', () => { 30 | it('Should return top tracks from a country', async () => { 31 | const data = await client.geo.getTopTracks({ country: 'New Zealand' }); 32 | 33 | expect(() => GeoGetTopTracksSchema.parse(data.tracks)).not.toThrow(); 34 | }); 35 | 36 | it('Should error when the country is invalid', async () => { 37 | try { 38 | const data = await client.geo.getTopTracks({ country: 'Texas' }); 39 | 40 | expect(() => GeoGetTopTracksSchema.parse(data)).toThrow(); 41 | } catch (err) { 42 | if (err instanceof LastFMError) expect(err.message).toEqual('country param required'); 43 | } 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/responses/track.response.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArtistResponse, 3 | AlbumResponse, 4 | OpenSearchResponse, 5 | TagResponse, 6 | TrackResponse, 7 | } from '@responses/index.js'; 8 | 9 | export declare interface TrackGetInfoResponse { 10 | track: TrackResponse & { 11 | duration: string; 12 | listeners: string; 13 | playcount: string; 14 | artist: ArtistResponse & { 15 | mbid: string; 16 | }; 17 | album?: 18 | | (AlbumResponse & { 19 | title?: string; 20 | '@attr': { position: string }; 21 | }) 22 | | undefined; 23 | toptags: { 24 | tag: Array< 25 | TagResponse & { 26 | url: string; 27 | } 28 | >; 29 | }; 30 | userplaycount?: string; 31 | userloved?: string; 32 | }; 33 | } 34 | 35 | export declare interface TrackGetSimilarResponse { 36 | similartracks: { 37 | track: Array< 38 | TrackResponse & { 39 | playcount: number; 40 | match: number; 41 | duration: number; 42 | artist: ArtistResponse & { 43 | mbid: string; 44 | }; 45 | } 46 | >; 47 | '@attr': { 48 | artist: string; 49 | }; 50 | }; 51 | } 52 | 53 | export declare interface TrackGetTopTagsResponse { 54 | toptags: { 55 | tag: Array< 56 | TagResponse & { 57 | count: number; 58 | url: string; 59 | } 60 | >; 61 | '@attr': { 62 | artist: string; 63 | track: string; 64 | }; 65 | }; 66 | } 67 | 68 | export declare interface TrackSearchResponse { 69 | results: OpenSearchResponse & { 70 | trackmatches: { 71 | track: Array< 72 | TrackResponse & { 73 | artist: string; 74 | listeners: string; 75 | } 76 | >; 77 | }; 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /test/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodObject, ZodRawShape, UnknownKeysParam, ZodTypeAny } from 'zod'; 2 | 3 | import { 4 | UserGetInfoType, 5 | UserGetLovedTracksType, 6 | UserGetPersonalTagsType, 7 | UserGetRecentTracksType, 8 | UserGetTopAlbumsType, 9 | UserGetTopArtistsType, 10 | UserGetTopTagsType, 11 | UserGetTopTracksType, 12 | } from '../../src/typings/user.type.js'; 13 | 14 | export const UserGetFriendsSchema = z.array( 15 | z.object({}) as ZodObject 16 | ); 17 | 18 | export const UserGetInfoSchema = z.object({}) as ZodObject; 19 | 20 | export const UserGetLovedTracksSchema = z.array( 21 | z.object({}) as ZodObject 22 | ); 23 | 24 | export const UserGetPersonalTagsSchema = z.array( 25 | z.object({}) as ZodObject 26 | ); 27 | 28 | export const UserGetRecentTracksSchema = z.array( 29 | z.object({}) as ZodObject 30 | ); 31 | 32 | export const UserGetTopAlbumsSchema = z.array( 33 | z.object({}) as ZodObject 34 | ); 35 | 36 | export const UserGetTopArtistsSchema = z.array( 37 | z.object({}) as ZodObject 38 | ); 39 | 40 | export const UserGetTopTagsSchema = z.array( 41 | z.object({}) as ZodObject 42 | ); 43 | 44 | export const UserGetTopTracksSchema = z.array( 45 | z.object({}) as ZodObject 46 | ); 47 | -------------------------------------------------------------------------------- /src/responses/index.ts: -------------------------------------------------------------------------------- 1 | export type ObjectArray = T | T[]; 2 | 3 | export interface AttrResponse { 4 | page: string; 5 | perPage: string; 6 | totalPages: string; 7 | total: string; 8 | } 9 | 10 | export interface OpenSearchResponse { 11 | 'opensearch:Query': { 12 | startPage: string; 13 | }; 14 | 'opensearch:totalResults': string; 15 | 'opensearch:itemsPerPage': string; 16 | } 17 | 18 | export interface ImageResponse { 19 | size: string; 20 | '#text': string; 21 | } 22 | 23 | export interface Registered { 24 | '#text': number; 25 | unixtime: string; 26 | } 27 | 28 | export interface AlbumResponse { 29 | name: string; 30 | mbid?: string; 31 | artist: ArtistResponse | string; 32 | url: string; 33 | image: ImageResponse[]; 34 | } 35 | 36 | export interface ArtistResponse { 37 | name: string; 38 | mbid?: string; 39 | url: string; 40 | image?: ImageResponse[]; 41 | } 42 | 43 | export interface TagResponse { 44 | name: string; 45 | url?: string; 46 | count: number; 47 | total: number; 48 | reach: number; 49 | } 50 | 51 | export interface TrackResponse { 52 | name: string; 53 | mbid: string; 54 | artist: ArtistResponse | string; 55 | url: string; 56 | image: ImageResponse[]; 57 | } 58 | 59 | export interface UserResponse { 60 | name: string; 61 | realname?: string; 62 | country?: string; 63 | type: string; 64 | subscriber?: string; 65 | registered: Registered; 66 | url: string; 67 | image?: ImageResponse[]; 68 | } 69 | 70 | export * from '@responses/album.response.js'; 71 | export * from '@responses/artist.response.js'; 72 | export * from '@responses/chart.response.js'; 73 | export * from '@responses/geo.response.js'; 74 | export * from '@responses/tag.response.js'; 75 | export * from '@responses/track.response.js'; 76 | export * from '@responses/user.response.js'; 77 | -------------------------------------------------------------------------------- /test/tests/tag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import { env } from '../env.js'; 5 | import { 6 | TagGetInfoSchema, 7 | TagGetTopAlbumsSchema, 8 | TagGetTopArtistsSchema, 9 | TagGetTopTracksSchema, 10 | TagGetWeeklyChartListSchema, 11 | } from '../schemas/tag.schema.js'; 12 | 13 | const client = new simpleFM(env.LASTFM_TOKEN); 14 | 15 | describe('Tag', () => { 16 | describe('getInfo', () => { 17 | it('Should return info for a tag', async () => { 18 | const data = await client.tag.getInfo({ tag: 'metal' }); 19 | 20 | expect(() => TagGetInfoSchema.parse(data)).not.toThrow(); 21 | }); 22 | }); 23 | 24 | describe('getTopAlbums', () => { 25 | it('Should return top albums for a tag', async () => { 26 | const data = await client.tag.getTopAlbums({ tag: 'pop punk' }); 27 | 28 | expect(() => TagGetTopAlbumsSchema.parse(data.albums)).not.toThrow(); 29 | }); 30 | }); 31 | 32 | describe('getTopArtists', () => { 33 | it('Should return top artists for a tag', async () => { 34 | const data = await client.tag.getTopArtists({ tag: 'progressive house' }); 35 | 36 | expect(() => TagGetTopArtistsSchema.parse(data.artists)).not.toThrow(); 37 | }); 38 | }); 39 | 40 | describe('getTopTracks', () => { 41 | it('Should return top tracks for a tag', async () => { 42 | const data = await client.tag.getTopTracks({ tag: 'emo' }); 43 | 44 | expect(() => TagGetTopTracksSchema.parse(data.tracks)).not.toThrow(); 45 | }); 46 | }); 47 | 48 | describe('getWeeklyChartList', () => { 49 | it("Should return a tag's weekly chart list", async () => { 50 | const data = await client.tag.getWeeklyChartList({ tag: 'rock' }); 51 | 52 | expect(() => TagGetWeeklyChartListSchema.parse(data.positions)).not.toThrow(); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/typings/artist.type.ts: -------------------------------------------------------------------------------- 1 | import type { ArtistType, AlbumType, ImageType, SearchMeta, TagType, TrackType } from '@typings/index.js'; 2 | 3 | export declare interface ArtistGetInfoType { 4 | name: string; 5 | mbid: string | undefined; 6 | onTour: boolean; 7 | stats: { 8 | scrobbles: number; 9 | listeners: number; 10 | }; 11 | userStats: { 12 | userPlayCount: number | undefined; 13 | }; 14 | tags: Array<{ 15 | name: string; 16 | url: string | undefined; 17 | }>; 18 | bio: { 19 | summary: string; 20 | extended: string; 21 | published: Date; 22 | url: string; 23 | }; 24 | similarArtists: Array<{ 25 | name: string; 26 | image: ImageType[] | undefined; 27 | url: string; 28 | }>; 29 | url: string; 30 | } 31 | 32 | export declare interface ArtistGetSimilarType { 33 | search: { 34 | artist: { 35 | name: string; 36 | url: string | undefined; 37 | }; 38 | }; 39 | artists: Array< 40 | ArtistType & { 41 | match: number; 42 | mbid: string | undefined; 43 | } 44 | >; 45 | } 46 | 47 | export declare interface ArtistGetTopAlbumsType { 48 | search: SearchMeta & { 49 | artist: ArtistType; 50 | }; 51 | albums: Array< 52 | AlbumType & { 53 | scrobbles: number; 54 | } 55 | >; 56 | } 57 | 58 | export declare interface ArtistGetTopTagsType { 59 | search: { artist: ArtistType }; 60 | tags: Array< 61 | TagType & { 62 | count: number; 63 | } 64 | >; 65 | } 66 | 67 | export declare interface ArtistGetTopTracksType { 68 | search: SearchMeta & { 69 | artist: ArtistType; 70 | }; 71 | tracks: Array< 72 | TrackType & { 73 | rank: number; 74 | stats: { 75 | scrobbles: number; 76 | listeners: number; 77 | }; 78 | } 79 | >; 80 | } 81 | 82 | export declare interface ArtistSearchType { 83 | search: SearchMeta & { 84 | query: string; 85 | }; 86 | artists: Array< 87 | ArtistType & { 88 | mbid: string | undefined; 89 | listeners: number; 90 | } 91 | >; 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/caster.ts: -------------------------------------------------------------------------------- 1 | export const toInt = (value?: T): number => { 2 | if (typeof value === 'number') return value; 3 | if (typeof value === 'string') return Number.parseInt(value); 4 | 5 | return Number.NaN; 6 | }; 7 | 8 | export const toFloat = (value?: T): number => { 9 | if (typeof value === 'number') return value; 10 | if (typeof value === 'string') return Number.parseFloat(value); 11 | 12 | return Number.NaN; 13 | }; 14 | 15 | export const toArray = (value: T | T[]): T[] => { 16 | if (Array.isArray(value)) return value; 17 | if (!value) return []; 18 | 19 | return [value]; 20 | }; 21 | 22 | export const toBool = (value: any): boolean => { 23 | return value !== 0 && value && value !== '0'; 24 | }; 25 | 26 | export const convertMeta = (meta: any) => { 27 | for (const k in ['from', 'perPage', 'page', 'to', 'total', 'totalPages'] as const) 28 | if (Object.hasOwn(meta, k)) meta[k] = toInt(meta[k]); 29 | 30 | return meta; 31 | }; 32 | 33 | export const convertSearch = (res: any): any => { 34 | res.itemsPerPage = toInt(res['opensearch:itemsPerPage']); 35 | delete res['opensearch:itemsPerPage']; 36 | 37 | res.totalResults = toInt(res['opensearch:totalResults']); 38 | delete res['opensearch:totalResults']; 39 | 40 | return { 41 | query: res['opensearch:Query'].searchTerms, 42 | page: res['opensearch:Query'].startPage, 43 | itemsPerPage: res.itemsPerPage, 44 | totalResults: res.totalResults, 45 | }; 46 | }; 47 | 48 | export function convertEntryToInt(entry: any): any { 49 | for (const k of [ 50 | 'count', 51 | 'duration', 52 | 'listeners', 53 | 'match', 54 | 'playcount', 55 | 'rank', 56 | 'reach', 57 | 'taggings', 58 | 'userplaycount', 59 | ]) 60 | if (Object.hasOwn(entry, k)) entry[k] = toInt(entry[k]); 61 | 62 | return entry; 63 | } 64 | 65 | export const sanitizeBio = (input: string) => { 66 | return input 67 | .replace(/<[^>]*>/g, '') 68 | .replace('Read more on Last.fm', '') 69 | .trim(); 70 | }; 71 | -------------------------------------------------------------------------------- /test/tests/album.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import LastFMError from '../../src/utils/error.js'; 5 | import { env } from '../env.js'; 6 | import { AlbumGetInfoSchema, AlbumGetTopTagsSchema, AlbumSearchSchema } from '../schemas/album.schema.js'; 7 | 8 | const client = new simpleFM(env.LASTFM_TOKEN); 9 | 10 | const errorMessage = 'Album not found'; 11 | 12 | describe('Album', () => { 13 | describe('getInfo', () => { 14 | it('Should return info for an album', async () => { 15 | const data = await client.album.getInfo({ artist: 'Nirvana', album: 'Nevermind' }); 16 | 17 | expect(() => AlbumGetInfoSchema.parse(data)).not.toThrow(); 18 | }); 19 | 20 | it("Should error when the album doesn't exist", async () => { 21 | try { 22 | const data = await client.album.getInfo({ artist: 'rj-9wugh', album: '102edgreth' }); 23 | 24 | expect(() => AlbumGetInfoSchema.parse(data)).toThrow(); 25 | } catch (err) { 26 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 27 | } 28 | }); 29 | }); 30 | 31 | describe('getTopTags', () => { 32 | it("Should return an album's top tags", async () => { 33 | const data = await client.album.getTopTags({ artist: 'Fall Out Boy', album: 'Save Rock and Roll' }); 34 | 35 | expect(() => AlbumGetTopTagsSchema.parse(data.tags)).not.toThrow(); 36 | }); 37 | 38 | it("Should error when the album doesn't exist", async () => { 39 | try { 40 | const data = await client.album.getTopTags({ artist: 'rj-9wugh', album: '102edgreth' }); 41 | 42 | expect(() => AlbumGetTopTagsSchema.parse(data)).toThrow(); 43 | } catch (err) { 44 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 45 | } 46 | }); 47 | }); 48 | 49 | describe('search', () => { 50 | it('Should search and return albums from a query', async () => { 51 | const data = await client.album.search({ album: 'RIOT!' }); 52 | 53 | expect(() => AlbumSearchSchema.parse(data.albums)).not.toThrow(); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import { $fetch, FetchError } from 'ofetch'; 2 | 3 | import { BASE_URL } from './constants.js'; 4 | 5 | import LastFMError from '@utils/error.js'; 6 | 7 | import type { RequestMethod } from '@typings/index.js'; 8 | 9 | export interface LastFMArgument { 10 | method: RequestMethod; 11 | 12 | album?: string; 13 | artist?: string; 14 | country?: string; 15 | tag?: string; 16 | track?: string; 17 | user?: string; 18 | 19 | lang?: string; 20 | location?: string; 21 | username?: string; 22 | 23 | sk?: string; 24 | token?: string; 25 | api_sig?: string; 26 | 27 | page?: number; 28 | limit?: number; 29 | 30 | mbid?: string; 31 | 32 | taggingtype?: string; 33 | autocorrect?: boolean | number; 34 | recenttracks?: boolean | number; 35 | } 36 | 37 | type Data = T & { 38 | message: string; 39 | error: number; 40 | }; 41 | 42 | export class LastFMRequest { 43 | private readonly key: string; 44 | private readonly params: LastFMArgument; 45 | private readonly userAgent: string; 46 | 47 | constructor(key: string, userAgent: string, params: LastFMArgument) { 48 | this.key = key; 49 | this.params = params; 50 | this.userAgent = userAgent; 51 | } 52 | 53 | // TODO: Implement post methods. 54 | private isPostRequest() { 55 | return Object.hasOwn(this.params, 'sk'); 56 | } 57 | 58 | private post(): Promise { 59 | throw new Error('Method not implemented yet.'); 60 | } 61 | 62 | private async get(): Promise { 63 | const params = { 64 | api_key: this.key, 65 | format: 'json', 66 | ...this.params, 67 | }; 68 | 69 | try { 70 | const data = await $fetch>(BASE_URL, { 71 | params, 72 | headers: { 73 | 'User-Agent': this.userAgent, 74 | }, 75 | }); 76 | 77 | if (data.error === 6) throw new LastFMError(data); 78 | 79 | return data; 80 | } catch (err) { 81 | if (err instanceof FetchError) throw new LastFMError(err.data); 82 | if (err instanceof LastFMError) throw new LastFMError(err.response); 83 | console.error(err); 84 | } 85 | } 86 | 87 | execute() { 88 | if (this.isPostRequest()) return this.post(); 89 | return this.get(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solely/simple-fm", 3 | "version": "1.7.2", 4 | "license": "Zlib", 5 | "author": "Chloe Arciniega (https://arciniega.one)", 6 | "description": "A simple, asynchronous Last.fm wrapper in TypeScript.", 7 | "type": "module", 8 | "main": "./dist/index.js", 9 | "files": [ 10 | "./dist" 11 | ], 12 | "scripts": { 13 | "build": "tsc -p tsconfig.build.json && tsconfig-replace-paths -p tsconfig.build.json -s src/", 14 | "change": "pnpm changeset", 15 | "ci:publish": "pnpm publish -r", 16 | "ci:version": "pnpm changeset version", 17 | "dev": "tsc --watch", 18 | "lint": "pnpm eslint .", 19 | "package": "tar -xvf solely-simple-fm-*.tgz && pnpm rimraf ./solely-simple-fm-*.tgz", 20 | "prepublish": "rimraf ./dist && pnpm build", 21 | "preview": "pnpm pack && pnpm package", 22 | "test": "vitest" 23 | }, 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | "engines": { 28 | "node": ">=18" 29 | }, 30 | "exports": { 31 | ".": "./dist/index.js", 32 | "./package.json": "./package.json" 33 | }, 34 | "homepage": "https://github.com/solelychloe/simple-fm#README", 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/solelychloe/simple-fm.git" 38 | }, 39 | "keywords": [ 40 | "lastfm", 41 | "last.fm", 42 | "last-fm", 43 | "lastfm-api", 44 | "last.fm-api", 45 | "last-fm-api" 46 | ], 47 | "funding": { 48 | "type": "individual", 49 | "url": "https://ko-fi.com/solelychloe" 50 | }, 51 | "dependencies": { 52 | "ofetch": "^1.1.1" 53 | }, 54 | "devDependencies": { 55 | "@changesets/cli": "^2.26.2", 56 | "@types/node": "^20.4.5", 57 | "@typescript-eslint/eslint-plugin": "^6.2.1", 58 | "@typescript-eslint/parser": "^6.2.1", 59 | "dotenv": "^16.3.1", 60 | "eslint": "^8.46.0", 61 | "eslint-config-clarity": "^1.0.6", 62 | "eslint-import-resolver-typescript": "^3.5.5", 63 | "eslint-plugin-import": "^2.28.0", 64 | "eslint-plugin-prettier": "^5.0.0", 65 | "prettier": "^3.0.0", 66 | "rimraf": "^5.0.1", 67 | "tsconfig-replace-paths": "^0.0.14", 68 | "tsx": "^3.12.7", 69 | "typescript": "^5.1.6", 70 | "vite": "^4.4.8", 71 | "vite-tsconfig-paths": "^4.2.0", 72 | "vitest": "^0.34.1", 73 | "zod": "^3.21.4" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_run: 4 | workflows: ['CI'] 5 | types: 6 | - completed 7 | push: 8 | branches: 9 | - 'main' 10 | 11 | jobs: 12 | publish: 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | with: 26 | version: 8 27 | run_install: false 28 | 29 | - name: Get pnpm store directory 30 | shell: bash 31 | run: | 32 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 33 | 34 | - uses: actions/cache@v3 35 | name: Setup pnpm cache 36 | with: 37 | path: ${{ env.STORE_PATH }} 38 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pnpm-store- 41 | 42 | - name: Install Dependencies 43 | run: pnpm install --frozen-lockfile 44 | 45 | - name: Retrieve package.json version 46 | id: get_version 47 | run: echo ::set-output name=version::$(node -e "console.log(require('./package.json').version)") 48 | 49 | - name: Update simple-fm version 50 | run: | 51 | sed -i "s/const PACKAGE_VERSION = '[0-9.]*'/const PACKAGE_VERSION = '${{ steps.get_version.outputs.version }}'/" src/constants.ts 52 | 53 | - name: Commit version update 54 | run: | 55 | git config --local user.email "action@github.com" 56 | git config --local user.name "GitHub Action" 57 | git add src/constants.ts 58 | git commit -m "chore: update version to ${{ steps.get_version.outputs.version }}" 59 | git push 60 | 61 | - name: Create Release Pull Request or Publish to npm 62 | # https://github.com/changesets/action 63 | uses: changesets/action@v1 64 | with: 65 | # this expects you to have a script called release which does a build for your packages and calls changeset publish 66 | publish: pnpm ci:publish 67 | version: pnpm ci:version 68 | commit: 'chore(changesets): release' 69 | title: 'chore(changesets): release' 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 73 | -------------------------------------------------------------------------------- /src/responses/artist.response.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AlbumResponse, 3 | ArtistResponse, 4 | AttrResponse, 5 | OpenSearchResponse, 6 | TagResponse, 7 | TrackResponse, 8 | } from '@responses/index.js'; 9 | 10 | export declare interface ArtistGetInfoResponse { 11 | artist: ArtistResponse & { 12 | mbid: string; 13 | ontour: string; 14 | stats: { 15 | listeners: string; 16 | playcount: string; 17 | userplaycount?: string; 18 | }; 19 | tags: { 20 | tag: TagResponse[]; 21 | }; 22 | bio: { 23 | links: { 24 | link: { 25 | '#text': string; 26 | rel: string; 27 | href: string; 28 | }; 29 | }; 30 | published: string; 31 | summary: string; 32 | content: string; 33 | }; 34 | similar: { 35 | artist: ArtistResponse[]; 36 | }; 37 | }; 38 | } 39 | 40 | export declare interface ArtistGetSimilarResponse { 41 | similarartists: { 42 | artist: Array< 43 | ArtistResponse & { 44 | mbid: string; 45 | match: string; 46 | } 47 | >; 48 | '@attr': { 49 | artist: string; 50 | }; 51 | }; 52 | } 53 | 54 | export declare interface ArtistGetTopAlbumsResponse { 55 | topalbums: { 56 | album: Array< 57 | AlbumResponse & { 58 | playcount: number; 59 | artist: ArtistResponse; 60 | } 61 | >; 62 | '@attr': AttrResponse & { artist: string }; 63 | }; 64 | } 65 | 66 | export declare interface ArtistGetTopTagsResponse { 67 | toptags: { 68 | tag: Array< 69 | TagResponse & { 70 | url: string; 71 | count: number; 72 | } 73 | >; 74 | '@attr': { 75 | artist: string; 76 | }; 77 | }; 78 | } 79 | 80 | export declare interface ArtistGetTopTracksResponse { 81 | toptracks: { 82 | track: Array< 83 | TrackResponse & { 84 | listeners: string; 85 | playcount: string; 86 | artist: ArtistResponse; 87 | '@attr': { 88 | rank: string; 89 | }; 90 | } 91 | >; 92 | '@attr': AttrResponse & { artist: string }; 93 | }; 94 | } 95 | 96 | export declare interface ArtistSearchResponse { 97 | results: OpenSearchResponse & { 98 | 'opensearch:Query': { 99 | searchTerms: string; 100 | }; 101 | artistmatches: { 102 | artist: Array< 103 | ArtistResponse & { 104 | mbid: string; 105 | listeners: string; 106 | } 107 | >; 108 | }; 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /src/typings/user.type.ts: -------------------------------------------------------------------------------- 1 | import type { AlbumType, ArtistType, PersonalTag, SearchMeta, TagType, TrackType, UserType } from '@typings/index.js'; 2 | 3 | export declare interface UserGetFriendsType { 4 | search: SearchMeta & { 5 | user: string; 6 | }; 7 | friends: UserType[]; 8 | } 9 | 10 | export declare interface UserGetInfoType extends UserType { 11 | stats: { 12 | albumCount: number; 13 | artistCount: number; 14 | playCount: number; 15 | trackCount: number; 16 | }; 17 | } 18 | 19 | export declare interface UserGetLovedTracksType { 20 | search: SearchMeta & { 21 | user: string; 22 | }; 23 | tracks: Array< 24 | TrackType & { 25 | date: Date; 26 | } 27 | >; 28 | } 29 | 30 | export declare interface UserGetPersonalTagsType { 31 | search: SearchMeta & { 32 | user: string; 33 | tag: string; 34 | }; 35 | response?: PersonalTag[]; 36 | } 37 | 38 | export declare interface UserGetRecentTracksType { 39 | search: SearchMeta & { 40 | user: string; 41 | nowPlaying: boolean; 42 | }; 43 | tracks: Array< 44 | Omit & { 45 | dateAdded: Date | undefined; 46 | mbid: string | undefined; 47 | album: { 48 | name: string; 49 | mbid: string | undefined; 50 | }; 51 | } 52 | >; 53 | } 54 | 55 | export declare interface UserGetTopAlbumsType { 56 | search: SearchMeta & { 57 | user: string; 58 | }; 59 | albums: Array< 60 | AlbumType & { 61 | rank: number; 62 | mbid: string | undefined; 63 | playCount: number; 64 | artist: { 65 | mbid: string | undefined; 66 | }; 67 | } 68 | >; 69 | } 70 | 71 | export declare interface UserGetTopArtistsType { 72 | search: SearchMeta & { 73 | user: string; 74 | }; 75 | artists: Array< 76 | ArtistType & { 77 | rank: number; 78 | mbid: string | undefined; 79 | scrobbles: number; 80 | } 81 | >; 82 | } 83 | 84 | export declare interface UserGetTopTagsType { 85 | search: { 86 | user: string; 87 | }; 88 | tags: Array< 89 | TagType & { 90 | count: number; 91 | } 92 | >; 93 | } 94 | 95 | export declare interface UserGetTopTracksType { 96 | search: SearchMeta & { 97 | user: string; 98 | }; 99 | tracks: Array< 100 | TrackType & { 101 | rank: number; 102 | mbid: string | undefined; 103 | stats: { 104 | duration?: number; 105 | userPlayCount: number; 106 | }; 107 | artist: { 108 | mbid: string | undefined; 109 | }; 110 | } 111 | >; 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | Headphones with musical notes coming out of it. 11 | 12 | 13 | ## simple-fm 14 | 15 | _A simple, asynchronous Last.fm wrapper in TypeScript._ 16 | 17 | Search for what someone has been listening to lately, what tracks are trending in a country, an artist's top tracks, and 18 | a lot more. 19 | 20 | For more information, please visit the [documentation website][docs]. 21 | 22 | [![CI][actions-image]][actions-link] [![npm-image]][npm-link] [![downloads-image]][npm-link] [![license-image]][license] 23 | 24 |
25 | 26 | # Install 27 | 28 | `simple-fm` requires that you have **Node.js 18** (and above) and **TypeScript v5+** installed. 29 | 30 | - npm: `npm i @solely/simple-fm` 31 | - pnpm: `pnpm i @solely/simple-fm` 32 | - yarn: `yarn add @solely/simple-fm` 33 | - bun: `bun i @solely/simple-fm` 34 | 35 | # Notice 36 | 37 | `simple-fm` requires you to have a Last.fm API key. 38 | 39 | To obtain a Last.fm API key, click [here to register an API account][last-fm-api]. 40 | 41 | # Example usage 42 | 43 | ```ts 44 | // Import the simple-fm package. 45 | import SimpleFM from '@solely/simple-fm'; // ESM 46 | import SimpleFM from 'https://esm.sh/@solely/simple-fm'; // Deno 47 | const SimpleFM = require('@solely/simple-fm'); // CommonJS 48 | ``` 49 | 50 | ```ts 51 | // Replace the token with your Last.fm API key. 52 | const client = new SimpleFM('Last.fm API key'); 53 | 54 | // Fetch the recent track from a user. 55 | const json = await client.user.getRecentTracks({ username: 'solelychloe' }); 56 | 57 | console.log(json); 58 | ``` 59 | 60 | # License 61 | 62 | This package is licensed under the [zlib][license] license. 63 | 64 | © 2024 Chloe Arciniega. 65 | 66 | [actions-image]: 67 | https://img.shields.io/github/actions/workflow/status/SapphicMoe/simple-fm/main.yml?colorA=18181B&colorB=de3931 68 | [actions-link]: https://github.com/SapphicMoe/simple-fm/actions/workflows/main.yml 69 | [docs]: https://simple.sapphic.moe 70 | [logo]: /public/logo.svg 'The Twitter headphone emoji with musical notes in it.' 71 | [license]: /LICENSE 72 | [downloads-image]: https://img.shields.io/npm/dm/@solely/simple-fm.svg?style=flat&colorA=18181B&colorB=de3931 73 | [last-fm-api]: https://www.last.fm/api/account/create 74 | [license-image]: https://img.shields.io/npm/l/@solely/simple-fm.svg?style=flat&colorA=18181B&colorB=de3931 75 | [npm-image]: https://img.shields.io/npm/v/@solely/simple-fm.svg?style=flat&colorA=18181B&colorB=de3931 76 | [npm-link]: https://npmjs.org/package/@solely/simple-fm 77 | -------------------------------------------------------------------------------- /test/tests/track.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import LastFMError from '../../src/utils/error.js'; 5 | import { env } from '../env.js'; 6 | import { 7 | TrackGetInfoSchema, 8 | TrackGetSimilarSchema, 9 | TrackGetTopTagsSchema, 10 | TrackSearchSchema, 11 | } from '../schemas/track.schema.js'; 12 | 13 | const client = new simpleFM(env.LASTFM_TOKEN); 14 | 15 | const errorMessage = 'Track not found'; 16 | 17 | describe('Track', () => { 18 | describe('getInfo', () => { 19 | it('Should return info for a track', async () => { 20 | const data = await client.track.getInfo({ artist: 'Lyn', track: 'Take Over' }); 21 | 22 | expect(() => TrackGetInfoSchema.parse(data)).not.toThrow(); 23 | }); 24 | 25 | it("Should error when the track doesn't exist", async () => { 26 | try { 27 | const data = await client.track.getInfo({ artist: 'mrrowk[rpgk', track: '=-ks0-[hkt0phj' }); 28 | 29 | expect(() => TrackGetInfoSchema.parse(data)).toThrow(); 30 | } catch (err) { 31 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 32 | } 33 | }); 34 | }); 35 | 36 | describe('getSimilar', () => { 37 | it('Should return similar tracks from a query', async () => { 38 | const data = await client.track.getSimilar({ artist: 'Metallica', track: 'Sad But True' }); 39 | 40 | expect(() => TrackGetSimilarSchema.parse(data.tracks)).not.toThrow(); 41 | }); 42 | 43 | it("Should error when the track doesn't exist", async () => { 44 | try { 45 | const data = await client.track.getSimilar({ artist: 'mrrowk[rpgk', track: '=-ks0-[hkt0phj' }); 46 | 47 | expect(() => TrackGetSimilarSchema.parse(data)).toThrow(); 48 | } catch (err) { 49 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 50 | } 51 | }); 52 | }); 53 | 54 | describe('getTopTags', () => { 55 | it("Should return a track's top tags", async () => { 56 | const data = await client.track.getTopTags({ artist: 'Taylor Swift', track: 'New Romantics' }); 57 | 58 | expect(() => TrackGetTopTagsSchema.parse(data.tags)).not.toThrow(); 59 | }); 60 | 61 | it("Should error when the track doesn't exist", async () => { 62 | try { 63 | const data = await client.track.getTopTags({ artist: 'mrrowk[rpgk', track: '=-ks0-[hkt0phj' }); 64 | 65 | expect(() => TrackGetTopTagsSchema.parse(data)).toThrow(); 66 | } catch (err) { 67 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 68 | } 69 | }); 70 | }); 71 | 72 | describe('search', () => { 73 | it('Should search and return tracks for a query', async () => { 74 | const data = await client.track.search({ track: "Ain't It Fun" }); 75 | 76 | expect(() => TrackSearchSchema.parse(data.tracks)).not.toThrow(); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/classes/geo.class.ts: -------------------------------------------------------------------------------- 1 | import Base from '~/base.js'; 2 | import { toArray, toInt } from '~/utils/caster.js'; 3 | 4 | import type { GeoGetTopArtistsParams, GeoGetTopTracksParams } from '@params/geo.params.js'; 5 | import type { GeoGetTopArtistsResponse, GeoGetTopTracksResponse } from '@responses/index.js'; 6 | import type { GeoGetTopArtistsType, GeoGetTopTracksType } from '@typings/index.js'; 7 | 8 | export default class Geo extends Base { 9 | /** 10 | * Returns the most popular artists by country. 11 | * @param country - The name of the country. 12 | * @param limit - The number of results to fetch per page. Defaults to 50. 13 | * @param page - The page number to fetch. Defaults to the first page. 14 | * */ 15 | async getTopArtists(params: GeoGetTopArtistsParams): Promise { 16 | const { 17 | topartists: { artist: artistMatches, '@attr': attr }, 18 | } = await this.sendRequest({ 19 | method: 'geo.getTopArtists', 20 | ...params, 21 | limit: params.limit ?? 50, 22 | page: params.page ?? 1, 23 | }); 24 | 25 | return { 26 | search: { 27 | country: attr.country, 28 | page: toInt(attr.page), 29 | itemsPerPage: toInt(attr.perPage), 30 | totalPages: toInt(attr.totalPages), 31 | totalResults: toInt(attr.total), 32 | }, 33 | artists: artistMatches.map((artist) => ({ 34 | name: artist.name, 35 | mbid: artist.mbid === '' ? undefined : artist.mbid, 36 | listeners: toInt(artist.listeners), 37 | url: artist.url, 38 | })), 39 | }; 40 | } 41 | 42 | /** 43 | * Returns the most popular tracks by country. 44 | * @param country - The name of the country. 45 | * @param limit - The number of results to fetch per page. Defaults to 50. 46 | * @param page - The page number to fetch. Defaults to the first page. 47 | * */ 48 | async getTopTracks(params: GeoGetTopTracksParams): Promise { 49 | const { 50 | tracks: { track: trackMatches, '@attr': attr }, 51 | } = await this.sendRequest({ 52 | method: 'geo.getTopTracks', 53 | ...params, 54 | limit: params.limit ?? 50, 55 | page: params.page ?? 1, 56 | }); 57 | 58 | return { 59 | search: { 60 | country: attr.country, 61 | page: toInt(attr.page), 62 | itemsPerPage: toInt(attr.perPage), 63 | totalPages: toInt(attr.totalPages), 64 | totalResults: toInt(attr.total), 65 | }, 66 | tracks: toArray(trackMatches).map((track) => ({ 67 | rank: toInt(track['@attr'].rank), 68 | name: track.name, 69 | mbid: track.mbid === '' ? undefined : track.mbid, 70 | duration: toInt(track.duration), 71 | listeners: toInt(track.listeners), 72 | artist: { 73 | name: track.artist.name, 74 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 75 | url: track.artist.url, 76 | }, 77 | url: track.url, 78 | })), 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | import type { TrackResponse } from '@responses/index.js'; 2 | 3 | export type RequestMethod = 4 | | 'album.addTags' 5 | | 'album.getInfo' 6 | | 'album.getTopTags' 7 | | 'album.removeTag' 8 | | 'album.search' 9 | | 'artist.addTags' 10 | | 'artist.getCorrection' 11 | | 'artist.getInfo' 12 | | 'artist.getSimilar' 13 | | 'artist.getTopAlbums' 14 | | 'artist.getTopTags' 15 | | 'artist.getTopTracks' 16 | | 'artist.removeTag' 17 | | 'artist.search' 18 | | 'auth.getMobileSession' 19 | | 'auth.getSession' 20 | | 'auth.getToken' 21 | | 'chart.getTopArtists' 22 | | 'chart.getTopTags' 23 | | 'chart.getTopTracks' 24 | | 'geo.getTopArtists' 25 | | 'geo.getTopTracks' 26 | | 'tag.getInfo' 27 | | 'tag.getTopAlbums' 28 | | 'tag.getTopArtists' 29 | | 'tag.getTopTags' 30 | | 'tag.getTopTracks' 31 | | 'tag.getWeeklyChartList' 32 | | 'track.addTags' 33 | | 'track.getCorrection' 34 | | 'track.getInfo' 35 | | 'track.getSimilar' 36 | | 'track.getTopTags' 37 | | 'track.love' 38 | | 'track.removeTag' 39 | | 'track.scrobble' 40 | | 'track.search' 41 | | 'track.unlove' 42 | | 'track.updateNowPlaying' 43 | | 'user.getFriends' 44 | | 'user.getInfo' 45 | | 'user.getLovedTracks' 46 | | 'user.getPersonalTags' 47 | | 'user.getRecentTracks' 48 | | 'user.getTopAlbums' 49 | | 'user.getTopArtists' 50 | | 'user.getTopTags' 51 | | 'user.getTopTracks' 52 | | 'user.getWeeklyAlbumChart' 53 | | 'user.getWeeklyArtistChart' 54 | | 'user.getWeeklyChartList' 55 | | 'user.getWeeklyTrackList'; 56 | 57 | export interface SearchMeta { 58 | page: number; 59 | itemsPerPage: number; 60 | totalPages: number; 61 | totalResults: number; 62 | } 63 | 64 | export interface ImageType { 65 | size: string; 66 | url: string; 67 | } 68 | 69 | export interface PersonalTag { 70 | name?: string; 71 | artist?: Partial; 72 | url?: string; 73 | image?: ImageType[]; 74 | } 75 | 76 | export interface AlbumType { 77 | name: string; 78 | mbid?: string; 79 | artist: ArtistType; 80 | url: string | undefined; 81 | image: ImageType[] | undefined; 82 | } 83 | 84 | export interface ArtistType { 85 | name: string; 86 | mbid?: string; 87 | url: string | undefined; 88 | } 89 | 90 | export interface TagType { 91 | name: string; 92 | url: string | undefined; 93 | } 94 | 95 | export interface TrackType { 96 | name: string; 97 | mbid: string | undefined; 98 | artist: ArtistType | undefined; 99 | album?: AlbumType; 100 | url: string | undefined; 101 | } 102 | 103 | export interface UserType { 104 | name: string; 105 | realName: string | undefined; 106 | country: string | undefined; 107 | subscriber: boolean; 108 | type: string; 109 | registered: Date; 110 | url: string | undefined; 111 | image: ImageType[] | undefined; 112 | } 113 | 114 | export interface TrackReturnType extends TrackResponse { 115 | duration: string; 116 | '@attr': { 117 | rank: number; 118 | }; 119 | } 120 | 121 | export * from '@typings/album.type.js'; 122 | export * from '@typings/artist.type.js'; 123 | export * from '@typings/chart.type.js'; 124 | export * from '@typings/geo.type.js'; 125 | export * from '@typings/tag.type.js'; 126 | export * from '@typings/track.type.js'; 127 | export * from '@typings/user.type.js'; 128 | -------------------------------------------------------------------------------- /src/responses/user.response.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArtistResponse, 3 | AlbumResponse, 4 | AttrResponse, 5 | TagResponse, 6 | TrackResponse, 7 | UserResponse, 8 | } from '@responses/index.js'; 9 | 10 | export declare interface UserGetFriendsResponse { 11 | friends: { 12 | user: UserResponse[]; 13 | '@attr': AttrResponse & { user: string }; 14 | }; 15 | } 16 | 17 | export declare interface UserGetInfoResponse { 18 | user: UserResponse & { 19 | album_count: string; 20 | artist_count: string; 21 | playcount: string; 22 | track_count: string; 23 | }; 24 | } 25 | 26 | export declare interface UserGetLovedTracksResponse { 27 | lovedtracks: { 28 | track: Array< 29 | TrackResponse & { 30 | artist: ArtistResponse & { 31 | mbid: string; 32 | }; 33 | date: { 34 | uts: string; 35 | '#text': string; 36 | }; 37 | } 38 | >; 39 | '@attr': AttrResponse & { user: string }; 40 | }; 41 | } 42 | 43 | export declare interface UserGetPersonalTagsResponse { 44 | taggings: { 45 | albums?: { 46 | album: Array; 47 | }; 48 | artists?: { 49 | artist: ArtistResponse[]; 50 | }; 51 | tracks?: { 52 | track: Array< 53 | TrackResponse & { 54 | artist: ArtistResponse; 55 | } 56 | >; 57 | }; 58 | '@attr': AttrResponse & { user: string; tag: string }; 59 | }; 60 | } 61 | 62 | export declare interface UserGetRecentTracksResponse { 63 | recenttracks: { 64 | track: Array< 65 | TrackResponse & { 66 | artist: { 67 | mbid: string; 68 | '#text': string; 69 | }; 70 | album: { 71 | mbid: string; 72 | '#text': string; 73 | }; 74 | date: 75 | | { 76 | uts: string; 77 | '#text': string; 78 | } 79 | | undefined; 80 | '@attr'?: { nowplaying: string }; 81 | } 82 | >; 83 | '@attr': AttrResponse & { user: string }; 84 | }; 85 | } 86 | 87 | export declare interface UserGetTopAlbumsResponse { 88 | topalbums: { 89 | album: Array< 90 | AlbumResponse & { 91 | artist: ArtistResponse & { 92 | mbid: string; 93 | }; 94 | playcount: string; 95 | '@attr': { 96 | rank: string; 97 | }; 98 | } 99 | >; 100 | '@attr': AttrResponse & { user: string }; 101 | }; 102 | } 103 | 104 | export declare interface UserGetTopArtistsResponse { 105 | topartists: { 106 | artist: Array< 107 | ArtistResponse & { 108 | mbid: string; 109 | playcount: string; 110 | '@attr': { 111 | rank: number; 112 | }; 113 | } 114 | >; 115 | '@attr': AttrResponse & { user: string }; 116 | }; 117 | } 118 | 119 | export declare interface UserGetTopTagsResponse { 120 | toptags: { 121 | tag: Array< 122 | TagResponse & { 123 | count: number; 124 | url: string; 125 | } 126 | >; 127 | '@attr': { 128 | user: string; 129 | }; 130 | }; 131 | } 132 | 133 | export declare interface UserGetTopTracksResponse { 134 | toptracks: { 135 | track: Array< 136 | TrackResponse & { 137 | duration: string; 138 | artist: ArtistResponse & { 139 | mbid: string; 140 | }; 141 | '@attr': { 142 | rank: string; 143 | }; 144 | playcount: string; 145 | } 146 | >; 147 | '@attr': AttrResponse & { user: string }; 148 | }; 149 | } 150 | -------------------------------------------------------------------------------- /test/tests/artist.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import LastFMError from '../../src/utils/error.js'; 5 | import { env } from '../env.js'; 6 | import { 7 | ArtistGetInfoSchema, 8 | ArtistGetSimilarSchema, 9 | ArtistGetTopAlbumsSchema, 10 | ArtistGetTopTagsSchema, 11 | ArtistGetTopTracksSchema, 12 | } from '../schemas/artist.schema.js'; 13 | 14 | const client = new simpleFM(env.LASTFM_TOKEN); 15 | 16 | const errorMessage = 'The artist you supplied could not be found'; 17 | 18 | describe('Artist', () => { 19 | describe('getInfo', () => { 20 | it('Should return info about an artist', async () => { 21 | const data = await client.artist.getInfo({ artist: 'Nirvana' }); 22 | 23 | expect(() => ArtistGetInfoSchema.parse(data)).not.toThrow(); 24 | }); 25 | 26 | it("Should error when the artist doesn't exist", async () => { 27 | try { 28 | const data = await client.artist.getInfo({ artist: 'rj-9wugh' }); 29 | 30 | expect(() => ArtistGetInfoSchema.parse(data)).toThrow(); 31 | } catch (err) { 32 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 33 | } 34 | }); 35 | }); 36 | 37 | describe('getSimilar', () => { 38 | it('Should return similar artists from a query', async () => { 39 | const data = await client.artist.getSimilar({ artist: 'Paramore' }); 40 | 41 | expect(() => ArtistGetSimilarSchema.parse(data.artists)).not.toThrow(); 42 | }); 43 | 44 | it("Should error when the artist doesn't exist", async () => { 45 | try { 46 | const data = await client.artist.getSimilar({ artist: 'rj-9wugh' }); 47 | 48 | expect(() => ArtistGetSimilarSchema.parse(data)).toThrow(); 49 | } catch (err) { 50 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 51 | } 52 | }); 53 | }); 54 | 55 | describe('getTopAlbums', () => { 56 | it("Should return an artist's top albums", async () => { 57 | const data = await client.artist.getTopAlbums({ artist: 'blink-182' }); 58 | 59 | expect(() => ArtistGetTopAlbumsSchema.parse(data.albums)).not.toThrow(); 60 | }); 61 | 62 | it("Should error when the artist doesn't exist", async () => { 63 | try { 64 | const data = await client.artist.getTopAlbums({ artist: 'rj-9wugh' }); 65 | 66 | expect(() => ArtistGetTopAlbumsSchema.parse(data)).toThrow(); 67 | } catch (err) { 68 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 69 | } 70 | }); 71 | }); 72 | 73 | describe('getTopTags', () => { 74 | it("Should return an artist's top tags", async () => { 75 | const data = await client.artist.getTopTags({ artist: 'Porter Robinson' }); 76 | 77 | expect(() => ArtistGetTopTagsSchema.parse(data.tags)).not.toThrow(); 78 | }); 79 | 80 | it("Should error when the artist doesn't exist", async () => { 81 | try { 82 | const data = await client.artist.getTopTags({ artist: 'rj-9wugh' }); 83 | 84 | expect(() => ArtistGetTopTagsSchema.parse(data)).toThrow(); 85 | } catch (err) { 86 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 87 | } 88 | }); 89 | }); 90 | 91 | describe('getTopTracks', () => { 92 | it("Should return an artist's top tracks", async () => { 93 | const data = await client.artist.getTopTracks({ artist: 'Muse' }); 94 | 95 | expect(() => ArtistGetTopTracksSchema.parse(data.tracks)).not.toThrow(); 96 | }); 97 | it("Should error when the artist doesn't exist", async () => { 98 | try { 99 | const data = await client.artist.getTopTracks({ artist: 'rj-9wugh' }); 100 | 101 | expect(() => ArtistGetTopTracksSchema.parse(data)).toThrow(); 102 | } catch (err) { 103 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 104 | } 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/classes/chart.class.ts: -------------------------------------------------------------------------------- 1 | import Base from '~/base.js'; 2 | import { toArray, toInt } from '~/utils/caster.js'; 3 | 4 | import type { ChartGetTopArtistParams, ChartGetTopTagsParams, ChartGetTopTracksParams } from '@params/chart.params.js'; 5 | import type { 6 | ChartGetTopArtistsResponse, 7 | ChartGetTopTagsResponse, 8 | ChartGetTopTracksResponse, 9 | } from '@responses/index.js'; 10 | import type { ChartGetTopArtistsType, ChartGetTopTagsType, ChartGetTopTracksType } from '@typings/index.js'; 11 | 12 | export default class Chart extends Base { 13 | /** 14 | * Returns the most popular artists. 15 | * @param limit - The number of results to fetch per page. Defaults to 30. 16 | * @param page - The page number to fetch. Defaults to the first page. 17 | * */ 18 | async getTopArtists(params?: ChartGetTopArtistParams): Promise { 19 | const { 20 | artists: { artist: artistMatches, '@attr': attr }, 21 | } = await this.sendRequest({ 22 | method: 'chart.getTopArtists', 23 | limit: params?.limit ?? 30, 24 | page: params?.page ?? 1, 25 | }); 26 | 27 | return { 28 | search: { 29 | page: toInt(attr.page), 30 | itemsPerPage: toInt(attr.perPage), 31 | totalPages: toInt(attr.totalPages), 32 | totalResults: toInt(attr.total), 33 | }, 34 | artists: toArray(artistMatches).map((artist) => ({ 35 | name: artist.name, 36 | mbid: artist.mbid === '' ? undefined : artist.mbid, 37 | stats: { 38 | scrobbles: toInt(artist.playcount), 39 | listeners: toInt(artist.listeners), 40 | }, 41 | url: artist.url, 42 | })), 43 | }; 44 | } 45 | 46 | /** 47 | * Returns the most popular tags for tracks. 48 | * @param limit - The number of results to fetch per page. Defaults to 30. 49 | * @param page - The page number to fetch. Defaults to the first page. 50 | * */ 51 | async getTopTags(params?: ChartGetTopTagsParams): Promise { 52 | const { 53 | tags: { tag: tagMatches, '@attr': attr }, 54 | } = await this.sendRequest({ 55 | method: 'chart.getTopTags', 56 | limit: params?.limit ?? 30, 57 | page: params?.page ?? 1, 58 | }); 59 | 60 | return { 61 | search: { 62 | page: toInt(attr.page), 63 | itemsPerPage: toInt(attr.perPage), 64 | totalPages: toInt(attr.totalPages), 65 | totalResults: toInt(attr.total), 66 | }, 67 | tags: toArray(tagMatches).map((tag) => ({ 68 | name: tag.name, 69 | stats: { 70 | count: toInt(tag.taggings), 71 | reach: toInt(tag.reach), 72 | }, 73 | url: tag.url, 74 | })), 75 | }; 76 | } 77 | 78 | /** 79 | * Returns the most popular tracks. 80 | * @param limit - The number of results to fetch per page. Defaults to 30. 81 | * @param page - The page number to fetch. Defaults to the first page. 82 | * */ 83 | async getTopTracks(params?: ChartGetTopTracksParams): Promise { 84 | const { 85 | tracks: { track: trackMatches, '@attr': attr }, 86 | } = await this.sendRequest({ 87 | method: 'chart.getTopTracks', 88 | limit: params?.limit ?? 30, 89 | page: params?.page ?? 1, 90 | }); 91 | 92 | return { 93 | search: { 94 | page: toInt(attr.page), 95 | itemsPerPage: toInt(attr.perPage), 96 | totalPages: toInt(attr.totalPages), 97 | totalResults: toInt(attr.total), 98 | }, 99 | tracks: toArray(trackMatches).map((track) => ({ 100 | name: track.name, 101 | mbid: track.mbid === '' ? undefined : track.mbid, 102 | stats: { 103 | scrobbles: toInt(track.playcount), 104 | listeners: toInt(track.listeners), 105 | }, 106 | artist: { 107 | name: track.artist.name, 108 | url: track.artist.url, 109 | }, 110 | url: track.url, 111 | })), 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/classes/album.class.ts: -------------------------------------------------------------------------------- 1 | import { convertImageSizes, createLastFmURL } from '@utils/convert.js'; 2 | import Base from '~/base.js'; 3 | import { toInt, toArray, convertSearch } from '~/utils/caster.js'; 4 | 5 | import type { AlbumGetInfoParams, AlbumGetTopTagsParams, AlbumSearchParams } from '@params/index.js'; 6 | import type { AlbumGetInfoResponse, AlbumGetTopTagsResponse, AlbumSearchResponse } from '@responses/index.js'; 7 | import type { AlbumGetInfoType, AlbumGetTopTagsType, AlbumSearchType } from '@typings/index.js'; 8 | 9 | export default class Album extends Base { 10 | /** 11 | * Returns metadata information for an artist. 12 | * @param artist - The name of the artist. 13 | * @param album - The name of the album. 14 | * @param username - The username for the context of the request. If supplied, the user's playcount for this artist's album is included in the response. 15 | */ 16 | async getInfo(params: AlbumGetInfoParams): Promise { 17 | const { 18 | album, 19 | album: { 20 | tracks: { track: trackMatches }, 21 | tags: { tag: tagMatches }, 22 | }, 23 | } = await this.sendRequest({ 24 | method: 'album.getInfo', 25 | ...params, 26 | }); 27 | 28 | return { 29 | name: album.name, 30 | mbid: album.mbid === '' ? undefined : album.mbid, 31 | artist: { 32 | name: album.artist, 33 | url: createLastFmURL({ type: 'artist', value: album.artist }), 34 | }, 35 | stats: { 36 | scrobbles: toInt(album.playcount), 37 | listeners: toInt(album.listeners), 38 | }, 39 | userStats: { 40 | userPlayCount: album.userplaycount, 41 | }, 42 | tags: toArray(tagMatches).map((tag) => ({ 43 | name: tag.name, 44 | url: tag.url, 45 | })), 46 | tracks: toArray(trackMatches).map((track) => ({ 47 | rank: toInt(track['@attr'].rank), 48 | name: track.name, 49 | duration: toInt(track.duration), 50 | url: track.url, 51 | })), 52 | url: album.url, 53 | image: convertImageSizes(album.image), 54 | }; 55 | } 56 | 57 | /** 58 | * Returns popular tags for an album. 59 | * @param artist - The name of the artist. 60 | * @param album - The name of the album. 61 | */ 62 | async getTopTags(params: AlbumGetTopTagsParams): Promise { 63 | const { 64 | toptags: { tag: tagMatches, '@attr': attr }, 65 | } = await this.sendRequest({ 66 | method: 'album.getTopTags', 67 | ...params, 68 | }); 69 | 70 | return { 71 | name: attr.album, 72 | artist: { 73 | name: attr.artist, 74 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 75 | }, 76 | tags: toArray(tagMatches).map((tag) => ({ 77 | count: tag.count, 78 | name: tag.name, 79 | url: tag.url, 80 | })), 81 | }; 82 | } 83 | 84 | /** 85 | * Search for an album by name. 86 | * @param album - The name of the album. 87 | * @param limit - The number of results to fetch per page. Defaults to 30. 88 | * @param page - The page number to fetch. Defaults to the first page. 89 | * */ 90 | async search(params: AlbumSearchParams): Promise { 91 | const { 92 | results, 93 | results: { 94 | albummatches: { album: albumMatches }, 95 | }, 96 | } = await this.sendRequest({ 97 | method: 'album.search', 98 | ...params, 99 | limit: params.limit ?? 30, 100 | page: params.page ?? 1, 101 | }); 102 | 103 | return { 104 | search: convertSearch(results), 105 | albums: toArray(albumMatches).map((album) => ({ 106 | name: album.name, 107 | mbid: album.mbid === '' ? undefined : album.mbid, 108 | artist: { 109 | name: album.artist, 110 | url: createLastFmURL({ type: 'artist', value: album.artist }), 111 | }, 112 | url: album.url, 113 | image: convertImageSizes(album.image), 114 | })), 115 | }; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/classes/track.class.ts: -------------------------------------------------------------------------------- 1 | import { convertImageSizes, createLastFmURL } from '@utils/convert.js'; 2 | import Base from '~/base.js'; 3 | import { convertSearch, toArray, toBool, toInt } from '~/utils/caster.js'; 4 | 5 | import type { 6 | TrackGetInfoParams, 7 | TrackGetSimilarParams, 8 | TrackGetTopTagsParams, 9 | TrackSearchParams, 10 | } from '@params/index.js'; 11 | import type { 12 | TrackGetInfoResponse, 13 | TrackGetSimilarResponse, 14 | TrackGetTopTagsResponse, 15 | TrackSearchResponse, 16 | } from '@responses/index.js'; 17 | import type { TrackGetInfoType, TrackGetSimilarType, TrackGetTopTagsType, TrackSearchType } from '@typings/index.js'; 18 | 19 | export default class Track extends Base { 20 | /** 21 | * Returns metadata information for a track. 22 | * @param artist - The name of the artist. 23 | * @param track - The name of the track. 24 | * @param username - The username for the context of the request. If supplied, the user's playcount for this track and whether they have loved the track is included in the response. 25 | */ 26 | async getInfo(params: TrackGetInfoParams): Promise { 27 | const { 28 | track, 29 | track: { 30 | album, 31 | toptags: { tag: tagMatches }, 32 | }, 33 | } = await this.sendRequest({ 34 | method: 'track.getInfo', 35 | ...params, 36 | }); 37 | 38 | return { 39 | name: track.name, 40 | mbid: track.mbid === '' ? undefined : track.mbid, 41 | duration: toInt(track.duration), 42 | stats: { 43 | scrobbles: toInt(track.playcount), 44 | listeners: toInt(track.listeners), 45 | }, 46 | userStats: { 47 | userLoved: toBool(track.userloved), 48 | userPlayCount: track.userplaycount ? toInt(track.userplaycount) : undefined, 49 | }, 50 | artist: { 51 | name: track.artist.name, 52 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 53 | url: track.artist.url, 54 | }, 55 | album: 56 | album === undefined 57 | ? undefined 58 | : { 59 | position: album['@attr'] ? toInt(album['@attr'].position) : undefined, 60 | name: album.title, 61 | mbid: album.mbid === '' ? undefined : album.mbid, 62 | image: convertImageSizes(album.image), 63 | url: album.url, 64 | }, 65 | tags: toArray(tagMatches).map((tag) => ({ 66 | name: tag.name, 67 | url: tag.url, 68 | })), 69 | url: track.url, 70 | }; 71 | } 72 | 73 | /** 74 | * Returns similar tracks for this track. 75 | * @param artist - The name of the artist. 76 | * @param track - The name of the track. 77 | * @param limit - The number of results to fetch per page. Defaults to 30. 78 | */ 79 | async getSimilar(params: TrackGetSimilarParams): Promise { 80 | const { 81 | similartracks: { track: trackMatches, '@attr': attr }, 82 | } = await this.sendRequest({ 83 | method: 'track.getSimilar', 84 | ...params, 85 | limit: params.limit ?? 30, 86 | }); 87 | 88 | return { 89 | name: params.track, 90 | artist: { 91 | name: attr.artist, 92 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 93 | }, 94 | url: createLastFmURL({ type: 'track', value: attr.artist, track: params.track }), 95 | tracks: toArray(trackMatches).map((track) => ({ 96 | match: toInt(track.match), 97 | name: track.name, 98 | mbid: track.mbid === '' ? undefined : track.mbid, 99 | duration: toInt(track.duration), 100 | scrobbles: toInt(track.playcount), 101 | artist: { 102 | name: track.artist.name, 103 | url: track.artist.url, 104 | }, 105 | url: track.url, 106 | image: convertImageSizes(track.image), 107 | })), 108 | }; 109 | } 110 | 111 | /** 112 | * Returns popular tags for a track. 113 | * @param artist - The name of the artist. 114 | * @param track - The name of the track. 115 | */ 116 | async getTopTags(params: TrackGetTopTagsParams): Promise { 117 | const { 118 | toptags: { tag: tagMatches, '@attr': attr }, 119 | } = await this.sendRequest({ 120 | method: 'track.getTopTags', 121 | ...params, 122 | }); 123 | 124 | return { 125 | name: attr.track, 126 | artist: { 127 | name: attr.artist, 128 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 129 | }, 130 | url: createLastFmURL({ type: 'track', value: attr.artist, track: attr.track }), 131 | tags: toArray(tagMatches).map((tag) => ({ 132 | count: toInt(tag.count), 133 | name: tag.name, 134 | url: tag.url, 135 | })), 136 | }; 137 | } 138 | 139 | /** 140 | * Search for a track by name. 141 | * @param track - The name of the track. 142 | * @param limit - The number of results to fetch per page. Defaults to 30. 143 | * @param page - The page number to fetch. Defaults to the first page. 144 | * */ 145 | async search(params: TrackSearchParams): Promise { 146 | const { 147 | results, 148 | results: { 149 | trackmatches: { track: trackMatches }, 150 | }, 151 | } = await this.sendRequest({ 152 | method: 'track.search', 153 | ...params, 154 | limit: params.limit ?? 30, 155 | page: params.page ?? 1, 156 | }); 157 | 158 | return { 159 | search: { 160 | ...convertSearch(results), 161 | query: params.track, 162 | }, 163 | tracks: toArray(trackMatches).map((track) => ({ 164 | name: track.name, 165 | mbid: track.mbid === '' ? undefined : track.mbid, 166 | listeners: toInt(track.listeners), 167 | artist: { 168 | name: track.artist, 169 | url: createLastFmURL({ type: 'artist', value: track.artist }), 170 | }, 171 | url: track.url, 172 | })), 173 | }; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/classes/tag.class.ts: -------------------------------------------------------------------------------- 1 | import { convertImageSizes, createLastFmURL } from '@utils/convert.js'; 2 | import Base from '~/base.js'; 3 | import { toArray, toInt } from '~/utils/caster.js'; 4 | 5 | import type { 6 | TagGetInfoParams, 7 | TagGetTopAlbumsParams, 8 | TagGetTopArtistsParams, 9 | TagGetTopTracksParams, 10 | TagGetWeeklyChartListParams, 11 | } from '@params/index.js'; 12 | import type { 13 | TagGetInfoResponse, 14 | TagGetTopAlbumsResponse, 15 | TagGetTopArtistsResponse, 16 | TagGetTopTracksResponse, 17 | TagGetWeeklyChartListResponse, 18 | } from '@responses/index.js'; 19 | import type { 20 | TagGetInfoType, 21 | TagGetTopAlbumsType, 22 | TagGetTopArtistsType, 23 | TagGetTopTracksType, 24 | TagGetWeeklyChartListType, 25 | } from '@typings/index.js'; 26 | 27 | export default class Tag extends Base { 28 | /** 29 | * Returns metadata information on a tag. 30 | * @param tag - The name of the tag. 31 | * */ 32 | async getInfo(params: TagGetInfoParams): Promise { 33 | const { tag } = await this.sendRequest({ 34 | method: 'tag.getInfo', 35 | tag: params.tag, 36 | }); 37 | 38 | return { 39 | name: tag.name, 40 | description: tag.wiki.summary, 41 | stats: { 42 | count: tag.total, 43 | reach: tag.reach, 44 | }, 45 | url: createLastFmURL({ type: 'tag', value: tag.name }), 46 | }; 47 | } 48 | 49 | /** 50 | * Returns popular albums that are tagged by a tag name. 51 | * @param tag - The name of the tag. 52 | * @param limit - The number of results to fetch per page. Defaults to 50. 53 | * @param page - The page number to fetch. Defaults to the first page. 54 | * */ 55 | async getTopAlbums(params: TagGetTopAlbumsParams): Promise { 56 | const { 57 | albums: { album: albumMatches, '@attr': attr }, 58 | } = await this.sendRequest({ 59 | method: 'tag.getTopAlbums', 60 | ...params, 61 | limit: params.limit ?? 50, 62 | page: params.page ?? 1, 63 | }); 64 | 65 | return { 66 | search: { 67 | tag: attr.tag, 68 | page: toInt(attr.page), 69 | itemsPerPage: toInt(attr.perPage), 70 | totalPages: toInt(attr.totalPages), 71 | totalResults: toInt(attr.total), 72 | }, 73 | albums: toArray(albumMatches).map((album) => ({ 74 | rank: toInt(album['@attr'].rank), 75 | name: album.name, 76 | mbid: album.mbid === '' ? undefined : album.mbid, 77 | artist: { 78 | name: album.artist.name, 79 | mbid: album.artist.mbid === '' ? undefined : album.artist.mbid, 80 | url: album.artist.url, 81 | }, 82 | url: createLastFmURL({ type: 'album', value: album.artist.name, album: album.name }), 83 | image: convertImageSizes(album.image), 84 | })), 85 | }; 86 | } 87 | 88 | /** 89 | * Returns popular artists that are tagged by a tag name. 90 | * @param tag - The name of the tag. 91 | * @param limit - The number of results to fetch per page. Defaults to 50. 92 | * @param page - The page number to fetch. Defaults to the first page. 93 | * */ 94 | async getTopArtists(params: TagGetTopArtistsParams): Promise { 95 | const { 96 | topartists: { artist: artistMatches, '@attr': attr }, 97 | } = await this.sendRequest({ 98 | method: 'tag.getTopArtists', 99 | ...params, 100 | limit: params.limit ?? 50, 101 | page: params.page ?? 1, 102 | }); 103 | 104 | return { 105 | search: { 106 | tag: attr.tag, 107 | page: toInt(attr.page), 108 | itemsPerPage: toInt(attr.perPage), 109 | totalPages: toInt(attr.totalPages), 110 | totalResults: toInt(attr.total), 111 | }, 112 | artists: toArray(artistMatches).map((artist) => ({ 113 | rank: Number(artist['@attr'].rank), 114 | name: artist.name, 115 | mbid: artist.mbid === '' ? undefined : artist.mbid, 116 | url: artist.url, 117 | })), 118 | }; 119 | } 120 | 121 | /** 122 | * Returns popular tracks that are tagged by a tag name. 123 | * @param tag - The name of the tag. 124 | * @param limit - The number of results to fetch per page. Defaults to 50. 125 | * @param page - The page number to fetch. Defaults to the first page. 126 | * */ 127 | async getTopTracks(params: TagGetTopTracksParams): Promise { 128 | const { 129 | tracks: { track: trackMatches, '@attr': attr }, 130 | } = await this.sendRequest({ 131 | method: 'tag.getTopTracks', 132 | ...params, 133 | limit: params.limit ?? 50, 134 | page: params.page ?? 1, 135 | }); 136 | 137 | return { 138 | search: { 139 | tag: attr.tag, 140 | page: toInt(attr.page), 141 | itemsPerPage: toInt(attr.perPage), 142 | totalPages: toInt(attr.totalPages), 143 | totalResults: toInt(attr.total), 144 | }, 145 | tracks: toArray(trackMatches).map((track) => ({ 146 | rank: toInt(track['@attr'].rank), 147 | name: track.name, 148 | mbid: track.mbid === '' ? undefined : track.mbid, 149 | duration: toInt(track.duration), 150 | artist: { 151 | name: track.artist.name, 152 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 153 | url: track.artist.url, 154 | }, 155 | url: track.url, 156 | })), 157 | }; 158 | } 159 | 160 | /** 161 | * Returns a list of available charts for a tag. 162 | * @param tag - The name of the tag. 163 | * */ 164 | async getWeeklyChartList(params: TagGetWeeklyChartListParams): Promise { 165 | const { 166 | weeklychartlist: { chart: chartMatches, '@attr': attr }, 167 | } = await this.sendRequest({ 168 | method: 'tag.getWeeklyChartList', 169 | ...params, 170 | }); 171 | 172 | return { 173 | search: { 174 | tag: attr.tag, 175 | }, 176 | positions: toArray(chartMatches).map((chart) => ({ 177 | from: new Date(toInt(chart.from) * 1000), 178 | to: new Date(toInt(chart.to) * 1000), 179 | })), 180 | }; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /test/tests/user.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import simpleFM from '../../src/index.js'; 4 | import LastFMError from '../../src/utils/error.js'; 5 | import { env } from '../env.js'; 6 | import { 7 | UserGetFriendsSchema, 8 | UserGetInfoSchema, 9 | UserGetLovedTracksSchema, 10 | UserGetPersonalTagsSchema, 11 | UserGetRecentTracksSchema, 12 | UserGetTopAlbumsSchema, 13 | UserGetTopArtistsSchema, 14 | UserGetTopTagsSchema, 15 | UserGetTopTracksSchema, 16 | } from '../schemas/user.schema.js'; 17 | 18 | const client = new simpleFM(env.LASTFM_TOKEN); 19 | 20 | const errorMessage = 'User not found'; 21 | 22 | describe('User', () => { 23 | describe('getInfo', () => { 24 | it('Should return info about a user', async () => { 25 | const data = await client.user.getInfo({ username: 'solelychloe' }); 26 | 27 | expect(() => UserGetInfoSchema.parse(data)).not.toThrow(); 28 | }); 29 | 30 | it("Should error when the user doesn't exist", async () => { 31 | try { 32 | const data = await client.user.getInfo({ username: '102edgreth' }); 33 | 34 | expect(() => UserGetInfoSchema.parse(data)).toThrow(); 35 | } catch (err) { 36 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 37 | } 38 | }); 39 | }); 40 | 41 | describe('getFriends', () => { 42 | it('Should return a list of friends for a user', async () => { 43 | const data = await client.user.getFriends({ username: 'megumin' }); 44 | 45 | expect(() => UserGetFriendsSchema.parse(data.friends)).not.toThrow(); 46 | }); 47 | 48 | it("Should error when the user doesn't exist", async () => { 49 | try { 50 | const data = await client.user.getFriends({ username: '102edgreth' }); 51 | 52 | expect(() => UserGetFriendsSchema.parse(data)).toThrow(); 53 | } catch (err) { 54 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 55 | } 56 | }); 57 | }); 58 | 59 | describe('getLovedTracks', () => { 60 | it("Should return a user's loved tracks", async () => { 61 | const data = await client.user.getLovedTracks({ username: 'Ovyerus' }); 62 | 63 | expect(() => UserGetLovedTracksSchema.parse(data.tracks)).not.toThrow(); 64 | }); 65 | 66 | it("Should error when the user doesn't exist", async () => { 67 | try { 68 | const data = await client.user.getLovedTracks({ username: '102edgreth' }); 69 | 70 | expect(() => UserGetLovedTracksSchema.parse(data)).toThrow(); 71 | } catch (err) { 72 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 73 | } 74 | }); 75 | }); 76 | 77 | describe('getPersonalTags', () => { 78 | it("Should return a user's personal tags", async () => { 79 | const data = await client.user.getPersonalTags({ username: 'rj', tag: 'rock', taggingtype: 'artist' }); 80 | 81 | expect(() => UserGetPersonalTagsSchema.parse(data.response)).not.toThrow(); 82 | }); 83 | 84 | it("Should error when the user doesn't exist", async () => { 85 | try { 86 | const data = await client.user.getPersonalTags({ 87 | username: 'sawdesrtyuilk;jjhgf', 88 | tag: 'mrrow', 89 | taggingtype: 'album', 90 | }); 91 | 92 | expect(() => UserGetPersonalTagsSchema.parse(data)).toThrow(); 93 | } catch (err) { 94 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 95 | } 96 | }); 97 | }); 98 | 99 | describe('getRecentTracks', () => { 100 | it('Should return a list of recent tracks listened by this user', async () => { 101 | const data = await client.user.getRecentTracks({ username: 'solelychloe' }); 102 | 103 | expect(() => UserGetRecentTracksSchema.parse(data.tracks)).not.toThrow(); 104 | }); 105 | 106 | it("Should error when the user doesn't exist", async () => { 107 | try { 108 | const data = await client.user.getRecentTracks({ username: '102edgreth' }); 109 | 110 | expect(() => UserGetRecentTracksSchema.parse(data)).toThrow(); 111 | } catch (err) { 112 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 113 | } 114 | }); 115 | }); 116 | 117 | describe('getTopAlbums', () => { 118 | it('Should return a list of the top listened albums for this user', async () => { 119 | const data = await client.user.getTopAlbums({ username: 'kotdev' }); 120 | 121 | expect(() => UserGetTopAlbumsSchema.parse(data.albums)).not.toThrow(); 122 | }); 123 | 124 | it("Should error when the user doesn't exist", async () => { 125 | try { 126 | const data = await client.user.getTopAlbums({ username: '102edgreth' }); 127 | 128 | expect(() => UserGetTopAlbumsSchema.parse(data)).toThrow(); 129 | } catch (err) { 130 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 131 | } 132 | }); 133 | }); 134 | 135 | describe('getArtists', () => { 136 | it('Should return a list of the top listened artists by this user', async () => { 137 | const data = await client.user.getTopArtists({ username: 'lewisakura' }); 138 | 139 | expect(() => UserGetTopArtistsSchema.parse(data.artists)).not.toThrow(); 140 | }); 141 | 142 | it("Should error when the user doesn't exist", async () => { 143 | try { 144 | const data = await client.user.getTopArtists({ username: '102edgreth' }); 145 | 146 | expect(() => UserGetTopArtistsSchema.parse(data)).toThrow(); 147 | } catch (err) { 148 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 149 | } 150 | }); 151 | }); 152 | 153 | describe('getTopTags', () => { 154 | it('Should return a list of the top listened tracks/albums by tags for this user', async () => { 155 | const data = await client.user.getTopTags({ username: 'rj' }); 156 | 157 | expect(() => UserGetTopTagsSchema.parse(data.tags)).not.toThrow(); 158 | }); 159 | 160 | it("Should error when the user doesn't exist", async () => { 161 | try { 162 | const data = await client.user.getTopTags({ username: '102edgreth' }); 163 | 164 | expect(() => UserGetTopTagsSchema.parse(data)).toThrow(); 165 | } catch (err) { 166 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 167 | } 168 | }); 169 | }); 170 | 171 | describe('getTopTracks', () => { 172 | it('Should return a list of the top listened tracks for this user', async () => { 173 | const data = await client.user.getTopTracks({ username: 'Vininator' }); 174 | 175 | expect(() => UserGetTopTracksSchema.parse(data.tracks)).not.toThrow(); 176 | }); 177 | 178 | it("Should error when the user doesn't exist", async () => { 179 | try { 180 | const data = await client.user.getTopTracks({ username: '102edgreth' }); 181 | 182 | expect(() => UserGetTopTracksSchema.parse(data)).toThrow(); 183 | } catch (err) { 184 | if (err instanceof LastFMError) expect(err.message).toEqual(errorMessage); 185 | } 186 | }); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /src/classes/artist.class.ts: -------------------------------------------------------------------------------- 1 | import { convertImageSizes, createLastFmURL } from '@utils/convert.js'; 2 | import Base from '~/base.js'; 3 | import { convertSearch, sanitizeBio, toArray, toBool, toFloat, toInt } from '~/utils/caster.js'; 4 | 5 | import type { 6 | ArtistGetInfoParams, 7 | ArtistGetSimilarParams, 8 | ArtistGetTopAlbumsParams, 9 | ArtistGetTopTagsParams, 10 | ArtistGetTopTracksParams, 11 | ArtistSearchParams, 12 | } from '@params/index.js'; 13 | import type { 14 | ArtistGetInfoResponse, 15 | ArtistGetSimilarResponse, 16 | ArtistGetTopAlbumsResponse, 17 | ArtistGetTopTagsResponse, 18 | ArtistGetTopTracksResponse, 19 | ArtistSearchResponse, 20 | } from '@responses/index.js'; 21 | import type { 22 | ArtistGetInfoType, 23 | ArtistSearchType, 24 | ArtistGetSimilarType, 25 | ArtistGetTopAlbumsType, 26 | ArtistGetTopTagsType, 27 | ArtistGetTopTracksType, 28 | } from '@typings/index.js'; 29 | 30 | export default class Artist extends Base { 31 | /** 32 | * Returns metadata information for an artist. 33 | * @param artist - The name of the artist. 34 | * @param username - The username for the context of the request. If supplied, the user's playcount for this artist is included in the response. 35 | * */ 36 | async getInfo(params: ArtistGetInfoParams): Promise { 37 | const { artist } = await this.sendRequest({ 38 | method: 'artist.getInfo', 39 | ...params, 40 | }); 41 | 42 | return { 43 | name: artist.name, 44 | mbid: artist.mbid === '' ? undefined : artist.mbid, 45 | onTour: toBool(artist.ontour), 46 | stats: { 47 | scrobbles: toInt(artist.stats.playcount), 48 | listeners: toInt(artist.stats.listeners), 49 | }, 50 | userStats: { 51 | userPlayCount: artist.stats.userplaycount ? toInt(artist.stats.userplaycount) : undefined, 52 | }, 53 | tags: toArray(artist.tags.tag).map((tag) => ({ 54 | name: tag.name, 55 | url: tag.url, 56 | })), 57 | bio: { 58 | summary: sanitizeBio(artist.bio.summary), 59 | extended: sanitizeBio(artist.bio.content), 60 | published: new Date(`${artist.bio.published} UTC`), 61 | url: artist.bio.links.link.href, 62 | }, 63 | similarArtists: toArray(artist.similar.artist).map((artist) => ({ 64 | name: artist.name, 65 | image: convertImageSizes(artist.image), 66 | url: artist.url, 67 | })), 68 | url: artist.url, 69 | }; 70 | } 71 | 72 | /** 73 | * Returns similar artists to this artist. 74 | * @param artist - The name of the artist. 75 | * @param limit - The number of results to fetch per page. Defaults to 30. 76 | * */ 77 | async getSimilar(params: ArtistGetSimilarParams): Promise { 78 | const { 79 | similarartists: { artist: artistMatches, '@attr': attr }, 80 | } = await this.sendRequest({ 81 | method: 'artist.getSimilar', 82 | ...params, 83 | limit: params.limit ?? 30, 84 | }); 85 | 86 | return { 87 | search: { 88 | artist: { 89 | name: attr.artist, 90 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 91 | }, 92 | }, 93 | artists: toArray(artistMatches).map((artist) => ({ 94 | match: toFloat(artist.match), 95 | name: artist.name, 96 | mbid: artist.mbid === '' ? undefined : artist.mbid, 97 | url: artist.url, 98 | })), 99 | }; 100 | } 101 | 102 | /** 103 | * Returns popular albums for an artist. 104 | * @param artist - The name of the artist. 105 | * @param limit - The number of results to fetch per page. Defaults to 50. 106 | * @param page - The page number to fetch. Defaults to the first page. 107 | * */ 108 | async getTopAlbums(params: ArtistGetTopAlbumsParams): Promise { 109 | const { 110 | topalbums: { album: albumMatches, '@attr': attr }, 111 | } = await this.sendRequest({ 112 | method: 'artist.getTopAlbums', 113 | ...params, 114 | limit: params.limit ?? 50, 115 | page: params.page ?? 1, 116 | }); 117 | 118 | return { 119 | search: { 120 | artist: { 121 | name: attr.artist, 122 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 123 | }, 124 | page: toInt(attr.page), 125 | itemsPerPage: toInt(attr.perPage), 126 | totalPages: toInt(attr.totalPages), 127 | totalResults: toInt(attr.total), 128 | }, 129 | albums: toArray(albumMatches).map((album) => ({ 130 | name: album.name, 131 | scrobbles: toInt(album.playcount), 132 | artist: { 133 | name: album.artist.name, 134 | url: album.artist.url, 135 | }, 136 | url: album.url, 137 | image: convertImageSizes(album.image), 138 | })), 139 | }; 140 | } 141 | 142 | /** 143 | * Returns popular tags for an artist. 144 | * @param artist - The name of the artist. 145 | * */ 146 | async getTopTags(params: ArtistGetTopTagsParams): Promise { 147 | const { 148 | toptags: { tag: tagMatches, '@attr': attr }, 149 | } = await this.sendRequest({ 150 | method: 'artist.getTopTags', 151 | ...params, 152 | }); 153 | 154 | return { 155 | search: { 156 | artist: { 157 | name: attr.artist, 158 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 159 | }, 160 | }, 161 | tags: toArray(tagMatches).map((tag) => ({ 162 | count: tag.count, 163 | name: tag.name, 164 | url: tag.url, 165 | })), 166 | }; 167 | } 168 | 169 | /** 170 | * Returns popular tracks for an artist. 171 | * @param artist - The name of the artist. 172 | * @param limit - The number of results to fetch per page. Defaults to 50. 173 | * @param page - The page number to fetch. Defaults to the first page. 174 | * */ 175 | async getTopTracks(params: ArtistGetTopTracksParams): Promise { 176 | const { 177 | toptracks: { track: trackMatches, '@attr': attr }, 178 | } = await this.sendRequest({ 179 | method: 'artist.getTopTracks', 180 | ...params, 181 | limit: params.limit ?? 50, 182 | page: params.page ?? 1, 183 | }); 184 | 185 | return { 186 | search: { 187 | artist: { 188 | name: attr.artist, 189 | url: createLastFmURL({ type: 'artist', value: attr.artist }), 190 | }, 191 | page: toInt(attr.page), 192 | itemsPerPage: toInt(attr.perPage), 193 | totalPages: toInt(attr.totalPages), 194 | totalResults: toInt(attr.total), 195 | }, 196 | tracks: trackMatches.map((track) => ({ 197 | rank: toInt(track['@attr'].rank), 198 | name: track.name, 199 | mbid: track.mbid === '' ? undefined : track.mbid, 200 | artist: { 201 | name: track.artist.name, 202 | url: track.artist.url, 203 | }, 204 | stats: { 205 | scrobbles: toInt(track.playcount), 206 | listeners: toInt(track.listeners), 207 | }, 208 | url: track.url, 209 | })), 210 | }; 211 | } 212 | 213 | /** 214 | * Search for an artist by name. 215 | * @param artist - The name of the artist. 216 | * @param limit - The number of results to fetch per page. Defaults to 30. 217 | * @param page - The page number to fetch. Defaults to the first page. 218 | * */ 219 | async search(params: ArtistSearchParams): Promise { 220 | const { 221 | results, 222 | results: { 223 | artistmatches: { artist: artistMatches }, 224 | }, 225 | } = await this.sendRequest({ 226 | method: 'artist.search', 227 | ...params, 228 | limit: params.limit ?? 30, 229 | page: params.page ?? 1, 230 | }); 231 | 232 | return { 233 | search: convertSearch(results), 234 | artists: toArray(artistMatches).map((artist) => ({ 235 | name: artist.name, 236 | mbid: artist.mbid === '' ? undefined : artist.mbid, 237 | listeners: toInt(artist.listeners), 238 | url: artist.url, 239 | })), 240 | }; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/classes/user.class.ts: -------------------------------------------------------------------------------- 1 | import { convertImageSizes, createLastFmURL } from '@utils/convert.js'; 2 | import Base from '~/base.js'; 3 | import { toArray, toBool, toInt } from '~/utils/caster.js'; 4 | 5 | import type { 6 | UserGetFriendsParams, 7 | UserGetInfoParams, 8 | UserGetLovedTracksParams, 9 | UserGetPersonalTagsParams, 10 | UserGetRecentTracksParams, 11 | UserGetTopAlbumsParams, 12 | UserGetTopArtistsParams, 13 | UserGetTopTagsParams, 14 | UserGetTopTracksParams, 15 | } from '@params/index.js'; 16 | import type { 17 | UserGetFriendsResponse, 18 | UserGetInfoResponse, 19 | UserGetLovedTracksResponse, 20 | UserGetPersonalTagsResponse, 21 | UserGetRecentTracksResponse, 22 | UserGetTopAlbumsResponse, 23 | UserGetTopArtistsResponse, 24 | UserGetTopTagsResponse, 25 | UserGetTopTracksResponse, 26 | } from '@responses/index.js'; 27 | import type { 28 | UserGetInfoType, 29 | UserGetLovedTracksType, 30 | UserGetPersonalTagsType, 31 | UserGetRecentTracksType, 32 | UserGetTopAlbumsType, 33 | UserGetTopArtistsType, 34 | UserGetTopTagsType, 35 | UserGetTopTracksType, 36 | UserGetFriendsType, 37 | } from '@typings/index.js'; 38 | 39 | export default class User extends Base { 40 | /** 41 | * Returns a list of the user's friends. 42 | * @param username - The name of the user. 43 | * @param limit - The number of results to fetch per page. Defaults to 50. 44 | * @param page - The page number to fetch. Defaults to the first page. 45 | * */ 46 | async getFriends(params: UserGetFriendsParams): Promise { 47 | const { 48 | friends: { user: userMatches, '@attr': attr }, 49 | } = await this.sendRequest({ 50 | method: 'user.getFriends', 51 | ...params, 52 | limit: params.limit ?? 50, 53 | page: params.page ?? 1, 54 | }); 55 | 56 | return { 57 | search: { 58 | user: attr.user, 59 | page: toInt(attr.page), 60 | itemsPerPage: toInt(attr.perPage), 61 | totalPages: toInt(attr.totalPages), 62 | totalResults: toInt(attr.total), 63 | }, 64 | friends: toArray(userMatches).map((user) => ({ 65 | name: user.name, 66 | realName: user.realname === '' ? undefined : user.realname, 67 | country: user.country === 'None' ? undefined : user.country, 68 | type: user.type, 69 | subscriber: toBool(user.subscriber), 70 | registered: new Date(toInt(user.registered.unixtime) * 1000), 71 | url: user.url, 72 | image: convertImageSizes(user.image), 73 | })), 74 | }; 75 | } 76 | 77 | /** 78 | * Returns information about a user's profile. 79 | * @param username - The name of the user. 80 | * */ 81 | async getInfo(params: UserGetInfoParams): Promise { 82 | const { user } = await this.sendRequest({ 83 | method: 'user.getInfo', 84 | ...params, 85 | }); 86 | 87 | return { 88 | name: user.name, 89 | realName: user.realname === '' ? undefined : user.realname, 90 | country: user.country === 'None' ? undefined : user.country, 91 | type: user.type, 92 | subscriber: toBool(user.subscriber), 93 | registered: new Date(user.registered['#text'] * 1000), 94 | stats: { 95 | albumCount: toInt(user.album_count), 96 | artistCount: toInt(user.artist_count), 97 | playCount: toInt(user.playcount), 98 | trackCount: toInt(user.track_count), 99 | }, 100 | url: user.url, 101 | image: convertImageSizes(user.image), 102 | }; 103 | } 104 | 105 | /** 106 | * Returns the loved tracks as set by the user. 107 | * @param username - The name of the user. 108 | * @param limit - The number of results to fetch per page. Defaults to 50. 109 | * @param page - The page number to fetch. Defaults to the first page. 110 | * */ 111 | async getLovedTracks(params: UserGetLovedTracksParams): Promise { 112 | const { 113 | lovedtracks: { track: trackMatches, '@attr': attr }, 114 | } = await this.sendRequest({ 115 | method: 'user.getLovedTracks', 116 | ...params, 117 | limit: params.limit ?? 50, 118 | page: params.page ?? 1, 119 | }); 120 | 121 | return { 122 | search: { 123 | user: attr.user, 124 | page: toInt(attr.page), 125 | itemsPerPage: toInt(attr.perPage), 126 | totalPages: toInt(attr.totalPages), 127 | totalResults: toInt(attr.total), 128 | }, 129 | tracks: toArray(trackMatches).map((track) => ({ 130 | name: track.name, 131 | mbid: track.mbid === '' ? undefined : track.mbid, 132 | date: new Date(toInt(track.date.uts) * 1000), 133 | artist: { 134 | name: track.artist.name, 135 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 136 | url: track.artist.url, 137 | }, 138 | url: track.url, 139 | })), 140 | }; 141 | } 142 | 143 | /** 144 | * Returns a list of the user's personal tags. 145 | * @param username - The name of the user. 146 | * @param tag - The name of the tag. 147 | * @param tagType - The type of items which have been tagged. 148 | * */ 149 | async getPersonalTags(params: UserGetPersonalTagsParams): Promise { 150 | const { 151 | taggings: { 152 | albums: { album: albumMatches } = { album: undefined }, 153 | artists: { artist: artistMatches } = { artist: undefined }, 154 | tracks: { track: trackMatches } = { track: undefined }, 155 | '@attr': attr, 156 | }, 157 | } = await this.sendRequest({ 158 | method: 'user.getPersonalTags', 159 | ...params, 160 | }); 161 | const responseTypes = { 162 | album: toArray(albumMatches).map((album) => ({ 163 | name: album?.name, 164 | mbid: album?.mbid === '' ? undefined : album?.mbid, 165 | artist: { 166 | name: album?.artist.name, 167 | mbid: album?.artist.mbid === '' ? undefined : album?.artist.mbid, 168 | url: album?.artist.url, 169 | }, 170 | url: album?.url, 171 | })), 172 | 173 | artist: toArray(artistMatches).map((artist) => ({ 174 | name: artist?.name, 175 | mbid: artist?.mbid === '' ? undefined : artist?.mbid, 176 | url: artist?.url, 177 | })), 178 | 179 | track: toArray(trackMatches).map((track) => ({ 180 | name: track?.name, 181 | mbid: track?.mbid === '' ? undefined : track?.mbid, 182 | artist: { 183 | name: track?.artist.name, 184 | mbid: track?.artist.mbid === '' ? undefined : track?.artist.mbid, 185 | url: track?.artist.url, 186 | }, 187 | url: track?.url, 188 | })), 189 | }; 190 | 191 | return { 192 | search: { 193 | user: attr.user, 194 | tag: attr.tag, 195 | page: toInt(attr.page), 196 | itemsPerPage: toInt(attr.perPage), 197 | totalPages: toInt(attr.totalPages), 198 | totalResults: toInt(attr.total), 199 | }, 200 | response: responseTypes[params.taggingtype], 201 | }; 202 | } 203 | 204 | /** 205 | * Returns the most recent tracks listened by the user. 206 | * @param username - The name of the user. 207 | * @param limit - The number of results to fetch per page. Defaults to 50. Maximum is 200. 208 | * @param page - The page number to fetch. Defaults to the first page. 209 | * */ 210 | async getRecentTracks(params: UserGetRecentTracksParams): Promise { 211 | const { 212 | recenttracks: { track: trackMatches, '@attr': attr }, 213 | } = await this.sendRequest({ 214 | method: 'user.getRecentTracks', 215 | ...params, 216 | limit: params.limit ?? 50, 217 | page: params.page ?? 1, 218 | }); 219 | 220 | return { 221 | search: { 222 | user: attr.user, 223 | nowPlaying: !!toBool(trackMatches?.[0]?.['@attr']?.nowplaying), 224 | page: toInt(attr.page), 225 | itemsPerPage: toInt(attr.perPage), 226 | totalPages: toInt(attr.totalPages), 227 | totalResults: toInt(attr.total), 228 | }, 229 | tracks: toArray(trackMatches).map((track) => ({ 230 | dateAdded: track.date ? new Date(toInt(track.date.uts) * 1000) : undefined, 231 | name: track.name, 232 | mbid: track.mbid === '' ? undefined : track.mbid, 233 | artist: { 234 | name: track.artist['#text'], 235 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 236 | url: createLastFmURL({ type: 'artist', value: track.artist['#text'] }), 237 | }, 238 | album: { 239 | name: track.album['#text'], 240 | mbid: track.album.mbid === '' ? undefined : track.album.mbid, 241 | }, 242 | url: track.url, 243 | image: convertImageSizes(track.image), 244 | })), 245 | }; 246 | } 247 | 248 | /** 249 | * Returns a list of popular albums in a user's library. 250 | * @param username - The name of the user. 251 | * @param limit - The number of results to fetch per page. Defaults to 50. 252 | * @param page - The page number to fetch. Defaults to the first page. 253 | * */ 254 | async getTopAlbums(params: UserGetTopAlbumsParams): Promise { 255 | const { 256 | topalbums: { album: albumMatches, '@attr': attr }, 257 | } = await this.sendRequest({ 258 | method: 'user.getTopAlbums', 259 | ...params, 260 | limit: params.limit ?? 50, 261 | page: params.page ?? 1, 262 | }); 263 | 264 | return { 265 | search: { 266 | user: attr.user, 267 | page: toInt(attr.page), 268 | itemsPerPage: toInt(attr.perPage), 269 | totalPages: toInt(attr.totalPages), 270 | totalResults: toInt(attr.total), 271 | }, 272 | albums: toArray(albumMatches).map((album) => ({ 273 | rank: toInt(album['@attr'].rank), 274 | name: album.name, 275 | mbid: album.mbid === '' ? undefined : album.mbid, 276 | playCount: toInt(album.playcount), 277 | artist: { 278 | name: album.artist.name, 279 | mbid: album.artist.mbid === '' ? undefined : album.artist.mbid, 280 | url: album.artist.url, 281 | }, 282 | url: album.url, 283 | image: convertImageSizes(album.image), 284 | })), 285 | }; 286 | } 287 | 288 | /** 289 | * Returns a list of popular artists in a user's library. 290 | * @param username - The name of the user. 291 | * @param limit - The number of results to fetch per page. Defaults to 50. 292 | * @param page - The page number to fetch. Defaults to the first page. 293 | * */ 294 | async getTopArtists(params: UserGetTopArtistsParams): Promise { 295 | const { 296 | topartists: { artist: artistMatches, '@attr': attr }, 297 | } = await this.sendRequest({ 298 | method: 'user.getTopArtists', 299 | ...params, 300 | limit: params.limit ?? 50, 301 | page: params.page ?? 1, 302 | }); 303 | 304 | return { 305 | search: { 306 | user: attr.user, 307 | page: toInt(attr.page), 308 | itemsPerPage: toInt(attr.perPage), 309 | totalPages: toInt(attr.totalPages), 310 | totalResults: toInt(attr.total), 311 | }, 312 | artists: toArray(artistMatches).map((artist) => ({ 313 | rank: toInt(artist['@attr'].rank), 314 | name: artist.name, 315 | mbid: artist.mbid === '' ? undefined : artist.mbid, 316 | scrobbles: toInt(artist.playcount), 317 | url: artist.url, 318 | })), 319 | }; 320 | } 321 | 322 | /** 323 | * Returns a list of all the tags used by the user. 324 | * @param username - The name of the user. 325 | * @param limit - The number of results to fetch per page. Defaults to 50. 326 | * */ 327 | async getTopTags(params: UserGetTopTagsParams): Promise { 328 | const { 329 | toptags: { tag: tagMatches, '@attr': attr }, 330 | } = await this.sendRequest({ 331 | method: 'user.getTopTags', 332 | ...params, 333 | limit: params.limit ?? 50, 334 | }); 335 | 336 | return { 337 | search: { 338 | user: attr.user, 339 | }, 340 | tags: toArray(tagMatches).map((tag) => ({ 341 | count: toInt(tag.count), 342 | name: tag.name, 343 | url: tag.url, 344 | })), 345 | }; 346 | } 347 | 348 | /** 349 | * Returns a list of popular tracks in a user's library. 350 | * @param username - The name of the user. 351 | * @param limit - The number of results to fetch per page. Defaults to 50. 352 | * @param page - The page number to fetch. Defaults to the first page. 353 | * */ 354 | async getTopTracks(params: UserGetTopTracksParams): Promise { 355 | const { 356 | toptracks: { track: trackMatches, '@attr': attr }, 357 | } = await this.sendRequest({ 358 | method: 'user.getTopTracks', 359 | ...params, 360 | limit: params.limit ?? 50, 361 | page: params.page ?? 1, 362 | }); 363 | 364 | return { 365 | search: { 366 | user: attr.user, 367 | page: toInt(attr.page), 368 | itemsPerPage: toInt(attr.perPage), 369 | totalPages: toInt(attr.totalPages), 370 | totalResults: toInt(attr.total), 371 | }, 372 | tracks: toArray(trackMatches).map((track) => ({ 373 | rank: toInt(track['@attr'].rank), 374 | name: track.name, 375 | mbid: track.mbid === '' ? undefined : track.mbid, 376 | stats: { 377 | duration: toInt(track.duration), 378 | userPlayCount: toInt(track.playcount), 379 | }, 380 | artist: { 381 | name: track.artist.name, 382 | mbid: track.artist.mbid === '' ? undefined : track.artist.mbid, 383 | url: track.artist.url, 384 | }, 385 | url: track.url, 386 | image: convertImageSizes(track.image), 387 | })), 388 | }; 389 | } 390 | } 391 | --------------------------------------------------------------------------------