Delete client.js.js
This commit is contained in:
934
client.js.js
934
client.js.js
@@ -1,934 +0,0 @@
|
|||||||
console.log("Jellyfin plugin loading - version 30");
|
|
||||||
let config = {};
|
|
||||||
const PLATFORM = "Jellyfin";
|
|
||||||
|
|
||||||
// Ensure all functions exist before assignment
|
|
||||||
try {
|
|
||||||
// Check if required objects exist
|
|
||||||
if (typeof source === 'undefined') {
|
|
||||||
console.error('Source object not available');
|
|
||||||
// Can't return here, just log the error
|
|
||||||
} else {
|
|
||||||
source.enable = enable;
|
|
||||||
source.disable = disable;
|
|
||||||
source.searchSuggestions = searchSuggestions;
|
|
||||||
source.getHome = getHome;
|
|
||||||
source.isContentDetailsUrl = isContentDetailsUrl;
|
|
||||||
source.getContentDetails = getContentDetails;
|
|
||||||
source.isChannelUrl = isChannelUrl;
|
|
||||||
source.getChannel = getChannel;
|
|
||||||
source.getChannelContents = getChannelContents;
|
|
||||||
source.isPlaylistUrl = isPlaylistUrl;
|
|
||||||
source.getPlaylist = getPlaylist;
|
|
||||||
source.searchSuggestions = searchSuggestions;
|
|
||||||
source.getSearchCapabilities = getSearchCapabilities;
|
|
||||||
source.search = search;
|
|
||||||
source.searchChannels = searchChannels;
|
|
||||||
source.searchPlaylists = searchPlaylists;
|
|
||||||
source.getComments = getComments;
|
|
||||||
source.getSubComments = getSubComments;
|
|
||||||
|
|
||||||
console.log("Jellyfin plugin functions assigned successfully");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error assigning plugin functions:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
class JellyfinContentPager extends ContentPager {
|
|
||||||
constructor({ url, type, limit = 20, errorMessage = "Could not fetch items" }) {
|
|
||||||
let baseUrl;
|
|
||||||
|
|
||||||
if (url instanceof URL) {
|
|
||||||
baseUrl = url;
|
|
||||||
} else {
|
|
||||||
baseUrl = new URL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
baseUrl.searchParams.set('limit', limit);
|
|
||||||
|
|
||||||
// Fix sorting for music albums
|
|
||||||
if (type == "MusicAlbum") {
|
|
||||||
baseUrl.searchParams.set('sortBy', 'IndexNumber');
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = simpleJsonGet(baseUrl.toString(), errorMessage).body;
|
|
||||||
let items = body.Items;
|
|
||||||
let authorCache = {};
|
|
||||||
let authors = extractAuthors(items, authorCache);
|
|
||||||
let entries = formatEntries({ items, authors });
|
|
||||||
// let entries = body.Items.map(formatItem);
|
|
||||||
|
|
||||||
const totalItemCount = body.TotalRecordCount;
|
|
||||||
super(entries, entries.length < totalItemCount);
|
|
||||||
|
|
||||||
this.url = baseUrl;
|
|
||||||
this.limit = limit;
|
|
||||||
this.errorMessage = errorMessage;
|
|
||||||
this.totalItemCount = totalItemCount;
|
|
||||||
this.currentIndex = 0;
|
|
||||||
this.authorCache = authorCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPage() {
|
|
||||||
this.currentIndex += this.limit;
|
|
||||||
this.url.searchParams.set('startIndex', this.currentIndex);
|
|
||||||
|
|
||||||
let body = simpleJsonGet(this.url.toString(), this.errorMessage).body;
|
|
||||||
let items = body.Items;
|
|
||||||
let authors = extractAuthors(items, this.authorCache);
|
|
||||||
this.results = formatEntries({ items, authors });
|
|
||||||
this.hasMore = this.currentIndex + this.results.length < this.totalItemCount;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class JellyfinSearchContentPager extends ContentPager {
|
|
||||||
// TODO: Do something with these filter options
|
|
||||||
constructor({ url = toUrl('/Search/Hints'), query, type, order, filters, channelId, errorMessage = "Search failed", limit = 20 }) {
|
|
||||||
let searchUrl = new URL(url);
|
|
||||||
searchUrl.searchParams.append("SearchTerm", query);
|
|
||||||
|
|
||||||
const body = simpleJsonGet(searchUrl.toString(), errorMessage).body;
|
|
||||||
const items = body.SearchHints.map((hint) => hint.Id);
|
|
||||||
|
|
||||||
super([], items.length > 0);
|
|
||||||
this.errorMessage = errorMessage;
|
|
||||||
this.items = items;
|
|
||||||
this.limit = limit;
|
|
||||||
this.authorCache = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPage() {
|
|
||||||
const requestedItems = this.items.slice(0, this.limit);
|
|
||||||
const url = toUrl(`/Items?ids=${requestedItems.join(",")}`);
|
|
||||||
let body = simpleJsonGet(url, this.errorMessage).body;
|
|
||||||
let items = body.Items;
|
|
||||||
let authors = extractAuthors(items, this.authorCache);
|
|
||||||
this.results = formatEntries({ items, authors })
|
|
||||||
this.items = this.items.slice(this.limit);
|
|
||||||
this.hasMore = this.items.length > 0;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enable(conf) {
|
|
||||||
config = conf;
|
|
||||||
|
|
||||||
// Initialize constants if not provided
|
|
||||||
if (!config.constants) {
|
|
||||||
config.constants = {
|
|
||||||
uri: config.uri || "",
|
|
||||||
token: config.token || "",
|
|
||||||
client: config.client || "Grayjay",
|
|
||||||
version: config.version || "1.0.0",
|
|
||||||
device_id: config.device_id || "grayjay-device",
|
|
||||||
device_name: config.device_name || "Grayjay Client"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
function disable() { };
|
|
||||||
|
|
||||||
function getHome(continuationToken) {
|
|
||||||
return new JellyfinContentPager({
|
|
||||||
url: toUrl("/Shows/NextUp?fields=DateCreated"),
|
|
||||||
errorMessage: "Could not fetch latest updates",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function isContentDetailsUrl(url) {
|
|
||||||
// Basic URL pattern check that works before initialization
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const pathname = urlObj.pathname;
|
|
||||||
const typeParam = urlObj.searchParams.get("type");
|
|
||||||
|
|
||||||
// Check if it's a Jellyfin URL with an item ID and type parameter
|
|
||||||
if (pathname.includes("/Items/") && typeParam) {
|
|
||||||
return ["Episode", "Video", "Audio", "Movie"].includes(typeParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for URLs without type parameter
|
|
||||||
if (pathname.includes("/Items/")) {
|
|
||||||
return true; // Assume it's content if it has an item ID
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid URL
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getContentDetails(url) {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const tokens = parsed.pathname.split("/");
|
|
||||||
const itemId = tokens[tokens.length - 1];
|
|
||||||
|
|
||||||
const playbackDetails = {
|
|
||||||
DeviceProfile: {
|
|
||||||
DirectPlayProfiles: [
|
|
||||||
{ Container: "mkv", VideoCodec: "h264", Type: "Video" },
|
|
||||||
{ Container: "mp4", VideoCodec: "h264", Type: "Video" },
|
|
||||||
{ Container: "webm", Type: "Audio" },
|
|
||||||
{ Container: "mp3", Type: "Audio" },
|
|
||||||
],
|
|
||||||
TranscodingProfiles: [
|
|
||||||
{
|
|
||||||
Container: "mp4",
|
|
||||||
Type: "Video",
|
|
||||||
VideoCodec: "h264",
|
|
||||||
AudioCodec: "aac",
|
|
||||||
Protocol: "hls",
|
|
||||||
},
|
|
||||||
{ Container: "mp3", Type: "Audio", AudioCodec: "aac", Protocol: "hls" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const [details, mediaSources] = batchedJSONRequests([
|
|
||||||
{ url: toUrl(`/Items/${itemId}?fields=DateCreated`) },
|
|
||||||
{
|
|
||||||
url: toUrl(`/Items/${itemId}/PlaybackInfo`),
|
|
||||||
body: JSON.stringify(playbackDetails),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Start playback session to get valid PlaySessionId
|
|
||||||
if (mediaSources.body.PlaySessionId) {
|
|
||||||
try {
|
|
||||||
http.POST(
|
|
||||||
toUrl(`/Sessions/Playing`),
|
|
||||||
JSON.stringify({
|
|
||||||
ItemId: itemId,
|
|
||||||
MediaSourceId: mediaSources.body.MediaSources[0]?.Id,
|
|
||||||
PlaySessionId: mediaSources.body.PlaySessionId,
|
|
||||||
}),
|
|
||||||
authHeaders()
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Could not start playback session: " + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (details.body.Type) {
|
|
||||||
case "Episode":
|
|
||||||
case "Movie":
|
|
||||||
return videoContent(details.body, mediaSources.body, itemId);
|
|
||||||
|
|
||||||
case "Audio":
|
|
||||||
return audioContent(details.body, mediaSources.body, itemId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractSources(details, mediaSource, itemId, playSessionId) {
|
|
||||||
let sources = [];
|
|
||||||
let subtitles = [];
|
|
||||||
|
|
||||||
console.log("Extracting sources for itemId:", itemId, "playSessionId:", playSessionId);
|
|
||||||
|
|
||||||
const videoStream = mediaSource.MediaStreams.find(s => s.Type === "Video");
|
|
||||||
const audioStream = mediaSource.MediaStreams.find(s => s.Type === "Audio");
|
|
||||||
|
|
||||||
if (videoStream) {
|
|
||||||
// Calculate total bitrate (video + audio if available)
|
|
||||||
const videoBitrate = videoStream.BitRate || mediaSource.Bitrate || 0;
|
|
||||||
const audioBitrate = audioStream?.BitRate || 0;
|
|
||||||
const totalBitrate = videoBitrate + audioBitrate;
|
|
||||||
|
|
||||||
// Use HLS streaming endpoint like the Jellyfin web client
|
|
||||||
// This is the format that works in browsers: /videos/{id}/master.m3u8
|
|
||||||
// Note: We need to start playback session first for some Jellyfin servers
|
|
||||||
const hlsUrl = toUrl(
|
|
||||||
`/Videos/${itemId}/master.m3u8?MediaSourceId=${mediaSource.Id}&PlaySessionId=${playSessionId}&VideoCodec=h264,hevc,vp9&AudioCodec=aac,mp3,opus&VideoBitrate=${totalBitrate || 10000000}&AudioBitrate=384000&MaxFramerate=60&SegmentContainer=mp4&MinSegments=1&BreakOnNonKeyFrames=True&TranscodingMaxAudioChannels=6&api_key=${config.constants.token}`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("Jellyfin HLS URL:", hlsUrl);
|
|
||||||
|
|
||||||
sources.push(
|
|
||||||
new HLSSource({
|
|
||||||
name: "HLS",
|
|
||||||
url: hlsUrl,
|
|
||||||
duration: toDuration(mediaSource.RunTimeTicks),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also add direct stream as fallback
|
|
||||||
const directUrl = toUrl(
|
|
||||||
`/Videos/${itemId}/stream?MediaSourceId=${mediaSource.Id}&PlaySessionId=${playSessionId}&Static=true&api_key=${config.constants.token}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (directUrl) {
|
|
||||||
console.log("Jellyfin Direct URL:", directUrl);
|
|
||||||
sources.push(
|
|
||||||
new VideoUrlSource({
|
|
||||||
url: directUrl,
|
|
||||||
width: videoStream?.Width || 1920,
|
|
||||||
height: videoStream?.Height || 1080,
|
|
||||||
container: "mp4",
|
|
||||||
codec: "h264",
|
|
||||||
name: "Direct Stream",
|
|
||||||
bitrate: totalBitrate || 0
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mediaStream of mediaSource.MediaStreams) {
|
|
||||||
if (mediaStream.Type == "Subtitle") {
|
|
||||||
const url = toUrl(
|
|
||||||
`/Videos/${details.Id}/${mediaSource.Id}/Subtitles/${mediaStream.Index}/0/Stream.vtt?api_key=${config.constants.token}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
subtitles.push({
|
|
||||||
name: mediaStream.DisplayTitle,
|
|
||||||
url: url,
|
|
||||||
format: "text/vtt",
|
|
||||||
|
|
||||||
getSubtitles() {
|
|
||||||
const resp = http.GET(url, authHeaders(), false);
|
|
||||||
|
|
||||||
if (!resp.isOk) {
|
|
||||||
throw new ScriptException("Could not fetch subtitles");
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.body;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Returning", sources.length, "sources and", subtitles.length, "subtitles");
|
|
||||||
return { sources, subtitles };
|
|
||||||
}
|
|
||||||
|
|
||||||
function audioContent(details, mediaSources, itemId) {
|
|
||||||
let { sources, _subtitles } = extractSources(
|
|
||||||
details,
|
|
||||||
mediaSources.MediaSources[0],
|
|
||||||
itemId,
|
|
||||||
mediaSources.PlaySessionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [author] = extractAuthors([details], {});
|
|
||||||
|
|
||||||
return new PlatformVideoDetails({
|
|
||||||
id: new PlatformID(PLATFORM, details.Id, config.id),
|
|
||||||
author: author,
|
|
||||||
name: details.Name,
|
|
||||||
thumbnails: itemThumbnails(details.AlbumId),
|
|
||||||
dateTime:
|
|
||||||
new Date(details.PremiereDate || details.DateCreated).getTime() / 1000,
|
|
||||||
duration: toDuration(details.RunTimeTicks),
|
|
||||||
viewCount: null,
|
|
||||||
isLive: false,
|
|
||||||
description: null,
|
|
||||||
video: new VideoSourceDescriptor(sources),
|
|
||||||
url: toUrl(`/Items/${details.Id}?type=Audio`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoContent(details, mediaSources, itemId) {
|
|
||||||
let { sources, subtitles } = extractSources(
|
|
||||||
details,
|
|
||||||
mediaSources.MediaSources[0],
|
|
||||||
itemId,
|
|
||||||
mediaSources.PlaySessionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [author] = extractAuthors([details], {});
|
|
||||||
|
|
||||||
return new PlatformVideoDetails({
|
|
||||||
id: new PlatformID(PLATFORM, details.Id, config.id),
|
|
||||||
author: author,
|
|
||||||
name: details.Name,
|
|
||||||
thumbnails: itemThumbnails(details.Id),
|
|
||||||
dateTime: new Date(details.DateCreated).getTime() / 1000,
|
|
||||||
duration: toDuration(details.RunTimeTicks),
|
|
||||||
viewCount: null,
|
|
||||||
isLive: false,
|
|
||||||
description: null,
|
|
||||||
subtitles: subtitles,
|
|
||||||
video: new VideoSourceDescriptor(sources),
|
|
||||||
url: toUrl(`/Items/${details.Id}?type=Video`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChannelUrl(url) {
|
|
||||||
// Basic URL pattern check that works before initialization
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const pathname = urlObj.pathname;
|
|
||||||
const typeParam = urlObj.searchParams.get("type");
|
|
||||||
|
|
||||||
// Check if it's a Jellyfin URL with an item ID and type parameter
|
|
||||||
if (pathname.includes("/Items/") && typeParam) {
|
|
||||||
return ["Series", "MusicArtist", "Person", "Studio"].includes(typeParam);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid URL
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getChannel(url) {
|
|
||||||
const req = simpleJsonGet(url);
|
|
||||||
const item = req.body;
|
|
||||||
let parsed = new URL(url);
|
|
||||||
parsed.searchParams.set("type", item.Type);
|
|
||||||
|
|
||||||
return formatItem(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
function getChannelContents(url) {
|
|
||||||
const itemId = urlId(url);
|
|
||||||
|
|
||||||
return new JellyfinContentPager({
|
|
||||||
url: toUrl(`/Items?ParentId=${itemId}`),
|
|
||||||
errorMessage: "Could not fetch Channel contents",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function isPlaylistUrl(url) {
|
|
||||||
return isType(url, ["Playlist", "MusicAlbum", "Season", "Folder"]);
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPlaylist(url) {
|
|
||||||
let externalUrls = new Map();
|
|
||||||
let parsed = new URL(url);
|
|
||||||
|
|
||||||
const item = simpleJsonGet(url).body;
|
|
||||||
const [author] = extractAuthors([item], {});
|
|
||||||
parsed.searchParams.set("type", item.Type);
|
|
||||||
|
|
||||||
const contents = new JellyfinContentPager({
|
|
||||||
type: item.Type,
|
|
||||||
url: toUrl(`/Items?ParentId=${item.Id}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return new PlatformPlaylistDetails({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
thumbnail: thumbnail({ item, query: { fillWidth: 240 } }),
|
|
||||||
banner: banner({ item }),
|
|
||||||
subscribers: null,
|
|
||||||
description: item.Overview,
|
|
||||||
url: parsed.toString(),
|
|
||||||
links: externalUrls,
|
|
||||||
author: author,
|
|
||||||
contents: contents,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function searchSuggestions(searchTerm) {
|
|
||||||
try {
|
|
||||||
const resp = simpleJsonGet(toUrl(`/Search/Hints?searchTerm=${searchTerm}`));
|
|
||||||
|
|
||||||
return resp.body.SearchHints.map((item) => item.Name).filter(onlyUnique);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSearchCapabilities() {
|
|
||||||
return {
|
|
||||||
types: [Type.Feed.Mixed, Type.Feed.Streams, Type.Feed.Videos],
|
|
||||||
sorts: [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function search(query, type, order, filters, channelId) {
|
|
||||||
const url = toUrl('/Search/Hints?MediaTypes=Video,Audio')
|
|
||||||
|
|
||||||
return new JellyfinSearchContentPager({ url, query, type, order, filters, channelId });
|
|
||||||
};
|
|
||||||
|
|
||||||
function searchChannels(query) {
|
|
||||||
// TODO: Add back Person, Studio
|
|
||||||
const url = toUrl('/Search/Hints?includeItemTypes=Channel,Genre,MusicArtist,MusicGenre,Series')
|
|
||||||
|
|
||||||
return new JellyfinSearchContentPager({ url, query });
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// source.searchChannelContents = function (
|
|
||||||
// channelUrl,
|
|
||||||
// query,
|
|
||||||
// type,
|
|
||||||
// order,
|
|
||||||
// filters,
|
|
||||||
// ) {
|
|
||||||
// return new ParentPaginator(channelUrl, query, type, order, filters);
|
|
||||||
// };
|
|
||||||
|
|
||||||
function searchPlaylists(query, type, order, filters, channelId) {
|
|
||||||
const url = toUrl('/Search/Hints?includeItemTypes=Folder,ManualPlaylistsFolder,MusicAlbum,Playlist,PlaylistsFolder,Season')
|
|
||||||
|
|
||||||
return new JellyfinSearchContentPager({ url, query, type, order, filters, channelId })
|
|
||||||
};
|
|
||||||
|
|
||||||
// Jellyfin does not have comments AFAIK
|
|
||||||
function getComments(url) {
|
|
||||||
return new CommentPager([], false, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSubComments(comment) {
|
|
||||||
return new CommentPager([], false, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// HELPERS
|
|
||||||
function authHeaders() {
|
|
||||||
// Return empty headers if config not initialized
|
|
||||||
if (!config.constants) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
authorization: `MediaBrowser Token="${config.constants.token || ""}", Client="${config.constants.client || "Grayjay"}", Version="${config.constants.version || "1.0.0"}", DeviceId="${config.constants.device_id || "grayjay-device"}", Device="${config.constants.device_name || "Grayjay Client"}"`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toUrl(path) {
|
|
||||||
// Return empty string if config not initialized to prevent errors
|
|
||||||
if (!config.constants || !config.constants.uri) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return `${config.constants.uri}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function simpleJsonGet(url, error) {
|
|
||||||
const resp = simpleGet(url, error);
|
|
||||||
return { ...resp, body: JSON.parse(resp.body) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function simpleGet(url, error) {
|
|
||||||
const resp = http.GET(url, authHeaders(), false);
|
|
||||||
|
|
||||||
if (!resp.isOk) {
|
|
||||||
throw new ScriptException(error || "Failed to request data from Jellyfin");
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
function batchedJSONRequests(requests, error) {
|
|
||||||
// Inject content-type into all headers
|
|
||||||
for (const request of requests) {
|
|
||||||
request.headers = Object.assign(
|
|
||||||
{ "content-type": "application/json" },
|
|
||||||
request.headers || {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const responses = batchedRequests(requests, error);
|
|
||||||
|
|
||||||
return responses.map((response) => ({ ...response, body: JSON.parse(response.body) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function batchedRequests(requests, error) {
|
|
||||||
let client = http.batch();
|
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
const headers = Object.assign(authHeaders(), request.headers || {});
|
|
||||||
|
|
||||||
if (request.body != null) {
|
|
||||||
client.requestWithBody(
|
|
||||||
request.method || "POST",
|
|
||||||
request.url,
|
|
||||||
request.body,
|
|
||||||
headers,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
client.request(request.method || "GET", request.url, headers, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const responses = client.execute();
|
|
||||||
|
|
||||||
for (const response of responses) {
|
|
||||||
if (!response.isOk) {
|
|
||||||
throw new ScriptException(
|
|
||||||
error || "Failed to request data from Jellyfin",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isType(url, types) {
|
|
||||||
try {
|
|
||||||
// Check if config is initialized before using toUrl
|
|
||||||
if (!config.constants || !config.constants.uri) {
|
|
||||||
// If not initialized, fall back to basic URL pattern matching
|
|
||||||
// Check for Jellyfin URL patterns with specific types
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const typeParam = urlObj.searchParams.get("type");
|
|
||||||
if (typeParam) {
|
|
||||||
return types.includes(typeParam);
|
|
||||||
}
|
|
||||||
// For URLs without type param, we can't determine type without config
|
|
||||||
// Return false and let the initialized version handle it later
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith(toUrl("/Items"))) {
|
|
||||||
let parsed = new URL(url);
|
|
||||||
let type = parsed.searchParams.get("type");
|
|
||||||
|
|
||||||
if (type == null) {
|
|
||||||
// Use pathname to extract itemId, avoiding query string issues
|
|
||||||
const itemId = urlId(url);
|
|
||||||
let resp = simpleJsonGet(
|
|
||||||
toUrl(`/Items/${itemId}`),
|
|
||||||
"Could not fetch details",
|
|
||||||
);
|
|
||||||
|
|
||||||
return types.includes(resp.body.Type);
|
|
||||||
} else {
|
|
||||||
return types.includes(type);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("isType error: " + e.message + " for url: " + url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onlyUnique(value, index, array) {
|
|
||||||
return array.indexOf(value) == index;
|
|
||||||
}
|
|
||||||
|
|
||||||
function map_push_duplicate(map, key, value, index) {
|
|
||||||
let insertKey = key;
|
|
||||||
|
|
||||||
if (index != null) {
|
|
||||||
insertKey = insertKey + ` ${index}`;
|
|
||||||
} else {
|
|
||||||
index = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map.has(insertKey)) {
|
|
||||||
map_push_duplicate(map, key, value, index + 1);
|
|
||||||
} else {
|
|
||||||
map.set(insertKey, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDuration(runTimeTicks) {
|
|
||||||
return Math.round(runTimeTicks / 10_000_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function author(item) {
|
|
||||||
return new PlatformAuthorLink(
|
|
||||||
new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
item.Name,
|
|
||||||
itemUrl(item),
|
|
||||||
thumbnail({ item, query: { fillWidth: 256} })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemThumbnails(itemId) {
|
|
||||||
let url = new URL(toUrl(`/Items/${itemId}/Images/Primary`));
|
|
||||||
url.searchParams.set("quality", "50");
|
|
||||||
|
|
||||||
url.searchParams.set("fillWidth", "240");
|
|
||||||
let url1 = url.toString();
|
|
||||||
|
|
||||||
url.searchParams.set("fillWidth", "480");
|
|
||||||
let url2 = url.toString();
|
|
||||||
|
|
||||||
url.searchParams.set("quality", "50");
|
|
||||||
url.searchParams.set("fillWidth", "720");
|
|
||||||
let url3 = url.toString();
|
|
||||||
|
|
||||||
url.searchParams.set("fillWidth", "1080");
|
|
||||||
let url4 = url.toString();
|
|
||||||
|
|
||||||
return new Thumbnails([
|
|
||||||
new Thumbnail(url1, 240),
|
|
||||||
new Thumbnail(url2, 480),
|
|
||||||
new Thumbnail(url3, 720),
|
|
||||||
new Thumbnail(url4, 1080),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlId(url) {
|
|
||||||
const segments = new URL(url).pathname.split("/")
|
|
||||||
return segments[segments.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseItem(item) {
|
|
||||||
switch (item.Type) {
|
|
||||||
case "Episode":
|
|
||||||
case "Movie":
|
|
||||||
// case "MusicVideo":
|
|
||||||
// case "Video":
|
|
||||||
return new PlatformVideo({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
thumbnails: itemThumbnails(item.Id),
|
|
||||||
// uploadDate: new Date(item.DateCreated).getTime() / 1000,
|
|
||||||
url: toUrl(`/Items/${item.Id}?type=Video`),
|
|
||||||
duration: toDuration(item.RunTimeTicks),
|
|
||||||
isLive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
case "Audio":
|
|
||||||
return new PlatformVideo({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
thumbnails: item.AlbumId
|
|
||||||
? itemThumbnails(item.AlbumId)
|
|
||||||
: itemThumbnails(item.Id),
|
|
||||||
// uploadDate: new Date(item.DateCreated).getTime() / 1000,
|
|
||||||
url: toUrl(`/Items/${item.Id}?type=Audio`),
|
|
||||||
duration: toDuration(item.RunTimeTicks),
|
|
||||||
isLive: false,
|
|
||||||
});
|
|
||||||
case "AudioBook":
|
|
||||||
return new PlatformVideo({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
thumbnails: itemThumbnails(item.Id),
|
|
||||||
// uploadDate: new Date(item.DateCreated).getTime() / 1000,
|
|
||||||
url: toUrl(`/Items/${item.Id}?type=Audio`),
|
|
||||||
duration: toDuration(item.RunTimeTicks),
|
|
||||||
isLive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// case "Channel":
|
|
||||||
// case "LiveTvChannel":
|
|
||||||
case "MusicArtist":
|
|
||||||
// case "MusicGenre":
|
|
||||||
// case "Person":
|
|
||||||
case "Studio":
|
|
||||||
case "Series":
|
|
||||||
return new PlatformChannel({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
description: item.Overview,
|
|
||||||
thumbnail: thumbnail({ item }),
|
|
||||||
banner: banner({ item }),
|
|
||||||
url: toUrl(`/Items/${item.Id}?type=${item.Type}`),
|
|
||||||
links: item.ExternalUrls?.reduce((acc, item) => {
|
|
||||||
acc[item.Name] = item.Url;
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
});
|
|
||||||
// return new
|
|
||||||
|
|
||||||
case "Playlist":
|
|
||||||
case "Season":
|
|
||||||
case "MusicAlbum":
|
|
||||||
// case "Program":
|
|
||||||
return new PlatformPlaylist({
|
|
||||||
id: new PlatformID(PLATFORM, item.Id, config.id),
|
|
||||||
name: item.Name,
|
|
||||||
url: toUrl(`/Items/${item.Id}?type=${item.Type}`),
|
|
||||||
thumbnail: banner({ item }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAuthors(items, authors) {
|
|
||||||
const authorIds = items.map(authorId);
|
|
||||||
const uniqueIds = authorIds
|
|
||||||
.filter(id => id != null)
|
|
||||||
.filter(authorId => !(authorId in authors))
|
|
||||||
.filter(onlyUnique);
|
|
||||||
|
|
||||||
if (uniqueIds.length == 0) return [];
|
|
||||||
|
|
||||||
simpleJsonGet(toUrl(`/Items?Ids=${uniqueIds.join(",")}`))
|
|
||||||
.body
|
|
||||||
.Items
|
|
||||||
.forEach((item) => authors[item.Id] = author(item));
|
|
||||||
|
|
||||||
return authorIds.map((id) => id && authors[id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorId(item) {
|
|
||||||
switch (item.Type) {
|
|
||||||
case "Episode":
|
|
||||||
case "Season":
|
|
||||||
return item.SeriesId;
|
|
||||||
|
|
||||||
case "Audio":
|
|
||||||
case "MusicAlbum":
|
|
||||||
if (item.AlbumArtists.length > 0) {
|
|
||||||
return item.AlbumArtists[0].Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Artists.length > 0) {
|
|
||||||
return item.Artists[0].Id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
case "Movie":
|
|
||||||
if (item.Studios.length > 0) {
|
|
||||||
return item.Studios[0].Id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatEntries({ items, authors }) {
|
|
||||||
return zip(items, authors).map(([item, author]) => {
|
|
||||||
return formatItem(item, author)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function zip(...args) {
|
|
||||||
const [first, ...rest] = args;
|
|
||||||
|
|
||||||
return first.map((item, i) => {
|
|
||||||
let acc = [item];
|
|
||||||
rest.forEach((other) => acc.push(other[i]))
|
|
||||||
return acc;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatItem(item, author) {
|
|
||||||
const url = toUrl(`/Items/${item.Id}?type=${item.Type}`);
|
|
||||||
|
|
||||||
// Create a default author if none provided - use series/album info or item itself
|
|
||||||
const defaultAuthor = author || createDefaultAuthor(item);
|
|
||||||
|
|
||||||
switch (item.Type) {
|
|
||||||
case "Folder":
|
|
||||||
case "ManualPlaylistFolder":
|
|
||||||
case "Playlist":
|
|
||||||
case "PlaylistsFolder":
|
|
||||||
case "MusicAlbum":
|
|
||||||
case "Season":
|
|
||||||
return new PlatformPlaylist({
|
|
||||||
id: itemId(item),
|
|
||||||
author: defaultAuthor,
|
|
||||||
name: item.Name,
|
|
||||||
url: url,
|
|
||||||
thumbnail: banner({ item }),
|
|
||||||
datetime: parseDate(item.PremiereDate || item.DateCreated),
|
|
||||||
videoCount: item.ChildCount
|
|
||||||
});
|
|
||||||
|
|
||||||
case "Channel":
|
|
||||||
case "Genre":
|
|
||||||
case "MusicArtist":
|
|
||||||
case "MusicGenre":
|
|
||||||
// case "Person":
|
|
||||||
case "Series":
|
|
||||||
case "Studio":
|
|
||||||
return new PlatformChannel({
|
|
||||||
id: itemId(item),
|
|
||||||
name: item.Name,
|
|
||||||
description: item.Overview || item.Description,
|
|
||||||
url: url,
|
|
||||||
thumbnail: thumbnail({ item, order: ["Logo", "Thumb", "Primary"], query: { fillWidth: 128 } }),
|
|
||||||
banner: banner({ item }),
|
|
||||||
links: item.ExternalUrls?.reduce((acc, item) => {
|
|
||||||
acc[item.Name] = item.Url;
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
});
|
|
||||||
|
|
||||||
default:
|
|
||||||
switch (item.MediaType) {
|
|
||||||
case "Video":
|
|
||||||
case "Audio":
|
|
||||||
return new PlatformVideo({
|
|
||||||
id: itemId(item),
|
|
||||||
author: defaultAuthor,
|
|
||||||
name: item.Name,
|
|
||||||
thumbnails: itemThumbnails(item.Id),
|
|
||||||
url: url,
|
|
||||||
duration: toDuration(item.RunTimeTicks),
|
|
||||||
isLive: false,
|
|
||||||
viewCount: item.UserData?.PlaybackCount || 0,
|
|
||||||
datetime: parseDate(item.PremiereDate || item.DateCreated),
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
throw new ScriptException("Unknown item type");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultAuthor(item) {
|
|
||||||
// Try to get author info from item's series/album/studio info
|
|
||||||
let authorName = item.SeriesName || item.AlbumArtist || item.Album || item.Name;
|
|
||||||
let authorId = item.SeriesId || item.AlbumId || item.Id;
|
|
||||||
let authorUrl = item.SeriesId
|
|
||||||
? toUrl(`/Items/${item.SeriesId}?type=Series`)
|
|
||||||
: toUrl(`/Items/${item.Id}?type=${item.Type}`);
|
|
||||||
|
|
||||||
return new PlatformAuthorLink(
|
|
||||||
new PlatformID(PLATFORM, authorId, config.id),
|
|
||||||
authorName,
|
|
||||||
authorUrl,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemId(item) {
|
|
||||||
return new PlatformID(PLATFORM, item.Id, config.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemUrl({ Id, Type }) {
|
|
||||||
return toUrl(`/Items/${Id}?type=${Type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumbnail({ item, order = ["Primary", "Logo", "Thumb"], query }) {
|
|
||||||
let type;
|
|
||||||
let tag;
|
|
||||||
|
|
||||||
if (item.ImageTags != null) {
|
|
||||||
type = order.find((type) => type in item.ImageTags);
|
|
||||||
tag = item.ImageTags[type];
|
|
||||||
} else {
|
|
||||||
type = order.find((type) => `${type}ImageTag` in item);
|
|
||||||
|
|
||||||
if (type == null) return null;
|
|
||||||
|
|
||||||
tag = item[`${type}ImageTag`];
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = toUrl(`/Items/${item.Id}/Images/${type}?tag=${tag}`);
|
|
||||||
|
|
||||||
return withQuery(url, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
function banner({ item, query }) {
|
|
||||||
if (item.BackdropImageTag != null) {
|
|
||||||
return withQuery(toUrl(`/Items/${item.Id}/Images/Backdrop?tag=${item.BackdropImageTag}]`), query);
|
|
||||||
} else if (item.BackgroundImageTags != null && item.BackdropImageTags.length > 0) {
|
|
||||||
return withQuery(toUrl(`/Items/${item.Id}/Images/Backdrop/0?tag=${item.BackdropImageTags[0]}]`), query);
|
|
||||||
} else {
|
|
||||||
return thumbnail({ item, query });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withQuery(url, query) {
|
|
||||||
if (query == null) return url;
|
|
||||||
|
|
||||||
let parsedUrl = new URL(url);
|
|
||||||
for (let key in query) parsedUrl.searchParams.append(key, query[key]);
|
|
||||||
return parsedUrl.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDate(value) {
|
|
||||||
return new Date(value).getTime() / 1000;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user