-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathserver.ts
228 lines (210 loc) · 6.73 KB
/
server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import { PlaylistJSON, YtdlPlaylistDownloader } from "./ytdl";
import express, { Response, Request } from "express";
import * as fs from "fs";
import humanizeDuration from "humanize-duration";
import cors from "cors";
import md5 from "object-hash";
const app = express();
const port = 6060; // default port to listen
const playlistDirectory = "./playlist_data";
const playlists = new Map<string, PlaylistJSON>();
const playlistHashes = new Map<string, string>();
const playlistMetadata = new Map<string, PlaylistMetadata>();
let ytdlSingleton = false;
app.use(cors(), express.static("dist", {}));
/**
* Generates metadata for a provided playlist such as total length, a default thumbnail, video count, etc.
* This is intended to be a wrapper for missing info not generated by the ytdl tool
* @param playlistData
*/
const generateMetadata = async (playlistData: PlaylistJSON) => {
if (Object.keys(playlistData.videos).length === 0) return false;
let totalLength = 0;
Object.values(playlistData.videos).forEach((v) => {
if (v.duration) totalLength += v.duration;
});
const metadata: PlaylistMetadata = {
id: playlistData.id,
videoCount: Object.keys(playlistData.videos).length,
videosWithSubs: Object.keys(playlistData.subs).filter(
(s) => playlistData.subs[s].length !== 0
).length,
thumbnail: Object.values(playlistData.videos)[0].thumbnail,
totalLength: humanizeDuration(1000 * totalLength, { largest: 2 }),
};
playlistMetadata.set(playlistData.id, metadata);
return true;
};
/**
* Checks for existing playlist folder data, delegates to generate
* metadata for each and create hashes of each for the auto-update mechanism.
*/
const initPlaylistData = async () => {
fs.mkdirSync(playlistDirectory, { recursive: true });
const dir: string[] = fs.readdirSync(playlistDirectory);
const metadataPromise = [];
for (const filename of dir) {
const playlistData: PlaylistJSON = await JSON.parse(
fs.readFileSync(playlistDirectory + "/" + filename, "utf-8")
);
metadataPromise.push(generateMetadata(playlistData));
playlistHashes.set(filename.split(".json")[0], md5(playlistData.videos));
playlists.set(filename.split(".json")[0], playlistData);
}
await Promise.all(metadataPromise);
};
/**
* By default checks for any changes in directory data against internal hashes and lists.
* If any changes are found, data is deleted/generated appropriately.
*
* @param id optionally provide a specific playlist ID to check for updates
*/
const checkUpdates = async (id?: string) => {
let toUpdate: PlaylistJSON[] = [];
let data;
// Single video
if (id) {
const filename = id + ".json";
try {
data = await JSON.parse(
fs.readFileSync(playlistDirectory + "/" + filename, "utf-8")
);
} catch (e) {
console.error(e);
return false;
}
if (md5(data.videos) !== playlistHashes.get(filename)) {
playlists.set(filename, data);
toUpdate.push(data);
}
} else {
// Check updates for all internal data and directory data
const playlistDir = fs.readdirSync(playlistDirectory);
const readAsync = playlistDir.map((f) =>
JSON.parse(fs.readFileSync(playlistDirectory + "/" + f, "utf-8"))
);
await Promise.all(readAsync);
// Check directory data against internal to see if update is necessary due to addition/change
for (const file of playlistDir) {
let data = readAsync.find((d) => file === d.id + ".json");
const filename = file.split(".json")[0];
const hash = playlistHashes.get(filename);
if (!hash || md5(data.videos) !== hash) {
playlists.set(filename, data);
toUpdate.push(data);
}
}
// Check internal data against directory data to see if anything was removed
for (const playlist of playlists.keys()) {
if (!playlistDir.includes(playlist + ".json")) {
playlists.delete(playlist);
playlistMetadata.delete(playlist);
playlistHashes.delete(playlist);
}
}
}
// Perform async update of all metadata, then return to indicate completion.
if (toUpdate.length !== 0) {
const metadataPromise: Promise<boolean>[] = [];
toUpdate.forEach((data) => {
metadataPromise.push(generateMetadata(data));
});
await Promise.all(metadataPromise);
}
return true;
};
export interface PlaylistMetadata {
id: string;
videoCount: number;
videosWithSubs: number;
totalLength: string;
thumbnail: string;
}
// ROUTES
/**
* Retutns an array of all playlist metadata
* @param req
* @param res
*/
const getMetadata = (req: Request, res: Response<any>) => {
checkUpdates()
.then(() => res.json([...playlistMetadata.values()]))
.catch((e) => res.status(500).send(e));
};
/**
* Returns playlist data for a requested playlist ID. The full .json is transmitted.
* @param req
* @param res
*/
const getPlaylist = (req: Request, res: Response<any>) => {
const { id } = req.params;
if (!playlists.has(id)) {
res.status(404).send();
return;
}
const playlist = playlists.get(id);
if (!playlist) {
res.status(500).send();
return;
}
res.json(playlist);
};
/**
* Method to request a playlist to be downloaded. This function is clunky, and delegates to ytld.js to
* download a particular playlist. Work needed to standardise responses between ytdl.js, the server, and
* the API response.
* @param req
* @param res
*/
const requestDownload = (req: Request, res: Response<any>) => {
const id = req.query.id;
if (typeof id !== "string") {
res.status(400).send("Please provide a single ID string");
return;
}
// Only allow one 'instance' of ytdl to be running
if (ytdlSingleton)
return res.status(400).json({
success: false,
message: "Ytdl download already in progress",
});
ytdlSingleton = true;
YtdlPlaylistDownloader(id)
.then((playlistId) => {
if (playlistId) {
res.json({ success: true, message: playlistId });
} else {
res.status(500).json({
success: false,
message: "Downloading failed, check server output",
});
}
})
.catch((e) => {
console.error(e);
res.status(500).json({
success: false,
message: "Downloading failed, check server output",
error: e,
});
})
.finally(() => (ytdlSingleton = false));
};
/**
* Initialise API endpoints. Consider moving to separate file.
*/
const initRoutes = () => {
app.get("/playlist_metadata", getMetadata);
app.get("/playlist/:id", getPlaylist);
app.post("/download", requestDownload);
};
/**
* Main server function. Intitialist the data, then the routes, then go live.
*/
initPlaylistData()
.then(initRoutes)
.then(() => {
app.listen(port, () => {
console.log(`server started at http://localhost:${port}`);
});
});