@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 +21 -0
- package/README.md +156 -0
- package/dist/auth/LastfmOAuthProvider.d.ts +24 -0
- package/dist/auth/LastfmOAuthProvider.js +79 -0
- package/dist/connectors/LastfmFriendsExtractor.d.ts +41 -0
- package/dist/connectors/LastfmFriendsExtractor.js +86 -0
- package/dist/connectors/LastfmLovedTracksExtractor.d.ts +40 -0
- package/dist/connectors/LastfmLovedTracksExtractor.js +84 -0
- package/dist/connectors/LastfmRecentTracksExtractor.d.ts +41 -0
- package/dist/connectors/LastfmRecentTracksExtractor.js +87 -0
- package/dist/connectors/LastfmTransformer.d.ts +15 -0
- package/dist/connectors/LastfmTransformer.js +228 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -0
- package/dist/utils/LastfmProxy.d.ts +127 -0
- package/dist/utils/LastfmProxy.js +150 -0
- package/package.json +77 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|