commit d7bd1706328e1862f4760de842c66bda0579dd00 Author: Pete Date: Sun Feb 1 20:56:56 2026 -0600 Upload files to "/" 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 0000000..0d9ecf8 Binary files /dev/null and b/jellyfin-logo.png differ