Upload files to "/"
This commit is contained in:
934
client.js.js
Normal file
934
client.js.js
Normal file
@@ -0,0 +1,934 @@
|
||||
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;
|
||||
}
|
||||
1
https___jellyfin.piracy.lol.json
Normal file
1
https___jellyfin.piracy.lol.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"1d00dfbf-aa8d-4e3a-8d52-d63e5999fe09-jellyfin.piracy.lol","name":"Jellyfin (jellyfin.piracy.lol)","version":30,"description":"An unofficial source for your own Jellyfin server","author":"awlexus","authorUrl":"https://github.com/awlexus","sourceUrl":"http://192.168.8.190:4000/plugin_config/https%3A%2F%2Fjellyfin.piracy.lol?client=Grayjay+client&device_id=019c1be6-5088-7ae2-919b-5f1b09c8d1ab&device_name=Grayjay+client&token=2be574f062014dc3b307b35598f4a074&version=30","scriptUrl":"http://192.168.8.190:4000/js/client.js","scriptSignature":null,"scriptPublicKey":null,"iconUrl":"http://192.168.8.190:4000/images/jellyfin-logo.png","packages":["Http"],"allowEval":false,"allowUrls":["jellyfin.piracy.lol"],"constants":{"client":"Grayjay client","device_id":"019c1be6-5088-7ae2-919b-5f1b09c8d1ab","device_name":"Grayjay client","token":"2be574f062014dc3b307b35598f4a074","uri":"https://jellyfin.piracy.lol","version":"30"},"changelog":{"4":["Properly fetch Authors in lists","Improve fetching playlist item details","Improve fetching channels contents","Allow multiple installations of plugin","Known issue: Audio content like songs may not have thumbnails","Known issue: Some content may not show the correct thumbnail"]}}
|
||||
BIN
jellyfin-logo.png
Normal file
BIN
jellyfin-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Reference in New Issue
Block a user