From d7bd1706328e1862f4760de842c66bda0579dd00 Mon Sep 17 00:00:00 2001 From: Pete Date: Sun, 1 Feb 2026 20:56:56 -0600 Subject: [PATCH] Upload files to "/" --- client.js.js | 934 +++++++++++++++++++++++++++++++ https___jellyfin.piracy.lol.json | 1 + jellyfin-logo.png | Bin 0 -> 5828 bytes 3 files changed, 935 insertions(+) create mode 100644 client.js.js create mode 100644 https___jellyfin.piracy.lol.json create mode 100644 jellyfin-logo.png diff --git a/client.js.js b/client.js.js new file mode 100644 index 0000000..2091ee4 --- /dev/null +++ b/client.js.js @@ -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; +} diff --git a/https___jellyfin.piracy.lol.json b/https___jellyfin.piracy.lol.json new file mode 100644 index 0000000..795ec13 --- /dev/null +++ b/https___jellyfin.piracy.lol.json @@ -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"]}} \ No newline at end of file diff --git a/jellyfin-logo.png b/jellyfin-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0d9ecf81dfd4a721dd8bbc7c36d0c8786adef93a GIT binary patch literal 5828 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 ok8EjIoa24n$@9O#w{t__^5c_|V0ljeZ7*6&O