@cascateer/sterio 1.0.1

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.
Files changed (58) hide show
  1. package/.env +0 -0
  2. package/.github/workflows/publish.yml +11 -0
  3. package/.vscode/settings.json +4 -0
  4. package/build/routes.ts +413 -0
  5. package/build/swagger.json +775 -0
  6. package/openapitools.json +14 -0
  7. package/package.json +56 -0
  8. package/src/Spotify.service.ts +202 -0
  9. package/src/Sterio.service.ts +208 -0
  10. package/src/Youtube.service.ts +112 -0
  11. package/src/YoutubeMusic.service.ts +52 -0
  12. package/src/api/.openapi-generator/FILES +29 -0
  13. package/src/api/.openapi-generator/VERSION +1 -0
  14. package/src/api/.openapi-generator-ignore +23 -0
  15. package/src/api/apis/DefaultApi.ts +154 -0
  16. package/src/api/apis/index.ts +1 -0
  17. package/src/api/index.ts +4 -0
  18. package/src/api/models/GetYoutubeMusicAlbums200ResponseInner.ts +29 -0
  19. package/src/api/models/PartialSterioAlbumResourcesFull.ts +39 -0
  20. package/src/api/models/SpotifyApiAlbumObjectSimplified.ts +152 -0
  21. package/src/api/models/SpotifyApiArtistObjectSimplified.ts +68 -0
  22. package/src/api/models/SpotifyApiExternalUrlObject.ts +25 -0
  23. package/src/api/models/SpotifyApiImageObject.ts +38 -0
  24. package/src/api/models/SpotifyApiRestrictionsObject.ts +24 -0
  25. package/src/api/models/SterioAlbum.ts +39 -0
  26. package/src/api/models/SterioAlbumResource.ts +29 -0
  27. package/src/api/models/SterioAlbumResourcesTable.ts +34 -0
  28. package/src/api/models/SterioAlbumSongs.ts +38 -0
  29. package/src/api/models/SterioAlbumSongsVideoIdsInner.ts +34 -0
  30. package/src/api/models/YoutubePlaylist.ts +38 -0
  31. package/src/api/models/YoutubeV3Schema36PlaylistItem.ts +59 -0
  32. package/src/api/models/YoutubeV3Schema36PlaylistItemContentDetails.ts +49 -0
  33. package/src/api/models/YoutubeV3Schema36PlaylistItemSnippet.ts +89 -0
  34. package/src/api/models/YoutubeV3Schema36PlaylistItemStatus.ts +26 -0
  35. package/src/api/models/YoutubeV3Schema36ResourceId.ts +44 -0
  36. package/src/api/models/YoutubeV3Schema36Thumbnail.ts +38 -0
  37. package/src/api/models/YoutubeV3Schema36ThumbnailDetails.ts +49 -0
  38. package/src/api/models/index.ts +20 -0
  39. package/src/api/runtime.ts +193 -0
  40. package/src/api/servers.ts +45 -0
  41. package/src/api/tsconfig.json +22 -0
  42. package/src/app.ts +58 -0
  43. package/src/config.ts +6 -0
  44. package/src/controller.ts +148 -0
  45. package/src/lib.ts +112 -0
  46. package/src/main.ts +225 -0
  47. package/src/tables.ts +178 -0
  48. package/src/types.ts +113 -0
  49. package/src/views/spotify/auth-callback.ejs +22 -0
  50. package/tables/document-files/82745273-7eda-4169-8b4a-998d2de00ac2.json +5208 -0
  51. package/tables/spotify-albums/5bff8eba-bd2b-4485-8136-1f89da5db6d7.json +1 -0
  52. package/tables/sterio-albums/81d5b327-68b1-4fef-8e40-491c792e8951.json +1 -0
  53. package/tables/stream-files/f6697f37-7320-41df-aa50-b70264fa1bbf.json +3788 -0
  54. package/tables/youtube-music-albums/55fd9bb8-46ba-4d0e-9376-f2f2cbb1aa38.json +1 -0
  55. package/tables/youtube-playlists/e3153ab8-8e1f-491e-995e-3142d07f4ecc.json +1 -0
  56. package/tables/youtube-videos/9067172e-4803-48f1-8b8e-4b942f3ca228.json +1 -0
  57. package/tsconfig.json +16 -0
  58. package/tsoa.json +14 -0
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
3
+ "spaces": 2,
4
+ "generator-cli": {
5
+ "version": "7.8.0",
6
+ "generators": {
7
+ "v1.0": {
8
+ "generatorName": "typescript-rxjs",
9
+ "output": "./src/api",
10
+ "inputSpec": "./build/swagger.json"
11
+ }
12
+ }
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@cascateer/sterio",
3
+ "version": "1.0.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/cascateer/sterio.git"
7
+ },
8
+ "scripts": {
9
+ "semver-patch": "npm version patch && git push origin main --tags",
10
+ "update": "npm cache clean --force && npx npm-check-updates -u && npm i",
11
+ "start": "tsx src/main.ts",
12
+ "cli": "tsx src/client.ts",
13
+ "serve": "tsx src/app.ts",
14
+ "tsoa": "tsoa spec-and-routes && npx openapi-generator-cli generate"
15
+ },
16
+ "exports": {
17
+ "./api": "./src/api/index.ts"
18
+ },
19
+ "dependencies": {
20
+ "@cascateer/database": "^0.0.9",
21
+ "@cascateer/lib": "^1.0.53",
22
+ "@inquirer/prompts": "^8.5.2",
23
+ "cors": "^2.8.6",
24
+ "deepmerge": "^4.3.1",
25
+ "dotenv": "^17.4.2",
26
+ "ejs": "^6.0.1",
27
+ "express": "^5.2.1",
28
+ "express-session": "^1.19.0",
29
+ "filenamify": "^7.0.2",
30
+ "googleapis": "^173.0.0",
31
+ "http-status-codes": "^2.3.0",
32
+ "lodash": "^4.18.1",
33
+ "mime-types": "^3.0.2",
34
+ "node-id3": "^0.2.9",
35
+ "object-hash": "^3.0.0",
36
+ "ora": "^9.4.1",
37
+ "rxjs": "^7.8.2",
38
+ "spotify-web-api-node": "^5.0.2",
39
+ "tsoa": "^7.0.0-alpha.0",
40
+ "uuid": "^14.0.1",
41
+ "ytdlp-nodejs": "^3.4.4",
42
+ "ytmusic-api": "^5.3.1"
43
+ },
44
+ "devDependencies": {
45
+ "@openapitools/openapi-generator-cli": "^2.39.1",
46
+ "@types/cors": "^2.8.19",
47
+ "@types/express": "^5.0.6",
48
+ "@types/express-session": "^1.19.0",
49
+ "@types/lodash": "^4.17.24",
50
+ "@types/mime-types": "^3.0.1",
51
+ "@types/node": "^26.0.1",
52
+ "@types/object-hash": "^3.0.6",
53
+ "@types/spotify-web-api-node": "^5.0.11",
54
+ "typescript": "^6.0.3"
55
+ }
56
+ }
@@ -0,0 +1,202 @@
1
+ import { AsyncEndoFunction, nonNullable } from "@cascateer/lib";
2
+ import { LazyPromise } from "@cascateer/lib/promise";
3
+ import { exec } from "child_process";
4
+ import { readFile, writeFile } from "fs/promises";
5
+ import { StatusCodes } from "http-status-codes";
6
+ import { thru } from "lodash";
7
+ import { firstValueFrom, Subject, timeout, UnaryFunction } from "rxjs";
8
+ import SpotifyWebApi from "spotify-web-api-node";
9
+ import { v4 } from "uuid";
10
+ import { envConfig } from "./config";
11
+ import { chainFunctions, tapPromise } from "./lib";
12
+ import { SpotifyAlbumTable } from "./tables";
13
+ import { SpotifyAlbum, SpotifyGrant } from "./types";
14
+
15
+ const {
16
+ SPOTIFY_CLIENT_ID,
17
+ SPOTIFY_CLIENT_SECRET,
18
+ SPOTIFY_REDIRECT_URI,
19
+ SPOTIFY_GRANT_PATH,
20
+ } = envConfig();
21
+
22
+ export class SpotifyService {
23
+ public static codes = new Subject<string>();
24
+
25
+ private static readonly readGrant = () =>
26
+ readFile(
27
+ nonNullable(SPOTIFY_GRANT_PATH),
28
+ "utf-8",
29
+ ).then<SpotifyGrant | null>(JSON.parse);
30
+
31
+ private static readonly writeGrant = (grant: SpotifyGrant) =>
32
+ writeFile(
33
+ nonNullable(SPOTIFY_GRANT_PATH),
34
+ JSON.stringify(grant, null, "\t"),
35
+ ).then(() => grant);
36
+
37
+ private static lock = new Subject<AsyncEndoFunction<SpotifyWebApi>>();
38
+
39
+ static {
40
+ this.lock
41
+ .pipe(
42
+ chainFunctions(() =>
43
+ this.readGrant().then(
44
+ (grant) =>
45
+ new SpotifyWebApi({
46
+ clientId: SPOTIFY_CLIENT_ID,
47
+ clientSecret: SPOTIFY_CLIENT_SECRET,
48
+ redirectUri: SPOTIFY_REDIRECT_URI,
49
+ accessToken: grant?.access_token,
50
+ refreshToken: grant?.refresh_token,
51
+ }),
52
+ ),
53
+ ),
54
+ )
55
+ .subscribe();
56
+ }
57
+
58
+ private async chain(
59
+ predicate: AsyncEndoFunction<SpotifyWebApi>,
60
+ ): Promise<SpotifyWebApi> {
61
+ return new Promise<SpotifyWebApi>((finish) =>
62
+ SpotifyService.lock.next((api) =>
63
+ tapPromise(Promise.resolve(predicate(api)), { finish }),
64
+ ),
65
+ );
66
+ }
67
+
68
+ private async refreshAccessToken(): Promise<SpotifyWebApi> {
69
+ return new Promise((callback) =>
70
+ this.chain((api) =>
71
+ api
72
+ .refreshAccessToken()
73
+ .then(async ({ body: grant }) => {
74
+ await SpotifyService.writeGrant(grant);
75
+
76
+ api.setAccessToken(grant.access_token);
77
+
78
+ if (grant.refresh_token != null) {
79
+ api.setRefreshToken(grant.refresh_token);
80
+ }
81
+
82
+ return api;
83
+ })
84
+ .catch((error) => {
85
+ console.log(error);
86
+
87
+ this.codeGrantAuthorization(callback);
88
+
89
+ return api;
90
+ }),
91
+ ),
92
+ );
93
+ }
94
+
95
+ private async codeGrantAuthorization(
96
+ callback?: UnaryFunction<SpotifyWebApi, void>,
97
+ ): Promise<SpotifyWebApi> {
98
+ return tapPromise(
99
+ this.chain((api) =>
100
+ tapPromise(
101
+ firstValueFrom(SpotifyService.codes.pipe(timeout(20 * 60e3))),
102
+ {
103
+ start: () =>
104
+ exec(
105
+ `start chrome --new-window "${api.createAuthorizeURL(
106
+ /**
107
+ * https://developer.spotify.com/documentation/web-api/concepts/scopes
108
+ */
109
+ ["user-library-read", "user-read-email", "user-read-private"],
110
+ v4(),
111
+ )}"`,
112
+ ),
113
+ },
114
+ ).then((code) =>
115
+ api.authorizationCodeGrant(code).then(async ({ body: grant }) => {
116
+ await SpotifyService.writeGrant(grant);
117
+
118
+ api.setAccessToken(grant.access_token);
119
+ api.setRefreshToken(grant.refresh_token);
120
+
121
+ return api;
122
+ }),
123
+ ),
124
+ ),
125
+ { finish: callback },
126
+ );
127
+ }
128
+
129
+ async request<T>(
130
+ predicate: UnaryFunction<SpotifyWebApi, Promise<T>>,
131
+ callback?: UnaryFunction<T, void>,
132
+ ): Promise<T> {
133
+ return tapPromise(
134
+ new Promise<T>((resolve) =>
135
+ this.chain((api) =>
136
+ predicate(api)
137
+ .then(resolve)
138
+ .catch((error) => {
139
+ if (error.statusCode === StatusCodes.UNAUTHORIZED) {
140
+ (api.getRefreshToken() != null
141
+ ? this.refreshAccessToken()
142
+ : this.codeGrantAuthorization()
143
+ ).then(() => this.request(predicate, resolve));
144
+
145
+ return;
146
+ }
147
+
148
+ throw error;
149
+ })
150
+ .then(() => api),
151
+ ),
152
+ ),
153
+ { finish: callback },
154
+ );
155
+ }
156
+
157
+ async getAlbums(ids: string[]): Promise<SpotifyAlbum[]> {
158
+ return this.request((api) =>
159
+ LazyPromise.concatAll(
160
+ ids.map(
161
+ (id) =>
162
+ new LazyPromise(() => api.getAlbum(id).then(({ body }) => body)),
163
+ ),
164
+ ),
165
+ );
166
+ }
167
+
168
+ get albums() {
169
+ return new SpotifyAlbumTable();
170
+ }
171
+
172
+ public async searchAlbums(
173
+ query: string,
174
+ maxResults = Infinity,
175
+ ): Promise<SpotifyApi.AlbumObjectSimplified[]> {
176
+ return query
177
+ ? this.request((api) => {
178
+ const searchAlbums = (
179
+ query: string,
180
+ offset?: number,
181
+ ): Promise<SpotifyApi.AlbumObjectSimplified[]> =>
182
+ api
183
+ .searchAlbums(query, { limit: 10, offset })
184
+ .then(async ({ body }) => {
185
+ const nextOffset = thru(body.albums?.next, (next) => {
186
+ if (next != null) {
187
+ return new URL(next).searchParams.get("offset");
188
+ }
189
+ });
190
+
191
+ return (body.albums?.items ?? []).concat(
192
+ nextOffset != null && +nextOffset < maxResults
193
+ ? await searchAlbums(query, +nextOffset)
194
+ : [],
195
+ );
196
+ });
197
+
198
+ return searchAlbums(query);
199
+ })
200
+ : [];
201
+ }
202
+ }
@@ -0,0 +1,208 @@
1
+ import { nonNullable, property } from "@cascateer/lib";
2
+ import { LazyPromise } from "@cascateer/lib/promise";
3
+ import deepmerge from "deepmerge";
4
+ import { cloneDeep, compact, isEqual, maxBy, thru } from "lodash";
5
+ import { Ora } from "ora";
6
+ import { pageIndex } from "./lib";
7
+ import { DocumentFileTable, SterioAlbumTable } from "./tables";
8
+ import {
9
+ SterioAlbum,
10
+ SterioAlbumFull,
11
+ SterioDuration,
12
+ SterioSongFull,
13
+ } from "./types";
14
+ import { YoutubeService } from "./Youtube.service";
15
+ import { YoutubeMusicService } from "./YoutubeMusic.service";
16
+
17
+ export class SterioService {
18
+ get albums() {
19
+ return new SterioAlbumTable();
20
+ }
21
+
22
+ async getAlbumFull(id: string, spinner?: Ora): Promise<SterioAlbumFull> {
23
+ return this.albums.accessSome([id], spinner).then(([sterioAlbum]) =>
24
+ new YoutubeMusicService().albums
25
+ .accessSome(
26
+ [nonNullable(nonNullable(sterioAlbum.resources).youtubeMusic).id],
27
+ spinner,
28
+ )
29
+ .then(([youtubeMusicAlbum]) => {
30
+ const album = cloneDeep(youtubeMusicAlbum);
31
+
32
+ for (const { index, expectedValue, newValue } of sterioAlbum.songs
33
+ ?.videoIds ?? []) {
34
+ try {
35
+ const song = nonNullable(album.songs[index]);
36
+ const value = song.videoId;
37
+
38
+ if (value !== expectedValue) {
39
+ throw new Error(
40
+ `expected song[${index}].videoId = ${value} to equal ${expectedValue}`,
41
+ );
42
+ }
43
+
44
+ song.videoId = newValue;
45
+ } catch (error) {
46
+ console.error(`🐞 [Album ${id} patcher]: ${error} 🐞`);
47
+ }
48
+ }
49
+
50
+ return { album, youtubeMusicAlbum };
51
+ })
52
+ .then(
53
+ ({
54
+ album: { artist, name, songs, thumbnails, year },
55
+ youtubeMusicAlbum,
56
+ }) =>
57
+ new YoutubeService().videos
58
+ .accessSome(songs.map(property("videoId")), spinner)
59
+ .then((videos) =>
60
+ Promise.resolve(
61
+ thru(
62
+ maxBy(thumbnails, (thumbnail) => thumbnail.height)?.url,
63
+ (url) =>
64
+ url != null
65
+ ? new DocumentFileTable().getFile(url, spinner)
66
+ : void 0,
67
+ ),
68
+ ).then((albumArtworkFile) =>
69
+ LazyPromise.concatAll(
70
+ songs
71
+ .map((song) => ({
72
+ song,
73
+ video: videos.find(
74
+ (video) => video.id === song.videoId,
75
+ ),
76
+ }))
77
+ .map(
78
+ ({ song, video }, songIndex, { length }) =>
79
+ new LazyPromise(async (): Promise<SterioSongFull> => {
80
+ const patched = !isEqual(
81
+ song,
82
+ youtubeMusicAlbum.songs[songIndex],
83
+ );
84
+
85
+ const excluded = !thru(
86
+ sterioAlbum?.songs ?? {},
87
+ ({ include, exclude }) =>
88
+ (include == null ||
89
+ include.includes(songIndex)) &&
90
+ !exclude?.includes(songIndex),
91
+ );
92
+
93
+ const songArtworkFile = await thru(
94
+ maxBy(
95
+ compact(
96
+ [
97
+ "default" as const,
98
+ "high" as const,
99
+ "maxres" as const,
100
+ "medium" as const,
101
+ "standard" as const,
102
+ ].map(
103
+ (key) => video?.snippet?.thumbnails?.[key],
104
+ ),
105
+ ),
106
+ (thumbnail) => thumbnail.height,
107
+ )?.url,
108
+ (url) =>
109
+ url != null
110
+ ? new DocumentFileTable().getFile(
111
+ url,
112
+ spinner,
113
+ )
114
+ : void 0,
115
+ );
116
+
117
+ const duration = SterioDuration.fromPeriodOfTime(
118
+ video?.contentDetails?.duration,
119
+ );
120
+
121
+ return {
122
+ videoId: song.videoId,
123
+ patched,
124
+ excluded,
125
+ channel:
126
+ video?.snippet?.channelId != null ||
127
+ video?.snippet?.channelTitle != null
128
+ ? {
129
+ id: video?.snippet?.channelId ?? void 0,
130
+ title:
131
+ video?.snippet?.channelTitle ?? void 0,
132
+ }
133
+ : void 0,
134
+ artwork: await [
135
+ {
136
+ key: "album" as const,
137
+ file: albumArtworkFile,
138
+ },
139
+ {
140
+ key: "song" as const,
141
+ file: songArtworkFile,
142
+ },
143
+ ].reduce(
144
+ (artwork, { key, file }) =>
145
+ artwork.then(async (artwork) => {
146
+ if (file != null) {
147
+ artwork[key] = {
148
+ path: `file://${file.path.replace(/\\/g, "/")}`,
149
+ checksum: await file.hash(),
150
+ };
151
+ }
152
+
153
+ return artwork;
154
+ }),
155
+ Promise.resolve<SterioSongFull["artwork"]>({}),
156
+ ),
157
+ duration,
158
+ tags: {
159
+ title: song.name,
160
+ artist: artist.name,
161
+ performerInfo: artist.name,
162
+ album: name,
163
+ trackNumber: pageIndex(songIndex + 1, length),
164
+ year: year?.toString() ?? void 0,
165
+ image: albumArtworkFile?.toString(),
166
+ audioSourceUrl: `https://youtube.com/watch?v=${song.videoId}`,
167
+ length:
168
+ duration != null
169
+ ? `${(duration.minutes * 60 + duration.seconds) * 1e3}`
170
+ : void 0,
171
+ },
172
+ };
173
+ }),
174
+ ),
175
+ ),
176
+ ),
177
+ )
178
+
179
+ .then((songs) => ({
180
+ id: sterioAlbum.id,
181
+ artist,
182
+ name,
183
+ songs,
184
+ thumbnails,
185
+ year,
186
+ })),
187
+ ),
188
+ );
189
+ }
190
+
191
+ async getAlbumsFull(spinner?: Ora): Promise<SterioAlbumFull[]> {
192
+ return this.albums
193
+ .accessAll()
194
+ .then((albums) =>
195
+ Promise.all(albums.map(({ id }) => this.getAlbumFull(id, spinner))),
196
+ );
197
+ }
198
+
199
+ async updateAlbum(id: string, partialAlbum: SterioAlbum) {
200
+ if (partialAlbum.id != null && partialAlbum.id !== id) {
201
+ throw new Error();
202
+ }
203
+
204
+ return this.albums.dispatch("update", id, (album: SterioAlbum) =>
205
+ deepmerge(album, partialAlbum),
206
+ );
207
+ }
208
+ }
@@ -0,0 +1,112 @@
1
+ import { nonNullable } from "@cascateer/lib";
2
+ import { google, youtube_v3 } from "googleapis";
3
+ import { intersectionWith, uniq } from "lodash";
4
+ import { envConfig } from "./config";
5
+ import { YoutubePlaylistTable, YoutubeVideoTable } from "./tables";
6
+ import { YoutubePlaylist, YoutubeVideo } from "./types";
7
+
8
+ const { YOUTUBE_DATA_API_KEY } = envConfig();
9
+
10
+ export class YoutubeService {
11
+ private api = google.youtube("v3");
12
+
13
+ async getPlaylist(id: string): Promise<YoutubePlaylist> {
14
+ const getPlaylistItems = (
15
+ playlistId: string,
16
+ pageToken?: string,
17
+ ): Promise<youtube_v3.Schema$PlaylistItem[]> =>
18
+ this.api.playlistItems
19
+ .list({
20
+ auth: YOUTUBE_DATA_API_KEY,
21
+ playlistId,
22
+ part: ["contentDetails", "snippet"],
23
+ fields:
24
+ "items(id,contentDetails(videoId),snippet(title,position)),nextPageToken",
25
+ pageToken,
26
+ maxResults: 50,
27
+ })
28
+ .then(async ({ data: { items, nextPageToken } }) =>
29
+ (items ?? []).concat(
30
+ nextPageToken != null
31
+ ? await getPlaylistItems(playlistId, nextPageToken)
32
+ : [],
33
+ ),
34
+ );
35
+
36
+ return this.api.playlists
37
+ .list({
38
+ auth: YOUTUBE_DATA_API_KEY,
39
+ id: [id],
40
+ part: ["snippet"],
41
+ fields: "items(id,snippet(title))",
42
+ })
43
+ .then(({ data }) => nonNullable(data.items?.[0]))
44
+ .then((playlist) => ({
45
+ id: nonNullable(playlist.id),
46
+ title: playlist.snippet?.title ?? void 0,
47
+ }))
48
+ .then(({ id, title }) =>
49
+ getPlaylistItems(id).then((items) => ({
50
+ id,
51
+ title,
52
+ items,
53
+ })),
54
+ );
55
+ }
56
+
57
+ async getVideos(ids: string[], pageToken?: string): Promise<YoutubeVideo[]> {
58
+ if (ids.length === 0) {
59
+ return [];
60
+ }
61
+
62
+ return this.api.videos
63
+ .list({
64
+ auth: YOUTUBE_DATA_API_KEY,
65
+ id: uniq(ids),
66
+ part: ["contentDetails", "snippet"],
67
+ fields: "items(id,contentDetails,snippet),nextPageToken",
68
+ pageToken,
69
+ maxResults: 50,
70
+ })
71
+ .then(async ({ data: { items, nextPageToken } }) =>
72
+ intersectionWith(
73
+ (items ?? []).concat(
74
+ nextPageToken != null
75
+ ? await this.getVideos(ids, nextPageToken)
76
+ : [],
77
+ ),
78
+ ids,
79
+ (video, id) => video.id === id,
80
+ ),
81
+ );
82
+ }
83
+
84
+ get videos() {
85
+ return new YoutubeVideoTable();
86
+ }
87
+
88
+ get playlists() {
89
+ return new YoutubePlaylistTable();
90
+ }
91
+
92
+ async search(
93
+ query: { q?: string; channelId?: string },
94
+ pageToken?: string,
95
+ ): Promise<youtube_v3.Schema$SearchResult[]> {
96
+ return this.api.search
97
+ .list({
98
+ auth: YOUTUBE_DATA_API_KEY,
99
+ q: query.q,
100
+ channelId: query.channelId,
101
+ part: ["snippet"],
102
+ fields: "items(id,snippet),nextPageToken",
103
+ pageToken,
104
+ maxResults: 50,
105
+ })
106
+ .then(async ({ data: { items, nextPageToken } }) =>
107
+ (items ?? []).concat(
108
+ nextPageToken != null ? await this.search(query, nextPageToken) : [],
109
+ ),
110
+ );
111
+ }
112
+ }
@@ -0,0 +1,52 @@
1
+ import { nonNullable, property } from "@cascateer/lib";
2
+ import { groupBy, mapValues, once, pickBy } from "lodash";
3
+ import { defer, Observable, of, UnaryFunction } from "rxjs";
4
+ import YTMusic, { AlbumDetailed, SongDetailed } from "ytmusic-api";
5
+ import { YoutubeMusicAlbumTable } from "./tables";
6
+
7
+ export class YoutubeMusicService {
8
+ private static initialize = once(
9
+ async (): Promise<YTMusic> => new YTMusic().initialize().then(nonNullable),
10
+ );
11
+
12
+ initialize = YoutubeMusicService.initialize;
13
+
14
+ get albums() {
15
+ return new YoutubeMusicAlbumTable();
16
+ }
17
+
18
+ async getAlbum(albumId: string) {
19
+ return this.initialize().then((api) =>
20
+ api.getAlbum(albumId).then((album) => {
21
+ if (
22
+ new Set(album.songs.map(property("videoId"))).size !==
23
+ album.songs.length
24
+ ) {
25
+ console.warn(
26
+ `Duplicate videoIds in album ${JSON.stringify(album.albumId)}:\n${Object.entries(
27
+ mapValues(
28
+ pickBy(
29
+ groupBy(album.songs, property("videoId")),
30
+ (group) => group.length > 1,
31
+ ),
32
+ (songs) => songs.map(property("name")).join(", "),
33
+ ),
34
+ ).map(([videoId, songs]) => ["", videoId, songs].join("\t"))}`,
35
+ );
36
+ }
37
+
38
+ return album;
39
+ }),
40
+ );
41
+ }
42
+
43
+ searchAlbums: UnaryFunction<string, Observable<AlbumDetailed[]>> = (query) =>
44
+ query
45
+ ? defer(() => this.initialize().then((api) => api.searchAlbums(query)))
46
+ : of([]);
47
+
48
+ searchSongs: UnaryFunction<string, Observable<SongDetailed[]>> = (query) =>
49
+ query
50
+ ? defer(() => this.initialize().then((api) => api.searchSongs(query)))
51
+ : of([]);
52
+ }
@@ -0,0 +1,29 @@
1
+ .gitignore
2
+ .openapi-generator-ignore
3
+ apis/DefaultApi.ts
4
+ apis/index.ts
5
+ index.ts
6
+ models/GetYoutubeMusicAlbums200ResponseInner.ts
7
+ models/PartialSterioAlbumResourcesFull.ts
8
+ models/SpotifyApiAlbumObjectSimplified.ts
9
+ models/SpotifyApiArtistObjectSimplified.ts
10
+ models/SpotifyApiExternalUrlObject.ts
11
+ models/SpotifyApiImageObject.ts
12
+ models/SpotifyApiRestrictionsObject.ts
13
+ models/SterioAlbum.ts
14
+ models/SterioAlbumResource.ts
15
+ models/SterioAlbumResourcesTable.ts
16
+ models/SterioAlbumSongs.ts
17
+ models/SterioAlbumSongsVideoIdsInner.ts
18
+ models/YoutubePlaylist.ts
19
+ models/YoutubeV3Schema36PlaylistItem.ts
20
+ models/YoutubeV3Schema36PlaylistItemContentDetails.ts
21
+ models/YoutubeV3Schema36PlaylistItemSnippet.ts
22
+ models/YoutubeV3Schema36PlaylistItemStatus.ts
23
+ models/YoutubeV3Schema36ResourceId.ts
24
+ models/YoutubeV3Schema36Thumbnail.ts
25
+ models/YoutubeV3Schema36ThumbnailDetails.ts
26
+ models/index.ts
27
+ runtime.ts
28
+ servers.ts
29
+ tsconfig.json
@@ -0,0 +1 @@
1
+ 7.8.0