@chronicle.app/spotify 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 +107 -0
- package/dist/auth/SpotifyOAuthProvider.d.ts +10 -0
- package/dist/auth/SpotifyOAuthProvider.js +17 -0
- package/dist/connectors/SpotifyBaseExtractor.d.ts +43 -0
- package/dist/connectors/SpotifyBaseExtractor.js +57 -0
- package/dist/connectors/SpotifyPlaylistsExtractor.d.ts +26 -0
- package/dist/connectors/SpotifyPlaylistsExtractor.js +43 -0
- package/dist/connectors/SpotifyRecentlyPlayedExtractor.d.ts +27 -0
- package/dist/connectors/SpotifyRecentlyPlayedExtractor.js +32 -0
- package/dist/connectors/SpotifySavedAlbumsExtractor.d.ts +26 -0
- package/dist/connectors/SpotifySavedAlbumsExtractor.js +27 -0
- package/dist/connectors/SpotifySavedTracksExtractor.d.ts +26 -0
- package/dist/connectors/SpotifySavedTracksExtractor.js +27 -0
- package/dist/connectors/SpotifyTransformer.d.ts +14 -0
- package/dist/connectors/SpotifyTransformer.js +187 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/utils/SpotifyProxy.d.ts +144 -0
- package/dist/utils/SpotifyProxy.js +180 -0
- package/package.json +78 -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,107 @@
|
|
|
1
|
+
# Chronicle Spotify Plugin
|
|
2
|
+
|
|
3
|
+
Extract listening history, saved tracks, and playlists from Spotify using OAuth authentication.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Recently Played**: Extract your recent listening history
|
|
8
|
+
- **Saved Tracks**: Extract your liked/saved tracks
|
|
9
|
+
- **Saved Albums**: Extract your saved/liked albums
|
|
10
|
+
- **Playlists**: Extract your playlists and their metadata
|
|
11
|
+
- **OAuth Authentication**: Secure authentication via Spotify OAuth 2.0
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### 1. Create Spotify App
|
|
16
|
+
|
|
17
|
+
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
|
18
|
+
2. Create a new app
|
|
19
|
+
3. Note your **Client ID** and **Client Secret**
|
|
20
|
+
4. Add `http://127.0.0.1:7463/callback` to your app's redirect URIs (use 127.0.0.1 for Spotify's OAuth validation, port 7463 is Chronicle's default)
|
|
21
|
+
|
|
22
|
+
### 2. Authenticate
|
|
23
|
+
|
|
24
|
+
Run the OAuth flow to get an access token:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
chronicle auth login spotify --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This will open your browser to authenticate with Spotify and store your access token.
|
|
31
|
+
|
|
32
|
+
### 3. Extract Data
|
|
33
|
+
|
|
34
|
+
Chronicle automatically uses your stored credentials and reads every record type unless you pass `--type`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
chronicle extract spotify
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You can also manually provide a token:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
chronicle extract spotify --access-token YOUR_ACCESS_TOKEN
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Credential Management
|
|
47
|
+
|
|
48
|
+
Chronicle stores your OAuth credentials in `~/.config/chronicle/credentials.json` for easy access. You can manage them with:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Check stored credentials
|
|
52
|
+
chronicle auth list
|
|
53
|
+
|
|
54
|
+
# Check status of Spotify credentials
|
|
55
|
+
chronicle auth status spotify
|
|
56
|
+
|
|
57
|
+
# Remove stored credentials (requires re-authentication)
|
|
58
|
+
chronicle auth remove spotify
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration Options
|
|
62
|
+
|
|
63
|
+
- `--access-token` (optional): OAuth access token (auto-retrieved from secure storage if not provided)
|
|
64
|
+
- `--limit` (default: 100): Limit the number of records extracted (use 0 for no limit)
|
|
65
|
+
- `--since` (optional): Extract records since this date
|
|
66
|
+
- `--until` (optional): Extract records until this date
|
|
67
|
+
|
|
68
|
+
## Example Usage
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Extract every record type
|
|
72
|
+
chronicle extract spotify
|
|
73
|
+
|
|
74
|
+
# Extract one record type
|
|
75
|
+
chronicle extract spotify --type listens
|
|
76
|
+
|
|
77
|
+
# Extract with limits and date ranges
|
|
78
|
+
chronicle extract spotify --limit 50
|
|
79
|
+
chronicle extract spotify --since 2024-01-01
|
|
80
|
+
chronicle extract spotify --until 2024-12-31
|
|
81
|
+
|
|
82
|
+
# Use a specific access token (overrides stored credentials)
|
|
83
|
+
chronicle extract spotify --access-token YOUR_TOKEN
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Data Types
|
|
87
|
+
|
|
88
|
+
The plugin extracts the following record types and transforms them into Chronicle actions:
|
|
89
|
+
|
|
90
|
+
- `listen` → `ListenAction`: Tracks you've recently listened to
|
|
91
|
+
- `like` → `LikeAction`: Tracks and albums you've saved/liked
|
|
92
|
+
- `playlist-track` → `AddAction`: Tracks added to your playlists
|
|
93
|
+
|
|
94
|
+
## Spotify API Scopes
|
|
95
|
+
|
|
96
|
+
The plugin requests these OAuth scopes:
|
|
97
|
+
|
|
98
|
+
- `user-read-recently-played`: Access recently played tracks
|
|
99
|
+
- `user-library-read`: Access saved tracks and albums
|
|
100
|
+
- `playlist-read-private`: Access private playlists
|
|
101
|
+
- `playlist-read-collaborative`: Access collaborative playlists
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
The tests run each extractor and the transformer against synthetic Spotify API
|
|
106
|
+
responses served from a local HTTP server on 127.0.0.1, with a fake access token
|
|
107
|
+
passed in the config. They never call Spotify or read stored credentials.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { OAuthProvider } from '@chronicle.app/auth';
|
|
2
|
+
export declare class SpotifyOAuthProvider extends OAuthProvider {
|
|
3
|
+
static providerId: string;
|
|
4
|
+
static authorizationUrl: string;
|
|
5
|
+
static tokenUrl: string;
|
|
6
|
+
static scopes: string[];
|
|
7
|
+
static requiresClientSecret: boolean;
|
|
8
|
+
static tokenAuthStyle: "basic-header";
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=SpotifyOAuthProvider.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { OAuthProvider } from '@chronicle.app/auth';
|
|
2
|
+
export class SpotifyOAuthProvider extends OAuthProvider {
|
|
3
|
+
static providerId = 'spotify';
|
|
4
|
+
static authorizationUrl = 'https://accounts.spotify.com/authorize';
|
|
5
|
+
static tokenUrl = 'https://accounts.spotify.com/api/token';
|
|
6
|
+
static scopes = [
|
|
7
|
+
'user-read-recently-played',
|
|
8
|
+
'user-read-playback-state',
|
|
9
|
+
'user-read-currently-playing',
|
|
10
|
+
'user-top-read',
|
|
11
|
+
'user-library-read',
|
|
12
|
+
'playlist-read-private',
|
|
13
|
+
'playlist-read-collaborative',
|
|
14
|
+
];
|
|
15
|
+
static requiresClientSecret = true;
|
|
16
|
+
static tokenAuthStyle = 'basic-header';
|
|
17
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Extractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import SpotifyTransformer from './SpotifyTransformer.js';
|
|
4
|
+
import SpotifyProxy, { SpotifyUser } from '../utils/SpotifyProxy.js';
|
|
5
|
+
export declare abstract class SpotifyBaseExtractor<T extends typeof SpotifyBaseExtractor> extends Extractor<T> {
|
|
6
|
+
static source: string;
|
|
7
|
+
static description: string;
|
|
8
|
+
static delivery: "api";
|
|
9
|
+
static strategy: string;
|
|
10
|
+
static defaultTransformer: typeof SpotifyTransformer;
|
|
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
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
limit?: number | undefined;
|
|
19
|
+
accessToken?: string | undefined;
|
|
20
|
+
since?: Date | undefined;
|
|
21
|
+
until?: Date | undefined;
|
|
22
|
+
}, {
|
|
23
|
+
limit?: number | undefined;
|
|
24
|
+
accessToken?: string | undefined;
|
|
25
|
+
since?: Date | undefined;
|
|
26
|
+
until?: Date | undefined;
|
|
27
|
+
}>;
|
|
28
|
+
protected proxy: SpotifyProxy;
|
|
29
|
+
protected user?: SpotifyUser;
|
|
30
|
+
/**
|
|
31
|
+
* Check if valid Spotify credentials are available
|
|
32
|
+
*/
|
|
33
|
+
static hasValidCredentials(): Promise<boolean>;
|
|
34
|
+
setup(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Create a record with user context
|
|
37
|
+
*/
|
|
38
|
+
protected createRecordWithUser(data: any, options: {
|
|
39
|
+
recordType: string;
|
|
40
|
+
}): Record;
|
|
41
|
+
determineCount(): Promise<number | null>;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=SpotifyBaseExtractor.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Extractor } from '@chronicle.app/etl';
|
|
2
|
+
import { TokenHelper } from '@chronicle.app/auth';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import SpotifyTransformer from './SpotifyTransformer.js';
|
|
5
|
+
import SpotifyProxy from '../utils/SpotifyProxy.js';
|
|
6
|
+
export class SpotifyBaseExtractor extends Extractor {
|
|
7
|
+
static source = 'spotify';
|
|
8
|
+
static description = 'Base extractor for Spotify data';
|
|
9
|
+
static delivery = 'api';
|
|
10
|
+
static strategy = 'api';
|
|
11
|
+
static defaultTransformer = SpotifyTransformer;
|
|
12
|
+
static schema = Extractor.schema.extend({
|
|
13
|
+
accessToken: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('OAuth access token for Spotify API authentication (auto-retrieved if not provided)'),
|
|
17
|
+
});
|
|
18
|
+
proxy;
|
|
19
|
+
user;
|
|
20
|
+
/**
|
|
21
|
+
* Check if valid Spotify credentials are available
|
|
22
|
+
*/
|
|
23
|
+
static async hasValidCredentials() {
|
|
24
|
+
try {
|
|
25
|
+
await TokenHelper.getValidToken('spotify');
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async setup() {
|
|
33
|
+
const config = this.config;
|
|
34
|
+
// SpotifyProxy will automatically handle token retrieval from storage
|
|
35
|
+
// Only pass accessToken if explicitly provided by user
|
|
36
|
+
this.proxy = new SpotifyProxy(config.accessToken);
|
|
37
|
+
// Get user info for use in transformations
|
|
38
|
+
this.user = await this.proxy.getUser();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create a record with user context
|
|
42
|
+
*/
|
|
43
|
+
createRecordWithUser(data, options) {
|
|
44
|
+
return this.createRecord(data, {
|
|
45
|
+
recordType: options.recordType,
|
|
46
|
+
user: this.user,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async determineCount() {
|
|
50
|
+
try {
|
|
51
|
+
return await this.proxy.getTotalItemCount();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
4
|
+
export declare class SpotifyPlaylistsExtractor extends SpotifyBaseExtractor<typeof SpotifyPlaylistsExtractor> {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static schema: z.ZodObject<{
|
|
8
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
9
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
10
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
} & {
|
|
12
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, "strip", z.ZodTypeAny, {
|
|
14
|
+
limit?: number | undefined;
|
|
15
|
+
accessToken?: string | undefined;
|
|
16
|
+
since?: Date | undefined;
|
|
17
|
+
until?: Date | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
limit?: number | undefined;
|
|
20
|
+
accessToken?: string | undefined;
|
|
21
|
+
since?: Date | undefined;
|
|
22
|
+
until?: Date | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
extract(): AsyncGenerator<Record>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=SpotifyPlaylistsExtractor.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
2
|
+
export class SpotifyPlaylistsExtractor extends SpotifyBaseExtractor {
|
|
3
|
+
static description = 'Tracks added to playlists';
|
|
4
|
+
static recordTypes = ['playlist-tracks'];
|
|
5
|
+
static schema = SpotifyBaseExtractor.schema.extend({
|
|
6
|
+
// No additional schema - inherits limit from base Extractor
|
|
7
|
+
});
|
|
8
|
+
async *extract() {
|
|
9
|
+
const config = this.config;
|
|
10
|
+
try {
|
|
11
|
+
let recordCount = 0;
|
|
12
|
+
const maxRecords = config.limit || Number.POSITIVE_INFINITY;
|
|
13
|
+
const playlists = await this.proxy.getPlaylists();
|
|
14
|
+
for (const playlist of playlists) {
|
|
15
|
+
if (this.shouldStopExtracting(recordCount))
|
|
16
|
+
break;
|
|
17
|
+
const tracks = await this.proxy.getPlaylistTracks(playlist.id, maxRecords - recordCount);
|
|
18
|
+
for (const trackItem of tracks) {
|
|
19
|
+
if (this.shouldStopExtracting(recordCount))
|
|
20
|
+
break;
|
|
21
|
+
if (!trackItem.added_at) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
yield this.createRecordWithUser({
|
|
25
|
+
...trackItem,
|
|
26
|
+
playlist: {
|
|
27
|
+
id: playlist.id,
|
|
28
|
+
name: playlist.name,
|
|
29
|
+
external_urls: playlist.external_urls,
|
|
30
|
+
},
|
|
31
|
+
}, {
|
|
32
|
+
recordType: 'playlist-tracks',
|
|
33
|
+
});
|
|
34
|
+
recordCount++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
this.logger.error('Error extracting Spotify playlists data');
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
4
|
+
export declare class SpotifyRecentlyPlayedExtractor extends SpotifyBaseExtractor<typeof SpotifyRecentlyPlayedExtractor> {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static default: boolean;
|
|
8
|
+
static schema: z.ZodObject<{
|
|
9
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
10
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
11
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
} & {
|
|
13
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
limit?: number | undefined;
|
|
16
|
+
accessToken?: string | undefined;
|
|
17
|
+
since?: Date | undefined;
|
|
18
|
+
until?: Date | undefined;
|
|
19
|
+
}, {
|
|
20
|
+
limit?: number | undefined;
|
|
21
|
+
accessToken?: string | undefined;
|
|
22
|
+
since?: Date | undefined;
|
|
23
|
+
until?: Date | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
extract(): AsyncGenerator<Record>;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=SpotifyRecentlyPlayedExtractor.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
2
|
+
export class SpotifyRecentlyPlayedExtractor extends SpotifyBaseExtractor {
|
|
3
|
+
static description = 'Recently played tracks';
|
|
4
|
+
static recordTypes = ['listens'];
|
|
5
|
+
static default = true;
|
|
6
|
+
static schema = SpotifyBaseExtractor.schema.extend({
|
|
7
|
+
// No additional schema - inherits since/until/limit from base Extractor
|
|
8
|
+
});
|
|
9
|
+
async *extract() {
|
|
10
|
+
const config = this.config;
|
|
11
|
+
try {
|
|
12
|
+
let recordCount = 0;
|
|
13
|
+
const items = await this.proxy.getRecentlyPlayed({
|
|
14
|
+
since: config.since,
|
|
15
|
+
until: config.until,
|
|
16
|
+
limit: config.limit,
|
|
17
|
+
});
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
if (this.shouldStopExtracting(recordCount))
|
|
20
|
+
break;
|
|
21
|
+
yield this.createRecordWithUser(item, {
|
|
22
|
+
recordType: 'listens',
|
|
23
|
+
});
|
|
24
|
+
recordCount++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.logger.error('Error extracting Spotify recently played data');
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
4
|
+
export declare class SpotifySavedAlbumsExtractor extends SpotifyBaseExtractor<typeof SpotifySavedAlbumsExtractor> {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static schema: z.ZodObject<{
|
|
8
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
9
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
10
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
} & {
|
|
12
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, "strip", z.ZodTypeAny, {
|
|
14
|
+
limit?: number | undefined;
|
|
15
|
+
accessToken?: string | undefined;
|
|
16
|
+
since?: Date | undefined;
|
|
17
|
+
until?: Date | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
limit?: number | undefined;
|
|
20
|
+
accessToken?: string | undefined;
|
|
21
|
+
since?: Date | undefined;
|
|
22
|
+
until?: Date | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
extract(): AsyncGenerator<Record>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=SpotifySavedAlbumsExtractor.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
2
|
+
export class SpotifySavedAlbumsExtractor extends SpotifyBaseExtractor {
|
|
3
|
+
static description = 'Saved albums';
|
|
4
|
+
static recordTypes = ['saved-albums'];
|
|
5
|
+
static schema = SpotifyBaseExtractor.schema.extend({
|
|
6
|
+
// No additional schema - inherits limit from base Extractor
|
|
7
|
+
});
|
|
8
|
+
async *extract() {
|
|
9
|
+
const config = this.config;
|
|
10
|
+
try {
|
|
11
|
+
let recordCount = 0;
|
|
12
|
+
const items = await this.proxy.getSavedAlbums(config.limit);
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
if (this.shouldStopExtracting(recordCount))
|
|
15
|
+
break;
|
|
16
|
+
yield this.createRecordWithUser(item, {
|
|
17
|
+
recordType: 'saved-albums',
|
|
18
|
+
});
|
|
19
|
+
recordCount++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.logger.error('Error extracting Spotify saved albums data');
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
4
|
+
export declare class SpotifySavedTracksExtractor extends SpotifyBaseExtractor<typeof SpotifySavedTracksExtractor> {
|
|
5
|
+
static description: string;
|
|
6
|
+
static recordTypes: string[];
|
|
7
|
+
static schema: z.ZodObject<{
|
|
8
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
9
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
10
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
11
|
+
} & {
|
|
12
|
+
accessToken: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, "strip", z.ZodTypeAny, {
|
|
14
|
+
limit?: number | undefined;
|
|
15
|
+
accessToken?: string | undefined;
|
|
16
|
+
since?: Date | undefined;
|
|
17
|
+
until?: Date | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
limit?: number | undefined;
|
|
20
|
+
accessToken?: string | undefined;
|
|
21
|
+
since?: Date | undefined;
|
|
22
|
+
until?: Date | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
extract(): AsyncGenerator<Record>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=SpotifySavedTracksExtractor.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SpotifyBaseExtractor } from './SpotifyBaseExtractor.js';
|
|
2
|
+
export class SpotifySavedTracksExtractor extends SpotifyBaseExtractor {
|
|
3
|
+
static description = 'Saved tracks';
|
|
4
|
+
static recordTypes = ['saved-tracks'];
|
|
5
|
+
static schema = SpotifyBaseExtractor.schema.extend({
|
|
6
|
+
// No additional schema - inherits limit from base Extractor
|
|
7
|
+
});
|
|
8
|
+
async *extract() {
|
|
9
|
+
const config = this.config;
|
|
10
|
+
try {
|
|
11
|
+
let recordCount = 0;
|
|
12
|
+
const items = await this.proxy.getSavedTracks(config.limit);
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
if (this.shouldStopExtracting(recordCount))
|
|
15
|
+
break;
|
|
16
|
+
yield this.createRecordWithUser(item, {
|
|
17
|
+
recordType: 'saved-tracks',
|
|
18
|
+
});
|
|
19
|
+
recordCount++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.logger.error('Error extracting Spotify saved tracks data');
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { ActionAndChildren } from '@chronicle.app/schema';
|
|
3
|
+
export default class SpotifyTransformer extends ChronicleTransformer {
|
|
4
|
+
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
5
|
+
private buildListenActions;
|
|
6
|
+
private buildSaveActions;
|
|
7
|
+
private buildPlaylistTrackActions;
|
|
8
|
+
private buildMusicRecording;
|
|
9
|
+
private buildMusicGroup;
|
|
10
|
+
private buildPlaylistEntity;
|
|
11
|
+
private buildMusicAlbum;
|
|
12
|
+
private buildUser;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=SpotifyTransformer.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { ChronicleTransformer, createImageObject } from '@chronicle.app/etl';
|
|
2
|
+
export default class SpotifyTransformer extends ChronicleTransformer {
|
|
3
|
+
async transform(record) {
|
|
4
|
+
const actions = [];
|
|
5
|
+
switch (record.extraction.recordType) {
|
|
6
|
+
case 'listens':
|
|
7
|
+
actions.push(...this.buildListenActions(record));
|
|
8
|
+
break;
|
|
9
|
+
case 'saved-tracks':
|
|
10
|
+
case 'saved-albums':
|
|
11
|
+
actions.push(...this.buildSaveActions(record));
|
|
12
|
+
break;
|
|
13
|
+
case 'playlist-tracks':
|
|
14
|
+
actions.push(...this.buildPlaylistTrackActions(record));
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
return actions;
|
|
18
|
+
}
|
|
19
|
+
buildListenActions(record) {
|
|
20
|
+
const { data } = record;
|
|
21
|
+
const user = this.buildUser(record);
|
|
22
|
+
const track = this.buildMusicRecording(data.track);
|
|
23
|
+
const action = {
|
|
24
|
+
'@type': 'ListenAction',
|
|
25
|
+
'@key': ['@type', 'source', 'agent.handle', 'object.sourceId', 'timestamp'],
|
|
26
|
+
timestamp: new Date(data.played_at),
|
|
27
|
+
source: 'spotify',
|
|
28
|
+
agent: user,
|
|
29
|
+
object: track,
|
|
30
|
+
};
|
|
31
|
+
return [action];
|
|
32
|
+
}
|
|
33
|
+
buildSaveActions(record) {
|
|
34
|
+
const { data } = record;
|
|
35
|
+
const user = this.buildUser(record);
|
|
36
|
+
let object;
|
|
37
|
+
if (data.track) {
|
|
38
|
+
// Saved track
|
|
39
|
+
object = this.buildMusicRecording(data.track);
|
|
40
|
+
}
|
|
41
|
+
else if (data.album) {
|
|
42
|
+
// Saved album
|
|
43
|
+
object = this.buildMusicAlbum(data.album);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
throw new Error('Save action must have either track or album data');
|
|
47
|
+
}
|
|
48
|
+
const action = {
|
|
49
|
+
'@type': 'LikeAction',
|
|
50
|
+
'@key': ['@type', 'source', 'agent.handle', 'object.sourceId', 'timestamp'],
|
|
51
|
+
timestamp: new Date(data.added_at),
|
|
52
|
+
source: 'spotify',
|
|
53
|
+
agent: user,
|
|
54
|
+
object,
|
|
55
|
+
};
|
|
56
|
+
return [action];
|
|
57
|
+
}
|
|
58
|
+
buildPlaylistTrackActions(record) {
|
|
59
|
+
const { data } = record;
|
|
60
|
+
const user = this.buildUser(record);
|
|
61
|
+
const track = this.buildMusicRecording(data.track);
|
|
62
|
+
const playlist = this.buildPlaylistEntity(data.playlist);
|
|
63
|
+
// AddAction represents adding a track to a playlist collection
|
|
64
|
+
const action = {
|
|
65
|
+
'@type': 'AddAction',
|
|
66
|
+
'@key': ['@type', 'source', 'object.sourceId', 'target.sourceId', 'timestamp'],
|
|
67
|
+
timestamp: new Date(data.added_at),
|
|
68
|
+
source: 'spotify',
|
|
69
|
+
agent: user,
|
|
70
|
+
object: track, // The track being added
|
|
71
|
+
target: playlist, // The playlist collection
|
|
72
|
+
};
|
|
73
|
+
return [action];
|
|
74
|
+
}
|
|
75
|
+
buildMusicRecording(trackData) {
|
|
76
|
+
const recording = {
|
|
77
|
+
'@type': 'MusicRecording',
|
|
78
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
79
|
+
source: 'spotify',
|
|
80
|
+
sourceId: trackData.id,
|
|
81
|
+
url: trackData.external_urls.spotify,
|
|
82
|
+
name: trackData.name,
|
|
83
|
+
duration: `PT${Math.floor(trackData.duration_ms / 1000)}S`,
|
|
84
|
+
...(trackData.preview_url && { contentUrl: trackData.preview_url }),
|
|
85
|
+
};
|
|
86
|
+
// Add artists
|
|
87
|
+
if (trackData.artists && trackData.artists.length > 0) {
|
|
88
|
+
recording.artist = trackData.artists.map((artist) => this.buildMusicGroup(artist));
|
|
89
|
+
}
|
|
90
|
+
// Add album
|
|
91
|
+
if (trackData.album) {
|
|
92
|
+
recording.inAlbum = this.buildMusicAlbum(trackData.album);
|
|
93
|
+
}
|
|
94
|
+
// Add track image (usually from album artwork)
|
|
95
|
+
if (trackData.album?.images?.[0]) {
|
|
96
|
+
const largestImage = trackData.album.images[0];
|
|
97
|
+
recording.emblem = createImageObject({
|
|
98
|
+
url: largestImage.url,
|
|
99
|
+
width: largestImage.width,
|
|
100
|
+
height: largestImage.height,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return recording;
|
|
104
|
+
}
|
|
105
|
+
buildMusicGroup(artistData) {
|
|
106
|
+
const group = {
|
|
107
|
+
'@type': 'MusicGroup',
|
|
108
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
109
|
+
source: 'spotify',
|
|
110
|
+
sourceId: artistData.id,
|
|
111
|
+
url: artistData.external_urls?.spotify || `https://open.spotify.com/artist/${artistData.id}`,
|
|
112
|
+
name: artistData.name,
|
|
113
|
+
...(artistData.genres && { genre: artistData.genres }),
|
|
114
|
+
};
|
|
115
|
+
// Add artist image as attachment if available
|
|
116
|
+
if (artistData.images?.[0]) {
|
|
117
|
+
const largestImage = artistData.images[0];
|
|
118
|
+
group.emblem = createImageObject({
|
|
119
|
+
url: largestImage.url,
|
|
120
|
+
width: largestImage.width,
|
|
121
|
+
height: largestImage.height,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return group;
|
|
125
|
+
}
|
|
126
|
+
buildPlaylistEntity(playlistData) {
|
|
127
|
+
const entity = {
|
|
128
|
+
'@type': 'Entity',
|
|
129
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
130
|
+
source: 'spotify',
|
|
131
|
+
sourceId: playlistData.id,
|
|
132
|
+
url: playlistData.external_urls.spotify,
|
|
133
|
+
name: playlistData.name,
|
|
134
|
+
description: playlistData.description,
|
|
135
|
+
};
|
|
136
|
+
// Add playlist image as attachment if available
|
|
137
|
+
if (playlistData.images?.[0]) {
|
|
138
|
+
const largestImage = playlistData.images[0];
|
|
139
|
+
entity.emblem = createImageObject({
|
|
140
|
+
url: largestImage.url,
|
|
141
|
+
width: largestImage.width,
|
|
142
|
+
height: largestImage.height,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return entity;
|
|
146
|
+
}
|
|
147
|
+
buildMusicAlbum(albumData) {
|
|
148
|
+
const album = {
|
|
149
|
+
'@type': 'MusicAlbum',
|
|
150
|
+
'@key': ['@type', 'source', 'sourceId'],
|
|
151
|
+
source: 'spotify',
|
|
152
|
+
sourceId: albumData.id,
|
|
153
|
+
url: albumData.external_urls?.spotify || `https://open.spotify.com/album/${albumData.id}`,
|
|
154
|
+
name: albumData.name,
|
|
155
|
+
...(albumData.release_date && { datePublished: albumData.release_date }),
|
|
156
|
+
};
|
|
157
|
+
// Add album art as attachment if available
|
|
158
|
+
if (albumData.images?.[0]) {
|
|
159
|
+
const largestImage = albumData.images[0];
|
|
160
|
+
album.emblem = createImageObject({
|
|
161
|
+
url: largestImage.url,
|
|
162
|
+
width: largestImage.width,
|
|
163
|
+
height: largestImage.height,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Add album artists
|
|
167
|
+
if (albumData.artists && albumData.artists.length > 0) {
|
|
168
|
+
album.artist = albumData.artists.map((artist) => this.buildMusicGroup(artist));
|
|
169
|
+
}
|
|
170
|
+
return album;
|
|
171
|
+
}
|
|
172
|
+
buildUser(record) {
|
|
173
|
+
const userData = record.context?.user;
|
|
174
|
+
if (!userData) {
|
|
175
|
+
throw new Error('User data not available in record context');
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
'@type': 'Agent',
|
|
179
|
+
'@key': ['@type', 'source', 'handle'],
|
|
180
|
+
source: 'spotify',
|
|
181
|
+
handle: userData.id,
|
|
182
|
+
name: userData.display_name || userData.id,
|
|
183
|
+
url: userData.external_urls?.spotify || `https://open.spotify.com/user/${userData.id}`,
|
|
184
|
+
sameAs: ['@me'],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { SpotifyBaseExtractor } from './connectors/SpotifyBaseExtractor.js';
|
|
2
|
+
export { SpotifyRecentlyPlayedExtractor } from './connectors/SpotifyRecentlyPlayedExtractor.js';
|
|
3
|
+
export { SpotifySavedTracksExtractor } from './connectors/SpotifySavedTracksExtractor.js';
|
|
4
|
+
export { SpotifySavedAlbumsExtractor } from './connectors/SpotifySavedAlbumsExtractor.js';
|
|
5
|
+
export { SpotifyPlaylistsExtractor } from './connectors/SpotifyPlaylistsExtractor.js';
|
|
6
|
+
export { default as SpotifyTransformer } from './connectors/SpotifyTransformer.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Export extractors
|
|
2
|
+
export { SpotifyBaseExtractor } from './connectors/SpotifyBaseExtractor.js';
|
|
3
|
+
export { SpotifyRecentlyPlayedExtractor } from './connectors/SpotifyRecentlyPlayedExtractor.js';
|
|
4
|
+
export { SpotifySavedTracksExtractor } from './connectors/SpotifySavedTracksExtractor.js';
|
|
5
|
+
export { SpotifySavedAlbumsExtractor } from './connectors/SpotifySavedAlbumsExtractor.js';
|
|
6
|
+
export { SpotifyPlaylistsExtractor } from './connectors/SpotifyPlaylistsExtractor.js';
|
|
7
|
+
export { default as SpotifyTransformer } from './connectors/SpotifyTransformer.js';
|
|
8
|
+
// Register OAuth provider
|
|
9
|
+
import { OAuthProviderRegistry } from '@chronicle.app/auth';
|
|
10
|
+
import { SpotifyOAuthProvider } from './auth/SpotifyOAuthProvider.js';
|
|
11
|
+
OAuthProviderRegistry.register(SpotifyOAuthProvider);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ApiProxy } from '@chronicle.app/etl';
|
|
2
|
+
export interface SpotifyTrack {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
artists: Array<{
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}>;
|
|
9
|
+
album: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
release_date: string;
|
|
13
|
+
images: Array<{
|
|
14
|
+
url: string;
|
|
15
|
+
height: number;
|
|
16
|
+
width: number;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
duration_ms: number;
|
|
20
|
+
explicit: boolean;
|
|
21
|
+
external_urls: {
|
|
22
|
+
spotify: string;
|
|
23
|
+
};
|
|
24
|
+
popularity: number;
|
|
25
|
+
preview_url?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SpotifyRecentlyPlayed {
|
|
28
|
+
track: SpotifyTrack;
|
|
29
|
+
played_at: string;
|
|
30
|
+
context?: {
|
|
31
|
+
type: string;
|
|
32
|
+
href: string;
|
|
33
|
+
external_urls: {
|
|
34
|
+
spotify: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface SpotifySavedTrack {
|
|
39
|
+
added_at: string;
|
|
40
|
+
track: SpotifyTrack;
|
|
41
|
+
}
|
|
42
|
+
export interface SpotifyPlaylist {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
public: boolean;
|
|
47
|
+
collaborative: boolean;
|
|
48
|
+
tracks: {
|
|
49
|
+
total: number;
|
|
50
|
+
};
|
|
51
|
+
external_urls: {
|
|
52
|
+
spotify: string;
|
|
53
|
+
};
|
|
54
|
+
images: Array<{
|
|
55
|
+
url: string;
|
|
56
|
+
height?: number;
|
|
57
|
+
width?: number;
|
|
58
|
+
}>;
|
|
59
|
+
owner: {
|
|
60
|
+
id: string;
|
|
61
|
+
display_name?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export interface SpotifyArtist {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
genres: string[];
|
|
68
|
+
popularity: number;
|
|
69
|
+
external_urls: {
|
|
70
|
+
spotify: string;
|
|
71
|
+
};
|
|
72
|
+
images: Array<{
|
|
73
|
+
url: string;
|
|
74
|
+
height: number;
|
|
75
|
+
width: number;
|
|
76
|
+
}>;
|
|
77
|
+
followers: {
|
|
78
|
+
total: number;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export interface SpotifyAlbum {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
artists: Array<{
|
|
85
|
+
id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
}>;
|
|
88
|
+
album_type: string;
|
|
89
|
+
release_date: string;
|
|
90
|
+
release_date_precision: string;
|
|
91
|
+
total_tracks: number;
|
|
92
|
+
images: Array<{
|
|
93
|
+
url: string;
|
|
94
|
+
height?: number;
|
|
95
|
+
width?: number;
|
|
96
|
+
}>;
|
|
97
|
+
external_urls: {
|
|
98
|
+
spotify: string;
|
|
99
|
+
};
|
|
100
|
+
genres: string[];
|
|
101
|
+
popularity: number;
|
|
102
|
+
}
|
|
103
|
+
export interface SpotifySavedAlbum {
|
|
104
|
+
added_at: string;
|
|
105
|
+
album: SpotifyAlbum;
|
|
106
|
+
}
|
|
107
|
+
export interface SpotifyUser {
|
|
108
|
+
id: string;
|
|
109
|
+
display_name?: string;
|
|
110
|
+
email?: string;
|
|
111
|
+
external_urls: {
|
|
112
|
+
spotify: string;
|
|
113
|
+
};
|
|
114
|
+
followers: {
|
|
115
|
+
total: number;
|
|
116
|
+
};
|
|
117
|
+
images: Array<{
|
|
118
|
+
url: string;
|
|
119
|
+
height?: number;
|
|
120
|
+
width?: number;
|
|
121
|
+
}>;
|
|
122
|
+
country?: string;
|
|
123
|
+
}
|
|
124
|
+
export default class SpotifyProxy extends ApiProxy {
|
|
125
|
+
constructor(accessToken?: string);
|
|
126
|
+
/**
|
|
127
|
+
* Ensure we have a valid access token, using stored credentials if available
|
|
128
|
+
*/
|
|
129
|
+
initialize(): Promise<void>;
|
|
130
|
+
getRecentlyPlayed(options?: {
|
|
131
|
+
since?: Date;
|
|
132
|
+
until?: Date;
|
|
133
|
+
limit?: number;
|
|
134
|
+
}): Promise<SpotifyRecentlyPlayed[]>;
|
|
135
|
+
getSavedTracks(limit?: number): Promise<SpotifySavedTrack[]>;
|
|
136
|
+
getSavedAlbums(limit?: number): Promise<SpotifySavedAlbum[]>;
|
|
137
|
+
getPlaylists(): Promise<SpotifyPlaylist[]>;
|
|
138
|
+
getPlaylistTracks(playlistId: string, limit?: number): Promise<any[]>;
|
|
139
|
+
getTopTracks(timeRange?: 'short_term' | 'medium_term' | 'long_term'): Promise<SpotifyTrack[]>;
|
|
140
|
+
getTopArtists(timeRange?: 'short_term' | 'medium_term' | 'long_term'): Promise<SpotifyArtist[]>;
|
|
141
|
+
getUser(): Promise<SpotifyUser>;
|
|
142
|
+
getTotalItemCount(): Promise<number>;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=SpotifyProxy.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ApiAuthError, ApiProxy } from '@chronicle.app/etl';
|
|
2
|
+
import { TokenHelper } from '@chronicle.app/auth';
|
|
3
|
+
export default class SpotifyProxy extends ApiProxy {
|
|
4
|
+
constructor(accessToken) {
|
|
5
|
+
super({
|
|
6
|
+
baseURL: 'https://api.spotify.com/v1',
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
},
|
|
10
|
+
pageDelayMs: 100,
|
|
11
|
+
});
|
|
12
|
+
// If token provided, use it directly
|
|
13
|
+
if (accessToken) {
|
|
14
|
+
this.setAccessToken(accessToken);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Ensure we have a valid access token, using stored credentials if available
|
|
19
|
+
*/
|
|
20
|
+
async initialize() {
|
|
21
|
+
// If we already have an access token, we're good
|
|
22
|
+
if (this.accessToken) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Try to get token from stored credentials
|
|
26
|
+
const token = await TokenHelper.getValidToken('spotify');
|
|
27
|
+
this.setAccessToken(token);
|
|
28
|
+
}
|
|
29
|
+
async getRecentlyPlayed(options = {}) {
|
|
30
|
+
await this.initialize();
|
|
31
|
+
const params = {
|
|
32
|
+
limit: Math.min(options.limit || 50, 50), // Spotify max is 50
|
|
33
|
+
};
|
|
34
|
+
if (options.since) {
|
|
35
|
+
params.after = options.since.getTime();
|
|
36
|
+
}
|
|
37
|
+
if (options.until) {
|
|
38
|
+
params.before = options.until.getTime();
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const data = await this.request({
|
|
42
|
+
url: '/me/player/recently-played',
|
|
43
|
+
method: 'GET',
|
|
44
|
+
params,
|
|
45
|
+
});
|
|
46
|
+
return data.items || [];
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof ApiAuthError) {
|
|
50
|
+
// eslint-disable-next-line unicorn/prefer-type-error -- rethrowing a friendlier message for a caught ApiAuthError, not validating an argument type
|
|
51
|
+
throw new Error('Spotify authentication failed. Please re-authenticate:\n' +
|
|
52
|
+
' chronicle auth login spotify --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET\n\n' +
|
|
53
|
+
'Or get your credentials at: https://developer.spotify.com/dashboard');
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async getSavedTracks(limit) {
|
|
59
|
+
await this.initialize();
|
|
60
|
+
const batchSize = 50; // Spotify max per request
|
|
61
|
+
return this.paginateOffset({
|
|
62
|
+
pageSize: batchSize,
|
|
63
|
+
limit,
|
|
64
|
+
fetchPage: async (offset) => {
|
|
65
|
+
const data = await this.request({
|
|
66
|
+
url: '/me/tracks',
|
|
67
|
+
method: 'GET',
|
|
68
|
+
params: { limit: batchSize, offset },
|
|
69
|
+
});
|
|
70
|
+
return data.items || [];
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async getSavedAlbums(limit) {
|
|
75
|
+
await this.initialize();
|
|
76
|
+
const batchSize = 50; // Spotify max per request
|
|
77
|
+
return this.paginateOffset({
|
|
78
|
+
pageSize: batchSize,
|
|
79
|
+
limit,
|
|
80
|
+
fetchPage: async (offset) => {
|
|
81
|
+
const data = await this.request({
|
|
82
|
+
url: '/me/albums',
|
|
83
|
+
method: 'GET',
|
|
84
|
+
params: { limit: batchSize, offset },
|
|
85
|
+
});
|
|
86
|
+
return data.items || [];
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async getPlaylists() {
|
|
91
|
+
const pageSize = 50; // Spotify max per request
|
|
92
|
+
return this.paginateOffset({
|
|
93
|
+
pageSize,
|
|
94
|
+
fetchPage: async (offset) => {
|
|
95
|
+
const data = await this.request({
|
|
96
|
+
url: '/me/playlists',
|
|
97
|
+
method: 'GET',
|
|
98
|
+
params: { limit: pageSize, offset },
|
|
99
|
+
});
|
|
100
|
+
return data.items || [];
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async getPlaylistTracks(playlistId, limit) {
|
|
105
|
+
await this.initialize();
|
|
106
|
+
const batchSize = 50; // Spotify max per request
|
|
107
|
+
return this.paginateOffset({
|
|
108
|
+
pageSize: batchSize,
|
|
109
|
+
limit,
|
|
110
|
+
fetchPage: async (offset) => {
|
|
111
|
+
const data = await this.request({
|
|
112
|
+
url: `/playlists/${playlistId}/tracks`,
|
|
113
|
+
method: 'GET',
|
|
114
|
+
params: {
|
|
115
|
+
limit: batchSize,
|
|
116
|
+
offset,
|
|
117
|
+
fields: 'items(added_at,track(id,name,artists,album,duration_ms,external_urls,popularity)),total',
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
return data.items || [];
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async getTopTracks(timeRange = 'medium_term') {
|
|
125
|
+
const data = await this.request({
|
|
126
|
+
url: '/me/top/tracks',
|
|
127
|
+
method: 'GET',
|
|
128
|
+
params: {
|
|
129
|
+
time_range: timeRange,
|
|
130
|
+
limit: 50,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return data.items || [];
|
|
134
|
+
}
|
|
135
|
+
async getTopArtists(timeRange = 'medium_term') {
|
|
136
|
+
const data = await this.request({
|
|
137
|
+
url: '/me/top/artists',
|
|
138
|
+
method: 'GET',
|
|
139
|
+
params: {
|
|
140
|
+
time_range: timeRange,
|
|
141
|
+
limit: 50,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
return data.items || [];
|
|
145
|
+
}
|
|
146
|
+
async getUser() {
|
|
147
|
+
await this.initialize();
|
|
148
|
+
return this.request({
|
|
149
|
+
url: '/me',
|
|
150
|
+
method: 'GET',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async getTotalItemCount() {
|
|
154
|
+
try {
|
|
155
|
+
// Get user profile to check if API is working
|
|
156
|
+
await this.request({ url: '/me', method: 'GET' });
|
|
157
|
+
// Get counts from different endpoints
|
|
158
|
+
const [savedTracksResponse, playlistsResponse] = await Promise.all([
|
|
159
|
+
this.request({
|
|
160
|
+
url: '/me/tracks',
|
|
161
|
+
method: 'GET',
|
|
162
|
+
params: { limit: 1 },
|
|
163
|
+
}),
|
|
164
|
+
this.request({
|
|
165
|
+
url: '/me/playlists',
|
|
166
|
+
method: 'GET',
|
|
167
|
+
params: { limit: 1 },
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
const savedTracksCount = savedTracksResponse.total || 0;
|
|
171
|
+
const playlistsCount = playlistsResponse.total || 0;
|
|
172
|
+
// Recently played doesn't have a total count, so we estimate based on recent activity
|
|
173
|
+
const recentlyPlayedCount = 50; // Approximate recent activity
|
|
174
|
+
return savedTracksCount + playlistsCount + recentlyPlayedCount;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw new Error(`Failed to get item count: ${error}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/spotify",
|
|
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
|
+
"spotify"
|
|
19
|
+
],
|
|
20
|
+
"author": "Andrew Louis",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"description": "Chronicle plugin for extracting data from Spotify",
|
|
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/spotify"
|
|
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
|
+
"spotify": {
|
|
63
|
+
"strategies": {
|
|
64
|
+
"api": {
|
|
65
|
+
"delivery": "api",
|
|
66
|
+
"recordTypes": [
|
|
67
|
+
"playlist-tracks",
|
|
68
|
+
"listens",
|
|
69
|
+
"saved-albums",
|
|
70
|
+
"saved-tracks"
|
|
71
|
+
],
|
|
72
|
+
"default": true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|