Delete client.js.js

This commit is contained in:
2026-02-01 21:00:14 -06:00
parent d21245c5fb
commit 967411d1af

View File

@@ -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;
}