@chronicle.app/lastfm 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @chronicle.app/lastfm
2
+
3
+ Chronicle plugin for Last.fm music listening history and loved tracks.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/lastfm
9
+ ```
10
+
11
+ ## Authentication
12
+
13
+ The plugin supports multiple authentication methods:
14
+
15
+ ### OAuth Session (Recommended)
16
+
17
+ 1. Obtain a session key from Last.fm using their OAuth flow
18
+ 2. Use the session key with the `--access-token` flag
19
+
20
+ ### API Key
21
+
22
+ Use a traditional Last.fm API key with the `--api-key` flag
23
+
24
+ ### Stored Credentials
25
+
26
+ The plugin automatically retrieves stored credentials from Chronicle's credential manager when available.
27
+
28
+ ## Usage
29
+
30
+ ### Extract recent listening history
31
+
32
+ ```bash
33
+ # Short form (recommended)
34
+ chronicle extract lastfm --type listens --limit 500
35
+
36
+ # Full form (select the strategy explicitly)
37
+ chronicle extract lastfm --strategy api --type listens --limit 500
38
+ ```
39
+
40
+ ### Extract friends
41
+
42
+ ```bash
43
+ chronicle extract lastfm --type friends
44
+ ```
45
+
46
+ ### Extract loved tracks
47
+
48
+ ```bash
49
+ chronicle extract lastfm --type loved-tracks --limit 100
50
+ ```
51
+
52
+ ### With API key authentication
53
+
54
+ ```bash
55
+ chronicle extract lastfm --type listens \
56
+ --api-key YOUR_API_KEY \
57
+ --username your_lastfm_username \
58
+ --since 2024-01-01 \
59
+ --limit 1000
60
+ ```
61
+
62
+ ### With OAuth session key
63
+
64
+ ```bash
65
+ chronicle extract lastfm --type loved-tracks \
66
+ --access-token SESSION_KEY \
67
+ --username your_lastfm_username
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ ### Authentication Options
73
+
74
+ - `--access-token`: OAuth session key from Last.fm
75
+ - `--api-key`: Last.fm API key (alternative to OAuth)
76
+ - `--username`: Last.fm username (auto-retrieved from stored credentials when available)
77
+
78
+ ### Extraction Options
79
+
80
+ - `--since`: Extract records from this date onwards (YYYY-MM-DD format)
81
+ - `--until`: Extract records up to this date (YYYY-MM-DD format)
82
+ - `--limit`: Maximum number of records to extract (default: 100)
83
+
84
+ ### Output Options
85
+
86
+ - `--output`: Output file path
87
+ - `--loader`: Output format (json, csv, table, yaml)
88
+
89
+ ## Commands
90
+
91
+ The extraction type is selected with `--type`; with no `--type`, every type is
92
+ read:
93
+
94
+ - `chronicle extract lastfm --type listens`: Recent listening history
95
+ - `chronicle extract lastfm --type loved-tracks`: Loved/favorited tracks
96
+ - `chronicle extract lastfm --type friends`: Friends (followed users)
97
+
98
+ List the available types with `chronicle extract lastfm --help`.
99
+
100
+ ## Data Types
101
+
102
+ The plugin generates the following Chronicle schema types:
103
+
104
+ ### Generated Chronicle Entities
105
+
106
+ - `ListenAction`: a scrobble (recent track), timestamped at the play time
107
+ - `LikeAction`: a loved/favorited track, timestamped when loved
108
+ - `FollowAction`: the listener following a friend; no timestamp (Last.fm gives no
109
+ befriended date), sighted at read time by the snapshot `friends` extractor
110
+ - `CreateAction`: a friend's Last.fm account registration, keyed on the account
111
+ handle and timestamped at their `registered` date (the account's
112
+ coming-into-being on `result`)
113
+ - `Agent`: the listener and their friends, keyed on the Last.fm `handle` (the
114
+ canonical username), with real name, profile URL, and avatar `image` when
115
+ available
116
+ - `MusicRecording`: track details, with cover `image` when available
117
+ - `MusicAlbum`: album information (when available), with cover `image`
118
+ - `MusicGroup`: artist information, with artist `image` when available
119
+
120
+ Recordings, albums, and artists are deduplicated on their MusicBrainz ID when
121
+ present, otherwise on their Last.fm URL.
122
+
123
+ ## API Integration
124
+
125
+ The plugin uses the following Last.fm API endpoints:
126
+
127
+ - `user.getRecentTracks`: Fetches listening history with automatic pagination
128
+ - `user.getLovedTracks`: Fetches loved tracks with automatic pagination
129
+ - `user.getFriends`: Fetches friends (followed users) with automatic pagination
130
+ - `user.getinfo`: Retrieves user profile information
131
+
132
+ ### Features
133
+
134
+ - Automatic pagination for large datasets
135
+ - Rate limiting (200ms delay between requests)
136
+ - Filters out "now playing" tracks and invalid timestamps
137
+ - MusicBrainz ID-based deduplication when available
138
+ - Date range filtering support
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ # Install dependencies
144
+ npm install
145
+
146
+ # Build the plugin
147
+ npm run build
148
+
149
+ # Run the tests
150
+ npm test
151
+ ```
152
+
153
+ The tests serve synthetic Last.fm API responses from a local HTTP server on
154
+ 127.0.0.1, pass fake credentials as config, and point the credential store at an
155
+ empty temporary directory, so they never call Last.fm or read the host's stored
156
+ credentials or accounts.
@@ -0,0 +1,24 @@
1
+ import { OAuthProvider, TokenResponse } from '@chronicle.app/auth';
2
+ export declare class LastfmOAuthProvider extends OAuthProvider {
3
+ static providerId: string;
4
+ static authorizationUrl: string;
5
+ static tokenUrl: string;
6
+ static scopes: string[];
7
+ static requiresClientSecret: boolean;
8
+ /**
9
+ * Build the authorization URL for Last.fm
10
+ * https://www.last.fm/api/authentication
11
+ */
12
+ buildAuthUrl(): string;
13
+ /**
14
+ * Exchange authorization token for session key
15
+ * Last.fm uses a different flow than standard OAuth2
16
+ */
17
+ exchangeCodeForToken(token: string): Promise<TokenResponse>;
18
+ /**
19
+ * Generate API signature required by Last.fm
20
+ * Based on working omniauth-lastfm implementation
21
+ */
22
+ private generateApiSignature;
23
+ }
24
+ //# sourceMappingURL=LastfmOAuthProvider.d.ts.map
@@ -0,0 +1,79 @@
1
+ import axios from 'axios';
2
+ import { createHash } from 'node:crypto';
3
+ import { OAuthProvider } from '@chronicle.app/auth';
4
+ export class LastfmOAuthProvider extends OAuthProvider {
5
+ static providerId = 'lastfm';
6
+ static authorizationUrl = 'https://www.last.fm/api/auth';
7
+ static tokenUrl = 'https://ws.audioscrobbler.com/2.0/';
8
+ static scopes = []; // Last.fm doesn't use scopes
9
+ static requiresClientSecret = true;
10
+ /**
11
+ * Build the authorization URL for Last.fm
12
+ * https://www.last.fm/api/authentication
13
+ */
14
+ buildAuthUrl() {
15
+ const params = {
16
+ api_key: this.params.clientId,
17
+ cb: this.params.redirectUri,
18
+ };
19
+ const queryString = this.buildQueryString(params);
20
+ return `${LastfmOAuthProvider.authorizationUrl}?${queryString}`;
21
+ }
22
+ /**
23
+ * Exchange authorization token for session key
24
+ * Last.fm uses a different flow than standard OAuth2
25
+ */
26
+ async exchangeCodeForToken(token) {
27
+ try {
28
+ // Step 1: Get session key using auth.getSession
29
+ const paramsForSignature = {
30
+ method: 'auth.getSession',
31
+ api_key: this.params.clientId,
32
+ token,
33
+ };
34
+ // Add API signature required by Last.fm
35
+ const signature = this.generateApiSignature(paramsForSignature, this.params.clientSecret);
36
+ const allParams = {
37
+ ...paramsForSignature,
38
+ format: 'json',
39
+ api_sig: signature,
40
+ };
41
+ const sessionResponse = await axios.get(LastfmOAuthProvider.tokenUrl, {
42
+ params: allParams,
43
+ });
44
+ const sessionData = sessionResponse.data;
45
+ if (sessionData.error) {
46
+ throw new Error(`Last.fm API error: ${sessionData.message || sessionData.error}`);
47
+ }
48
+ if (!sessionData.session || !sessionData.session.key) {
49
+ throw new Error('No session key received from Last.fm');
50
+ }
51
+ // Last.fm returns a session key instead of access/refresh tokens
52
+ const tokens = {
53
+ provider: 'lastfm',
54
+ access_token: sessionData.session.key,
55
+ token_type: 'session',
56
+ created_at: new Date().toISOString(),
57
+ // Store the username from the session data
58
+ username: sessionData.session.name,
59
+ };
60
+ return tokens;
61
+ }
62
+ catch (error) {
63
+ if (axios.isAxiosError(error)) {
64
+ const message = error.response?.data?.message || error.message;
65
+ throw new Error(`Failed to get Last.fm session: ${message}`);
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+ /**
71
+ * Generate API signature required by Last.fm
72
+ * Based on working omniauth-lastfm implementation
73
+ */
74
+ generateApiSignature(params, secret) {
75
+ // Last.fm signature for auth.getSession: api_key{api_key}methodauth.getSessiontoken{token}{secret}
76
+ const signatureString = `api_key${params.api_key}method${params.method}token${params.token}${secret}`;
77
+ return createHash('md5').update(signatureString, 'utf8').digest('hex');
78
+ }
79
+ }
@@ -0,0 +1,41 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import LastfmTransformer from './LastfmTransformer.js';
4
+ export declare class LastfmFriendsExtractor extends Extractor<typeof LastfmFriendsExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "api";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static temporality: "snapshot";
11
+ static defaultTransformer: typeof LastfmTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ accessToken: z.ZodOptional<z.ZodString>;
18
+ apiKey: z.ZodOptional<z.ZodString>;
19
+ username: z.ZodOptional<z.ZodString>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ limit?: number | undefined;
22
+ accessToken?: string | undefined;
23
+ apiKey?: string | undefined;
24
+ username?: string | undefined;
25
+ since?: Date | undefined;
26
+ until?: Date | undefined;
27
+ }, {
28
+ limit?: number | undefined;
29
+ accessToken?: string | undefined;
30
+ apiKey?: string | undefined;
31
+ username?: string | undefined;
32
+ since?: Date | undefined;
33
+ until?: Date | undefined;
34
+ }>;
35
+ private proxy;
36
+ private username;
37
+ setup(): Promise<void>;
38
+ extract(): AsyncGenerator<Record>;
39
+ determineCount(): Promise<number | null>;
40
+ }
41
+ //# sourceMappingURL=LastfmFriendsExtractor.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import { resolveCredentials } from '@chronicle.app/auth';
4
+ import LastfmTransformer from './LastfmTransformer.js';
5
+ import LastfmProxy from '../utils/LastfmProxy.js';
6
+ export class LastfmFriendsExtractor extends Extractor {
7
+ static source = 'lastfm';
8
+ static description = 'Users you follow';
9
+ static delivery = 'api';
10
+ static strategy = 'api';
11
+ static recordTypes = ['friends'];
12
+ // The friends list is a re-read of current following state: re-running
13
+ // re-reports the same friends, and unfollowing drops one. The follow is
14
+ // sighted at read time (Last.fm gives no befriended date). See
15
+ // Extractor.temporality.
16
+ static temporality = 'snapshot';
17
+ static defaultTransformer = LastfmTransformer;
18
+ static schema = Extractor.schema.extend({
19
+ // OAuth token (from auth command)
20
+ accessToken: z
21
+ .string()
22
+ .optional()
23
+ .describe('OAuth session key from Last.fm (auto-retrieved from stored credentials if not provided)'),
24
+ // Traditional API key (alternative)
25
+ apiKey: z.string().optional().describe('Last.fm API key'),
26
+ username: z
27
+ .string()
28
+ .optional()
29
+ .describe('Last.fm username (auto-retrieved from stored credentials if not provided)'),
30
+ });
31
+ proxy;
32
+ username;
33
+ async setup() {
34
+ const config = this.config;
35
+ // Use the flag-provided key, else the stored client ID, else the stored
36
+ // OAuth session token.
37
+ const { authToken } = await resolveCredentials('lastfm', {
38
+ authToken: { from: ['clientId', 'accessToken'] },
39
+ }, {
40
+ overrides: { authToken: config.accessToken || config.apiKey },
41
+ errorMessage: 'Authentication required. Either:\n' +
42
+ '1. Run: chronicle auth login lastfm --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET\n' +
43
+ '2. Or use: --api-key YOUR_API_KEY for traditional auth\n' +
44
+ '3. Or use: --access-token TOKEN for OAuth session key',
45
+ });
46
+ // Get username from config or stored credentials
47
+ const { username } = await resolveCredentials('lastfm', {
48
+ username: { from: ['username'] },
49
+ }, {
50
+ overrides: { username: config.username },
51
+ errorMessage: 'Last.fm username is required. Either provide --username or authenticate with: chronicle auth login lastfm',
52
+ });
53
+ this.proxy = new LastfmProxy(authToken, username);
54
+ this.username = username;
55
+ }
56
+ async *extract() {
57
+ const config = this.config;
58
+ try {
59
+ // The listener whose friends these are (for the FollowAction agent).
60
+ const userInfo = await this.proxy.getUserInfo();
61
+ const friends = await this.proxy.getFriends({ limit: config.limit });
62
+ this.logger.info(`Retrieved ${friends.length} friends from Last.fm`);
63
+ for (const friend of friends) {
64
+ yield this.createRecord(friend, {
65
+ recordType: 'friends',
66
+ metadata: {
67
+ username: this.username,
68
+ userInfo,
69
+ },
70
+ });
71
+ }
72
+ }
73
+ catch (error) {
74
+ this.logger.error('Error fetching Last.fm friends');
75
+ throw error;
76
+ }
77
+ }
78
+ async determineCount() {
79
+ try {
80
+ return await this.proxy.getFriendsCount();
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,40 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import LastfmTransformer from './LastfmTransformer.js';
4
+ export declare class LastfmLovedTracksExtractor extends Extractor<typeof LastfmLovedTracksExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "api";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static defaultTransformer: typeof LastfmTransformer;
11
+ static schema: z.ZodObject<{
12
+ since: z.ZodOptional<z.ZodDate>;
13
+ until: z.ZodOptional<z.ZodDate>;
14
+ limit: z.ZodOptional<z.ZodNumber>;
15
+ } & {
16
+ accessToken: z.ZodOptional<z.ZodString>;
17
+ apiKey: z.ZodOptional<z.ZodString>;
18
+ username: z.ZodOptional<z.ZodString>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ limit?: number | undefined;
21
+ accessToken?: string | undefined;
22
+ apiKey?: string | undefined;
23
+ username?: string | undefined;
24
+ since?: Date | undefined;
25
+ until?: Date | undefined;
26
+ }, {
27
+ limit?: number | undefined;
28
+ accessToken?: string | undefined;
29
+ apiKey?: string | undefined;
30
+ username?: string | undefined;
31
+ since?: Date | undefined;
32
+ until?: Date | undefined;
33
+ }>;
34
+ private proxy;
35
+ private username;
36
+ setup(): Promise<void>;
37
+ extract(): AsyncGenerator<Record>;
38
+ determineCount(): Promise<number | null>;
39
+ }
40
+ //# sourceMappingURL=LastfmLovedTracksExtractor.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import { resolveCredentials } from '@chronicle.app/auth';
4
+ import LastfmTransformer from './LastfmTransformer.js';
5
+ import LastfmProxy from '../utils/LastfmProxy.js';
6
+ export class LastfmLovedTracksExtractor extends Extractor {
7
+ static source = 'lastfm';
8
+ static description = 'Loved tracks';
9
+ static delivery = 'api';
10
+ static strategy = 'api';
11
+ static recordTypes = ['loved-tracks'];
12
+ static defaultTransformer = LastfmTransformer;
13
+ static schema = Extractor.schema.extend({
14
+ // OAuth token (from auth command)
15
+ accessToken: z
16
+ .string()
17
+ .optional()
18
+ .describe('OAuth session key from Last.fm (auto-retrieved from stored credentials if not provided)'),
19
+ // Traditional API key (alternative)
20
+ apiKey: z.string().optional().describe('Last.fm API key'),
21
+ username: z
22
+ .string()
23
+ .optional()
24
+ .describe('Last.fm username (auto-retrieved from stored credentials if not provided)'),
25
+ });
26
+ proxy;
27
+ username;
28
+ async setup() {
29
+ const config = this.config;
30
+ // Use the flag-provided key, else the stored client ID, else the stored
31
+ // OAuth session token.
32
+ const { authToken } = await resolveCredentials('lastfm', {
33
+ authToken: { from: ['clientId', 'accessToken'] },
34
+ }, {
35
+ overrides: { authToken: config.accessToken || config.apiKey },
36
+ errorMessage: 'Authentication required. Either:\n' +
37
+ '1. Run: chronicle auth login lastfm --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET\n' +
38
+ '2. Or use: --api-key YOUR_API_KEY for traditional auth\n' +
39
+ '3. Or use: --access-token TOKEN for OAuth session key',
40
+ });
41
+ // Get username from config or stored credentials
42
+ const { username } = await resolveCredentials('lastfm', {
43
+ username: { from: ['username'] },
44
+ }, {
45
+ overrides: { username: config.username },
46
+ errorMessage: 'Last.fm username is required. Either provide --username or authenticate with: chronicle auth login lastfm',
47
+ });
48
+ this.proxy = new LastfmProxy(authToken, username);
49
+ this.username = username;
50
+ }
51
+ async *extract() {
52
+ const config = this.config;
53
+ try {
54
+ // Get user info for real name
55
+ const userInfo = await this.proxy.getUserInfo();
56
+ // Extract loved tracks
57
+ const lovedTracks = await this.proxy.getLovedTracks({
58
+ limit: config.limit,
59
+ });
60
+ this.logger.info(`Retrieved ${lovedTracks.length} loved tracks from Last.fm`);
61
+ for (const track of lovedTracks) {
62
+ yield this.createRecord(track, {
63
+ recordType: 'loved-tracks',
64
+ metadata: {
65
+ username: this.username,
66
+ userInfo,
67
+ },
68
+ });
69
+ }
70
+ }
71
+ catch (error) {
72
+ this.logger.error('Error fetching Last.fm loved tracks');
73
+ throw error;
74
+ }
75
+ }
76
+ async determineCount() {
77
+ try {
78
+ return await this.proxy.getLovedCount();
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,41 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import LastfmTransformer from './LastfmTransformer.js';
4
+ export declare class LastfmRecentTracksExtractor extends Extractor<typeof LastfmRecentTracksExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "api";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static defaultTransformer: typeof LastfmTransformer;
12
+ static schema: z.ZodObject<{
13
+ since: z.ZodOptional<z.ZodDate>;
14
+ until: z.ZodOptional<z.ZodDate>;
15
+ limit: z.ZodOptional<z.ZodNumber>;
16
+ } & {
17
+ accessToken: z.ZodOptional<z.ZodString>;
18
+ apiKey: z.ZodOptional<z.ZodString>;
19
+ username: z.ZodOptional<z.ZodString>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ limit?: number | undefined;
22
+ accessToken?: string | undefined;
23
+ apiKey?: string | undefined;
24
+ username?: string | undefined;
25
+ since?: Date | undefined;
26
+ until?: Date | undefined;
27
+ }, {
28
+ limit?: number | undefined;
29
+ accessToken?: string | undefined;
30
+ apiKey?: string | undefined;
31
+ username?: string | undefined;
32
+ since?: Date | undefined;
33
+ until?: Date | undefined;
34
+ }>;
35
+ private proxy;
36
+ private username;
37
+ setup(): Promise<void>;
38
+ extract(): AsyncGenerator<Record>;
39
+ determineCount(): Promise<number | null>;
40
+ }
41
+ //# sourceMappingURL=LastfmRecentTracksExtractor.d.ts.map
@@ -0,0 +1,87 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import { resolveCredentials } from '@chronicle.app/auth';
4
+ import LastfmTransformer from './LastfmTransformer.js';
5
+ import LastfmProxy from '../utils/LastfmProxy.js';
6
+ export class LastfmRecentTracksExtractor extends Extractor {
7
+ static source = 'lastfm';
8
+ static description = 'Scrobbled listens';
9
+ static delivery = 'api';
10
+ static strategy = 'api';
11
+ static recordTypes = ['listens'];
12
+ static default = true;
13
+ static defaultTransformer = LastfmTransformer;
14
+ static schema = Extractor.schema.extend({
15
+ // OAuth token (from auth command)
16
+ accessToken: z
17
+ .string()
18
+ .optional()
19
+ .describe('OAuth session key from Last.fm (auto-retrieved from stored credentials if not provided)'),
20
+ // Traditional API key (alternative)
21
+ apiKey: z.string().optional().describe('Last.fm API key'),
22
+ username: z
23
+ .string()
24
+ .optional()
25
+ .describe('Last.fm username (auto-retrieved from stored credentials if not provided)'),
26
+ });
27
+ proxy;
28
+ username;
29
+ async setup() {
30
+ const config = this.config;
31
+ // Use the flag-provided key, else the stored client ID, else the stored
32
+ // OAuth session token.
33
+ const { authToken } = await resolveCredentials('lastfm', {
34
+ authToken: { from: ['clientId', 'accessToken'] },
35
+ }, {
36
+ overrides: { authToken: config.accessToken || config.apiKey },
37
+ errorMessage: 'Authentication required. Either:\n' +
38
+ '1. Run: chronicle auth login lastfm --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET\n' +
39
+ '2. Or use: --api-key YOUR_API_KEY for traditional auth\n' +
40
+ '3. Or use: --access-token TOKEN for OAuth session key',
41
+ });
42
+ // Get username from config or stored credentials
43
+ const { username } = await resolveCredentials('lastfm', {
44
+ username: { from: ['username'] },
45
+ }, {
46
+ overrides: { username: config.username },
47
+ errorMessage: 'Last.fm username is required. Either provide --username or authenticate with: chronicle auth login lastfm',
48
+ });
49
+ this.proxy = new LastfmProxy(authToken, username);
50
+ this.username = username;
51
+ }
52
+ async *extract() {
53
+ const config = this.config;
54
+ try {
55
+ // Get user info for real name
56
+ const userInfo = await this.proxy.getUserInfo();
57
+ // Extract recent tracks (listened)
58
+ const recentTracks = await this.proxy.getRecentTracks({
59
+ since: config.since,
60
+ until: config.until,
61
+ limit: config.limit,
62
+ });
63
+ this.logger.info(`Retrieved ${recentTracks.length} recent tracks from Last.fm`);
64
+ for (const track of recentTracks) {
65
+ yield this.createRecord(track, {
66
+ recordType: 'listens',
67
+ metadata: {
68
+ username: this.username,
69
+ userInfo,
70
+ },
71
+ });
72
+ }
73
+ }
74
+ catch (error) {
75
+ this.logger.error('Error fetching Last.fm recent tracks');
76
+ throw error;
77
+ }
78
+ }
79
+ async determineCount() {
80
+ try {
81
+ return await this.proxy.getItemCount();
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,15 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class LastfmTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildFriendAction;
6
+ private buildJoinAction;
7
+ private buildListenAction;
8
+ private buildLoveAction;
9
+ private buildMusicRecording;
10
+ private buildMusicGroup;
11
+ private resolveUser;
12
+ private buildUser;
13
+ private buildLastfmAgent;
14
+ }
15
+ //# sourceMappingURL=LastfmTransformer.d.ts.map
@@ -0,0 +1,228 @@
1
+ import { ChronicleTransformer, createImageObject } from '@chronicle.app/etl';
2
+ // Last.fm returns each image at several sizes; pick the largest non-empty URL.
3
+ function largestImage(images) {
4
+ if (!images?.length)
5
+ return undefined;
6
+ const order = ['mega', 'extralarge', 'large', 'medium', 'small'];
7
+ for (const size of order) {
8
+ const found = images.find(i => i.size === size && i['#text']?.trim());
9
+ if (found)
10
+ return found['#text'].trim();
11
+ }
12
+ return images.find(i => i['#text']?.trim())?.['#text'].trim();
13
+ }
14
+ export default class LastfmTransformer extends ChronicleTransformer {
15
+ async transform(record) {
16
+ const actions = [];
17
+ switch (record.extraction.recordType) {
18
+ case 'listens': {
19
+ actions.push(...this.buildListenAction(record));
20
+ break;
21
+ }
22
+ case 'loved-tracks': {
23
+ actions.push(...this.buildLoveAction(record));
24
+ break;
25
+ }
26
+ case 'friends': {
27
+ actions.push(...this.buildFriendAction(record));
28
+ break;
29
+ }
30
+ }
31
+ return actions;
32
+ }
33
+ // A Last.fm friend is a user the listener follows. Last.fm gives no
34
+ // befriended date, so the FollowAction carries no timestamp — it's sighted at
35
+ // read time by the snapshot extractor — and keys on the followed user's handle
36
+ // so re-reads supersede rather than duplicate.
37
+ buildFriendAction(record) {
38
+ const friend = record.data;
39
+ const friendAgent = this.buildLastfmAgent(friend.name, {
40
+ realname: friend.realname,
41
+ url: friend.url,
42
+ image: largestImage(friend.image),
43
+ });
44
+ const actions = [
45
+ {
46
+ '@type': 'FollowAction',
47
+ '@key': ['@type', 'source', 'sourceId'],
48
+ source: 'lastfm',
49
+ sourceId: friend.name,
50
+ agent: this.resolveUser(record),
51
+ object: friendAgent,
52
+ },
53
+ ];
54
+ const join = this.buildJoinAction(friend.name, friend.registered?.unixtime);
55
+ if (join) {
56
+ actions.push(join);
57
+ }
58
+ return actions;
59
+ }
60
+ // When a Last.fm account was created (its `registered` date). This is a real
61
+ // source event with a real timestamp, so it's a keyed action — not synthetic —
62
+ // keyed on the account handle so it's referenceable and re-reads supersede. The
63
+ // account's coming-into-being is origin-grade evidence carried on `result`; a
64
+ // bare handle reference is used so the friend's *current* avatar/name aren't
65
+ // back-dated to the registration instant — those stay on the FollowAction.
66
+ buildJoinAction(handle, unixtime) {
67
+ if (!unixtime)
68
+ return null;
69
+ const at = new Date(Number.parseInt(unixtime, 10) * 1000);
70
+ if (Number.isNaN(at.getTime()))
71
+ return null;
72
+ const ref = {
73
+ '@type': 'Agent',
74
+ '@key': ['@type', 'source', 'handle'],
75
+ source: 'lastfm',
76
+ handle,
77
+ };
78
+ return {
79
+ '@type': 'CreateAction',
80
+ '@key': ['@type', 'source', 'sourceId'],
81
+ source: 'lastfm',
82
+ sourceId: handle,
83
+ timestamp: at,
84
+ agent: ref,
85
+ result: ref,
86
+ };
87
+ }
88
+ buildListenAction(record) {
89
+ const track = record.data;
90
+ const timestamp = new Date(Number.parseInt(track.date.uts) * 1000);
91
+ const action = {
92
+ '@type': 'ListenAction',
93
+ timestamp,
94
+ '@key': ['@type', 'source', 'timestamp'],
95
+ source: 'lastfm',
96
+ agent: this.resolveUser(record),
97
+ object: this.buildMusicRecording(track),
98
+ };
99
+ return [action];
100
+ }
101
+ buildLoveAction(record) {
102
+ const track = record.data;
103
+ const action = {
104
+ '@type': 'LikeAction',
105
+ timestamp: track.date ? new Date(Number.parseInt(track.date.uts) * 1000) : new Date(),
106
+ '@key': ['@type', 'source', 'timestamp'],
107
+ source: 'lastfm',
108
+ agent: this.resolveUser(record),
109
+ object: this.buildMusicRecording(track),
110
+ };
111
+ return [action];
112
+ }
113
+ buildMusicRecording(track) {
114
+ const artist = this.buildMusicGroup(track.artist, track.url);
115
+ // Last.fm serves a track's image as its album art.
116
+ const artUrl = largestImage(track.image);
117
+ // Use MBID for recording deduplication if available, otherwise fall back to URL
118
+ const recordingDedupeOn = track.mbid ? ['@type', 'source', 'sourceId'] : ['url'];
119
+ const recording = {
120
+ '@type': 'MusicRecording',
121
+ '@key': recordingDedupeOn,
122
+ url: track.url,
123
+ name: track.name,
124
+ artist: [artist],
125
+ ...(artUrl && { emblem: createImageObject({ url: artUrl }) }),
126
+ };
127
+ // Add MBID as sourceId if available
128
+ if (track.mbid) {
129
+ recording.sourceId = track.mbid;
130
+ recording.source = 'lastfm';
131
+ }
132
+ // Add album if available
133
+ if (track.album?.['#text']) {
134
+ const album = {
135
+ '@type': 'MusicAlbum',
136
+ '@key': track.album.mbid
137
+ ? ['@type', 'source', 'sourceId']
138
+ : ['@type', 'source', 'name', 'byArtist[*].name'],
139
+ source: 'lastfm',
140
+ name: track.album['#text'],
141
+ artist: [artist],
142
+ ...(artUrl && { emblem: createImageObject({ url: artUrl }) }),
143
+ };
144
+ // Add MBID as sourceId only if available
145
+ if (track.album.mbid) {
146
+ album.sourceId = track.album.mbid;
147
+ }
148
+ recording.inAlbum = album;
149
+ }
150
+ return recording;
151
+ }
152
+ buildMusicGroup(artistData, trackUrl) {
153
+ // Use MBID for artist deduplication if available, otherwise fall back to URL
154
+ const artistDedupeOn = artistData.mbid ? ['@type', 'source', 'sourceId'] : ['url'];
155
+ // Handle both name and #text fields from Last.fm API
156
+ const artistName = artistData.name || artistData['#text'];
157
+ // Derive artist URL from track URL if not provided
158
+ let artistUrl = artistData.url;
159
+ if (!artistUrl && trackUrl) {
160
+ // Remove the last two path segments from track URL to get artist URL
161
+ // e.g., "https://www.last.fm/music/Reese+Witherspoon/_/Break+Free+(with+Nick+Kroll)"
162
+ // becomes "https://www.last.fm/music/Reese+Witherspoon"
163
+ const urlParts = trackUrl.split('/');
164
+ if (urlParts.length >= 2) {
165
+ urlParts.pop(); // Remove track name
166
+ urlParts.pop(); // Remove "_" separator
167
+ artistUrl = urlParts.join('/');
168
+ }
169
+ }
170
+ const artUrl = largestImage(artistData.image);
171
+ const artist = {
172
+ '@type': 'MusicGroup',
173
+ '@key': artistDedupeOn,
174
+ source: 'lastfm',
175
+ name: artistName,
176
+ url: artistUrl,
177
+ ...(artUrl && { emblem: createImageObject({ url: artUrl }) }),
178
+ };
179
+ // Add MBID as sourceId if available
180
+ if (artistData.mbid) {
181
+ artist.sourceId = artistData.mbid;
182
+ }
183
+ return artist;
184
+ }
185
+ // The listener, from the canonical Last.fm username (user.getInfo), falling
186
+ // back to the resolved extractor config. Never a hardcoded handle.
187
+ resolveUser(record) {
188
+ const metadata = record.context?.metadata;
189
+ const userInfo = metadata?.userInfo;
190
+ const username = userInfo?.name ?? metadata?.username;
191
+ if (!username) {
192
+ throw new Error('Last.fm username could not be resolved for the listener');
193
+ }
194
+ // Only the listener (the account owner) is tagged as self; friends route
195
+ // through the same buildLastfmAgent and must not.
196
+ const user = this.buildUser(username, userInfo);
197
+ return { ...user, sameAs: [...(user.sameAs ?? []), '@me'] };
198
+ }
199
+ buildUser(username, userInfo) {
200
+ return this.buildLastfmAgent(username, {
201
+ realname: userInfo?.realname,
202
+ url: userInfo?.url || `https://last.fm/user/${username}`,
203
+ image: largestImage(userInfo?.image),
204
+ });
205
+ }
206
+ // A Last.fm user as an Agent, keyed on their handle (username), carrying the
207
+ // real name, profile URL, and avatar when available. Shared by the listener
208
+ // and their friends so both merge on the same handle.
209
+ buildLastfmAgent(handle, opts) {
210
+ const agent = {
211
+ '@type': 'Agent',
212
+ '@key': ['@type', 'source', 'handle'],
213
+ source: 'lastfm',
214
+ handle,
215
+ };
216
+ if (opts.url) {
217
+ agent.url = opts.url;
218
+ }
219
+ const realname = opts.realname?.trim();
220
+ if (realname) {
221
+ agent.name = realname;
222
+ }
223
+ if (opts.image) {
224
+ agent.emblem = createImageObject({ url: opts.image });
225
+ }
226
+ return agent;
227
+ }
228
+ }
@@ -0,0 +1,5 @@
1
+ export { LastfmRecentTracksExtractor } from './connectors/LastfmRecentTracksExtractor.js';
2
+ export { LastfmLovedTracksExtractor } from './connectors/LastfmLovedTracksExtractor.js';
3
+ export { LastfmFriendsExtractor } from './connectors/LastfmFriendsExtractor.js';
4
+ export { default as LastfmTransformer } from './connectors/LastfmTransformer.js';
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { LastfmRecentTracksExtractor } from './connectors/LastfmRecentTracksExtractor.js';
2
+ export { LastfmLovedTracksExtractor } from './connectors/LastfmLovedTracksExtractor.js';
3
+ export { LastfmFriendsExtractor } from './connectors/LastfmFriendsExtractor.js';
4
+ export { default as LastfmTransformer } from './connectors/LastfmTransformer.js';
5
+ // Register OAuth provider
6
+ import { OAuthProviderRegistry } from '@chronicle.app/auth';
7
+ import { LastfmOAuthProvider } from './auth/LastfmOAuthProvider.js';
8
+ OAuthProviderRegistry.register(LastfmOAuthProvider);
@@ -0,0 +1,127 @@
1
+ import { ApiProxy } from '@chronicle.app/etl';
2
+ export interface LastfmTrack {
3
+ name: string;
4
+ mbid: string;
5
+ url: string;
6
+ artist: {
7
+ name: string;
8
+ mbid: string;
9
+ url: string;
10
+ image?: Array<{
11
+ '#text': string;
12
+ size: string;
13
+ }>;
14
+ };
15
+ album?: {
16
+ '#text': string;
17
+ mbid: string;
18
+ };
19
+ image?: Array<{
20
+ '#text': string;
21
+ size: string;
22
+ }>;
23
+ date: {
24
+ uts: string;
25
+ '#text': string;
26
+ };
27
+ '@attr'?: {
28
+ nowplaying?: string;
29
+ };
30
+ }
31
+ export interface LastfmRecentTracksResponse {
32
+ recenttracks: {
33
+ track: LastfmTrack[];
34
+ '@attr': {
35
+ user: string;
36
+ totalPages: string;
37
+ page: string;
38
+ total: string;
39
+ };
40
+ };
41
+ }
42
+ export interface LastfmLovedTracksResponse {
43
+ lovedtracks: {
44
+ track: LastfmTrack[];
45
+ '@attr': {
46
+ user: string;
47
+ totalPages: string;
48
+ page: string;
49
+ total: string;
50
+ };
51
+ };
52
+ }
53
+ export interface LastfmFriend {
54
+ name: string;
55
+ realname?: string;
56
+ url: string;
57
+ country?: string;
58
+ playcount?: string;
59
+ image?: Array<{
60
+ '#text': string;
61
+ size: string;
62
+ }>;
63
+ registered?: {
64
+ unixtime: string;
65
+ '#text': number;
66
+ };
67
+ }
68
+ export interface LastfmFriendsResponse {
69
+ friends: {
70
+ user?: LastfmFriend[] | LastfmFriend;
71
+ '@attr': {
72
+ user: string;
73
+ totalPages: string;
74
+ page: string;
75
+ total: string;
76
+ };
77
+ };
78
+ }
79
+ export interface LastfmUser {
80
+ name: string;
81
+ realname?: string;
82
+ url?: string;
83
+ country?: string;
84
+ age?: string;
85
+ gender?: string;
86
+ subscriber?: string;
87
+ playcount?: string;
88
+ playlists?: string;
89
+ bootstrap?: string;
90
+ registered?: {
91
+ unixtime: string;
92
+ '#text': number;
93
+ };
94
+ image?: Array<{
95
+ '#text': string;
96
+ size: string;
97
+ }>;
98
+ }
99
+ export interface LastfmUserResponse {
100
+ user: LastfmUser;
101
+ }
102
+ export default class LastfmProxy extends ApiProxy {
103
+ private apiKey;
104
+ private username;
105
+ constructor(apiKey: string, username: string, baseUrl?: string);
106
+ initialize(): Promise<void>;
107
+ getRecentTracks(options?: {
108
+ since?: Date;
109
+ until?: Date;
110
+ limit?: number;
111
+ }): Promise<LastfmTrack[]>;
112
+ getLovedTracks(options?: {
113
+ limit?: number;
114
+ }): Promise<LastfmTrack[]>;
115
+ getFriends(options?: {
116
+ limit?: number;
117
+ }): Promise<LastfmFriend[]>;
118
+ private fetchFriends;
119
+ getFriendsCount(): Promise<number>;
120
+ private fetchRecentTracks;
121
+ private fetchLovedTracks;
122
+ private filterTracks;
123
+ getItemCount(): Promise<number>;
124
+ getLovedCount(): Promise<number>;
125
+ getUserInfo(): Promise<LastfmUser>;
126
+ }
127
+ //# sourceMappingURL=LastfmProxy.d.ts.map
@@ -0,0 +1,150 @@
1
+ import { ApiProxy } from '@chronicle.app/etl';
2
+ // Last.fm returns `friends.user` as an array, a bare object (single friend), or
3
+ // omits it entirely (no friends). Normalize to an array.
4
+ function normalizeFriends(response) {
5
+ const { user } = response.friends;
6
+ if (!user)
7
+ return [];
8
+ return Array.isArray(user) ? user : [user];
9
+ }
10
+ export default class LastfmProxy extends ApiProxy {
11
+ // Last.fm authenticates with an `api_key` query parameter, not a bearer
12
+ // header, so the key lives here and the base's accessToken stays unset.
13
+ apiKey;
14
+ username;
15
+ constructor(apiKey, username, baseUrl) {
16
+ super({
17
+ baseURL: baseUrl || 'https://ws.audioscrobbler.com/2.0',
18
+ headers: {
19
+ 'User-Agent': 'Chronicle/1.0',
20
+ },
21
+ pageDelayMs: 200,
22
+ });
23
+ this.apiKey = apiKey;
24
+ this.username = username;
25
+ }
26
+ // The API key and username are resolved by the extractor's setup() and
27
+ // handed in via the constructor, so there is nothing left to load here.
28
+ async initialize() { }
29
+ async getRecentTracks(options = {}) {
30
+ return this.paginateByPage({
31
+ limit: options.limit,
32
+ fetchPage: async (page) => {
33
+ const response = await this.fetchRecentTracks(page, options);
34
+ return {
35
+ items: this.filterTracks(response.recenttracks.track),
36
+ totalPages: Number.parseInt(response.recenttracks['@attr'].totalPages),
37
+ };
38
+ },
39
+ });
40
+ }
41
+ async getLovedTracks(options = {}) {
42
+ return this.paginateByPage({
43
+ limit: options.limit,
44
+ fetchPage: async (page) => {
45
+ const response = await this.fetchLovedTracks(page);
46
+ return {
47
+ items: response.lovedtracks.track,
48
+ totalPages: Number.parseInt(response.lovedtracks['@attr'].totalPages),
49
+ };
50
+ },
51
+ });
52
+ }
53
+ async getFriends(options = {}) {
54
+ return this.paginateByPage({
55
+ limit: options.limit,
56
+ fetchPage: async (page) => {
57
+ const response = await this.fetchFriends(page);
58
+ return {
59
+ items: normalizeFriends(response),
60
+ totalPages: Number.parseInt(response.friends['@attr']?.totalPages ?? '1'),
61
+ };
62
+ },
63
+ });
64
+ }
65
+ async fetchFriends(page) {
66
+ return this.request({
67
+ url: '/',
68
+ method: 'GET',
69
+ params: {
70
+ method: 'user.getFriends',
71
+ user: this.username,
72
+ api_key: this.apiKey,
73
+ format: 'json',
74
+ page: page.toString(),
75
+ limit: '200',
76
+ },
77
+ });
78
+ }
79
+ async getFriendsCount() {
80
+ const response = await this.fetchFriends(1);
81
+ return Number.parseInt(response.friends['@attr']?.total ?? '0');
82
+ }
83
+ async fetchRecentTracks(page, options) {
84
+ const params = {
85
+ method: 'user.getRecentTracks',
86
+ user: this.username,
87
+ api_key: this.apiKey,
88
+ format: 'json',
89
+ page: page.toString(),
90
+ limit: '200',
91
+ };
92
+ if (options.since) {
93
+ params.from = Math.floor(options.since.getTime() / 1000).toString();
94
+ }
95
+ if (options.until) {
96
+ params.to = Math.floor(options.until.getTime() / 1000).toString();
97
+ }
98
+ return this.request({
99
+ url: '/',
100
+ method: 'GET',
101
+ params,
102
+ });
103
+ }
104
+ async fetchLovedTracks(page) {
105
+ return this.request({
106
+ url: '/',
107
+ method: 'GET',
108
+ params: {
109
+ method: 'user.getLovedTracks',
110
+ user: this.username,
111
+ api_key: this.apiKey,
112
+ format: 'json',
113
+ page: page.toString(),
114
+ limit: '200',
115
+ },
116
+ });
117
+ }
118
+ filterTracks(tracks) {
119
+ return tracks.filter(track => {
120
+ // Skip currently playing tracks (they don't have timestamps yet)
121
+ if (track['@attr']?.nowplaying)
122
+ return false;
123
+ // Skip tracks with invalid timestamps
124
+ if (track.date?.uts === '0')
125
+ return false;
126
+ return true;
127
+ });
128
+ }
129
+ async getItemCount() {
130
+ const response = await this.fetchRecentTracks(1, {});
131
+ return Number.parseInt(response.recenttracks['@attr'].total);
132
+ }
133
+ async getLovedCount() {
134
+ const response = await this.fetchLovedTracks(1);
135
+ return Number.parseInt(response.lovedtracks['@attr'].total);
136
+ }
137
+ async getUserInfo() {
138
+ const response = await this.request({
139
+ url: '/',
140
+ method: 'GET',
141
+ params: {
142
+ method: 'user.getinfo',
143
+ user: this.username,
144
+ api_key: this.apiKey,
145
+ format: 'json',
146
+ },
147
+ });
148
+ return response.user;
149
+ }
150
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@chronicle.app/lastfm",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "lastfm"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for Last.fm listening history and loved tracks",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/lastfm"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@chronicle.app/auth": "0.3.0",
43
+ "axios": "^1.7.9",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
48
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.13.0"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ }
58
+ },
59
+ "chronicle": {
60
+ "plugin": true,
61
+ "sources": {
62
+ "lastfm": {
63
+ "strategies": {
64
+ "api": {
65
+ "delivery": "api",
66
+ "recordTypes": [
67
+ "friends",
68
+ "loved-tracks",
69
+ "listens"
70
+ ],
71
+ "default": true
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }