@francofantomius/jellyfin 0.1.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 +21 -0
- package/README.md +285 -0
- package/dist/index.cjs +2247 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1092 -0
- package/dist/index.d.ts +1092 -0
- package/dist/index.js +2213 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2247 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var JellyfinError = class extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "JellyfinError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var JellyfinApiError = class extends JellyfinError {
|
|
11
|
+
status;
|
|
12
|
+
endpoint;
|
|
13
|
+
constructor(message, status, endpoint) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "JellyfinApiError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.endpoint = endpoint;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var JellyfinAuthError = class extends JellyfinApiError {
|
|
21
|
+
constructor(message = "Unauthorized. Please check your Jellyfin credentials.", status = 401, endpoint) {
|
|
22
|
+
super(message, status, endpoint);
|
|
23
|
+
this.name = "JellyfinAuthError";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/http.ts
|
|
28
|
+
function cleanUrl(url) {
|
|
29
|
+
if (!url) return "";
|
|
30
|
+
let cleaned = url.trim().replace(/\/+$/, "");
|
|
31
|
+
if (!/^https?:\/\//i.test(cleaned)) {
|
|
32
|
+
cleaned = "https://" + cleaned;
|
|
33
|
+
}
|
|
34
|
+
return cleaned;
|
|
35
|
+
}
|
|
36
|
+
function buildAuthHeader(clientInfo, token) {
|
|
37
|
+
const tokenPart = token ? `, Token="${token}"` : "";
|
|
38
|
+
return `MediaBrowser Client="${clientInfo.name}", Device="${clientInfo.device}", DeviceId="${clientInfo.deviceId}", Version="${clientInfo.version}"${tokenPart}`;
|
|
39
|
+
}
|
|
40
|
+
var HttpTransport = class {
|
|
41
|
+
serverUrl = "";
|
|
42
|
+
token = "";
|
|
43
|
+
clientInfo;
|
|
44
|
+
fetchFn;
|
|
45
|
+
onUnauthorized;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.serverUrl = options.serverUrl ? cleanUrl(options.serverUrl) : "";
|
|
48
|
+
this.token = options.token || "";
|
|
49
|
+
this.clientInfo = options.clientInfo;
|
|
50
|
+
this.fetchFn = options.fetch || (globalThis.fetch ? globalThis.fetch.bind(globalThis) : fetch);
|
|
51
|
+
this.onUnauthorized = options.onUnauthorized;
|
|
52
|
+
}
|
|
53
|
+
setServerUrl(url) {
|
|
54
|
+
this.serverUrl = cleanUrl(url);
|
|
55
|
+
}
|
|
56
|
+
getServerUrl() {
|
|
57
|
+
return this.serverUrl;
|
|
58
|
+
}
|
|
59
|
+
setToken(token) {
|
|
60
|
+
this.token = token;
|
|
61
|
+
}
|
|
62
|
+
getToken() {
|
|
63
|
+
return this.token;
|
|
64
|
+
}
|
|
65
|
+
setClientInfo(info) {
|
|
66
|
+
this.clientInfo = { ...this.clientInfo, ...info };
|
|
67
|
+
}
|
|
68
|
+
getClientInfo() {
|
|
69
|
+
return this.clientInfo;
|
|
70
|
+
}
|
|
71
|
+
setOnUnauthorized(handler) {
|
|
72
|
+
this.onUnauthorized = handler;
|
|
73
|
+
}
|
|
74
|
+
getAuthHeader() {
|
|
75
|
+
return buildAuthHeader(this.clientInfo, this.token);
|
|
76
|
+
}
|
|
77
|
+
async requestRaw(endpoint, options = {}) {
|
|
78
|
+
if (!this.serverUrl) {
|
|
79
|
+
throw new JellyfinError("Jellyfin server URL is not configured.");
|
|
80
|
+
}
|
|
81
|
+
const { method = "GET", params = {}, body = null, contentType, headers: customHeaders = {} } = options;
|
|
82
|
+
const url = new URL(`${this.serverUrl}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`);
|
|
83
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
84
|
+
if (value !== void 0 && value !== null) {
|
|
85
|
+
url.searchParams.append(key, String(value));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const authHeader = this.getAuthHeader();
|
|
89
|
+
const headers = {
|
|
90
|
+
"X-Emby-Authorization": authHeader,
|
|
91
|
+
"Authorization": authHeader,
|
|
92
|
+
...customHeaders
|
|
93
|
+
};
|
|
94
|
+
let serializedBody;
|
|
95
|
+
if (contentType) {
|
|
96
|
+
headers["Content-Type"] = contentType;
|
|
97
|
+
serializedBody = typeof body === "string" ? body : JSON.stringify(body);
|
|
98
|
+
} else if (body !== null && body !== void 0) {
|
|
99
|
+
if (typeof body === "string") {
|
|
100
|
+
headers["Content-Type"] = "application/json";
|
|
101
|
+
serializedBody = body;
|
|
102
|
+
} else {
|
|
103
|
+
headers["Content-Type"] = "application/json";
|
|
104
|
+
serializedBody = JSON.stringify(body);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
response = await this.fetchFn(url.toString(), {
|
|
110
|
+
method,
|
|
111
|
+
headers,
|
|
112
|
+
body: serializedBody
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
throw new JellyfinError(`Network request failed (${endpoint}): ${errorMsg}`);
|
|
117
|
+
}
|
|
118
|
+
if (response.status === 401 || response.status === 403) {
|
|
119
|
+
if (response.status === 401 && this.onUnauthorized) {
|
|
120
|
+
try {
|
|
121
|
+
this.onUnauthorized();
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new JellyfinAuthError(`Unauthorized (HTTP ${response.status}) on ${endpoint}`, response.status, endpoint);
|
|
126
|
+
}
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
throw new JellyfinApiError(`Jellyfin API request failed (${endpoint}): ${response.statusText}`, response.status, endpoint);
|
|
129
|
+
}
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
132
|
+
async request(endpoint, options = {}) {
|
|
133
|
+
const response = await this.requestRaw(endpoint, {
|
|
134
|
+
...options,
|
|
135
|
+
headers: {
|
|
136
|
+
Accept: "application/json",
|
|
137
|
+
...options.headers || {}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
const contentTypeHeader = response.headers.get("Content-Type") || "";
|
|
141
|
+
if (contentTypeHeader.includes("application/json")) {
|
|
142
|
+
return await response.json();
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
async requestText(endpoint, options = {}) {
|
|
147
|
+
const response = await this.requestRaw(endpoint, {
|
|
148
|
+
...options,
|
|
149
|
+
headers: {
|
|
150
|
+
Accept: "text/vtt, text/plain, */*",
|
|
151
|
+
...options.headers || {}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return await response.text();
|
|
155
|
+
}
|
|
156
|
+
async requestBlob(endpoint, options = {}) {
|
|
157
|
+
const response = await this.requestRaw(endpoint, options);
|
|
158
|
+
return await response.blob();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// src/auth.ts
|
|
163
|
+
var AuthModule = class {
|
|
164
|
+
http;
|
|
165
|
+
currentUser = null;
|
|
166
|
+
currentUserId = "";
|
|
167
|
+
currentUsername = "";
|
|
168
|
+
constructor(http, userId = "") {
|
|
169
|
+
this.http = http;
|
|
170
|
+
this.currentUserId = userId;
|
|
171
|
+
}
|
|
172
|
+
getUserId() {
|
|
173
|
+
return this.currentUserId;
|
|
174
|
+
}
|
|
175
|
+
setUserId(id) {
|
|
176
|
+
this.currentUserId = id;
|
|
177
|
+
}
|
|
178
|
+
getUsername() {
|
|
179
|
+
return this.currentUsername || this.currentUser?.Name || "";
|
|
180
|
+
}
|
|
181
|
+
getCurrentUser() {
|
|
182
|
+
return this.currentUser;
|
|
183
|
+
}
|
|
184
|
+
setCurrentUser(user) {
|
|
185
|
+
this.currentUser = user;
|
|
186
|
+
if (user?.Id) {
|
|
187
|
+
this.currentUserId = user.Id;
|
|
188
|
+
}
|
|
189
|
+
if (user?.Name) {
|
|
190
|
+
this.currentUsername = user.Name;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Authenticate with the Jellyfin server using username and password.
|
|
195
|
+
*/
|
|
196
|
+
async authenticateByName(username, password = "") {
|
|
197
|
+
if (!username || !username.trim()) {
|
|
198
|
+
throw new JellyfinError("Username is required for authentication.");
|
|
199
|
+
}
|
|
200
|
+
const result = await this.http.request("/Users/AuthenticateByName", {
|
|
201
|
+
method: "POST",
|
|
202
|
+
body: {
|
|
203
|
+
Username: username.trim(),
|
|
204
|
+
Pw: password || ""
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
if (!result || !result.AccessToken || !result.User?.Id) {
|
|
208
|
+
throw new JellyfinAuthError("Invalid response received from Jellyfin authentication endpoint.");
|
|
209
|
+
}
|
|
210
|
+
this.http.setToken(result.AccessToken);
|
|
211
|
+
this.setCurrentUser(result.User);
|
|
212
|
+
this.currentUsername = result.User.Name;
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Report client capabilities to the Jellyfin server.
|
|
217
|
+
*/
|
|
218
|
+
async reportCapabilities(capabilities) {
|
|
219
|
+
const payload = {
|
|
220
|
+
PlayableMediaTypes: ["Audio", "Video"],
|
|
221
|
+
SupportedCommands: [
|
|
222
|
+
"Play",
|
|
223
|
+
"PlayState",
|
|
224
|
+
"PlayNext",
|
|
225
|
+
"SetRepeatMode",
|
|
226
|
+
"SetShuffleQueue"
|
|
227
|
+
],
|
|
228
|
+
SupportsMediaControl: true,
|
|
229
|
+
SupportsSync: false,
|
|
230
|
+
SupportsPersistentIdentifier: true,
|
|
231
|
+
MessageFormat: "Json",
|
|
232
|
+
...capabilities
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
await this.http.request("/Sessions/Capabilities/Full", {
|
|
236
|
+
method: "POST",
|
|
237
|
+
body: payload
|
|
238
|
+
});
|
|
239
|
+
} catch {
|
|
240
|
+
try {
|
|
241
|
+
await this.http.request("/Sessions/Capabilities", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: payload
|
|
244
|
+
});
|
|
245
|
+
} catch (fallbackErr) {
|
|
246
|
+
console.warn("[Jellyfin SDK] Failed to report client capabilities:", fallbackErr);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Build the user profile avatar image URL.
|
|
252
|
+
*/
|
|
253
|
+
getUserImageUrl(userId, tag) {
|
|
254
|
+
const uid = userId || this.currentUserId;
|
|
255
|
+
const serverUrl = this.http.getServerUrl();
|
|
256
|
+
if (!serverUrl || !uid) return "";
|
|
257
|
+
const tagToUse = tag || this.currentUser?.PrimaryImageTag || this.currentUser?.ImageTags?.Primary || "";
|
|
258
|
+
const tagParam = tagToUse ? `?tag=${tagToUse}` : "";
|
|
259
|
+
return `${serverUrl}/Users/${uid}/Images/Primary${tagParam}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Clear active user credentials and token.
|
|
263
|
+
*/
|
|
264
|
+
logout() {
|
|
265
|
+
this.http.setToken("");
|
|
266
|
+
this.currentUserId = "";
|
|
267
|
+
this.currentUsername = "";
|
|
268
|
+
this.currentUser = null;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// src/system.ts
|
|
273
|
+
function parseSemVer(version) {
|
|
274
|
+
if (!version) return null;
|
|
275
|
+
const match = version.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
276
|
+
if (!match) return null;
|
|
277
|
+
return {
|
|
278
|
+
major: parseInt(match[1], 10),
|
|
279
|
+
minor: parseInt(match[2], 10),
|
|
280
|
+
patch: match[3] ? parseInt(match[3], 10) : 0
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function isVersionAtLeast(currentVersion, targetVersion) {
|
|
284
|
+
const current = parseSemVer(currentVersion);
|
|
285
|
+
const target = parseSemVer(targetVersion);
|
|
286
|
+
if (!current || !target) return false;
|
|
287
|
+
if (current.major !== target.major) {
|
|
288
|
+
return current.major > target.major;
|
|
289
|
+
}
|
|
290
|
+
if (current.minor !== target.minor) {
|
|
291
|
+
return current.minor > target.minor;
|
|
292
|
+
}
|
|
293
|
+
return current.patch >= target.patch;
|
|
294
|
+
}
|
|
295
|
+
function isV12OrHigher(version) {
|
|
296
|
+
if (!version) return false;
|
|
297
|
+
const parsed = parseSemVer(version);
|
|
298
|
+
if (!parsed) return false;
|
|
299
|
+
return parsed.major >= 12;
|
|
300
|
+
}
|
|
301
|
+
var SystemModule = class {
|
|
302
|
+
http;
|
|
303
|
+
serverVersion = null;
|
|
304
|
+
targetVersion = "auto";
|
|
305
|
+
constructor(http, options) {
|
|
306
|
+
this.http = http;
|
|
307
|
+
if (options?.serverVersion) {
|
|
308
|
+
this.serverVersion = options.serverVersion;
|
|
309
|
+
}
|
|
310
|
+
if (options?.targetVersion) {
|
|
311
|
+
this.targetVersion = options.targetVersion;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Get public system info without requiring authentication.
|
|
316
|
+
*/
|
|
317
|
+
async getPublicInfo() {
|
|
318
|
+
const info = await this.http.request("/System/Info/Public");
|
|
319
|
+
if (info?.Version) {
|
|
320
|
+
this.serverVersion = info.Version;
|
|
321
|
+
}
|
|
322
|
+
return info;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Get full system info (requires authentication).
|
|
326
|
+
*/
|
|
327
|
+
async getInfo() {
|
|
328
|
+
const info = await this.http.request("/System/Info");
|
|
329
|
+
if (info?.Version) {
|
|
330
|
+
this.serverVersion = info.Version;
|
|
331
|
+
}
|
|
332
|
+
return info;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Get the cached or explicitly configured server version string.
|
|
336
|
+
*/
|
|
337
|
+
getVersion() {
|
|
338
|
+
return this.serverVersion;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Manually set or override the server version.
|
|
342
|
+
*/
|
|
343
|
+
setVersion(version) {
|
|
344
|
+
this.serverVersion = version;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get the configured target compatibility mode.
|
|
348
|
+
*/
|
|
349
|
+
getTargetVersion() {
|
|
350
|
+
return this.targetVersion;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Set target compatibility mode.
|
|
354
|
+
*/
|
|
355
|
+
setTargetVersion(target) {
|
|
356
|
+
this.targetVersion = target;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Detects and caches the server version by calling /System/Info/Public.
|
|
360
|
+
*/
|
|
361
|
+
async detectVersion() {
|
|
362
|
+
try {
|
|
363
|
+
const info = await this.getPublicInfo();
|
|
364
|
+
return info?.Version || null;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Checks whether the current server is Jellyfin 12 or higher.
|
|
371
|
+
* If targetVersion is explicitly set to '12' or '10', that preference is honored.
|
|
372
|
+
* If 'auto', resolves against known version or attempts detection.
|
|
373
|
+
*/
|
|
374
|
+
async isV12() {
|
|
375
|
+
if (this.targetVersion === "12") return true;
|
|
376
|
+
if (this.targetVersion === "10") return false;
|
|
377
|
+
if (!this.serverVersion) {
|
|
378
|
+
await this.detectVersion();
|
|
379
|
+
}
|
|
380
|
+
if (this.serverVersion) {
|
|
381
|
+
return isV12OrHigher(this.serverVersion);
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Synchronously checks if the server is known to be v12+ based on cached version or target setting.
|
|
387
|
+
* Defaults to true (modern behavior) if unknown.
|
|
388
|
+
*/
|
|
389
|
+
isV12Sync() {
|
|
390
|
+
if (this.targetVersion === "12") return true;
|
|
391
|
+
if (this.targetVersion === "10") return false;
|
|
392
|
+
if (this.serverVersion) {
|
|
393
|
+
return isV12OrHigher(this.serverVersion);
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// src/library.ts
|
|
400
|
+
var COMMON_ITEM_FIELDS = "PrimaryImageAspectRatio,PrimaryImageTag,ImageTags,BackdropImageTags,AlbumPrimaryImageTag,AlbumId,AudioInfo,MediaSources,Chapters,HasLyrics,Overview,Genres,Studios,ProductionYear,OfficialRating,CommunityRating,CriticRating,PremiereDate,RunTimeTicks,IndexNumber,ParentIndexNumber,SeriesName,SeriesId,SeasonId,SeasonName,UserData";
|
|
401
|
+
var LibraryModule = class {
|
|
402
|
+
http;
|
|
403
|
+
getUserId;
|
|
404
|
+
constructor(http, getUserId) {
|
|
405
|
+
this.http = http;
|
|
406
|
+
this.getUserId = getUserId;
|
|
407
|
+
}
|
|
408
|
+
requireUserId() {
|
|
409
|
+
const userId = this.getUserId();
|
|
410
|
+
if (!userId) {
|
|
411
|
+
throw new JellyfinError("User ID is required for library requests. Please authenticate or provide a userId.");
|
|
412
|
+
}
|
|
413
|
+
return userId;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Get list of music library views (folders) for the current user.
|
|
417
|
+
*/
|
|
418
|
+
async getMusicLibraries() {
|
|
419
|
+
const userId = this.requireUserId();
|
|
420
|
+
const data = await this.http.request(`/Users/${userId}/Views`);
|
|
421
|
+
return (data.Items || []).filter((item) => item.CollectionType === "music");
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Get list of video library views (movies, tv shows, home videos) for the current user.
|
|
425
|
+
*/
|
|
426
|
+
async getVideoLibraries() {
|
|
427
|
+
const userId = this.requireUserId();
|
|
428
|
+
const data = await this.http.request(`/Users/${userId}/Views`);
|
|
429
|
+
const videoTypes = /* @__PURE__ */ new Set(["movies", "tvshows", "homevideos", "videos", "musicvideos"]);
|
|
430
|
+
return (data.Items || []).filter(
|
|
431
|
+
(item) => typeof item.CollectionType === "string" && videoTypes.has(item.CollectionType)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Get music albums.
|
|
436
|
+
*/
|
|
437
|
+
async getAlbums(options = {}) {
|
|
438
|
+
const userId = this.requireUserId();
|
|
439
|
+
const {
|
|
440
|
+
limit = 50,
|
|
441
|
+
startIndex = 0,
|
|
442
|
+
parentId,
|
|
443
|
+
artistId,
|
|
444
|
+
sortBy = "SortName",
|
|
445
|
+
sortOrder = "Ascending"
|
|
446
|
+
} = options;
|
|
447
|
+
const params = {
|
|
448
|
+
IncludeItemTypes: "MusicAlbum",
|
|
449
|
+
Recursive: true,
|
|
450
|
+
Limit: limit,
|
|
451
|
+
StartIndex: startIndex,
|
|
452
|
+
SortBy: sortBy,
|
|
453
|
+
SortOrder: sortOrder,
|
|
454
|
+
Fields: "PrimaryImageAspectRatio,PrimaryImageTag,ImageTags,BasicSyncInfo,AlbumArtists,ArtistItems,DateCreated,DateLastMediaAdded,UserData"
|
|
455
|
+
};
|
|
456
|
+
if (parentId) params.ParentId = parentId;
|
|
457
|
+
if (artistId) params.ArtistIds = artistId;
|
|
458
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Get music artists.
|
|
462
|
+
*/
|
|
463
|
+
async getArtists(options = {}) {
|
|
464
|
+
const userId = this.requireUserId();
|
|
465
|
+
const {
|
|
466
|
+
limit = 50,
|
|
467
|
+
startIndex = 0,
|
|
468
|
+
sortBy = "SortName",
|
|
469
|
+
sortOrder = "Ascending"
|
|
470
|
+
} = options;
|
|
471
|
+
return await this.http.request("/Artists", {
|
|
472
|
+
params: {
|
|
473
|
+
UserId: userId,
|
|
474
|
+
Limit: limit,
|
|
475
|
+
StartIndex: startIndex,
|
|
476
|
+
SortBy: sortBy,
|
|
477
|
+
SortOrder: sortOrder,
|
|
478
|
+
Fields: "PrimaryImageTag,ImageTags,DateCreated,UserData"
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Get audio songs/tracks.
|
|
484
|
+
*/
|
|
485
|
+
async getSongs(options = {}) {
|
|
486
|
+
const userId = this.requireUserId();
|
|
487
|
+
const {
|
|
488
|
+
limit = 100,
|
|
489
|
+
startIndex = 0,
|
|
490
|
+
albumId,
|
|
491
|
+
artistId,
|
|
492
|
+
isFavorite,
|
|
493
|
+
sortBy = "ParentIndexNumber,IndexNumber,SortName",
|
|
494
|
+
sortOrder = "Ascending"
|
|
495
|
+
} = options;
|
|
496
|
+
const params = {
|
|
497
|
+
IncludeItemTypes: "Audio",
|
|
498
|
+
Recursive: true,
|
|
499
|
+
Limit: limit,
|
|
500
|
+
StartIndex: startIndex,
|
|
501
|
+
SortBy: sortBy,
|
|
502
|
+
SortOrder: sortOrder,
|
|
503
|
+
Fields: COMMON_ITEM_FIELDS
|
|
504
|
+
};
|
|
505
|
+
if (albumId) params.ParentId = albumId;
|
|
506
|
+
if (artistId) params.ArtistIds = artistId;
|
|
507
|
+
if (isFavorite) params.Filters = "IsFavorite";
|
|
508
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Get favorite songs/tracks for the current user.
|
|
512
|
+
*/
|
|
513
|
+
async getFavoriteSongs(options = {}) {
|
|
514
|
+
return await this.getSongs({
|
|
515
|
+
...options,
|
|
516
|
+
isFavorite: true,
|
|
517
|
+
limit: options.limit || 1e3,
|
|
518
|
+
sortBy: options.sortBy || "SortName"
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get movies for the current user.
|
|
523
|
+
*/
|
|
524
|
+
async getMovies(options = {}) {
|
|
525
|
+
const userId = this.requireUserId();
|
|
526
|
+
const {
|
|
527
|
+
limit = 50,
|
|
528
|
+
startIndex = 0,
|
|
529
|
+
parentId,
|
|
530
|
+
isFavorite,
|
|
531
|
+
sortBy = "SortName",
|
|
532
|
+
sortOrder = "Ascending",
|
|
533
|
+
genres,
|
|
534
|
+
years,
|
|
535
|
+
searchTerm
|
|
536
|
+
} = options;
|
|
537
|
+
const params = {
|
|
538
|
+
IncludeItemTypes: "Movie",
|
|
539
|
+
Recursive: true,
|
|
540
|
+
Limit: limit,
|
|
541
|
+
StartIndex: startIndex,
|
|
542
|
+
SortBy: sortBy,
|
|
543
|
+
SortOrder: sortOrder,
|
|
544
|
+
Fields: COMMON_ITEM_FIELDS
|
|
545
|
+
};
|
|
546
|
+
if (parentId) params.ParentId = parentId;
|
|
547
|
+
if (isFavorite) params.Filters = "IsFavorite";
|
|
548
|
+
if (genres && genres.length > 0) params.Genres = genres.join("|");
|
|
549
|
+
if (years && years.length > 0) params.Years = years.join(",");
|
|
550
|
+
if (searchTerm) params.SearchTerm = searchTerm;
|
|
551
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Get TV series for the current user.
|
|
555
|
+
*/
|
|
556
|
+
async getSeries(options = {}) {
|
|
557
|
+
const userId = this.requireUserId();
|
|
558
|
+
const {
|
|
559
|
+
limit = 50,
|
|
560
|
+
startIndex = 0,
|
|
561
|
+
parentId,
|
|
562
|
+
isFavorite,
|
|
563
|
+
sortBy = "SortName",
|
|
564
|
+
sortOrder = "Ascending",
|
|
565
|
+
genres,
|
|
566
|
+
years,
|
|
567
|
+
searchTerm
|
|
568
|
+
} = options;
|
|
569
|
+
const params = {
|
|
570
|
+
IncludeItemTypes: "Series",
|
|
571
|
+
Recursive: true,
|
|
572
|
+
Limit: limit,
|
|
573
|
+
StartIndex: startIndex,
|
|
574
|
+
SortBy: sortBy,
|
|
575
|
+
SortOrder: sortOrder,
|
|
576
|
+
Fields: COMMON_ITEM_FIELDS
|
|
577
|
+
};
|
|
578
|
+
if (parentId) params.ParentId = parentId;
|
|
579
|
+
if (isFavorite) params.Filters = "IsFavorite";
|
|
580
|
+
if (genres && genres.length > 0) params.Genres = genres.join("|");
|
|
581
|
+
if (years && years.length > 0) params.Years = years.join(",");
|
|
582
|
+
if (searchTerm) params.SearchTerm = searchTerm;
|
|
583
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Get seasons for a given TV series.
|
|
587
|
+
*/
|
|
588
|
+
async getSeasons(seriesId, options = {}) {
|
|
589
|
+
const userId = options.userId || this.requireUserId();
|
|
590
|
+
const params = {
|
|
591
|
+
UserId: userId,
|
|
592
|
+
Fields: options.fields || COMMON_ITEM_FIELDS
|
|
593
|
+
};
|
|
594
|
+
if (options.isSpecialSeason !== void 0) {
|
|
595
|
+
params.IsSpecialSeason = options.isSpecialSeason;
|
|
596
|
+
}
|
|
597
|
+
return await this.http.request(`/Shows/${seriesId}/Seasons`, { params });
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Get episodes for a TV series or specific season.
|
|
601
|
+
*/
|
|
602
|
+
async getEpisodes(seriesId, seasonId, options = {}) {
|
|
603
|
+
const userId = this.requireUserId();
|
|
604
|
+
const {
|
|
605
|
+
seasonNumber,
|
|
606
|
+
startIndex = 0,
|
|
607
|
+
limit = 100,
|
|
608
|
+
isMissing,
|
|
609
|
+
sortBy = "IndexNumber,SortName",
|
|
610
|
+
sortOrder = "Ascending"
|
|
611
|
+
} = options;
|
|
612
|
+
if (seriesId) {
|
|
613
|
+
const params2 = {
|
|
614
|
+
UserId: userId,
|
|
615
|
+
StartIndex: startIndex,
|
|
616
|
+
Limit: limit,
|
|
617
|
+
SortBy: sortBy,
|
|
618
|
+
SortOrder: sortOrder,
|
|
619
|
+
Fields: COMMON_ITEM_FIELDS
|
|
620
|
+
};
|
|
621
|
+
if (seasonId) params2.SeasonId = seasonId;
|
|
622
|
+
if (seasonNumber !== void 0) params2.Season = seasonNumber;
|
|
623
|
+
if (isMissing !== void 0) params2.IsMissing = isMissing;
|
|
624
|
+
return await this.http.request(`/Shows/${seriesId}/Episodes`, { params: params2 });
|
|
625
|
+
}
|
|
626
|
+
const params = {
|
|
627
|
+
IncludeItemTypes: "Episode",
|
|
628
|
+
Recursive: true,
|
|
629
|
+
StartIndex: startIndex,
|
|
630
|
+
Limit: limit,
|
|
631
|
+
SortBy: sortBy,
|
|
632
|
+
SortOrder: sortOrder,
|
|
633
|
+
Fields: COMMON_ITEM_FIELDS
|
|
634
|
+
};
|
|
635
|
+
if (seasonId) params.ParentId = seasonId;
|
|
636
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* General query for video items (movies, episodes, videos).
|
|
640
|
+
*/
|
|
641
|
+
async getVideos(options = {}) {
|
|
642
|
+
const userId = this.requireUserId();
|
|
643
|
+
const {
|
|
644
|
+
limit = 50,
|
|
645
|
+
startIndex = 0,
|
|
646
|
+
parentId,
|
|
647
|
+
includeItemTypes = "Movie,Episode,Video",
|
|
648
|
+
sortBy = "SortName",
|
|
649
|
+
sortOrder = "Ascending"
|
|
650
|
+
} = options;
|
|
651
|
+
const params = {
|
|
652
|
+
IncludeItemTypes: includeItemTypes,
|
|
653
|
+
Recursive: true,
|
|
654
|
+
Limit: limit,
|
|
655
|
+
StartIndex: startIndex,
|
|
656
|
+
SortBy: sortBy,
|
|
657
|
+
SortOrder: sortOrder,
|
|
658
|
+
Fields: COMMON_ITEM_FIELDS
|
|
659
|
+
};
|
|
660
|
+
if (parentId) params.ParentId = parentId;
|
|
661
|
+
return await this.http.request(`/Users/${userId}/Items`, { params });
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Request media playback information for an item (sources, streams, play session).
|
|
665
|
+
*/
|
|
666
|
+
async getPlaybackInfo(itemId, options = {}) {
|
|
667
|
+
if (!itemId) {
|
|
668
|
+
throw new JellyfinError("itemId is required for getPlaybackInfo");
|
|
669
|
+
}
|
|
670
|
+
const userId = options.userId || this.requireUserId();
|
|
671
|
+
const payload = {
|
|
672
|
+
UserId: userId
|
|
673
|
+
};
|
|
674
|
+
if (options.startTimeTicks !== void 0) payload.StartTimeTicks = options.startTimeTicks;
|
|
675
|
+
if (options.audioStreamIndex !== void 0) payload.AudioStreamIndex = options.audioStreamIndex;
|
|
676
|
+
if (options.subtitleStreamIndex !== void 0) payload.SubtitleStreamIndex = options.subtitleStreamIndex;
|
|
677
|
+
if (options.maxStreamingBitrate !== void 0) payload.MaxStreamingBitrate = options.maxStreamingBitrate;
|
|
678
|
+
if (options.mediaSourceId !== void 0) payload.MediaSourceId = options.mediaSourceId;
|
|
679
|
+
if (options.deviceProfile !== void 0) payload.DeviceProfile = options.deviceProfile;
|
|
680
|
+
if (options.autoOpenLiveStream !== void 0) payload.AutoOpenLiveStream = options.autoOpenLiveStream;
|
|
681
|
+
return await this.http.request(`/Items/${itemId}/PlaybackInfo`, {
|
|
682
|
+
method: "POST",
|
|
683
|
+
body: payload
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Get single item details by its Jellyfin ID.
|
|
688
|
+
*/
|
|
689
|
+
async getItem(itemId) {
|
|
690
|
+
if (!itemId) return null;
|
|
691
|
+
const userId = this.requireUserId();
|
|
692
|
+
return await this.http.request(`/Users/${userId}/Items/${itemId}`, {
|
|
693
|
+
params: {
|
|
694
|
+
Fields: COMMON_ITEM_FIELDS
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Search Jellyfin library for songs, albums, artists, playlists, movies, or series.
|
|
700
|
+
*/
|
|
701
|
+
async search(query, options = {}) {
|
|
702
|
+
if (!query || !query.trim()) {
|
|
703
|
+
return { Items: [], TotalRecordCount: 0 };
|
|
704
|
+
}
|
|
705
|
+
const userId = this.requireUserId();
|
|
706
|
+
const {
|
|
707
|
+
limit = 30,
|
|
708
|
+
startIndex = 0,
|
|
709
|
+
includeItemTypes = "Audio,MusicAlbum,MusicArtist,Playlist,Movie,Series,Episode"
|
|
710
|
+
} = options;
|
|
711
|
+
return await this.http.request(`/Users/${userId}/Items`, {
|
|
712
|
+
params: {
|
|
713
|
+
SearchTerm: query.trim(),
|
|
714
|
+
IncludeItemTypes: includeItemTypes,
|
|
715
|
+
Limit: limit,
|
|
716
|
+
StartIndex: startIndex,
|
|
717
|
+
Recursive: true,
|
|
718
|
+
Fields: COMMON_ITEM_FIELDS
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// src/playlists.ts
|
|
725
|
+
var PlaylistsModule = class {
|
|
726
|
+
http;
|
|
727
|
+
getUserId;
|
|
728
|
+
constructor(http, getUserId) {
|
|
729
|
+
this.http = http;
|
|
730
|
+
this.getUserId = getUserId;
|
|
731
|
+
}
|
|
732
|
+
requireUserId() {
|
|
733
|
+
const userId = this.getUserId();
|
|
734
|
+
if (!userId) {
|
|
735
|
+
throw new JellyfinError("User ID is required for playlist operations.");
|
|
736
|
+
}
|
|
737
|
+
return userId;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Get all playlists for the current user.
|
|
741
|
+
*/
|
|
742
|
+
async getPlaylists() {
|
|
743
|
+
const userId = this.requireUserId();
|
|
744
|
+
return await this.http.request(`/Users/${userId}/Items`, {
|
|
745
|
+
params: {
|
|
746
|
+
IncludeItemTypes: "Playlist",
|
|
747
|
+
Recursive: true,
|
|
748
|
+
Fields: "PrimaryImageTag,ImageTags,DateCreated,DateLastMediaAdded,UserData"
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Get items belonging to a specific playlist.
|
|
754
|
+
*/
|
|
755
|
+
async getPlaylistItems(playlistId) {
|
|
756
|
+
if (!playlistId) {
|
|
757
|
+
throw new JellyfinError("Playlist ID is required.");
|
|
758
|
+
}
|
|
759
|
+
const userId = this.requireUserId();
|
|
760
|
+
return await this.http.request(`/Playlists/${playlistId}/Items`, {
|
|
761
|
+
params: {
|
|
762
|
+
UserId: userId,
|
|
763
|
+
Fields: "PrimaryImageAspectRatio,PrimaryImageTag,ImageTags,AlbumPrimaryImageTag,AlbumId,AudioInfo,MediaSources,HasLyrics,UserData"
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Create a new playlist.
|
|
769
|
+
* Jellyfin 12 requires a JSON request body with CreatePlaylistDto.
|
|
770
|
+
* Falls back to legacy query parameters if server returns 400/415.
|
|
771
|
+
*/
|
|
772
|
+
async createPlaylist(options) {
|
|
773
|
+
const userId = options.userId || this.requireUserId();
|
|
774
|
+
const { name, isPublic = false, trackIds = [], mediaType = "Audio" } = options;
|
|
775
|
+
if (!name || !name.trim()) {
|
|
776
|
+
throw new JellyfinError("Playlist name is required.");
|
|
777
|
+
}
|
|
778
|
+
const idsArray = Array.isArray(trackIds) ? trackIds : [trackIds].filter(Boolean);
|
|
779
|
+
let playlistId;
|
|
780
|
+
try {
|
|
781
|
+
const resData = await this.http.request("/Playlists", {
|
|
782
|
+
method: "POST",
|
|
783
|
+
body: {
|
|
784
|
+
Name: name.trim(),
|
|
785
|
+
Ids: idsArray,
|
|
786
|
+
UserId: userId,
|
|
787
|
+
MediaType: mediaType
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
playlistId = resData?.Id;
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if (err instanceof JellyfinApiError && (err.status === 400 || err.status === 415)) {
|
|
793
|
+
const idsStr = idsArray.join(",");
|
|
794
|
+
const resData = await this.http.request("/Playlists", {
|
|
795
|
+
method: "POST",
|
|
796
|
+
params: {
|
|
797
|
+
Name: name.trim(),
|
|
798
|
+
UserId: userId,
|
|
799
|
+
MediaType: mediaType,
|
|
800
|
+
...idsStr ? { Ids: idsStr } : {}
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
playlistId = resData?.Id;
|
|
804
|
+
} else {
|
|
805
|
+
throw err;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (playlistId && isPublic) {
|
|
809
|
+
try {
|
|
810
|
+
await this.http.request(`/Playlists/${playlistId}/Users/${userId}`, {
|
|
811
|
+
method: "POST",
|
|
812
|
+
body: { UserId: userId, CanEdit: true }
|
|
813
|
+
});
|
|
814
|
+
} catch {
|
|
815
|
+
try {
|
|
816
|
+
await this.http.request(`/Playlists/${playlistId}`, {
|
|
817
|
+
method: "POST",
|
|
818
|
+
body: { IsPublic: true }
|
|
819
|
+
});
|
|
820
|
+
} catch (err) {
|
|
821
|
+
console.warn("[Jellyfin SDK] Failed to update public status for playlist:", err);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return playlistId || "";
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Update playlist metadata (e.g. name).
|
|
829
|
+
* Jellyfin 12 removed POST /Playlists/{id}; item updates use POST /Items/{id},
|
|
830
|
+
* which is also supported across older Jellyfin versions.
|
|
831
|
+
*/
|
|
832
|
+
async updatePlaylist(playlistId, options) {
|
|
833
|
+
if (!playlistId) {
|
|
834
|
+
throw new JellyfinError("Playlist ID is required.");
|
|
835
|
+
}
|
|
836
|
+
const body = { Id: playlistId };
|
|
837
|
+
if (options.name) body.Name = options.name;
|
|
838
|
+
try {
|
|
839
|
+
await this.http.request(`/Items/${playlistId}`, {
|
|
840
|
+
method: "POST",
|
|
841
|
+
body
|
|
842
|
+
});
|
|
843
|
+
} catch (err) {
|
|
844
|
+
await this.http.request(`/Playlists/${playlistId}`, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
body: options.name ? { Name: options.name } : {}
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Upload custom cover image for a playlist.
|
|
852
|
+
*/
|
|
853
|
+
async uploadPlaylistImage(playlistId, base64ImageString, mimeType = "image/jpeg") {
|
|
854
|
+
if (!playlistId || !base64ImageString) {
|
|
855
|
+
throw new JellyfinError("Playlist ID and image data are required.");
|
|
856
|
+
}
|
|
857
|
+
const base64Data = base64ImageString.includes(",") ? base64ImageString.split(",")[1] : base64ImageString;
|
|
858
|
+
await this.http.request(`/Items/${playlistId}/Images/Primary`, {
|
|
859
|
+
method: "POST",
|
|
860
|
+
contentType: mimeType,
|
|
861
|
+
body: base64Data
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Delete a playlist.
|
|
866
|
+
*/
|
|
867
|
+
async deletePlaylist(playlistId) {
|
|
868
|
+
if (!playlistId) {
|
|
869
|
+
throw new JellyfinError("Playlist ID is required.");
|
|
870
|
+
}
|
|
871
|
+
await this.http.request(`/Items/${playlistId}`, {
|
|
872
|
+
method: "DELETE"
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Add tracks to an existing playlist.
|
|
877
|
+
*/
|
|
878
|
+
async addTracks(playlistId, trackIds) {
|
|
879
|
+
if (!playlistId || !trackIds) return;
|
|
880
|
+
const userId = this.requireUserId();
|
|
881
|
+
const idsStr = Array.isArray(trackIds) ? trackIds.join(",") : trackIds;
|
|
882
|
+
if (!idsStr) return;
|
|
883
|
+
await this.http.request(`/Playlists/${playlistId}/Items`, {
|
|
884
|
+
method: "POST",
|
|
885
|
+
params: {
|
|
886
|
+
Ids: idsStr,
|
|
887
|
+
UserId: userId
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Remove a track from a playlist by playlist entry ID or track ID.
|
|
893
|
+
*/
|
|
894
|
+
async removeTrack(playlistId, entryIdOrTrackId) {
|
|
895
|
+
if (!playlistId || !entryIdOrTrackId) return;
|
|
896
|
+
try {
|
|
897
|
+
await this.http.request(`/Playlists/${playlistId}/Items`, {
|
|
898
|
+
method: "DELETE",
|
|
899
|
+
params: { EntryIds: entryIdOrTrackId }
|
|
900
|
+
});
|
|
901
|
+
} catch {
|
|
902
|
+
await this.http.request(`/Playlists/${playlistId}/Items`, {
|
|
903
|
+
method: "DELETE",
|
|
904
|
+
params: { ItemIds: entryIdOrTrackId }
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Remove a specific playlist item by its unique entry ID (PlaylistItemId).
|
|
910
|
+
* Especially recommended on Jellyfin 12+ when playlists contain duplicate tracks.
|
|
911
|
+
*/
|
|
912
|
+
async removePlaylistItem(playlistId, playlistItemId) {
|
|
913
|
+
return this.removeTrack(playlistId, playlistItemId);
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// src/playback.ts
|
|
918
|
+
var PlaybackModule = class {
|
|
919
|
+
http;
|
|
920
|
+
constructor(http) {
|
|
921
|
+
this.http = http;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Report to the Jellyfin server that playback has started for an item.
|
|
925
|
+
*/
|
|
926
|
+
async reportStart(itemId, positionTicks = 0) {
|
|
927
|
+
if (!itemId || !this.http.getToken()) return;
|
|
928
|
+
try {
|
|
929
|
+
await this.http.request("/Sessions/Playing", {
|
|
930
|
+
method: "POST",
|
|
931
|
+
body: {
|
|
932
|
+
ItemId: itemId,
|
|
933
|
+
PositionTicks: positionTicks,
|
|
934
|
+
CanSeek: true,
|
|
935
|
+
IsPaused: false
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.warn("[Jellyfin SDK] Start playback report error:", e);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Report current playback progress to the Jellyfin server.
|
|
944
|
+
*/
|
|
945
|
+
async reportProgress(itemId, positionTicks = 0, isPaused = false) {
|
|
946
|
+
if (!itemId || !this.http.getToken()) return;
|
|
947
|
+
try {
|
|
948
|
+
await this.http.request("/Sessions/Playing/Progress", {
|
|
949
|
+
method: "POST",
|
|
950
|
+
body: {
|
|
951
|
+
ItemId: itemId,
|
|
952
|
+
PositionTicks: positionTicks,
|
|
953
|
+
CanSeek: true,
|
|
954
|
+
IsPaused: isPaused
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
} catch (e) {
|
|
958
|
+
console.warn("[Jellyfin SDK] Progress playback report error:", e);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Report that playback has stopped for an item.
|
|
963
|
+
*/
|
|
964
|
+
async reportStopped(itemId, positionTicks = 0) {
|
|
965
|
+
if (!itemId || !this.http.getToken()) return;
|
|
966
|
+
try {
|
|
967
|
+
await this.http.request("/Sessions/Playing/Stopped", {
|
|
968
|
+
method: "POST",
|
|
969
|
+
body: {
|
|
970
|
+
ItemId: itemId,
|
|
971
|
+
PositionTicks: positionTicks
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
} catch (e) {
|
|
975
|
+
console.warn("[Jellyfin SDK] Stop playback report error:", e);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
// src/quality.ts
|
|
981
|
+
var VIDEO_QUALITY_PRESETS = {
|
|
982
|
+
direct: { key: "direct", label: "Original (Direct)" },
|
|
983
|
+
auto: { key: "auto", label: "Auto" },
|
|
984
|
+
"4k": { key: "4k", label: "4K Ultra HD (60 Mbps)", bitrate: 6e7, maxWidth: 3840, maxHeight: 2160 },
|
|
985
|
+
"1080p-high": { key: "1080p-high", label: "1080p High (20 Mbps)", bitrate: 2e7, maxWidth: 1920, maxHeight: 1080 },
|
|
986
|
+
"1080p": { key: "1080p", label: "1080p (10 Mbps)", bitrate: 1e7, maxWidth: 1920, maxHeight: 1080 },
|
|
987
|
+
"720p-high": { key: "720p-high", label: "720p High (6 Mbps)", bitrate: 6e6, maxWidth: 1280, maxHeight: 720 },
|
|
988
|
+
"720p": { key: "720p", label: "720p (4 Mbps)", bitrate: 4e6, maxWidth: 1280, maxHeight: 720 },
|
|
989
|
+
"480p": { key: "480p", label: "480p (2 Mbps)", bitrate: 2e6, maxWidth: 854, maxHeight: 480 },
|
|
990
|
+
"360p": { key: "360p", label: "360p (1 Mbps)", bitrate: 1e6, maxWidth: 640, maxHeight: 360 },
|
|
991
|
+
"240p": { key: "240p", label: "240p (420 kbps)", bitrate: 42e4, maxWidth: 426, maxHeight: 240 }
|
|
992
|
+
};
|
|
993
|
+
var AUDIO_QUALITY_PRESETS = {
|
|
994
|
+
direct: { key: "direct", label: "Original (Direct)" },
|
|
995
|
+
"320k": { key: "320k", label: "320 kbps", bitrate: 32e4 },
|
|
996
|
+
"256k": { key: "256k", label: "256 kbps", bitrate: 256e3 },
|
|
997
|
+
"192k": { key: "192k", label: "192 kbps", bitrate: 192e3 },
|
|
998
|
+
"128k": { key: "128k", label: "128 kbps", bitrate: 128e3 },
|
|
999
|
+
"64k": { key: "64k", label: "64 kbps", bitrate: 64e3 }
|
|
1000
|
+
};
|
|
1001
|
+
function getVideoQualityList() {
|
|
1002
|
+
return Object.values(VIDEO_QUALITY_PRESETS);
|
|
1003
|
+
}
|
|
1004
|
+
function getAudioQualityList() {
|
|
1005
|
+
return Object.values(AUDIO_QUALITY_PRESETS);
|
|
1006
|
+
}
|
|
1007
|
+
function resolveVideoQuality(quality) {
|
|
1008
|
+
if (!quality) return {};
|
|
1009
|
+
if (typeof quality === "number") {
|
|
1010
|
+
return { maxStreamingBitrate: quality };
|
|
1011
|
+
}
|
|
1012
|
+
if (typeof quality === "string") {
|
|
1013
|
+
const key = quality.toLowerCase();
|
|
1014
|
+
if (VIDEO_QUALITY_PRESETS[key]) {
|
|
1015
|
+
const preset = VIDEO_QUALITY_PRESETS[key];
|
|
1016
|
+
return {
|
|
1017
|
+
maxStreamingBitrate: preset.bitrate,
|
|
1018
|
+
maxWidth: preset.maxWidth,
|
|
1019
|
+
maxHeight: preset.maxHeight
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const parsed = parseInt(quality, 10);
|
|
1023
|
+
if (!Number.isNaN(parsed)) {
|
|
1024
|
+
return { maxStreamingBitrate: parsed };
|
|
1025
|
+
}
|
|
1026
|
+
return {};
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof quality === "object") {
|
|
1029
|
+
return {
|
|
1030
|
+
maxStreamingBitrate: quality.bitrate,
|
|
1031
|
+
maxWidth: quality.maxWidth,
|
|
1032
|
+
maxHeight: quality.maxHeight
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
return {};
|
|
1036
|
+
}
|
|
1037
|
+
function resolveAudioQuality(quality) {
|
|
1038
|
+
if (!quality) return {};
|
|
1039
|
+
if (typeof quality === "number") {
|
|
1040
|
+
return { maxStreamingBitrate: quality };
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof quality === "string") {
|
|
1043
|
+
const key = quality.toLowerCase();
|
|
1044
|
+
if (AUDIO_QUALITY_PRESETS[key]) {
|
|
1045
|
+
return { maxStreamingBitrate: AUDIO_QUALITY_PRESETS[key].bitrate };
|
|
1046
|
+
}
|
|
1047
|
+
const parsed = parseInt(quality, 10);
|
|
1048
|
+
if (!Number.isNaN(parsed)) {
|
|
1049
|
+
return { maxStreamingBitrate: parsed };
|
|
1050
|
+
}
|
|
1051
|
+
return {};
|
|
1052
|
+
}
|
|
1053
|
+
if (typeof quality === "object" && quality.bitrate) {
|
|
1054
|
+
return { maxStreamingBitrate: quality.bitrate };
|
|
1055
|
+
}
|
|
1056
|
+
return {};
|
|
1057
|
+
}
|
|
1058
|
+
function formatBitrate(bitrate) {
|
|
1059
|
+
if (!bitrate || bitrate <= 0) return "Direct";
|
|
1060
|
+
if (bitrate >= 1e6) {
|
|
1061
|
+
const mbps = bitrate / 1e6;
|
|
1062
|
+
return `${Number.isInteger(mbps) ? mbps : mbps.toFixed(1)} Mbps`;
|
|
1063
|
+
}
|
|
1064
|
+
const kbps = Math.round(bitrate / 1e3);
|
|
1065
|
+
return `${kbps} kbps`;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/media.ts
|
|
1069
|
+
var MediaModule = class {
|
|
1070
|
+
http;
|
|
1071
|
+
getUserId;
|
|
1072
|
+
constructor(http, getUserId) {
|
|
1073
|
+
this.http = http;
|
|
1074
|
+
this.getUserId = getUserId;
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Constructs the URL for an item's image/artwork.
|
|
1078
|
+
* If the item lacks images or server is not set, returns options.fallbackUrl or null.
|
|
1079
|
+
*/
|
|
1080
|
+
getArtworkUrl(itemOrId, options = {}) {
|
|
1081
|
+
const serverUrl = this.http.getServerUrl();
|
|
1082
|
+
const {
|
|
1083
|
+
imageType = "Primary",
|
|
1084
|
+
maxWidth = 400,
|
|
1085
|
+
maxHeight,
|
|
1086
|
+
quality = 90,
|
|
1087
|
+
fallbackUrl = null
|
|
1088
|
+
} = options;
|
|
1089
|
+
if (!serverUrl || !itemOrId) return fallbackUrl;
|
|
1090
|
+
if (typeof itemOrId === "object") {
|
|
1091
|
+
const item = itemOrId;
|
|
1092
|
+
let targetId = null;
|
|
1093
|
+
let tag = null;
|
|
1094
|
+
if (item.ImageTags && item.ImageTags[imageType]) {
|
|
1095
|
+
targetId = item.Id;
|
|
1096
|
+
tag = item.ImageTags[imageType];
|
|
1097
|
+
} else if (imageType === "Primary" && item.PrimaryImageTag) {
|
|
1098
|
+
targetId = item.Id;
|
|
1099
|
+
tag = item.PrimaryImageTag;
|
|
1100
|
+
} else if (imageType === "Primary" && item.AlbumPrimaryImageTag && item.AlbumId) {
|
|
1101
|
+
targetId = item.AlbumId;
|
|
1102
|
+
tag = item.AlbumPrimaryImageTag;
|
|
1103
|
+
}
|
|
1104
|
+
if (!targetId || !tag) {
|
|
1105
|
+
return fallbackUrl;
|
|
1106
|
+
}
|
|
1107
|
+
const params = new URLSearchParams({
|
|
1108
|
+
maxWidth: String(maxWidth),
|
|
1109
|
+
quality: String(quality),
|
|
1110
|
+
tag
|
|
1111
|
+
});
|
|
1112
|
+
if (maxHeight) params.append("maxHeight", String(maxHeight));
|
|
1113
|
+
return `${serverUrl}/Items/${targetId}/Images/${imageType}?${params.toString()}`;
|
|
1114
|
+
}
|
|
1115
|
+
if (typeof itemOrId === "string" && itemOrId.trim() !== "") {
|
|
1116
|
+
const params = new URLSearchParams({
|
|
1117
|
+
maxWidth: String(maxWidth),
|
|
1118
|
+
quality: String(quality)
|
|
1119
|
+
});
|
|
1120
|
+
if (maxHeight) params.append("maxHeight", String(maxHeight));
|
|
1121
|
+
return `${serverUrl}/Items/${itemOrId}/Images/${imageType}?${params.toString()}`;
|
|
1122
|
+
}
|
|
1123
|
+
return fallbackUrl;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Constructs the audio stream URL using Jellyfin's universal audio endpoint.
|
|
1127
|
+
* Transparently negotiates codecs (direct play or transcode to MP3/AAC as needed).
|
|
1128
|
+
*/
|
|
1129
|
+
getAudioStreamUrl(itemId, options = {}) {
|
|
1130
|
+
const serverUrl = this.http.getServerUrl();
|
|
1131
|
+
const token = this.http.getToken();
|
|
1132
|
+
const userId = this.getUserId();
|
|
1133
|
+
const clientInfo = this.http.getClientInfo();
|
|
1134
|
+
if (!serverUrl || !itemId) return "";
|
|
1135
|
+
const qualityResolved = resolveAudioQuality(options.quality);
|
|
1136
|
+
const maxStreamingBitrate = options.maxStreamingBitrate ?? qualityResolved.maxStreamingBitrate;
|
|
1137
|
+
const {
|
|
1138
|
+
startTimeTicks = 0,
|
|
1139
|
+
container = "opus,mp3|mp3,aac,m4a,m4b,flac,wav,ogg",
|
|
1140
|
+
transcodingContainer = "mp3",
|
|
1141
|
+
audioCodec = "mp3",
|
|
1142
|
+
useQueryToken = true
|
|
1143
|
+
} = options;
|
|
1144
|
+
const url = new URL(`${serverUrl}/Audio/${itemId}/universal`);
|
|
1145
|
+
if (token && useQueryToken) url.searchParams.append("api_key", token);
|
|
1146
|
+
if (userId) url.searchParams.append("UserId", userId);
|
|
1147
|
+
if (clientInfo.deviceId) url.searchParams.append("DeviceId", clientInfo.deviceId);
|
|
1148
|
+
url.searchParams.append("Container", container);
|
|
1149
|
+
url.searchParams.append("TranscodingContainer", transcodingContainer);
|
|
1150
|
+
url.searchParams.append("TranscodingProtocol", "http");
|
|
1151
|
+
url.searchParams.append("AudioCodec", audioCodec);
|
|
1152
|
+
if (maxStreamingBitrate && maxStreamingBitrate !== "Direct") {
|
|
1153
|
+
url.searchParams.append("MaxStreamingBitrate", String(maxStreamingBitrate));
|
|
1154
|
+
}
|
|
1155
|
+
if (startTimeTicks > 0) {
|
|
1156
|
+
url.searchParams.append("StartTimeTicks", String(startTimeTicks));
|
|
1157
|
+
}
|
|
1158
|
+
url.searchParams.append("EnableRedirection", "true");
|
|
1159
|
+
url.searchParams.append("EnableRemoteMedia", "false");
|
|
1160
|
+
return url.toString();
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Constructs the HLS master playlist URL for an audio stream.
|
|
1164
|
+
*/
|
|
1165
|
+
getAudioHlsStreamUrl(itemId, options = {}) {
|
|
1166
|
+
const serverUrl = this.http.getServerUrl();
|
|
1167
|
+
const token = this.http.getToken();
|
|
1168
|
+
const userId = this.getUserId();
|
|
1169
|
+
const clientInfo = this.http.getClientInfo();
|
|
1170
|
+
if (!serverUrl || !itemId) return "";
|
|
1171
|
+
const qualityResolved = resolveAudioQuality(options.quality);
|
|
1172
|
+
const maxStreamingBitrate = options.maxStreamingBitrate ?? qualityResolved.maxStreamingBitrate;
|
|
1173
|
+
const {
|
|
1174
|
+
startTimeTicks = 0,
|
|
1175
|
+
audioCodec = "aac,mp3",
|
|
1176
|
+
segmentLength = 3,
|
|
1177
|
+
useQueryToken = true
|
|
1178
|
+
} = options;
|
|
1179
|
+
const url = new URL(`${serverUrl}/Audio/${itemId}/master.m3u8`);
|
|
1180
|
+
if (token && useQueryToken) url.searchParams.append("api_key", token);
|
|
1181
|
+
if (userId) url.searchParams.append("UserId", userId);
|
|
1182
|
+
if (clientInfo.deviceId) url.searchParams.append("DeviceId", clientInfo.deviceId);
|
|
1183
|
+
url.searchParams.append("MediaSourceId", itemId);
|
|
1184
|
+
url.searchParams.append("AudioCodec", audioCodec);
|
|
1185
|
+
url.searchParams.append("TranscodingContainer", "ts");
|
|
1186
|
+
url.searchParams.append("TranscodingProtocol", "hls");
|
|
1187
|
+
url.searchParams.append("SegmentLength", String(segmentLength));
|
|
1188
|
+
url.searchParams.append("MinSegments", "2");
|
|
1189
|
+
if (maxStreamingBitrate && maxStreamingBitrate !== "Direct") {
|
|
1190
|
+
url.searchParams.append("MaxStreamingBitrate", String(maxStreamingBitrate));
|
|
1191
|
+
}
|
|
1192
|
+
if (startTimeTicks > 0) {
|
|
1193
|
+
url.searchParams.append("StartTimeTicks", String(startTimeTicks));
|
|
1194
|
+
}
|
|
1195
|
+
return url.toString();
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Constructs the progressive/universal video stream URL.
|
|
1199
|
+
* Supports direct play (static=true) or on-the-fly transcoding with quality, audio, and subtitle options.
|
|
1200
|
+
*/
|
|
1201
|
+
getVideoStreamUrl(itemId, options = {}) {
|
|
1202
|
+
const serverUrl = this.http.getServerUrl();
|
|
1203
|
+
const token = this.http.getToken();
|
|
1204
|
+
const userId = this.getUserId();
|
|
1205
|
+
const clientInfo = this.http.getClientInfo();
|
|
1206
|
+
if (!serverUrl || !itemId) return "";
|
|
1207
|
+
const qualityResolved = resolveVideoQuality(options.quality);
|
|
1208
|
+
const maxStreamingBitrate = options.maxStreamingBitrate ?? qualityResolved.maxStreamingBitrate;
|
|
1209
|
+
const maxWidth = options.maxWidth ?? qualityResolved.maxWidth;
|
|
1210
|
+
const maxHeight = options.maxHeight ?? qualityResolved.maxHeight;
|
|
1211
|
+
const {
|
|
1212
|
+
mediaSourceId = itemId,
|
|
1213
|
+
static: isStatic = false,
|
|
1214
|
+
videoCodec = "h264,hevc,vp9,av1",
|
|
1215
|
+
audioCodec = "aac,mp3,opus,flac",
|
|
1216
|
+
maxFramerate,
|
|
1217
|
+
audioStreamIndex,
|
|
1218
|
+
subtitleStreamIndex,
|
|
1219
|
+
subtitleMethod,
|
|
1220
|
+
startTimeTicks = 0,
|
|
1221
|
+
container,
|
|
1222
|
+
transcodingContainer = "ts",
|
|
1223
|
+
transcodingProtocol = "http",
|
|
1224
|
+
useQueryToken = true
|
|
1225
|
+
} = options;
|
|
1226
|
+
const endpoint = container && !container.includes(",") ? `/Videos/${itemId}/stream.${container}` : `/Videos/${itemId}/stream`;
|
|
1227
|
+
const url = new URL(`${serverUrl}${endpoint}`);
|
|
1228
|
+
if (token && useQueryToken) url.searchParams.append("api_key", token);
|
|
1229
|
+
if (userId) url.searchParams.append("UserId", userId);
|
|
1230
|
+
if (clientInfo.deviceId) url.searchParams.append("DeviceId", clientInfo.deviceId);
|
|
1231
|
+
if (mediaSourceId) url.searchParams.append("MediaSourceId", mediaSourceId);
|
|
1232
|
+
if (isStatic) {
|
|
1233
|
+
url.searchParams.append("Static", "true");
|
|
1234
|
+
} else {
|
|
1235
|
+
url.searchParams.append("VideoCodec", videoCodec);
|
|
1236
|
+
url.searchParams.append("AudioCodec", audioCodec);
|
|
1237
|
+
url.searchParams.append("TranscodingContainer", transcodingContainer);
|
|
1238
|
+
url.searchParams.append("TranscodingProtocol", transcodingProtocol);
|
|
1239
|
+
if (maxStreamingBitrate && maxStreamingBitrate !== "Direct") {
|
|
1240
|
+
url.searchParams.append("MaxStreamingBitrate", String(maxStreamingBitrate));
|
|
1241
|
+
}
|
|
1242
|
+
if (maxWidth) url.searchParams.append("MaxWidth", String(maxWidth));
|
|
1243
|
+
if (maxHeight) url.searchParams.append("MaxHeight", String(maxHeight));
|
|
1244
|
+
if (maxFramerate) url.searchParams.append("MaxFramerate", String(maxFramerate));
|
|
1245
|
+
}
|
|
1246
|
+
if (audioStreamIndex !== void 0 && audioStreamIndex !== null) {
|
|
1247
|
+
url.searchParams.append("AudioStreamIndex", String(audioStreamIndex));
|
|
1248
|
+
}
|
|
1249
|
+
if (subtitleStreamIndex !== void 0 && subtitleStreamIndex !== null) {
|
|
1250
|
+
url.searchParams.append("SubtitleStreamIndex", String(subtitleStreamIndex));
|
|
1251
|
+
if (subtitleMethod) url.searchParams.append("SubtitleMethod", subtitleMethod);
|
|
1252
|
+
}
|
|
1253
|
+
if (startTimeTicks > 0) {
|
|
1254
|
+
url.searchParams.append("StartTimeTicks", String(startTimeTicks));
|
|
1255
|
+
}
|
|
1256
|
+
url.searchParams.append("EnableRedirection", "true");
|
|
1257
|
+
url.searchParams.append("EnableRemoteMedia", "false");
|
|
1258
|
+
return url.toString();
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Constructs the HLS master playlist URL for an adaptive video stream.
|
|
1262
|
+
*/
|
|
1263
|
+
getVideoHlsStreamUrl(itemId, options = {}) {
|
|
1264
|
+
const serverUrl = this.http.getServerUrl();
|
|
1265
|
+
const token = this.http.getToken();
|
|
1266
|
+
const userId = this.getUserId();
|
|
1267
|
+
const clientInfo = this.http.getClientInfo();
|
|
1268
|
+
if (!serverUrl || !itemId) return "";
|
|
1269
|
+
const qualityResolved = resolveVideoQuality(options.quality);
|
|
1270
|
+
const maxStreamingBitrate = options.maxStreamingBitrate ?? qualityResolved.maxStreamingBitrate;
|
|
1271
|
+
const maxWidth = options.maxWidth ?? qualityResolved.maxWidth;
|
|
1272
|
+
const maxHeight = options.maxHeight ?? qualityResolved.maxHeight;
|
|
1273
|
+
const {
|
|
1274
|
+
mediaSourceId = itemId,
|
|
1275
|
+
videoCodec = "h264,hevc,vp9,av1",
|
|
1276
|
+
audioCodec = "aac,mp3,opus",
|
|
1277
|
+
maxFramerate,
|
|
1278
|
+
segmentLength = 3,
|
|
1279
|
+
minSegments = 2,
|
|
1280
|
+
audioStreamIndex,
|
|
1281
|
+
subtitleStreamIndex,
|
|
1282
|
+
subtitleMethod,
|
|
1283
|
+
transcodingContainer = "ts",
|
|
1284
|
+
transcodingProtocol = "hls",
|
|
1285
|
+
startTimeTicks = 0,
|
|
1286
|
+
useQueryToken = true
|
|
1287
|
+
} = options;
|
|
1288
|
+
const url = new URL(`${serverUrl}/Videos/${itemId}/master.m3u8`);
|
|
1289
|
+
if (token && useQueryToken) url.searchParams.append("api_key", token);
|
|
1290
|
+
if (userId) url.searchParams.append("UserId", userId);
|
|
1291
|
+
if (clientInfo.deviceId) url.searchParams.append("DeviceId", clientInfo.deviceId);
|
|
1292
|
+
if (mediaSourceId) url.searchParams.append("MediaSourceId", mediaSourceId);
|
|
1293
|
+
url.searchParams.append("VideoCodec", videoCodec);
|
|
1294
|
+
url.searchParams.append("AudioCodec", audioCodec);
|
|
1295
|
+
url.searchParams.append("TranscodingContainer", transcodingContainer);
|
|
1296
|
+
url.searchParams.append("TranscodingProtocol", transcodingProtocol);
|
|
1297
|
+
url.searchParams.append("SegmentLength", String(segmentLength));
|
|
1298
|
+
url.searchParams.append("MinSegments", String(minSegments));
|
|
1299
|
+
if (maxStreamingBitrate && maxStreamingBitrate !== "Direct") {
|
|
1300
|
+
url.searchParams.append("MaxStreamingBitrate", String(maxStreamingBitrate));
|
|
1301
|
+
}
|
|
1302
|
+
if (maxWidth) url.searchParams.append("MaxWidth", String(maxWidth));
|
|
1303
|
+
if (maxHeight) url.searchParams.append("MaxHeight", String(maxHeight));
|
|
1304
|
+
if (maxFramerate) url.searchParams.append("MaxFramerate", String(maxFramerate));
|
|
1305
|
+
if (audioStreamIndex !== void 0 && audioStreamIndex !== null) {
|
|
1306
|
+
url.searchParams.append("AudioStreamIndex", String(audioStreamIndex));
|
|
1307
|
+
}
|
|
1308
|
+
if (subtitleStreamIndex !== void 0 && subtitleStreamIndex !== null) {
|
|
1309
|
+
url.searchParams.append("SubtitleStreamIndex", String(subtitleStreamIndex));
|
|
1310
|
+
if (subtitleMethod) url.searchParams.append("SubtitleMethod", subtitleMethod);
|
|
1311
|
+
}
|
|
1312
|
+
if (startTimeTicks > 0) {
|
|
1313
|
+
url.searchParams.append("StartTimeTicks", String(startTimeTicks));
|
|
1314
|
+
}
|
|
1315
|
+
return url.toString();
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Constructs the URL for streaming a specific subtitle track.
|
|
1319
|
+
*/
|
|
1320
|
+
getSubtitleUrl(itemId, mediaSourceId, subtitleStreamIndex, options = {}) {
|
|
1321
|
+
const serverUrl = this.http.getServerUrl();
|
|
1322
|
+
const token = this.http.getToken();
|
|
1323
|
+
const { format = "vtt", useQueryToken = true, startTimeTicks = 0 } = options;
|
|
1324
|
+
if (!serverUrl || !itemId) return "";
|
|
1325
|
+
const sourceId = mediaSourceId || itemId;
|
|
1326
|
+
const url = new URL(`${serverUrl}/Videos/${itemId}/${sourceId}/Subtitles/${subtitleStreamIndex}/Stream.${format}`);
|
|
1327
|
+
if (token && useQueryToken) {
|
|
1328
|
+
url.searchParams.append("api_key", token);
|
|
1329
|
+
}
|
|
1330
|
+
if (startTimeTicks > 0) {
|
|
1331
|
+
url.searchParams.append("StartTimeTicks", String(startTimeTicks));
|
|
1332
|
+
}
|
|
1333
|
+
return url.toString();
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Fetches subtitle content as raw text (e.g. WebVTT or SRT format).
|
|
1337
|
+
*/
|
|
1338
|
+
async getSubtitles(itemId, mediaSourceId, subtitleStreamIndex, options = {}) {
|
|
1339
|
+
const { format = "vtt", startTimeTicks = 0 } = options;
|
|
1340
|
+
const sourceId = mediaSourceId || itemId;
|
|
1341
|
+
const params = {};
|
|
1342
|
+
if (startTimeTicks > 0) {
|
|
1343
|
+
params.StartTimeTicks = startTimeTicks;
|
|
1344
|
+
}
|
|
1345
|
+
return await this.http.requestText(
|
|
1346
|
+
`/Videos/${itemId}/${sourceId}/Subtitles/${subtitleStreamIndex}/Stream.${format}`,
|
|
1347
|
+
{ params }
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Extracts and normalizes subtitle streams from an item or media source into SubtitleTrackInfo objects with URLs.
|
|
1352
|
+
*/
|
|
1353
|
+
getSubtitleTracks(itemOrMediaSource) {
|
|
1354
|
+
if (!itemOrMediaSource) return [];
|
|
1355
|
+
let streams = [];
|
|
1356
|
+
let itemId = "";
|
|
1357
|
+
let mediaSourceId = "";
|
|
1358
|
+
if ("MediaSources" in itemOrMediaSource && Array.isArray(itemOrMediaSource.MediaSources)) {
|
|
1359
|
+
itemId = itemOrMediaSource.Id;
|
|
1360
|
+
const primarySource = itemOrMediaSource.MediaSources[0];
|
|
1361
|
+
if (primarySource) {
|
|
1362
|
+
mediaSourceId = primarySource.Id || itemId;
|
|
1363
|
+
streams = primarySource.MediaStreams || [];
|
|
1364
|
+
}
|
|
1365
|
+
} else if ("MediaStreams" in itemOrMediaSource && Array.isArray(itemOrMediaSource.MediaStreams)) {
|
|
1366
|
+
const source = itemOrMediaSource;
|
|
1367
|
+
mediaSourceId = source.Id || "";
|
|
1368
|
+
itemId = mediaSourceId;
|
|
1369
|
+
streams = source.MediaStreams || [];
|
|
1370
|
+
}
|
|
1371
|
+
return streams.filter((s) => s.Type === "Subtitle").map((s) => {
|
|
1372
|
+
const index = s.Index ?? 0;
|
|
1373
|
+
const codec = (s.Codec || "vtt").toLowerCase();
|
|
1374
|
+
const preferredFormat = codec === "subrip" || codec === "srt" ? "srt" : "vtt";
|
|
1375
|
+
const url = itemId ? this.getSubtitleUrl(itemId, mediaSourceId, index, { format: preferredFormat }) : "";
|
|
1376
|
+
return {
|
|
1377
|
+
index,
|
|
1378
|
+
codec: s.Codec || "vtt",
|
|
1379
|
+
language: s.Language,
|
|
1380
|
+
displayTitle: s.DisplayTitle || s.Language || `Subtitle ${index}`,
|
|
1381
|
+
displayLanguage: s.DisplayLanguage,
|
|
1382
|
+
isDefault: !!s.IsDefault,
|
|
1383
|
+
isForced: !!s.IsForced,
|
|
1384
|
+
isExternal: !!s.IsExternal,
|
|
1385
|
+
deliveryMethod: s.DeliveryMethod,
|
|
1386
|
+
deliveryUrl: s.DeliveryUrl,
|
|
1387
|
+
url
|
|
1388
|
+
};
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Constructs the direct download URL for an original, unmodified media file on the Jellyfin server.
|
|
1393
|
+
*/
|
|
1394
|
+
getDownloadUrl(itemId, options = {}) {
|
|
1395
|
+
const serverUrl = this.http.getServerUrl();
|
|
1396
|
+
const token = this.http.getToken();
|
|
1397
|
+
const { useQueryToken = true, filename } = options;
|
|
1398
|
+
if (!serverUrl || !itemId) return "";
|
|
1399
|
+
const url = new URL(`${serverUrl}/Items/${itemId}/Download`);
|
|
1400
|
+
if (token && useQueryToken) {
|
|
1401
|
+
url.searchParams.append("api_key", token);
|
|
1402
|
+
}
|
|
1403
|
+
if (filename) {
|
|
1404
|
+
url.searchParams.append("filename", filename);
|
|
1405
|
+
}
|
|
1406
|
+
return url.toString();
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Downloads a media file as a Blob with optional progress monitoring.
|
|
1410
|
+
*/
|
|
1411
|
+
async downloadFile(itemId, options = {}) {
|
|
1412
|
+
const downloadUrl = this.getDownloadUrl(itemId, { useQueryToken: options.useQueryToken });
|
|
1413
|
+
if (!downloadUrl) {
|
|
1414
|
+
throw new Error("Cannot construct download URL: missing serverUrl or itemId");
|
|
1415
|
+
}
|
|
1416
|
+
const response = await fetch(downloadUrl);
|
|
1417
|
+
if (!response.ok) {
|
|
1418
|
+
throw new Error(`Download failed with status ${response.status}: ${response.statusText}`);
|
|
1419
|
+
}
|
|
1420
|
+
const contentLength = parseInt(response.headers.get("Content-Length") || "0", 10) || 0;
|
|
1421
|
+
const reader = response.body ? response.body.getReader() : null;
|
|
1422
|
+
if (reader && contentLength > 0) {
|
|
1423
|
+
const chunks = [];
|
|
1424
|
+
let received = 0;
|
|
1425
|
+
while (true) {
|
|
1426
|
+
const { done, value } = await reader.read();
|
|
1427
|
+
if (done) break;
|
|
1428
|
+
if (value) {
|
|
1429
|
+
chunks.push(value);
|
|
1430
|
+
received += value.length;
|
|
1431
|
+
if (options.onProgress) {
|
|
1432
|
+
options.onProgress(received / contentLength);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
return new Blob(chunks, { type: response.headers.get("Content-Type") || "application/octet-stream" });
|
|
1437
|
+
}
|
|
1438
|
+
const blob = await response.blob();
|
|
1439
|
+
if (options.onProgress) {
|
|
1440
|
+
options.onProgress(1);
|
|
1441
|
+
}
|
|
1442
|
+
return blob;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Returns authentication headers for media streaming requests (e.g., configuring hls.js xhrSetup).
|
|
1446
|
+
* Crucial for Jellyfin 12+ servers where query-based auth (?api_key=) is disabled.
|
|
1447
|
+
*/
|
|
1448
|
+
getStreamHeaders() {
|
|
1449
|
+
const authHeader = this.http.getAuthHeader();
|
|
1450
|
+
return {
|
|
1451
|
+
Authorization: authHeader,
|
|
1452
|
+
"X-Emby-Authorization": authHeader
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
|
|
1457
|
+
// src/lyrics.ts
|
|
1458
|
+
var LyricsModule = class {
|
|
1459
|
+
http;
|
|
1460
|
+
constructor(http) {
|
|
1461
|
+
this.http = http;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Fetch synced or unsynced lyrics for an audio item.
|
|
1465
|
+
*/
|
|
1466
|
+
async getLyrics(itemId) {
|
|
1467
|
+
if (!itemId) return null;
|
|
1468
|
+
try {
|
|
1469
|
+
return await this.http.request(`/Audio/${itemId}/Lyrics`);
|
|
1470
|
+
} catch (err) {
|
|
1471
|
+
console.warn("[Jellyfin SDK] Failed to fetch lyrics:", err);
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
// src/favorites.ts
|
|
1478
|
+
var FavoritesModule = class {
|
|
1479
|
+
http;
|
|
1480
|
+
getUserId;
|
|
1481
|
+
constructor(http, getUserId) {
|
|
1482
|
+
this.http = http;
|
|
1483
|
+
this.getUserId = getUserId;
|
|
1484
|
+
}
|
|
1485
|
+
requireUserId() {
|
|
1486
|
+
const userId = this.getUserId();
|
|
1487
|
+
if (!userId) {
|
|
1488
|
+
throw new JellyfinError("User ID is required to mark favorites.");
|
|
1489
|
+
}
|
|
1490
|
+
return userId;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Mark an item as favorite.
|
|
1494
|
+
*/
|
|
1495
|
+
async markFavorite(itemId) {
|
|
1496
|
+
if (!itemId) return;
|
|
1497
|
+
const userId = this.requireUserId();
|
|
1498
|
+
await this.http.request(`/Users/${userId}/FavoriteItems/${itemId}`, {
|
|
1499
|
+
method: "POST"
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Unmark an item as favorite.
|
|
1504
|
+
*/
|
|
1505
|
+
async unmarkFavorite(itemId) {
|
|
1506
|
+
if (!itemId) return;
|
|
1507
|
+
const userId = this.requireUserId();
|
|
1508
|
+
await this.http.request(`/Users/${userId}/FavoriteItems/${itemId}`, {
|
|
1509
|
+
method: "DELETE"
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
// src/offline.ts
|
|
1515
|
+
var OfflineStorageManager = class {
|
|
1516
|
+
media;
|
|
1517
|
+
dbName;
|
|
1518
|
+
dbVersion;
|
|
1519
|
+
storeName;
|
|
1520
|
+
dbPromise = null;
|
|
1521
|
+
objectUrlCache = /* @__PURE__ */ new Map();
|
|
1522
|
+
downloadStatusCache = /* @__PURE__ */ new Map();
|
|
1523
|
+
constructor(media, dbName = "JellyfinOfflineAudio", dbVersion = 1, storeName = "tracks") {
|
|
1524
|
+
this.media = media;
|
|
1525
|
+
this.dbName = dbName;
|
|
1526
|
+
this.dbVersion = dbVersion;
|
|
1527
|
+
this.storeName = storeName;
|
|
1528
|
+
}
|
|
1529
|
+
isSupported() {
|
|
1530
|
+
return typeof window !== "undefined" && "indexedDB" in window;
|
|
1531
|
+
}
|
|
1532
|
+
async initDB() {
|
|
1533
|
+
if (this.dbPromise) return this.dbPromise;
|
|
1534
|
+
this.dbPromise = new Promise((resolve) => {
|
|
1535
|
+
if (!this.isSupported()) {
|
|
1536
|
+
resolve(null);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
1540
|
+
request.onupgradeneeded = (event) => {
|
|
1541
|
+
const db = event.target.result;
|
|
1542
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
1543
|
+
db.createObjectStore(this.storeName, { keyPath: "id" });
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
request.onsuccess = (event) => {
|
|
1547
|
+
resolve(event.target.result);
|
|
1548
|
+
};
|
|
1549
|
+
request.onerror = (event) => {
|
|
1550
|
+
console.warn("[Jellyfin Offline] IndexedDB open error:", event.target.error);
|
|
1551
|
+
resolve(null);
|
|
1552
|
+
};
|
|
1553
|
+
});
|
|
1554
|
+
return this.dbPromise;
|
|
1555
|
+
}
|
|
1556
|
+
getItemKey(item) {
|
|
1557
|
+
return item ? String(item.Id || item.id || "") : "";
|
|
1558
|
+
}
|
|
1559
|
+
dispatchDownloadChanged(itemId, downloaded) {
|
|
1560
|
+
if (typeof window === "undefined") return;
|
|
1561
|
+
window.dispatchEvent(
|
|
1562
|
+
new CustomEvent("jellyfin-download-changed", {
|
|
1563
|
+
detail: { itemId, trackId: itemId, downloaded }
|
|
1564
|
+
})
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
revokeObjectUrl(id) {
|
|
1568
|
+
const url = this.objectUrlCache.get(id);
|
|
1569
|
+
if (url) {
|
|
1570
|
+
try {
|
|
1571
|
+
URL.revokeObjectURL(url);
|
|
1572
|
+
} catch {
|
|
1573
|
+
}
|
|
1574
|
+
this.objectUrlCache.delete(id);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
async getDownloadedRecord(id) {
|
|
1578
|
+
if (!id) return null;
|
|
1579
|
+
try {
|
|
1580
|
+
const db = await this.initDB();
|
|
1581
|
+
if (!db) return null;
|
|
1582
|
+
return new Promise((resolve) => {
|
|
1583
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
1584
|
+
const store = tx.objectStore(this.storeName);
|
|
1585
|
+
const request = store.get(String(id));
|
|
1586
|
+
request.onsuccess = () => resolve(request.result || null);
|
|
1587
|
+
request.onerror = () => resolve(null);
|
|
1588
|
+
});
|
|
1589
|
+
} catch (err) {
|
|
1590
|
+
console.warn(`[Jellyfin Offline] getDownloadedRecord error for ${id}:`, err);
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async isItemDownloaded(id) {
|
|
1595
|
+
if (!id) return false;
|
|
1596
|
+
const key = String(id);
|
|
1597
|
+
if (this.downloadStatusCache.has(key)) {
|
|
1598
|
+
return this.downloadStatusCache.get(key);
|
|
1599
|
+
}
|
|
1600
|
+
const record = await this.getDownloadedRecord(key);
|
|
1601
|
+
const exists = !!record;
|
|
1602
|
+
this.downloadStatusCache.set(key, exists);
|
|
1603
|
+
return exists;
|
|
1604
|
+
}
|
|
1605
|
+
isItemDownloadedSync(id) {
|
|
1606
|
+
if (!id) return false;
|
|
1607
|
+
return this.downloadStatusCache.get(String(id)) === true;
|
|
1608
|
+
}
|
|
1609
|
+
// Backward compatibility aliases
|
|
1610
|
+
async isTrackDownloaded(id) {
|
|
1611
|
+
return this.isItemDownloaded(id);
|
|
1612
|
+
}
|
|
1613
|
+
isTrackDownloadedSync(id) {
|
|
1614
|
+
return this.isItemDownloadedSync(id);
|
|
1615
|
+
}
|
|
1616
|
+
async getDownloadedBlobUrl(id) {
|
|
1617
|
+
if (!id) return null;
|
|
1618
|
+
const key = String(id);
|
|
1619
|
+
if (this.objectUrlCache.has(key)) return this.objectUrlCache.get(key);
|
|
1620
|
+
const record = await this.getDownloadedRecord(key);
|
|
1621
|
+
if (!record || !record.blob) return null;
|
|
1622
|
+
const url = URL.createObjectURL(record.blob);
|
|
1623
|
+
this.objectUrlCache.set(key, url);
|
|
1624
|
+
return url;
|
|
1625
|
+
}
|
|
1626
|
+
getDownloadedBlobUrlSync(id) {
|
|
1627
|
+
if (!id) return null;
|
|
1628
|
+
return this.objectUrlCache.get(String(id)) || null;
|
|
1629
|
+
}
|
|
1630
|
+
async getAllDownloads() {
|
|
1631
|
+
try {
|
|
1632
|
+
const db = await this.initDB();
|
|
1633
|
+
if (!db) return [];
|
|
1634
|
+
return new Promise((resolve) => {
|
|
1635
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
1636
|
+
const store = tx.objectStore(this.storeName);
|
|
1637
|
+
const request = store.getAll();
|
|
1638
|
+
request.onsuccess = () => {
|
|
1639
|
+
const records = request.result || [];
|
|
1640
|
+
resolve(records.sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0)));
|
|
1641
|
+
};
|
|
1642
|
+
request.onerror = () => resolve([]);
|
|
1643
|
+
});
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
console.warn("[Jellyfin Offline] getAllDownloads error:", err);
|
|
1646
|
+
return [];
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
async warmOfflineCache() {
|
|
1650
|
+
try {
|
|
1651
|
+
const downloads = await this.getAllDownloads();
|
|
1652
|
+
for (const item of downloads) {
|
|
1653
|
+
if (item && item.id) {
|
|
1654
|
+
const key = String(item.id);
|
|
1655
|
+
this.downloadStatusCache.set(key, true);
|
|
1656
|
+
if (item.blob && !this.objectUrlCache.has(key)) {
|
|
1657
|
+
try {
|
|
1658
|
+
this.objectUrlCache.set(key, URL.createObjectURL(item.blob));
|
|
1659
|
+
} catch {
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
console.warn("[Jellyfin Offline] warmOfflineCache error:", err);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async saveDownloadRecord(record, blob) {
|
|
1669
|
+
try {
|
|
1670
|
+
const db = await this.initDB();
|
|
1671
|
+
if (!db) return false;
|
|
1672
|
+
return new Promise((resolve) => {
|
|
1673
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
1674
|
+
const store = tx.objectStore(this.storeName);
|
|
1675
|
+
const entry = { ...record, blob };
|
|
1676
|
+
const request = store.put(entry);
|
|
1677
|
+
request.onsuccess = () => resolve(true);
|
|
1678
|
+
request.onerror = () => resolve(false);
|
|
1679
|
+
});
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
console.warn("[Jellyfin Offline] saveDownloadRecord error:", err);
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
async removeDownload(id) {
|
|
1686
|
+
if (!id) return false;
|
|
1687
|
+
const key = String(id);
|
|
1688
|
+
try {
|
|
1689
|
+
const db = await this.initDB();
|
|
1690
|
+
if (!db) return false;
|
|
1691
|
+
const removed = await new Promise((resolve) => {
|
|
1692
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
1693
|
+
const store = tx.objectStore(this.storeName);
|
|
1694
|
+
const request = store.delete(key);
|
|
1695
|
+
request.onsuccess = () => resolve(true);
|
|
1696
|
+
request.onerror = () => resolve(false);
|
|
1697
|
+
});
|
|
1698
|
+
if (removed) {
|
|
1699
|
+
this.revokeObjectUrl(key);
|
|
1700
|
+
this.downloadStatusCache.set(key, false);
|
|
1701
|
+
this.dispatchDownloadChanged(key, false);
|
|
1702
|
+
}
|
|
1703
|
+
return removed;
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
console.warn("[Jellyfin Offline] removeDownload error:", err);
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Downloads an audio or video item to offline storage.
|
|
1711
|
+
* If already downloaded, toggles it off (removes it).
|
|
1712
|
+
*/
|
|
1713
|
+
async downloadItem(item, onProgress, group, index, options = {}) {
|
|
1714
|
+
if (!item) return { ok: false, error: "no-item" };
|
|
1715
|
+
const key = this.getItemKey(item);
|
|
1716
|
+
if (!key) return { ok: false, error: "no-id" };
|
|
1717
|
+
if (await this.isItemDownloaded(key)) {
|
|
1718
|
+
await this.removeDownload(key);
|
|
1719
|
+
return { ok: true, removed: true };
|
|
1720
|
+
}
|
|
1721
|
+
const isVideo = item.MediaType === "Video" || item.Type === "Movie" || item.Type === "Episode" || item.Type === "Video";
|
|
1722
|
+
let sourceUrl = "";
|
|
1723
|
+
if (isVideo) {
|
|
1724
|
+
if (options.quality) {
|
|
1725
|
+
sourceUrl = this.media.getVideoStreamUrl(key, { quality: options.quality });
|
|
1726
|
+
} else {
|
|
1727
|
+
sourceUrl = this.media.getDownloadUrl(key);
|
|
1728
|
+
}
|
|
1729
|
+
} else {
|
|
1730
|
+
sourceUrl = this.media.getAudioStreamUrl(key, { maxStreamingBitrate: "Direct" });
|
|
1731
|
+
}
|
|
1732
|
+
if (!sourceUrl) return { ok: false, error: "no-url" };
|
|
1733
|
+
let response;
|
|
1734
|
+
try {
|
|
1735
|
+
response = await fetch(sourceUrl);
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
console.warn("[Jellyfin Offline] Download fetch error:", err);
|
|
1738
|
+
return { ok: false, error: "network" };
|
|
1739
|
+
}
|
|
1740
|
+
if (!response.ok) {
|
|
1741
|
+
return { ok: false, error: `http-${response.status}` };
|
|
1742
|
+
}
|
|
1743
|
+
const contentLength = parseInt(response.headers.get("Content-Length") || "0", 10) || 0;
|
|
1744
|
+
try {
|
|
1745
|
+
const reader = response.body ? response.body.getReader() : null;
|
|
1746
|
+
let blob;
|
|
1747
|
+
const mimeType = response.headers.get("Content-Type") || (isVideo ? "video/mp4" : "audio/mpeg");
|
|
1748
|
+
if (reader) {
|
|
1749
|
+
const chunks = [];
|
|
1750
|
+
let received = 0;
|
|
1751
|
+
while (true) {
|
|
1752
|
+
const { done, value } = await reader.read();
|
|
1753
|
+
if (done) break;
|
|
1754
|
+
if (value) {
|
|
1755
|
+
chunks.push(value);
|
|
1756
|
+
received += value.length;
|
|
1757
|
+
if (onProgress) {
|
|
1758
|
+
onProgress(contentLength > 0 ? received / contentLength : 0);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
blob = new Blob(chunks, { type: mimeType });
|
|
1763
|
+
} else {
|
|
1764
|
+
blob = await response.blob();
|
|
1765
|
+
if (onProgress) onProgress(1);
|
|
1766
|
+
}
|
|
1767
|
+
const record = {
|
|
1768
|
+
id: key,
|
|
1769
|
+
name: item.Name || "Unknown",
|
|
1770
|
+
mediaType: isVideo ? "Video" : "Audio",
|
|
1771
|
+
type: item.Type,
|
|
1772
|
+
artists: item.Artists ? item.Artists.join(", ") : item.AlbumArtist || "",
|
|
1773
|
+
album: item.Album || "",
|
|
1774
|
+
albumId: item.AlbumId || "",
|
|
1775
|
+
seriesName: item.SeriesName || void 0,
|
|
1776
|
+
seriesId: item.SeriesId || void 0,
|
|
1777
|
+
seasonName: item.SeasonName || void 0,
|
|
1778
|
+
seasonId: item.SeasonId || void 0,
|
|
1779
|
+
artworkUrl: this.media.getArtworkUrl(item, { maxWidth: 300 }),
|
|
1780
|
+
size: blob.size,
|
|
1781
|
+
savedAt: Date.now(),
|
|
1782
|
+
hasLyrics: !!item.HasLyrics,
|
|
1783
|
+
mimeType
|
|
1784
|
+
};
|
|
1785
|
+
if (group && group.id) {
|
|
1786
|
+
record.parentId = String(group.id);
|
|
1787
|
+
record.parentName = group.name || "";
|
|
1788
|
+
record.parentType = group.type || "";
|
|
1789
|
+
record.parentArtworkUrl = group.artworkUrl || "";
|
|
1790
|
+
record.parentOwner = group.owner || "";
|
|
1791
|
+
record.parentCount = group.count || 0;
|
|
1792
|
+
}
|
|
1793
|
+
if (typeof index === "number" && !Number.isNaN(index)) {
|
|
1794
|
+
record.index = index;
|
|
1795
|
+
} else if (typeof item.IndexNumber === "number") {
|
|
1796
|
+
record.index = item.IndexNumber;
|
|
1797
|
+
}
|
|
1798
|
+
const saved = await this.saveDownloadRecord(record, blob);
|
|
1799
|
+
if (!saved) return { ok: false, error: "storage" };
|
|
1800
|
+
this.revokeObjectUrl(key);
|
|
1801
|
+
this.downloadStatusCache.set(key, true);
|
|
1802
|
+
this.dispatchDownloadChanged(key, true);
|
|
1803
|
+
return { ok: true, size: blob.size };
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
console.warn("[Jellyfin Offline] Download processing failed:", err);
|
|
1806
|
+
return { ok: false, error: "unknown" };
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Backward-compatible alias for downloadItem.
|
|
1811
|
+
*/
|
|
1812
|
+
async downloadTrack(track, onProgress, group, index) {
|
|
1813
|
+
return this.downloadItem(track, onProgress, group, index);
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Downloads multiple items with aggregated progress callbacks.
|
|
1817
|
+
*/
|
|
1818
|
+
async downloadItems(items, onProgress, group, options = {}) {
|
|
1819
|
+
const targets = [];
|
|
1820
|
+
for (const item of items || []) {
|
|
1821
|
+
const key = item && (item.Id || item.id);
|
|
1822
|
+
if (key && !await this.isItemDownloaded(key)) {
|
|
1823
|
+
targets.push(item);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const total = targets.length;
|
|
1827
|
+
let completed = 0;
|
|
1828
|
+
let failed = 0;
|
|
1829
|
+
for (let i = 0; i < targets.length; i++) {
|
|
1830
|
+
const res = await this.downloadItem(targets[i], void 0, group, i, options);
|
|
1831
|
+
completed++;
|
|
1832
|
+
if (!res.ok) failed++;
|
|
1833
|
+
if (onProgress) {
|
|
1834
|
+
onProgress({ completed, total, item: targets[i], track: targets[i], res });
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return { ok: failed === 0 && total > 0, downloaded: total - failed, failed, total };
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Backward-compatible alias for downloadItems.
|
|
1841
|
+
*/
|
|
1842
|
+
async downloadTracks(tracks, onProgress, group) {
|
|
1843
|
+
return this.downloadItems(tracks, onProgress, group);
|
|
1844
|
+
}
|
|
1845
|
+
async removeDownloads(items) {
|
|
1846
|
+
let removed = 0;
|
|
1847
|
+
for (const item of items || []) {
|
|
1848
|
+
const key = item && (item.Id || item.id);
|
|
1849
|
+
if (key && await this.removeDownload(key)) removed++;
|
|
1850
|
+
}
|
|
1851
|
+
return { ok: removed > 0, removed };
|
|
1852
|
+
}
|
|
1853
|
+
formatBytes(bytes) {
|
|
1854
|
+
if (!bytes || bytes <= 0) return "0 B";
|
|
1855
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
1856
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
1857
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
// src/cache.ts
|
|
1862
|
+
var MemoryCacheAdapter = class {
|
|
1863
|
+
cache = /* @__PURE__ */ new Map();
|
|
1864
|
+
get(key) {
|
|
1865
|
+
const entry = this.cache.get(key);
|
|
1866
|
+
return entry ? entry.data : null;
|
|
1867
|
+
}
|
|
1868
|
+
set(key, data) {
|
|
1869
|
+
this.cache.set(key, { data, timestamp: Date.now() });
|
|
1870
|
+
}
|
|
1871
|
+
delete(key) {
|
|
1872
|
+
this.cache.delete(key);
|
|
1873
|
+
}
|
|
1874
|
+
clear() {
|
|
1875
|
+
this.cache.clear();
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
var IndexedDBCacheAdapter = class {
|
|
1879
|
+
dbName;
|
|
1880
|
+
dbVersion;
|
|
1881
|
+
storeName;
|
|
1882
|
+
dbPromise = null;
|
|
1883
|
+
constructor(dbName = "JellyfinSdkCache", dbVersion = 1, storeName = "api_cache") {
|
|
1884
|
+
this.dbName = dbName;
|
|
1885
|
+
this.dbVersion = dbVersion;
|
|
1886
|
+
this.storeName = storeName;
|
|
1887
|
+
}
|
|
1888
|
+
async getDB() {
|
|
1889
|
+
if (this.dbPromise) return this.dbPromise;
|
|
1890
|
+
this.dbPromise = new Promise((resolve) => {
|
|
1891
|
+
if (typeof window === "undefined" || !("indexedDB" in window)) {
|
|
1892
|
+
resolve(null);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
1896
|
+
request.onupgradeneeded = (event) => {
|
|
1897
|
+
const db = event.target.result;
|
|
1898
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
1899
|
+
db.createObjectStore(this.storeName, { keyPath: "key" });
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
request.onsuccess = () => resolve(request.result);
|
|
1903
|
+
request.onerror = (e) => {
|
|
1904
|
+
console.warn("[Jellyfin Cache] IndexedDB open error:", e);
|
|
1905
|
+
resolve(null);
|
|
1906
|
+
};
|
|
1907
|
+
});
|
|
1908
|
+
return this.dbPromise;
|
|
1909
|
+
}
|
|
1910
|
+
async get(key) {
|
|
1911
|
+
try {
|
|
1912
|
+
const db = await this.getDB();
|
|
1913
|
+
if (!db) return null;
|
|
1914
|
+
return new Promise((resolve) => {
|
|
1915
|
+
const tx = db.transaction(this.storeName, "readonly");
|
|
1916
|
+
const store = tx.objectStore(this.storeName);
|
|
1917
|
+
const request = store.get(key);
|
|
1918
|
+
request.onsuccess = () => {
|
|
1919
|
+
const record = request.result;
|
|
1920
|
+
resolve(record ? record.data : null);
|
|
1921
|
+
};
|
|
1922
|
+
request.onerror = () => resolve(null);
|
|
1923
|
+
});
|
|
1924
|
+
} catch {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
async set(key, data) {
|
|
1929
|
+
if (!data) return;
|
|
1930
|
+
try {
|
|
1931
|
+
const db = await this.getDB();
|
|
1932
|
+
if (!db) return;
|
|
1933
|
+
return new Promise((resolve) => {
|
|
1934
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
1935
|
+
const store = tx.objectStore(this.storeName);
|
|
1936
|
+
const record = { key, data, timestamp: Date.now() };
|
|
1937
|
+
const request = store.put(record);
|
|
1938
|
+
request.onsuccess = () => resolve();
|
|
1939
|
+
request.onerror = () => resolve();
|
|
1940
|
+
});
|
|
1941
|
+
} catch {
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
async delete(key) {
|
|
1945
|
+
try {
|
|
1946
|
+
const db = await this.getDB();
|
|
1947
|
+
if (!db) return;
|
|
1948
|
+
return new Promise((resolve) => {
|
|
1949
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
1950
|
+
const store = tx.objectStore(this.storeName);
|
|
1951
|
+
const request = store.delete(key);
|
|
1952
|
+
request.onsuccess = () => resolve();
|
|
1953
|
+
request.onerror = () => resolve();
|
|
1954
|
+
});
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async clear() {
|
|
1959
|
+
try {
|
|
1960
|
+
const db = await this.getDB();
|
|
1961
|
+
if (!db) return;
|
|
1962
|
+
return new Promise((resolve) => {
|
|
1963
|
+
const tx = db.transaction(this.storeName, "readwrite");
|
|
1964
|
+
const store = tx.objectStore(this.storeName);
|
|
1965
|
+
const request = store.clear();
|
|
1966
|
+
request.onsuccess = () => resolve();
|
|
1967
|
+
request.onerror = () => resolve();
|
|
1968
|
+
});
|
|
1969
|
+
} catch {
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
async function fetchWithCache(cache, cacheKey, fetchFn, onFreshData) {
|
|
1974
|
+
const cached = await cache.get(cacheKey);
|
|
1975
|
+
const backgroundFetch = fetchFn().then(async (freshData) => {
|
|
1976
|
+
if (freshData !== null && freshData !== void 0) {
|
|
1977
|
+
await cache.set(cacheKey, freshData);
|
|
1978
|
+
if (onFreshData) {
|
|
1979
|
+
try {
|
|
1980
|
+
onFreshData(freshData);
|
|
1981
|
+
} catch (e) {
|
|
1982
|
+
console.warn("[Jellyfin Cache] Error in onFreshData callback for %s:", cacheKey, e);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
return freshData;
|
|
1987
|
+
}).catch((err) => {
|
|
1988
|
+
console.warn("[Jellyfin Cache] Background fetch failed for %s:", cacheKey, err);
|
|
1989
|
+
if (!cached) throw err;
|
|
1990
|
+
return cached;
|
|
1991
|
+
});
|
|
1992
|
+
if (cached !== null && cached !== void 0) {
|
|
1993
|
+
return cached;
|
|
1994
|
+
}
|
|
1995
|
+
return await backgroundFetch;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/storage.ts
|
|
1999
|
+
var MemoryStorage = class {
|
|
2000
|
+
store = /* @__PURE__ */ new Map();
|
|
2001
|
+
getItem(key) {
|
|
2002
|
+
return this.store.has(key) ? this.store.get(key) : null;
|
|
2003
|
+
}
|
|
2004
|
+
setItem(key, value) {
|
|
2005
|
+
this.store.set(key, value);
|
|
2006
|
+
}
|
|
2007
|
+
removeItem(key) {
|
|
2008
|
+
this.store.delete(key);
|
|
2009
|
+
}
|
|
2010
|
+
clear() {
|
|
2011
|
+
this.store.clear();
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
var LocalStorageAdapter = class {
|
|
2015
|
+
getItem(key) {
|
|
2016
|
+
if (typeof localStorage !== "undefined") {
|
|
2017
|
+
return localStorage.getItem(key);
|
|
2018
|
+
}
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
setItem(key, value) {
|
|
2022
|
+
if (typeof localStorage !== "undefined") {
|
|
2023
|
+
localStorage.setItem(key, value);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
removeItem(key) {
|
|
2027
|
+
if (typeof localStorage !== "undefined") {
|
|
2028
|
+
localStorage.removeItem(key);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
};
|
|
2032
|
+
function getDefaultStorage() {
|
|
2033
|
+
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
|
2034
|
+
return new LocalStorageAdapter();
|
|
2035
|
+
}
|
|
2036
|
+
return new MemoryStorage();
|
|
2037
|
+
}
|
|
2038
|
+
function generateDeviceId() {
|
|
2039
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
2040
|
+
const array = new Uint8Array(16);
|
|
2041
|
+
crypto.getRandomValues(array);
|
|
2042
|
+
return "sdk-" + Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
2043
|
+
}
|
|
2044
|
+
return "sdk-" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// src/client.ts
|
|
2048
|
+
var JellyfinClient = class {
|
|
2049
|
+
auth;
|
|
2050
|
+
system;
|
|
2051
|
+
library;
|
|
2052
|
+
playlists;
|
|
2053
|
+
playback;
|
|
2054
|
+
media;
|
|
2055
|
+
lyrics;
|
|
2056
|
+
favorites;
|
|
2057
|
+
offline;
|
|
2058
|
+
http;
|
|
2059
|
+
storage;
|
|
2060
|
+
cache;
|
|
2061
|
+
clientInfo;
|
|
2062
|
+
eventListeners = /* @__PURE__ */ new Map();
|
|
2063
|
+
constructor(options = {}) {
|
|
2064
|
+
this.storage = options.storage || getDefaultStorage();
|
|
2065
|
+
this.cache = new MemoryCacheAdapter();
|
|
2066
|
+
this.clientInfo = {
|
|
2067
|
+
name: options.clientInfo?.name || "@francofantomius/jellyfin",
|
|
2068
|
+
version: options.clientInfo?.version || "0.1.0",
|
|
2069
|
+
device: options.clientInfo?.device || (typeof window !== "undefined" ? "Web Browser" : "Node.js"),
|
|
2070
|
+
deviceId: options.clientInfo?.deviceId || generateDeviceId()
|
|
2071
|
+
};
|
|
2072
|
+
this.http = new HttpTransport({
|
|
2073
|
+
serverUrl: options.serverUrl,
|
|
2074
|
+
token: options.accessToken,
|
|
2075
|
+
clientInfo: this.clientInfo,
|
|
2076
|
+
fetch: options.fetch,
|
|
2077
|
+
onUnauthorized: () => {
|
|
2078
|
+
this.emit("unauthorized");
|
|
2079
|
+
if (options.onUnauthorized) {
|
|
2080
|
+
options.onUnauthorized();
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2084
|
+
const getUserId = () => this.auth.getUserId();
|
|
2085
|
+
this.system = new SystemModule(this.http, {
|
|
2086
|
+
serverVersion: options.serverVersion,
|
|
2087
|
+
targetVersion: options.targetVersion
|
|
2088
|
+
});
|
|
2089
|
+
this.auth = new AuthModule(this.http, options.userId || "");
|
|
2090
|
+
this.library = new LibraryModule(this.http, getUserId);
|
|
2091
|
+
this.playlists = new PlaylistsModule(this.http, getUserId);
|
|
2092
|
+
this.playback = new PlaybackModule(this.http);
|
|
2093
|
+
this.media = new MediaModule(this.http, getUserId);
|
|
2094
|
+
this.lyrics = new LyricsModule(this.http);
|
|
2095
|
+
this.favorites = new FavoritesModule(this.http, getUserId);
|
|
2096
|
+
this.offline = new OfflineStorageManager(this.media);
|
|
2097
|
+
}
|
|
2098
|
+
// --- Configuration Getters & Setters ---
|
|
2099
|
+
get serverUrl() {
|
|
2100
|
+
return this.http.getServerUrl();
|
|
2101
|
+
}
|
|
2102
|
+
set serverUrl(url) {
|
|
2103
|
+
this.http.setServerUrl(url);
|
|
2104
|
+
}
|
|
2105
|
+
get accessToken() {
|
|
2106
|
+
return this.http.getToken();
|
|
2107
|
+
}
|
|
2108
|
+
set accessToken(token) {
|
|
2109
|
+
this.http.setToken(token);
|
|
2110
|
+
}
|
|
2111
|
+
get userId() {
|
|
2112
|
+
return this.auth.getUserId();
|
|
2113
|
+
}
|
|
2114
|
+
set userId(id) {
|
|
2115
|
+
this.auth.setUserId(id);
|
|
2116
|
+
}
|
|
2117
|
+
get serverVersion() {
|
|
2118
|
+
return this.system.getVersion();
|
|
2119
|
+
}
|
|
2120
|
+
set serverVersion(version) {
|
|
2121
|
+
this.system.setVersion(version);
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Check whether the connected Jellyfin server is version 12.0 or higher.
|
|
2125
|
+
*/
|
|
2126
|
+
async isV12() {
|
|
2127
|
+
return await this.system.isV12();
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Query server and detect the server version via /System/Info/Public.
|
|
2131
|
+
*/
|
|
2132
|
+
async detectServerVersion() {
|
|
2133
|
+
return await this.system.detectVersion();
|
|
2134
|
+
}
|
|
2135
|
+
get info() {
|
|
2136
|
+
return this.http.getClientInfo();
|
|
2137
|
+
}
|
|
2138
|
+
setClientInfo(info) {
|
|
2139
|
+
this.http.setClientInfo(info);
|
|
2140
|
+
}
|
|
2141
|
+
getCache() {
|
|
2142
|
+
return this.cache;
|
|
2143
|
+
}
|
|
2144
|
+
setCache(cache) {
|
|
2145
|
+
this.cache = cache;
|
|
2146
|
+
}
|
|
2147
|
+
getStorage() {
|
|
2148
|
+
return this.storage;
|
|
2149
|
+
}
|
|
2150
|
+
setStorage(storage) {
|
|
2151
|
+
this.storage = storage;
|
|
2152
|
+
}
|
|
2153
|
+
// --- Event Handling ---
|
|
2154
|
+
on(event, listener) {
|
|
2155
|
+
if (!this.eventListeners.has(event)) {
|
|
2156
|
+
this.eventListeners.set(event, /* @__PURE__ */ new Set());
|
|
2157
|
+
}
|
|
2158
|
+
this.eventListeners.get(event).add(listener);
|
|
2159
|
+
return () => {
|
|
2160
|
+
this.off(event, listener);
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
off(event, listener) {
|
|
2164
|
+
const listeners = this.eventListeners.get(event);
|
|
2165
|
+
if (listeners) {
|
|
2166
|
+
listeners.delete(listener);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
emit(event, ...args) {
|
|
2170
|
+
const listeners = this.eventListeners.get(event);
|
|
2171
|
+
if (listeners) {
|
|
2172
|
+
listeners.forEach((fn) => {
|
|
2173
|
+
try {
|
|
2174
|
+
fn(...args);
|
|
2175
|
+
} catch (e) {
|
|
2176
|
+
console.error(`[Jellyfin SDK] Event handler error on '${event}':`, e);
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
// --- Convenience Shortcuts ---
|
|
2182
|
+
/**
|
|
2183
|
+
* Authenticate with username and password, update client state, and emit 'authenticated'.
|
|
2184
|
+
*/
|
|
2185
|
+
async authenticate(username, password = "") {
|
|
2186
|
+
const res = await this.auth.authenticateByName(username, password);
|
|
2187
|
+
if (!this.system.getVersion()) {
|
|
2188
|
+
this.system.detectVersion().catch(() => {
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
this.emit("authenticated", res);
|
|
2192
|
+
return res;
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Log out and clear tokens and session.
|
|
2196
|
+
*/
|
|
2197
|
+
logout() {
|
|
2198
|
+
this.auth.logout();
|
|
2199
|
+
this.emit("logout");
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Manually set authentication credentials.
|
|
2203
|
+
*/
|
|
2204
|
+
setCredentials(credentials) {
|
|
2205
|
+
if (credentials.serverUrl) {
|
|
2206
|
+
this.http.setServerUrl(credentials.serverUrl);
|
|
2207
|
+
}
|
|
2208
|
+
this.http.setToken(credentials.accessToken);
|
|
2209
|
+
this.auth.setUserId(credentials.userId);
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
exports.AUDIO_QUALITY_PRESETS = AUDIO_QUALITY_PRESETS;
|
|
2214
|
+
exports.AuthModule = AuthModule;
|
|
2215
|
+
exports.FavoritesModule = FavoritesModule;
|
|
2216
|
+
exports.HttpTransport = HttpTransport;
|
|
2217
|
+
exports.IndexedDBCacheAdapter = IndexedDBCacheAdapter;
|
|
2218
|
+
exports.JellyfinApiError = JellyfinApiError;
|
|
2219
|
+
exports.JellyfinAuthError = JellyfinAuthError;
|
|
2220
|
+
exports.JellyfinClient = JellyfinClient;
|
|
2221
|
+
exports.JellyfinError = JellyfinError;
|
|
2222
|
+
exports.LibraryModule = LibraryModule;
|
|
2223
|
+
exports.LocalStorageAdapter = LocalStorageAdapter;
|
|
2224
|
+
exports.LyricsModule = LyricsModule;
|
|
2225
|
+
exports.MediaModule = MediaModule;
|
|
2226
|
+
exports.MemoryCacheAdapter = MemoryCacheAdapter;
|
|
2227
|
+
exports.MemoryStorage = MemoryStorage;
|
|
2228
|
+
exports.OfflineStorageManager = OfflineStorageManager;
|
|
2229
|
+
exports.PlaybackModule = PlaybackModule;
|
|
2230
|
+
exports.PlaylistsModule = PlaylistsModule;
|
|
2231
|
+
exports.SystemModule = SystemModule;
|
|
2232
|
+
exports.VIDEO_QUALITY_PRESETS = VIDEO_QUALITY_PRESETS;
|
|
2233
|
+
exports.buildAuthHeader = buildAuthHeader;
|
|
2234
|
+
exports.cleanUrl = cleanUrl;
|
|
2235
|
+
exports.fetchWithCache = fetchWithCache;
|
|
2236
|
+
exports.formatBitrate = formatBitrate;
|
|
2237
|
+
exports.generateDeviceId = generateDeviceId;
|
|
2238
|
+
exports.getAudioQualityList = getAudioQualityList;
|
|
2239
|
+
exports.getDefaultStorage = getDefaultStorage;
|
|
2240
|
+
exports.getVideoQualityList = getVideoQualityList;
|
|
2241
|
+
exports.isV12OrHigher = isV12OrHigher;
|
|
2242
|
+
exports.isVersionAtLeast = isVersionAtLeast;
|
|
2243
|
+
exports.parseSemVer = parseSemVer;
|
|
2244
|
+
exports.resolveAudioQuality = resolveAudioQuality;
|
|
2245
|
+
exports.resolveVideoQuality = resolveVideoQuality;
|
|
2246
|
+
//# sourceMappingURL=index.cjs.map
|
|
2247
|
+
//# sourceMappingURL=index.cjs.map
|