@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.
- package/.env +0 -0
- package/.github/workflows/publish.yml +11 -0
- package/.vscode/settings.json +4 -0
- package/build/routes.ts +413 -0
- package/build/swagger.json +775 -0
- package/openapitools.json +14 -0
- package/package.json +56 -0
- package/src/Spotify.service.ts +202 -0
- package/src/Sterio.service.ts +208 -0
- package/src/Youtube.service.ts +112 -0
- package/src/YoutubeMusic.service.ts +52 -0
- package/src/api/.openapi-generator/FILES +29 -0
- package/src/api/.openapi-generator/VERSION +1 -0
- package/src/api/.openapi-generator-ignore +23 -0
- package/src/api/apis/DefaultApi.ts +154 -0
- package/src/api/apis/index.ts +1 -0
- package/src/api/index.ts +4 -0
- package/src/api/models/GetYoutubeMusicAlbums200ResponseInner.ts +29 -0
- package/src/api/models/PartialSterioAlbumResourcesFull.ts +39 -0
- package/src/api/models/SpotifyApiAlbumObjectSimplified.ts +152 -0
- package/src/api/models/SpotifyApiArtistObjectSimplified.ts +68 -0
- package/src/api/models/SpotifyApiExternalUrlObject.ts +25 -0
- package/src/api/models/SpotifyApiImageObject.ts +38 -0
- package/src/api/models/SpotifyApiRestrictionsObject.ts +24 -0
- package/src/api/models/SterioAlbum.ts +39 -0
- package/src/api/models/SterioAlbumResource.ts +29 -0
- package/src/api/models/SterioAlbumResourcesTable.ts +34 -0
- package/src/api/models/SterioAlbumSongs.ts +38 -0
- package/src/api/models/SterioAlbumSongsVideoIdsInner.ts +34 -0
- package/src/api/models/YoutubePlaylist.ts +38 -0
- package/src/api/models/YoutubeV3Schema36PlaylistItem.ts +59 -0
- package/src/api/models/YoutubeV3Schema36PlaylistItemContentDetails.ts +49 -0
- package/src/api/models/YoutubeV3Schema36PlaylistItemSnippet.ts +89 -0
- package/src/api/models/YoutubeV3Schema36PlaylistItemStatus.ts +26 -0
- package/src/api/models/YoutubeV3Schema36ResourceId.ts +44 -0
- package/src/api/models/YoutubeV3Schema36Thumbnail.ts +38 -0
- package/src/api/models/YoutubeV3Schema36ThumbnailDetails.ts +49 -0
- package/src/api/models/index.ts +20 -0
- package/src/api/runtime.ts +193 -0
- package/src/api/servers.ts +45 -0
- package/src/api/tsconfig.json +22 -0
- package/src/app.ts +58 -0
- package/src/config.ts +6 -0
- package/src/controller.ts +148 -0
- package/src/lib.ts +112 -0
- package/src/main.ts +225 -0
- package/src/tables.ts +178 -0
- package/src/types.ts +113 -0
- package/src/views/spotify/auth-callback.ejs +22 -0
- package/tables/document-files/82745273-7eda-4169-8b4a-998d2de00ac2.json +5208 -0
- package/tables/spotify-albums/5bff8eba-bd2b-4485-8136-1f89da5db6d7.json +1 -0
- package/tables/sterio-albums/81d5b327-68b1-4fef-8e40-491c792e8951.json +1 -0
- package/tables/stream-files/f6697f37-7320-41df-aa50-b70264fa1bbf.json +3788 -0
- package/tables/youtube-music-albums/55fd9bb8-46ba-4d0e-9376-f2f2cbb1aa38.json +1 -0
- package/tables/youtube-playlists/e3153ab8-8e1f-491e-995e-3142d07f4ecc.json +1 -0
- package/tables/youtube-videos/9067172e-4803-48f1-8b8e-4b942f3ca228.json +1 -0
- package/tsconfig.json +16 -0
- 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
|