@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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Represents the configuration of a server including its
|
|
4
|
+
* url template and variable configuration based on the url.
|
|
5
|
+
*
|
|
6
|
+
*/
|
|
7
|
+
export class ServerConfiguration<T extends { [key: string]: string }> {
|
|
8
|
+
public constructor(private url: string, private variableConfiguration: T, private description: string) {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sets the value of the variables of this server.
|
|
12
|
+
*
|
|
13
|
+
* @param variableConfiguration a partial variable configuration for the variables contained in the url
|
|
14
|
+
*/
|
|
15
|
+
public setVariables(variableConfiguration: Partial<T>) {
|
|
16
|
+
Object.assign(this.variableConfiguration, variableConfiguration);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public getConfiguration(): T {
|
|
20
|
+
return this.variableConfiguration;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public getDescription(): string {
|
|
24
|
+
return this.description;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constructions the URL this server using the url with variables
|
|
29
|
+
* replaced with their respective values
|
|
30
|
+
*/
|
|
31
|
+
public getUrl(): string {
|
|
32
|
+
let replacedUrl = this.url;
|
|
33
|
+
for (const key in this.variableConfiguration) {
|
|
34
|
+
if (this.variableConfiguration.hasOwnProperty(key)) {
|
|
35
|
+
const re = new RegExp("{" + key + "}","g");
|
|
36
|
+
replacedUrl = replacedUrl.replace(re, this.variableConfiguration[key]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return replacedUrl;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const server1 = new ServerConfiguration<{ }>("http://127.0.0.1:2700", { }, "");
|
|
44
|
+
|
|
45
|
+
export const servers = [server1];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"declaration": true,
|
|
4
|
+
"target": "es5",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": ".",
|
|
9
|
+
"lib": [
|
|
10
|
+
"es6",
|
|
11
|
+
"dom",
|
|
12
|
+
"es2017"
|
|
13
|
+
],
|
|
14
|
+
"typeRoots": [
|
|
15
|
+
"node_modules/@types"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"exclude": [
|
|
19
|
+
"dist",
|
|
20
|
+
"node_modules"
|
|
21
|
+
]
|
|
22
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { nonNullable } from "@cascateer/lib";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import express, { ErrorRequestHandler, json } from "express";
|
|
4
|
+
import session from "express-session";
|
|
5
|
+
import { StatusCodes } from "http-status-codes";
|
|
6
|
+
import { resolve } from "path";
|
|
7
|
+
import { RegisterRoutes } from "../build/routes";
|
|
8
|
+
import tsoaConfig from "../tsoa.json";
|
|
9
|
+
import { SpotifyService } from "./Spotify.service";
|
|
10
|
+
import { envConfig } from "./config";
|
|
11
|
+
|
|
12
|
+
const { SPOTIFY_REDIRECT_URI } = envConfig();
|
|
13
|
+
|
|
14
|
+
const [APP_HOST, APP_PORT] = tsoaConfig.spec.host.split(":");
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
|
|
18
|
+
app.use(json());
|
|
19
|
+
app.use(
|
|
20
|
+
cors({
|
|
21
|
+
credentials: true,
|
|
22
|
+
origin: true,
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
app.use(
|
|
26
|
+
session({
|
|
27
|
+
resave: false,
|
|
28
|
+
saveUninitialized: false,
|
|
29
|
+
secret: "secret secret",
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
app.set("view engine", "ejs");
|
|
34
|
+
app.set("views", resolve(__dirname, "views"));
|
|
35
|
+
|
|
36
|
+
app.get(new URL(nonNullable(SPOTIFY_REDIRECT_URI)).pathname, (req, res) => {
|
|
37
|
+
const code = req.query.code?.toString();
|
|
38
|
+
|
|
39
|
+
if (code != null) {
|
|
40
|
+
SpotifyService.codes.next(code);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
res.render("spotify/auth-callback", { code: req.query.code });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
RegisterRoutes(app);
|
|
47
|
+
|
|
48
|
+
app.use(<ErrorRequestHandler>function (error, req, res, next) {
|
|
49
|
+
console.error(error);
|
|
50
|
+
|
|
51
|
+
res
|
|
52
|
+
.status(StatusCodes.INTERNAL_SERVER_ERROR)
|
|
53
|
+
.send(error.stack ?? error.message);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.listen(+nonNullable(APP_PORT), nonNullable(APP_HOST), () =>
|
|
57
|
+
console.log(`Server is running on http://${APP_HOST}:${APP_PORT}`),
|
|
58
|
+
);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { findDupeBy, property, split } from "@cascateer/lib";
|
|
2
|
+
import { LazyPromise } from "@cascateer/lib/promise";
|
|
3
|
+
import { at, find, get, memoize } from "lodash";
|
|
4
|
+
import { lastValueFrom } from "rxjs";
|
|
5
|
+
import { Body, Controller, Get, Patch, Path, Post, Query, Route } from "tsoa";
|
|
6
|
+
import { SpotifyService } from "./Spotify.service";
|
|
7
|
+
import { SterioService } from "./Sterio.service";
|
|
8
|
+
import {
|
|
9
|
+
SterioAlbum,
|
|
10
|
+
SterioAlbumResourcesTable,
|
|
11
|
+
YoutubePlaylist,
|
|
12
|
+
} from "./types";
|
|
13
|
+
import { YoutubeService } from "./Youtube.service";
|
|
14
|
+
import { YoutubeMusicService } from "./YoutubeMusic.service";
|
|
15
|
+
|
|
16
|
+
@Route("sterio")
|
|
17
|
+
export class SterioController extends Controller {
|
|
18
|
+
@Get("album/{id}")
|
|
19
|
+
async getAlbum(@Path() id: string): Promise<SterioAlbum> {
|
|
20
|
+
return new SterioService().albums.accessSome([id]).then(property(0));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Patch("album")
|
|
24
|
+
async updateAlbum(
|
|
25
|
+
@Body()
|
|
26
|
+
album: SterioAlbum,
|
|
27
|
+
) {
|
|
28
|
+
return new SterioService().updateAlbum(album.id, album);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Post("album/{id}/resources/table")
|
|
32
|
+
async getAlbumResourcesTable(
|
|
33
|
+
@Path() id: string,
|
|
34
|
+
): Promise<SterioAlbumResourcesTable> {
|
|
35
|
+
const [{ resources: { youtubeMusic, youtube, spotify } = {} }] =
|
|
36
|
+
await new SterioService().albums.accessSome([id]);
|
|
37
|
+
|
|
38
|
+
if (youtubeMusic != null && youtube != null && spotify != null) {
|
|
39
|
+
const [[youtubeMusicAlbum], [youtubePlaylist], [spotifyAlbum]] =
|
|
40
|
+
await LazyPromise.concatAll([
|
|
41
|
+
new LazyPromise(() =>
|
|
42
|
+
new YoutubeMusicService().albums.accessSome([youtubeMusic.id]),
|
|
43
|
+
),
|
|
44
|
+
new LazyPromise(() =>
|
|
45
|
+
new YoutubeService().playlists.accessSome([youtube.id]),
|
|
46
|
+
),
|
|
47
|
+
new LazyPromise(() =>
|
|
48
|
+
new SpotifyService().albums.accessSome([spotify.id]),
|
|
49
|
+
),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
class KeyGenerator<T> {
|
|
53
|
+
constructor(
|
|
54
|
+
public items: T[],
|
|
55
|
+
public iteratees: {
|
|
56
|
+
uid?: string;
|
|
57
|
+
name: string;
|
|
58
|
+
},
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
keys = memoize((type?: keyof typeof this.iteratees) =>
|
|
62
|
+
this.items.map((item) =>
|
|
63
|
+
(type === "name"
|
|
64
|
+
? this.iteratees.name
|
|
65
|
+
: this.iteratees.uid || this.iteratees.name
|
|
66
|
+
).replaceAll(
|
|
67
|
+
/(\$\{([^\/]*)(?:\/([^\/]*)\/(?:([^\/]*)\/(?:([^\/]*))?)?)?\})/g,
|
|
68
|
+
(_, __, path, pattern, replacement, flags) =>
|
|
69
|
+
get(item, path).replace(
|
|
70
|
+
new RegExp(pattern, flags),
|
|
71
|
+
replacement ?? "",
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
at = <U>(mask: KeyGenerator<U>, type?: keyof typeof this.iteratees) =>
|
|
78
|
+
at(
|
|
79
|
+
this.keys(type),
|
|
80
|
+
mask.keys().map((key) => this.keys().indexOf(key)),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const youtubeMusicKeyGenerator = new KeyGenerator(
|
|
85
|
+
youtubeMusicAlbum.songs,
|
|
86
|
+
{
|
|
87
|
+
uid: youtubeMusic.iteratee,
|
|
88
|
+
name: "${name}",
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const youtubeKeyGenerator = new KeyGenerator(youtubePlaylist.items, {
|
|
93
|
+
uid: youtube?.iteratee,
|
|
94
|
+
name: "${snippet.title}",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const spotifyKeyGenerator = new KeyGenerator(spotifyAlbum.tracks.items, {
|
|
98
|
+
uid: spotify?.iteratee,
|
|
99
|
+
name: "${name}",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
findDupeBy(youtubeMusicKeyGenerator.keys()) == null &&
|
|
104
|
+
findDupeBy(youtubeKeyGenerator.keys()) == null &&
|
|
105
|
+
findDupeBy(spotifyKeyGenerator.keys()) == null
|
|
106
|
+
) {
|
|
107
|
+
return {
|
|
108
|
+
youtubeMusic: youtubeMusicKeyGenerator.keys("name"),
|
|
109
|
+
youtube: youtubeKeyGenerator.at(youtubeMusicKeyGenerator, "name"),
|
|
110
|
+
spotify: spotifyKeyGenerator.at(youtubeMusicKeyGenerator, "name"),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
youtubeMusic: [],
|
|
117
|
+
youtube: [],
|
|
118
|
+
spotify: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Get("spotify/albums")
|
|
123
|
+
async getSpotifyAlbums(
|
|
124
|
+
@Query("q") query: string,
|
|
125
|
+
): Promise<SpotifyApi.AlbumObjectSimplified[]> {
|
|
126
|
+
return new SpotifyService().searchAlbums(query, 10);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Get("youtube/playlists")
|
|
130
|
+
async getYoutubePlaylists(@Query() ids: string): Promise<YoutubePlaylist[]> {
|
|
131
|
+
return new YoutubeService().playlists.accessSome(split(ids));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@Get("youtube-music/albums")
|
|
135
|
+
async getYoutubeMusicAlbums(
|
|
136
|
+
@Query("q") query: string,
|
|
137
|
+
): Promise<{ albumId: string; text: string }[]> {
|
|
138
|
+
return lastValueFrom(new YoutubeMusicService().searchAlbums(query)).then(
|
|
139
|
+
(albums) =>
|
|
140
|
+
new SterioService().albums.accessAll().then((sterioAlbums) =>
|
|
141
|
+
albums.map(({ albumId, ...album }) => ({
|
|
142
|
+
albumId,
|
|
143
|
+
text: `${find(sterioAlbums, { resources: { youtubeMusic: { id: albumId } } }) != null ? "☑" : "☐"} ${album.name} / ${album.artist.name} (${album.year})`,
|
|
144
|
+
})),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncEndoFunction,
|
|
3
|
+
keys,
|
|
4
|
+
MaybePromise,
|
|
5
|
+
property,
|
|
6
|
+
} from "@cascateer/lib";
|
|
7
|
+
import { reduce } from "@cascateer/lib/observable";
|
|
8
|
+
import {
|
|
9
|
+
Dictionary,
|
|
10
|
+
maxBy,
|
|
11
|
+
pad,
|
|
12
|
+
padEnd,
|
|
13
|
+
padStart,
|
|
14
|
+
truncate,
|
|
15
|
+
TruncateOptions,
|
|
16
|
+
} from "lodash";
|
|
17
|
+
import {
|
|
18
|
+
identity,
|
|
19
|
+
mergeAll,
|
|
20
|
+
OperatorFunction,
|
|
21
|
+
startWith,
|
|
22
|
+
UnaryFunction,
|
|
23
|
+
} from "rxjs";
|
|
24
|
+
|
|
25
|
+
interface TableColumnOptions<U> extends TruncateOptions {
|
|
26
|
+
stringify?: UnaryFunction<U, string>;
|
|
27
|
+
align?: "start" | "end" | "center";
|
|
28
|
+
fill?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type TableTemplate<T extends Dictionary<unknown>> = {
|
|
32
|
+
[K in keyof T]: TableColumnOptions<T[K]>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class Table<T extends Dictionary<unknown>> {
|
|
36
|
+
constructor(
|
|
37
|
+
public rows: T[],
|
|
38
|
+
public template: TableTemplate<T>,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
toString(): string {
|
|
42
|
+
return this.rows
|
|
43
|
+
.map((row) =>
|
|
44
|
+
[
|
|
45
|
+
"",
|
|
46
|
+
...keys(this.template).map((key) => {
|
|
47
|
+
const {
|
|
48
|
+
stringify = String,
|
|
49
|
+
align = "start",
|
|
50
|
+
fill,
|
|
51
|
+
length = maxBy(
|
|
52
|
+
this.rows.map(property(key)).map(stringify),
|
|
53
|
+
property("length"),
|
|
54
|
+
)?.length,
|
|
55
|
+
omission,
|
|
56
|
+
separator,
|
|
57
|
+
} = this.template[key];
|
|
58
|
+
|
|
59
|
+
return { start: padEnd, end: padStart, center: pad }[align](
|
|
60
|
+
truncate(stringify(row[key]), { length, omission, separator }),
|
|
61
|
+
length,
|
|
62
|
+
fill,
|
|
63
|
+
);
|
|
64
|
+
}),
|
|
65
|
+
"",
|
|
66
|
+
].join("│"),
|
|
67
|
+
)
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
filter(predicate: (row: T, index: number) => boolean) {
|
|
72
|
+
return new Table(this.rows.filter(predicate), this.template);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const pageIndex = (x: number, y: number) =>
|
|
77
|
+
`${new Intl.NumberFormat("en-US", {
|
|
78
|
+
minimumIntegerDigits: `${y}`.length,
|
|
79
|
+
}).format(x)}/` + y;
|
|
80
|
+
|
|
81
|
+
export const secondsToHms = (seconds: number) =>
|
|
82
|
+
new Date(seconds * 1e3).toISOString().slice(11, 19).replace(/^00:/, "");
|
|
83
|
+
|
|
84
|
+
export const tapPromise = <T>(
|
|
85
|
+
input: Promise<T>,
|
|
86
|
+
on?: Partial<{
|
|
87
|
+
start: UnaryFunction<void, void>;
|
|
88
|
+
finish: UnaryFunction<T, void>;
|
|
89
|
+
error: UnaryFunction<any, void>;
|
|
90
|
+
}>,
|
|
91
|
+
) =>
|
|
92
|
+
Promise.resolve(on?.start?.call(null)).then(() =>
|
|
93
|
+
input
|
|
94
|
+
.catch((error) => {
|
|
95
|
+
on?.error?.call(null, error);
|
|
96
|
+
|
|
97
|
+
throw error;
|
|
98
|
+
})
|
|
99
|
+
.then((value) => (on?.finish?.call(null, value), value)),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export const chainFunctions =
|
|
103
|
+
<T>(seed: () => MaybePromise<T>): OperatorFunction<AsyncEndoFunction<T>, T> =>
|
|
104
|
+
(source) =>
|
|
105
|
+
source.pipe(
|
|
106
|
+
startWith(identity),
|
|
107
|
+
reduce<(() => MaybePromise<T>) | AsyncEndoFunction<T>, Promise<T>>(
|
|
108
|
+
(state, predicate) => state.then(predicate),
|
|
109
|
+
async () => seed(),
|
|
110
|
+
),
|
|
111
|
+
mergeAll(),
|
|
112
|
+
);
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { nonNullable } from "@cascateer/lib";
|
|
2
|
+
import filenamify from "filenamify";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { copyFile, mkdir, rm, writeFile } from "fs/promises";
|
|
5
|
+
import { compact, isString } from "lodash";
|
|
6
|
+
import NodeID3 from "node-id3";
|
|
7
|
+
import { oraPromise } from "ora";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
import { SterioService } from "./Sterio.service";
|
|
10
|
+
import { StreamFileTable } from "./tables";
|
|
11
|
+
import { SterioAlbumFull } from "./types";
|
|
12
|
+
|
|
13
|
+
const OUT_URL = resolve(__dirname, "../../..", "out");
|
|
14
|
+
const OUT = process.argv.includes("--out");
|
|
15
|
+
|
|
16
|
+
const run = async () => {
|
|
17
|
+
console.clear();
|
|
18
|
+
|
|
19
|
+
if (OUT && existsSync(OUT_URL)) {
|
|
20
|
+
await rm(OUT_URL, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const albums = new Array<SterioAlbumFull>();
|
|
24
|
+
|
|
25
|
+
for (const sterioAlbum of await new SterioService().albums.accessAll()) {
|
|
26
|
+
if (sterioAlbum.resources?.youtubeMusic == null) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
albums.push(
|
|
31
|
+
await oraPromise(
|
|
32
|
+
async (spinner) => {
|
|
33
|
+
const album = await new SterioService().getAlbumFull(
|
|
34
|
+
sterioAlbum.id,
|
|
35
|
+
spinner,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const albumPath = resolve(
|
|
39
|
+
OUT_URL,
|
|
40
|
+
filenamify([album.artist.name, album.name].join(" - "), {
|
|
41
|
+
replacement: "",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (OUT) {
|
|
46
|
+
await mkdir(albumPath, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const song of album.songs) {
|
|
50
|
+
if (song.excluded) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (OUT) {
|
|
55
|
+
const sourceFile = await new StreamFileTable().getFile(
|
|
56
|
+
nonNullable(song.tags.audioSourceUrl),
|
|
57
|
+
spinner,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const targetFile = resolve(
|
|
61
|
+
albumPath,
|
|
62
|
+
filenamify(
|
|
63
|
+
`${compact([song.tags.trackNumber?.split("/")[0], song.tags.title]).join(" ")}${sourceFile.extname}`,
|
|
64
|
+
{ replacement: "" },
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await copyFile(sourceFile.path, targetFile).then(() =>
|
|
69
|
+
NodeID3.Promise.write(song.tags, targetFile),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return album;
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
text: `Processing...`,
|
|
78
|
+
prefixText: `[💿 Album ${sterioAlbum.id}]`,
|
|
79
|
+
successText: (album) => `Processed (${album.name}).`,
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return oraPromise(
|
|
86
|
+
Promise.resolve(albums).then((albums) =>
|
|
87
|
+
mkdir(OUT_URL, { recursive: true }).then(async () =>
|
|
88
|
+
Promise.resolve(resolve(OUT_URL, "index.html")).then((path) =>
|
|
89
|
+
writeFile(
|
|
90
|
+
path,
|
|
91
|
+
`
|
|
92
|
+
<!doctype html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8" />
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
97
|
+
<title>STERI📀COLLECTION</title>
|
|
98
|
+
<style>
|
|
99
|
+
body {
|
|
100
|
+
background-color: black;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
table, tr, td {
|
|
104
|
+
border: 1px solid white;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
table {
|
|
108
|
+
border-collapse: collapse;
|
|
109
|
+
white-space: break-spaces;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tr[data-row-index="0"] td {
|
|
113
|
+
background-color: grey
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
tr[data-row-hidden="true"] {
|
|
117
|
+
text-decoration: line-through;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
td {
|
|
121
|
+
padding: 0.5em;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<table>
|
|
127
|
+
${albums
|
|
128
|
+
.map((album) => ({
|
|
129
|
+
index:
|
|
130
|
+
albums.findIndex(({ id }) => id === album.id) % 2,
|
|
131
|
+
rows: album.songs.map((song, hash) => ({
|
|
132
|
+
color: song.patched ? "blue" : "white",
|
|
133
|
+
columns: Object.entries({
|
|
134
|
+
albumArtwork: {
|
|
135
|
+
content: `<img width="60" src="${song.artwork.album?.path}">`,
|
|
136
|
+
hash: song.artwork.album?.checksum,
|
|
137
|
+
},
|
|
138
|
+
songArtwork: {
|
|
139
|
+
content: `<figure><img width="60" src="${song.artwork.song?.path}"><figcaption></figcaption>${song.artwork.song?.checksum.slice(-7)}</figure>`,
|
|
140
|
+
hash: song.artwork.song?.checksum,
|
|
141
|
+
},
|
|
142
|
+
channel: {
|
|
143
|
+
content: song.channel?.title,
|
|
144
|
+
hash: song.channel?.id,
|
|
145
|
+
},
|
|
146
|
+
trackNumber: song.tags.trackNumber,
|
|
147
|
+
title: song.tags.title,
|
|
148
|
+
duration: {
|
|
149
|
+
content: song.duration?.toISOString(),
|
|
150
|
+
hash,
|
|
151
|
+
},
|
|
152
|
+
album: [song.tags.album, album.id].join("\n"),
|
|
153
|
+
year: song.tags.year,
|
|
154
|
+
audioSourceUrl: {
|
|
155
|
+
content: `<a target="_blank" href="${song.tags.audioSourceUrl}">${song.tags.audioSourceUrl != null ? new URL(song.tags.audioSourceUrl).searchParams.get("v") : ""}</a>`,
|
|
156
|
+
hash: song.tags.audioSourceUrl,
|
|
157
|
+
},
|
|
158
|
+
}).flatMap(([key, column]) =>
|
|
159
|
+
column != null
|
|
160
|
+
? {
|
|
161
|
+
key,
|
|
162
|
+
...(isString(column)
|
|
163
|
+
? {
|
|
164
|
+
content: column,
|
|
165
|
+
hash: column,
|
|
166
|
+
}
|
|
167
|
+
: column),
|
|
168
|
+
}
|
|
169
|
+
: [],
|
|
170
|
+
),
|
|
171
|
+
excluded: song.excluded,
|
|
172
|
+
})),
|
|
173
|
+
}))
|
|
174
|
+
.flatMap((album) =>
|
|
175
|
+
album.rows.map(
|
|
176
|
+
(row, rowIndex, rows) =>
|
|
177
|
+
`<tr data-row-index="${album.index}" data-row-hidden="${row.excluded}" style="color: ${row.color};">${row.columns
|
|
178
|
+
.flatMap(({ key, content, hash }) => {
|
|
179
|
+
if (
|
|
180
|
+
rows
|
|
181
|
+
.map((row) =>
|
|
182
|
+
nonNullable(
|
|
183
|
+
row.columns.find(
|
|
184
|
+
(column) => column.key === key,
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
.every((column) => column.hash === hash)
|
|
189
|
+
) {
|
|
190
|
+
if (rowIndex > 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
rowSpan: rows.length,
|
|
196
|
+
content,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { rowSpan: 1, content };
|
|
201
|
+
})
|
|
202
|
+
.map(
|
|
203
|
+
({ rowSpan, content }) =>
|
|
204
|
+
`<td rowspan="${rowSpan}">${content}</td>`,
|
|
205
|
+
)
|
|
206
|
+
.join("\n")}</tr>`,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
.join("\n")}
|
|
210
|
+
</table>
|
|
211
|
+
</body>
|
|
212
|
+
</html>`,
|
|
213
|
+
).then(() => path),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
{
|
|
218
|
+
text: `Compiling...`,
|
|
219
|
+
prefixText: "[📒 Summary]",
|
|
220
|
+
successText: (path) => `Written to ${path}.`,
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
run();
|