@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 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
+ }
@@ -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
+ }