diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 5ef1bed4b..000000000 --- a/.yarnrc +++ /dev/null @@ -1,5 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -yarn-path ".yarn/releases/yarn-1.22.19.cjs" diff --git a/package.json b/package.json index c22e98533..9a3f4de7d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@ctrl/transmission": "^4.1.1", "@emotion/react": "^11.10.5", "@emotion/server": "^11.10.0", + "@jellyfin/sdk": "^0.7.0", "@mantine/core": "^5.9.3", "@mantine/dates": "^5.9.3", "@mantine/dropzone": "^5.9.3", @@ -49,6 +50,7 @@ "dockerode": "^3.3.2", "fily-publish-gridstack": "^0.0.13", "framer-motion": "^9.0.2", + "html-entities": "^2.3.3", "i18next": "^21.9.1", "js-file-download": "^0.4.12", "next": "^13.1.6", @@ -58,8 +60,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-simple-code-editor": "^0.13.1", + "rss-parser": "^3.12.0", "sabnzbd-api": "^1.5.0", "uuid": "^8.3.2", + "xml-js": "^1.6.11", "yarn": "^1.22.19", "zustand": "^4.1.4" }, diff --git a/public/locales/en/modules/iframe.json b/public/locales/en/modules/iframe.json new file mode 100644 index 000000000..6bfad9ea1 --- /dev/null +++ b/public/locales/en/modules/iframe.json @@ -0,0 +1,23 @@ +{ + "descriptor": { + "name": "IFrame", + "description": "Embed any content from the internet. Some websites may restrict access.", + "settings": { + "title": "IFrame settings", + "embedUrl": { + "label": "Embed URL" + }, + "allowFullScreen": { + "label": "Allow full screen" + } + } + }, + "card": { + "errors": { + "noUrl": { + "title": "Enter an URL", + "text": "Ensure that you've entered a valid address in the configuration of your widget" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/modules/media-server.json b/public/locales/en/modules/media-server.json new file mode 100644 index 000000000..1b93eb4c2 --- /dev/null +++ b/public/locales/en/modules/media-server.json @@ -0,0 +1,24 @@ +{ + "descriptor": { + "name": "Media Server", + "description": "Interact with your Jellyfin or Plex media server", + "settings": { + "title": "Settings for media server widget" + } + }, + "card": { + "table": { + "header": { + "session": "Session", + "user": "User", + "currentlyPlaying": "Currently playing" + } + }, + "errors": { + "general": { + "title": "Unable to load content", + "text": "Unable to retrieve information from the server. Please check the logs for more details" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/modules/rss.json b/public/locales/en/modules/rss.json new file mode 100644 index 000000000..26800307a --- /dev/null +++ b/public/locales/en/modules/rss.json @@ -0,0 +1,20 @@ +{ + "descriptor": { + "name": "RSS Widget", + "description": "Grabs the items from a RSS feed and displays them. Commonly used for online news", + "settings": { + "title": "Settings for RSS widget", + "rssFeedUrl": { + "label": "RSS feed url" + } + } + }, + "card": { + "errors": { + "general": { + "title": "Unable to retrieve RSS feed", + "text": "There was a problem reaching out the the RSS feed. Make sure that you've configured the feed correctly and use a valid RSS url, that matches the official standard specification. After updating the feed, you may need to save your dashboard and refresh the page." + } + } + } +} \ No newline at end of file diff --git a/src/components/AppAvatar.tsx b/src/components/AppAvatar.tsx new file mode 100644 index 000000000..08ca5c7b4 --- /dev/null +++ b/src/components/AppAvatar.tsx @@ -0,0 +1,26 @@ +import { Avatar, DefaultMantineColor, useMantineTheme } from '@mantine/core'; + +export const AppAvatar = ({ + iconUrl, + color, +}: { + iconUrl: string; + color?: DefaultMantineColor | undefined; +}) => { + const { colors, colorScheme } = useMantineTheme(); + + return ( + + ); +}; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index 7ba366339..cbb74405f 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -75,6 +75,16 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => { image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png', label: 'Readarr', }, + { + value: 'jellyfin', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png', + label: 'Jellyfin', + }, + { + value: 'plex', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png', + label: 'Plex', + }, ].filter((x) => Object.keys(integrationFieldProperties).includes(x.value)); const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => { diff --git a/src/hooks/widgets/media-servers/useGetMediaServers.tsx b/src/hooks/widgets/media-servers/useGetMediaServers.tsx new file mode 100644 index 000000000..7b3d10164 --- /dev/null +++ b/src/hooks/widgets/media-servers/useGetMediaServers.tsx @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { MediaServersResponseType } from '../../../types/api/media-server/response'; + +interface GetMediaServersParams { + enabled: boolean; +} + +export const useGetMediaServers = ({ enabled }: GetMediaServersParams) => + useQuery({ + queryKey: ['media-servers'], + queryFn: async (): Promise => { + const response = await fetch('/api/modules/media-server'); + return response.json(); + }, + enabled, + refetchInterval: 10 * 1000, + }); diff --git a/src/hooks/widgets/rss/useGetRssFeed.tsx b/src/hooks/widgets/rss/useGetRssFeed.tsx new file mode 100644 index 000000000..2fc9e07e3 --- /dev/null +++ b/src/hooks/widgets/rss/useGetRssFeed.tsx @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; + +export const useGetRssFeed = (feedUrl: string) => + useQuery({ + queryKey: ['rss-feed', feedUrl], + queryFn: async () => { + const response = await fetch('/api/modules/rss'); + return response.json(); + }, + }); diff --git a/src/pages/api/modules/media-server/index.ts b/src/pages/api/modules/media-server/index.ts new file mode 100644 index 000000000..3bbc82fb2 --- /dev/null +++ b/src/pages/api/modules/media-server/index.ts @@ -0,0 +1,227 @@ +import { Jellyfin } from '@jellyfin/sdk'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; + +import Consola from 'consola'; + +import { getCookie } from 'cookies-next'; + +import { NextApiRequest, NextApiResponse } from 'next'; + +import { BaseItemKind, ProgramAudio } from '@jellyfin/sdk/lib/generated-client/models'; +import { getConfig } from '../../../../tools/config/getConfig'; +import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient'; +import { GenericMediaServer } from '../../../../types/api/media-server/media-server'; +import { MediaServersResponseType } from '../../../../types/api/media-server/response'; +import { + GenericCurrentlyPlaying, + GenericSessionInfo, +} from '../../../../types/api/media-server/session-info'; +import { ConfigAppType } from '../../../../types/app'; + +const jellyfin = new Jellyfin({ + clientInfo: { + name: 'Homarr', + version: '0.0.1', + }, + deviceInfo: { + name: 'Homarr Jellyfin Widget', + id: 'homarr-jellyfin-widget', + }, +}); + +const Get = async (request: NextApiRequest, response: NextApiResponse) => { + const configName = getCookie('config-name', { req: request }); + const config = getConfig(configName?.toString() ?? 'default'); + + const apps = config.apps.filter((app) => + ['jellyfin', 'plex'].includes(app.integration?.type ?? '') + ); + + const servers = await Promise.all( + apps.map(async (app): Promise => { + try { + return await handleServer(app); + } catch (error) { + Consola.error( + `failed to communicate with media server '${app.name}' (${app.id}): ${error}` + ); + return { + serverAddress: app.url, + sessions: [], + success: false, + version: undefined, + type: undefined, + appId: app.id, + }; + } + }) + ); + + return response.status(200).json({ + servers: servers.filter((server) => server !== undefined), + } as MediaServersResponseType); +}; + +const handleServer = async (app: ConfigAppType): Promise => { + switch (app.integration?.type) { + case 'jellyfin': { + const username = app.integration.properties.find((x) => x.field === 'username'); + + if (!username || !username.value) { + return { + appId: app.id, + serverAddress: app.url, + sessions: [], + type: 'jellyfin', + version: undefined, + success: false, + }; + } + + const password = app.integration.properties.find((x) => x.field === 'password'); + + if (!password || !password.value) { + return { + appId: app.id, + serverAddress: app.url, + sessions: [], + type: 'jellyfin', + version: undefined, + success: false, + }; + } + + const api = jellyfin.createApi(app.url); + const infoApi = await getSystemApi(api).getPublicSystemInfo(); + await api.authenticateUserByName(username.value, password.value); + const sessionApi = await getSessionApi(api); + const sessions = await sessionApi.getSessions(); + return { + type: 'jellyfin', + appId: app.id, + serverAddress: app.url, + version: infoApi.data.Version ?? undefined, + sessions: sessions.data.map( + (session): GenericSessionInfo => ({ + id: session.Id ?? '?', + username: session.UserName ?? undefined, + sessionName: `${session.Client} (${session.DeviceName})`, + supportsMediaControl: session.SupportsMediaControl ?? false, + currentlyPlaying: session.NowPlayingItem + ? { + name: session.NowPlayingItem.Name as string, + seasonName: session.NowPlayingItem.SeasonName as string, + albumName: session.NowPlayingItem.Album as string, + episodeCount: session.NowPlayingItem.EpisodeCount ?? undefined, + metadata: { + video: + session.NowPlayingItem && + session.NowPlayingItem.Width && + session.NowPlayingItem.Height + ? { + videoCodec: undefined, + width: session.NowPlayingItem.Width ?? undefined, + height: session.NowPlayingItem.Height ?? undefined, + bitrate: undefined, + videoFrameRate: session.TranscodingInfo?.Framerate + ? String(session.TranscodingInfo?.Framerate) + : undefined, + } + : undefined, + audio: session.TranscodingInfo + ? { + audioChannels: session.TranscodingInfo.AudioChannels ?? undefined, + audioCodec: session.TranscodingInfo.AudioCodec ?? undefined, + } + : undefined, + transcoding: session.TranscodingInfo + ? { + audioChannels: session.TranscodingInfo.AudioChannels ?? -1, + audioCodec: session.TranscodingInfo.AudioCodec ?? undefined, + container: session.TranscodingInfo.Container ?? undefined, + width: session.TranscodingInfo.Width ?? undefined, + height: session.TranscodingInfo.Height ?? undefined, + videoCodec: session.TranscodingInfo?.VideoCodec ?? undefined, + audioDecision: undefined, + context: undefined, + duration: undefined, + error: undefined, + sourceAudioCodec: undefined, + sourceVideoCodec: undefined, + timeStamp: undefined, + transcodeHwRequested: undefined, + videoDecision: undefined, + } + : undefined, + }, + type: convertJellyfinType(session.NowPlayingItem.Type), + } + : undefined, + userProfilePicture: undefined, + }) + ), + success: true, + }; + } + case 'plex': { + const apiKey = app.integration.properties.find((x) => x.field === 'apiKey'); + + if (!apiKey || !apiKey.value) { + return { + serverAddress: app.url, + sessions: [], + type: 'plex', + appId: app.id, + version: undefined, + success: false, + }; + } + + const plexClient = new PlexClient(app.url, apiKey.value); + const sessions = await plexClient.getSessions(); + return { + serverAddress: app.url, + sessions, + type: 'plex', + version: undefined, + appId: app.id, + success: true, + }; + } + default: { + Consola.warn( + `media-server api entered a fallback case. This should normally not happen and must be reported. Cause: '${app.name}' (${app.id})` + ); + return undefined; + } + } +}; + +const convertJellyfinType = (kind: BaseItemKind | undefined): GenericCurrentlyPlaying['type'] => { + switch (kind) { + case BaseItemKind.Audio: + case BaseItemKind.MusicVideo: + return 'audio'; + case BaseItemKind.Episode: + case BaseItemKind.Video: + return 'video'; + case BaseItemKind.Movie: + return 'movie'; + case BaseItemKind.TvChannel: + case BaseItemKind.TvProgram: + case BaseItemKind.LiveTvChannel: + case BaseItemKind.LiveTvProgram: + return 'tv'; + default: + return undefined; + } +}; + +export default async (request: NextApiRequest, response: NextApiResponse) => { + if (request.method === 'GET') { + return Get(request, response); + } + + return response.status(405); +}; diff --git a/src/pages/api/modules/rss/index.ts b/src/pages/api/modules/rss/index.ts new file mode 100644 index 000000000..0a5623158 --- /dev/null +++ b/src/pages/api/modules/rss/index.ts @@ -0,0 +1,93 @@ +import Consola from 'consola'; + +import { getCookie } from 'cookies-next'; + +import { decode } from 'html-entities'; + +import { NextApiRequest, NextApiResponse } from 'next'; + +import Parser from 'rss-parser'; + +import { getConfig } from '../../../../tools/config/getConfig'; +import { Stopwatch } from '../../../../tools/shared/stopwatch'; +import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile'; + +type CustomItem = { + 'media:content': string; + enclosure: { + url: string; + }; +}; + +const parser: Parser = new Parser({ + customFields: { + item: ['media:content', 'enclosure'], + }, +}); + +export const Get = async (request: NextApiRequest, response: NextApiResponse) => { + const configName = getCookie('config-name', { req: request }); + const config = getConfig(configName?.toString() ?? 'default'); + + const rssWidget = config.widgets.find((x) => x.id === 'rss') as IRssWidget | undefined; + + if ( + !rssWidget || + !rssWidget.properties.rssFeedUrl || + rssWidget.properties.rssFeedUrl.length < 1 + ) { + response.status(400).json({ message: 'required widget does not exist' }); + return; + } + + Consola.info('Requesting RSS feed...'); + const stopWatch = new Stopwatch(); + const feed = await parser.parseURL(rssWidget.properties.rssFeedUrl); + Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`); + + const orderedFeed = { + ...feed, + items: feed.items + .map((item: { title: any; content: any }) => ({ + ...item, + title: item.title ? decode(item.title) : undefined, + content: decode(item.content), + enclosure: createEnclosure(item), + })) + .sort((a: { pubDate: number }, b: { pubDate: number }) => { + if (!a.pubDate || !b.pubDate) { + return 0; + } + + return a.pubDate - b.pubDate; + }) + .slice(0, 20), + }; + + response.status(200).json({ + feed: orderedFeed, + success: orderedFeed?.items !== undefined, + }); +}; + +const createEnclosure = (item: any) => { + if (item.enclosure) { + return item.enclosure; + } + + if (item['media:content']) { + return { + url: item['media:content'].$.url, + }; + } + + return undefined; +}; + +export default async (request: NextApiRequest, response: NextApiResponse) => { + if (request.method === 'GET') { + return Get(request, response); + } + + return response.status(405); +}; diff --git a/src/styles/global.scss b/src/styles/global.scss index ce1377510..13a8c5dda 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -92,3 +92,8 @@ height: 0px; min-height: 0px !important; } + +.scroll-area-w100 .mantine-ScrollArea-viewport > div:nth-of-type(1) { + width: 100%; + display: inherit !important; +} \ No newline at end of file diff --git a/src/tools/server/sdk/plex/plexClient.ts b/src/tools/server/sdk/plex/plexClient.ts new file mode 100644 index 000000000..6bc6707ce --- /dev/null +++ b/src/tools/server/sdk/plex/plexClient.ts @@ -0,0 +1,108 @@ +import { Element, xml2js } from 'xml-js'; + +import { + GenericCurrentlyPlaying, + GenericSessionInfo, +} from '../../../../types/api/media-server/session-info'; + +export class PlexClient { + constructor(private readonly apiAddress: string, private readonly token: string) {} + + async getSessions(): Promise { + const response = await fetch(`${this.apiAddress}/status/sessions?X-Plex-Token=${this.token}`); + const body = await response.text(); + + // convert xml response to objects, as there is no JSON api + const data = xml2js(body); + + // TODO: Investigate when there are no media containers + const mediaContainer = data.elements[0] as Element; + + // no sessions are open or available + if (!mediaContainer.elements?.some((_) => true)) { + return []; + } + + const videoElements = mediaContainer.elements as Element[]; + + const videos = videoElements + .map((videoElement): GenericSessionInfo | undefined => { + // extract the elements from the children + const userElement = this.findElement('User', videoElement.elements); + const playerElement = this.findElement('Player', videoElement.elements); + const mediaElement = this.findElement('Media', videoElement.elements); + const sessionElement = this.findElement('Session', videoElement.elements); + + if (!userElement || !playerElement || !mediaElement || !sessionElement) { + return undefined; + } + + const { videoCodec, videoFrameRate, audioCodec, audioChannels, height, width, bitrate } = + mediaElement; + + const transcodingElement = this.findElement('TranscodeSession', videoElement.elements); + + return { + id: sessionElement.id as string, + username: userElement.title as string, + userProfilePicture: userElement.thumb as string, + sessionName: `${playerElement.product} (${playerElement.title})`, + currentlyPlaying: { + name: videoElement.attributes?.title as string, + type: this.getCurrentlyPlayingType(videoElement.attributes?.type as string), + metadata: { + video: { + bitrate, + height, + videoCodec, + videoFrameRate, + width, + }, + audio: { + audioChannels, + audioCodec, + }, + transcoding: + transcodingElement === undefined + ? undefined + : { + audioChannels: transcodingElement.audioChannels, + audioCodec: transcodingElement.audioCodec, + audioDecision: transcodingElement.audioDecision, + container: transcodingElement.container, + context: transcodingElement.context, + duration: transcodingElement.duration, + error: transcodingElement.error === 1, + height: transcodingElement.height, + sourceAudioCodec: transcodingElement.sourceAudioCodec, + sourceVideoCodec: transcodingElement.sourceVideoCodec, + timeStamp: transcodingElement.timeStamp, + transcodeHwRequested: transcodingElement.transcodeHwRequested === 1, + videoCodec: transcodingElement.videoCodec, + videoDecision: transcodingElement.videoDecision, + width: transcodingElement.width, + }, + }, + }, + } as GenericSessionInfo; + }) + .filter((x) => x !== undefined) as GenericSessionInfo[]; + + return videos; + } + + private findElement(name: string, elements: Element[] | undefined) { + return elements?.find((x) => x.name === name)?.attributes; + } + + private getCurrentlyPlayingType(type: string): GenericCurrentlyPlaying['type'] { + switch (type) { + case 'movie': + return 'movie'; + case 'episode': + return 'video'; + default: + return undefined; + } + } +} diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index daa866df2..3e819398d 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -29,9 +29,12 @@ export const dashboardNamespaces = [ 'modules/torrents-status', 'modules/weather', 'modules/ping', + 'modules/iframe', + 'modules/rss', 'modules/docker', 'modules/dashdot', 'modules/overseerr', + 'modules/media-server', 'modules/common-media-cards', 'modules/video-stream', ]; diff --git a/src/tools/shared/stopwatch.ts b/src/tools/shared/stopwatch.ts new file mode 100644 index 000000000..0d2b2e853 --- /dev/null +++ b/src/tools/shared/stopwatch.ts @@ -0,0 +1,11 @@ +export class Stopwatch { + private startTime: Date; + + constructor() { + this.startTime = new Date(); + } + + getEllapsedMilliseconds() { + return new Date().getTime() - this.startTime.getTime(); + } +} diff --git a/src/types/api/media-server/media-server.ts b/src/types/api/media-server/media-server.ts new file mode 100644 index 000000000..1b6301caf --- /dev/null +++ b/src/types/api/media-server/media-server.ts @@ -0,0 +1,34 @@ +import { GenericSessionInfo } from './session-info'; + +export type GenericMediaServer = { + /** + * The type of the media server. + * Undefined indicates, that the type is either unsupported or recognizing went wrong + */ + type: 'jellyfin' | 'plex' | undefined; + + /** + * The address of the server + */ + serverAddress: string; + + /** + * The current version of the server + */ + version: string | undefined; + + /** + * The active sessions on the server + */ + sessions: GenericSessionInfo[]; + + /** + * The app id of the used app + */ + appId: string; + + /** + * Indicates, wether the communication was successfull or not + */ + success: boolean; +}; diff --git a/src/types/api/media-server/response.ts b/src/types/api/media-server/response.ts new file mode 100644 index 000000000..f4f89d726 --- /dev/null +++ b/src/types/api/media-server/response.ts @@ -0,0 +1,5 @@ +import { GenericMediaServer } from './media-server'; + +export type MediaServersResponseType = { + servers: GenericMediaServer[]; +}; diff --git a/src/types/api/media-server/session-info.ts b/src/types/api/media-server/session-info.ts new file mode 100644 index 000000000..5b8c25ab7 --- /dev/null +++ b/src/types/api/media-server/session-info.ts @@ -0,0 +1,46 @@ +export type GenericSessionInfo = { + supportsMediaControl: boolean; + username: string | undefined; + id: string; + sessionName: string; + userProfilePicture: string | undefined; + currentlyPlaying: GenericCurrentlyPlaying | undefined; +}; + +export type GenericCurrentlyPlaying = { + name: string; + seasonName: string | undefined; + albumName: string | undefined; + episodeCount: number | undefined; + type: 'audio' | 'video' | 'tv' | 'movie' | undefined; + metadata: { + video: { + videoCodec: string | undefined; + videoFrameRate: string | undefined; + height: number | undefined; + width: number | undefined; + bitrate: number | undefined; + } | undefined; + audio: { + audioCodec: string | undefined; + audioChannels: number | undefined; + } | undefined; + transcoding: { + context: string | undefined; + sourceVideoCodec: string | undefined; + sourceAudioCodec: string | undefined; + videoDecision: string | undefined; + audioDecision: string | undefined; + container: string | undefined; + videoCodec: string | undefined; + audioCodec: string | undefined; + error: boolean | undefined; + duration: number | undefined; + audioChannels: number | undefined; + width: number | undefined; + height: number | undefined; + transcodeHwRequested: boolean | undefined; + timeStamp: number | undefined; + } | undefined; + }; +}; diff --git a/src/types/app.ts b/src/types/app.ts index 0f6284628..2e611c031 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -41,6 +41,8 @@ export type IntegrationType = | 'deluge' | 'qBittorrent' | 'transmission' + | 'plex' + | 'jellyfin' | 'nzbGet'; export type AppIntegrationType = { @@ -79,6 +81,8 @@ export const integrationFieldProperties: { nzbGet: ['username', 'password'], qBittorrent: ['username', 'password'], transmission: ['username', 'password'], + jellyfin: ['username', 'password'], + plex: ['apiKey'], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/download-speed/Tile.tsx b/src/widgets/download-speed/Tile.tsx index 1926b1c8d..48b3117fb 100644 --- a/src/widgets/download-speed/Tile.tsx +++ b/src/widgets/download-speed/Tile.tsx @@ -15,6 +15,7 @@ import { Serie, Datum, ResponsiveLine } from '@nivo/line'; import { IconDownload, IconUpload } from '@tabler/icons'; import { useTranslation } from 'next-i18next'; import { useEffect } from 'react'; +import { AppAvatar } from '../../components/AppAvatar'; import { useConfigContext } from '../../config/provider'; import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed'; import { useColorTheme } from '../../tools/color'; @@ -258,17 +259,3 @@ export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTraf ); } - -const AppAvatar = ({ iconUrl }: { iconUrl: string }) => { - const { colors, colorScheme } = useMantineTheme(); - - return ( - - ); -}; diff --git a/src/widgets/iframe/IFrameTile.tsx b/src/widgets/iframe/IFrameTile.tsx new file mode 100644 index 000000000..44b17d089 --- /dev/null +++ b/src/widgets/iframe/IFrameTile.tsx @@ -0,0 +1,82 @@ +import { Center, createStyles, Stack, Title, Text, Container } from '@mantine/core'; +import { IconBrowser, IconUnlink } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'iframe', + icon: IconBrowser, + gridstack: { + maxHeight: 12, + maxWidth: 12, + minHeight: 1, + minWidth: 1, + }, + options: { + embedUrl: { + type: 'text', + defaultValue: '', + }, + allowFullScreen: { + type: 'switch', + defaultValue: false, + }, + }, + component: IFrameTile, +}); + +export type IIFrameWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface IFrameTileProps { + widget: IIFrameWidget; +} + +function IFrameTile({ widget }: IFrameTileProps) { + const { t } = useTranslation('modules/iframe'); + const { classes } = useStyles(); + + if (!widget.properties.embedUrl) { + return ( +
+ + + + + {t('card.errors.noUrl.title')} + + + {t('card.errors.noUrl.text')} + + + +
+ ); + } + + return ( + + + + ); +} + +const useStyles = createStyles(({ radius }) => ({ + iframe: { + borderRadius: radius.sm, + width: '100%', + height: '100%', + border: 'none', + background: 'none', + backgroundColor: 'transparent', + }, +})); + +export default definition; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index aac770ead..820795f24 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,11 +1,14 @@ -import date from './date/DateTile'; import calendar from './calendar/CalendarTile'; import dashdot from './dashDot/DashDotTile'; -import usenet from './useNet/UseNetTile'; -import weather from './weather/WeatherTile'; -import torrent from './torrent/TorrentTile'; +import date from './date/DateTile'; import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile'; +import iframe from './iframe/IFrameTile'; +import mediaServer from './media-server/MediaServerTile'; +import rss from './rss/RssWidgetTile'; +import torrent from './torrent/TorrentTile'; +import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; +import weather from './weather/WeatherTile'; export default { calendar, @@ -15,5 +18,8 @@ export default { 'torrents-status': torrent, dlspeed: torrentNetworkTraffic, date, + rss, 'video-stream': videoStream, + iframe, + 'media-server': mediaServer, }; diff --git a/src/widgets/media-server/DetailCollapseable.tsx b/src/widgets/media-server/DetailCollapseable.tsx new file mode 100644 index 000000000..536e3d0e7 --- /dev/null +++ b/src/widgets/media-server/DetailCollapseable.tsx @@ -0,0 +1,128 @@ +import { Card, Divider, Flex, Grid, Group, Text } from '@mantine/core'; +import { IconDeviceMobile, IconId } from '@tabler/icons'; +import { GenericSessionInfo } from '../../types/api/media-server/session-info'; + +export const DetailCollapseable = ({ session }: { session: GenericSessionInfo }) => { + let details: { title: string; metrics: { name: string; value: string | undefined }[] }[] = []; + + if (session.currentlyPlaying) { + if (session.currentlyPlaying.metadata.video) { + details = [ + ...details, + { + title: 'Video', + metrics: [ + { + name: 'Resolution', + value: `${session.currentlyPlaying.metadata.video.width}x${session.currentlyPlaying.metadata.video.height}`, + }, + { + name: 'Framerate', + value: session.currentlyPlaying.metadata.video.videoFrameRate, + }, + { + name: 'Codec', + value: session.currentlyPlaying.metadata.video.videoCodec, + }, + { + name: 'Bitrate', + value: session.currentlyPlaying.metadata.video.bitrate + ? String(session.currentlyPlaying.metadata.video.bitrate) + : undefined, + }, + ], + }, + ]; + } + if (session.currentlyPlaying.metadata.audio) { + details = [ + ...details, + { + title: 'Audio', + metrics: [ + { + name: 'Audio channels', + value: `${session.currentlyPlaying.metadata.audio.audioChannels}`, + }, + { + name: 'Audio codec', + value: session.currentlyPlaying.metadata.audio.audioCodec, + }, + ], + }, + ]; + } + + if (session.currentlyPlaying.metadata.transcoding) { + details = [ + ...details, + { + title: 'Transcoding', + metrics: [ + { + name: 'Resolution', + value: `${session.currentlyPlaying.metadata.transcoding.width}x${session.currentlyPlaying.metadata.transcoding.height}`, + }, + { + name: 'Context', + value: session.currentlyPlaying.metadata.transcoding.context, + }, + { + name: 'Hardware encoding requested', + value: session.currentlyPlaying.metadata.transcoding.transcodeHwRequested + ? 'yes' + : 'no', + }, + { + name: 'Source codec', + value: + session.currentlyPlaying.metadata.transcoding.sourceAudioCodec || + session.currentlyPlaying.metadata.transcoding.sourceVideoCodec + ? `${session.currentlyPlaying.metadata.transcoding.sourceVideoCodec} ${session.currentlyPlaying.metadata.transcoding.sourceAudioCodec}` + : undefined, + }, + { + name: 'Target codec', + value: `${session.currentlyPlaying.metadata.transcoding.videoCodec} ${session.currentlyPlaying.metadata.transcoding.audioCodec}`, + }, + ], + }, + ]; + } + } + + return ( + + + + + ID + + {session.id} + + + + + Device + + {session.sessionName} + + {details.length > 0 && } + + {details.map((detail, index) => ( + + {detail.title} + {detail.metrics + .filter((x) => x.value !== undefined) + .map((metric, index2) => ( + + {metric.name} + {metric.value} + + ))} + + ))} + + + ); +}; diff --git a/src/widgets/media-server/MediaServerTile.tsx b/src/widgets/media-server/MediaServerTile.tsx new file mode 100644 index 000000000..c1c40064d --- /dev/null +++ b/src/widgets/media-server/MediaServerTile.tsx @@ -0,0 +1,110 @@ +import { + Avatar, + Center, + Group, + Loader, + ScrollArea, + Stack, + Table, + Text, + Title, +} from '@mantine/core'; +import { IconAlertTriangle, IconMovie } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { AppAvatar } from '../../components/AppAvatar'; +import { useConfigContext } from '../../config/provider'; +import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers'; +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; +import { TableRow } from './TableRow'; + +const definition = defineWidget({ + id: 'media-server', + icon: IconMovie, + options: {}, + component: MediaServerTile, + gridstack: { + minWidth: 3, + minHeight: 2, + maxWidth: 12, + maxHeight: 12, + }, +}); + +export type MediaServerWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface MediaServerWidgetProps { + widget: MediaServerWidget; +} + +function MediaServerTile({ widget }: MediaServerWidgetProps) { + const { t } = useTranslation('modules/media-server'); + const { config } = useConfigContext(); + + const { data, isError } = useGetMediaServers({ + enabled: config !== undefined, + }); + + if (isError) { + return ( +
+ + + {t('card.errors.general.title')} + {t('card.errors.general.text')} + +
+ ); + } + + if (!data) { +
+ +
; + } + + return ( + + + + + + + + + + + + {data?.servers.map((server) => { + const app = config?.apps.find((x) => x.id === server.appId); + return server.sessions.map((session, index) => ( + + )); + })} + +
{t('card.table.header.session')}{t('card.table.header.user')}{t('card.table.header.currentlyPlaying')}
+
+ + + + {data?.servers.map((server) => { + const app = config?.apps.find((x) => x.id === server.appId); + + if (!app) { + return null; + } + + return ( + + ); + })} + + +
+ ); +} + +export default definition; diff --git a/src/widgets/media-server/NowPlayingDisplay.tsx b/src/widgets/media-server/NowPlayingDisplay.tsx new file mode 100644 index 000000000..c3bdc40d1 --- /dev/null +++ b/src/widgets/media-server/NowPlayingDisplay.tsx @@ -0,0 +1,50 @@ +import { Flex, Group, Stack, Text } from '@mantine/core'; +import { + IconDeviceTv, + IconHeadphones, + IconQuestionMark, + IconVideo, + TablerIcon, +} from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { GenericSessionInfo } from '../../types/api/media-server/session-info'; + +export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo }) => { + const { t } = useTranslation(); + + if (!session.currentlyPlaying) { + return null; + } + + const Icon = (): TablerIcon => { + switch (session.currentlyPlaying?.type) { + case 'audio': + return IconHeadphones; + case 'tv': + return IconDeviceTv; + case 'video': + return IconVideo; + default: + return IconQuestionMark; + } + }; + + const Test = Icon(); + + return ( + + + + {session.currentlyPlaying.name} + + {session.currentlyPlaying.albumName ? ( + {session.currentlyPlaying.albumName} + ) : ( + session.currentlyPlaying.seasonName && ( + {session.currentlyPlaying.seasonName} + ) + )} + + + ); +}; diff --git a/src/widgets/media-server/TableRow.tsx b/src/widgets/media-server/TableRow.tsx new file mode 100644 index 000000000..b7f4e966a --- /dev/null +++ b/src/widgets/media-server/TableRow.tsx @@ -0,0 +1,73 @@ +import { + Avatar, + Card, + Collapse, + createStyles, + Flex, + Grid, + Group, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useState } from 'react'; +import { AppAvatar } from '../../components/AppAvatar'; +import { GenericSessionInfo } from '../../types/api/media-server/session-info'; +import { AppType } from '../../types/app'; +import { DetailCollapseable } from './DetailCollapseable'; +import { NowPlayingDisplay } from './NowPlayingDisplay'; + +interface TableRowProps { + session: GenericSessionInfo; + app: AppType | undefined; +} + +export const TableRow = ({ session, app }: TableRowProps) => { + const [collapseOpen, setCollapseOpen] = useState(false); + const hasUserThumb = session.userProfilePicture !== undefined; + const { classes } = useStyles(); + return ( + <> + setCollapseOpen(!collapseOpen)}> + + + {app?.appearance.iconUrl && } + {session.sessionName} + + + + + {hasUserThumb ? ( + + ) : ( + + {session.username?.at(0)?.toUpperCase()} + + )} + {session.username} + + + + + + + + + + + + + + + ); +}; + +const useStyles = createStyles(() => ({ + dataRow: { + cursor: 'pointer', + }, + collapseTableDataCell: { + border: 'none !important', + padding: '0 !important', + }, +})); diff --git a/src/widgets/rss/RssWidgetTile.tsx b/src/widgets/rss/RssWidgetTile.tsx new file mode 100644 index 000000000..39b03db68 --- /dev/null +++ b/src/widgets/rss/RssWidgetTile.tsx @@ -0,0 +1,236 @@ +import { + ActionIcon, + Badge, + Card, + Center, + createStyles, + Flex, + Group, + Image, + Loader, + LoadingOverlay, + MediaQuery, + ScrollArea, + Stack, + Text, + Title, + UnstyledButton, +} from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { + IconBulldozer, + IconCalendarTime, + IconClock, + IconCopyright, + IconRefresh, + IconRss, + IconSpeakerphone, +} from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useGetRssFeed } from '../../hooks/widgets/rss/useGetRssFeed'; +import { sleep } from '../../tools/client/time'; +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'rss', + icon: IconRss, + options: { + rssFeedUrl: { + type: 'text', + defaultValue: '', + }, + }, + gridstack: { + minWidth: 2, + minHeight: 2, + maxWidth: 12, + maxHeight: 12, + }, + component: RssTile, +}); + +export type IRssWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface RssTileProps { + widget: IRssWidget; +} + +function RssTile({ widget }: RssTileProps) { + const { t } = useTranslation('modules/rss'); + const { data, isLoading, isFetching, isError, refetch } = useGetRssFeed( + widget.properties.rssFeedUrl + ); + const { classes } = useStyles(); + const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false); + const { ref, height } = useElementSize(); + + if (!data || isLoading) { + return ( +
+ +
+ ); + } + + if (!data.success || isError) { + return ( +
+ + + {t('card.errors.general.title')} + {t('card.errors.general.text')} + +
+ ); + } + + return ( + + + + {data.feed.image ? ( + {data.feed.image.title} + ) : ( + {data.feed.title} + )} + { + setLoadingOverlayVisible(true); + await Promise.all([sleep(1500), refetch()]); + setLoadingOverlayVisible(false); + }} + disabled={isFetching || isLoading} + > + + + + + + + + {data.feed.items.map((item: any, index: number) => ( + + {item.enclosure && ( + // eslint-disable-next-line @next/next/no-img-element + backdrop + )} + + + + + + + {item.categories && ( + + {item.categories.map((category: any, categoryIndex: number) => ( + {category._} + ))} + + )} + + {item.title} + + {item.content} + + + {item.pubDate && } + + + + ))} + + + + + + + + {data.feed.copyright} + + + + + + {data.feed.pubDate} + + + + + + {data.feed.lastBuildDate} + + + {data.feed.feedUrl && ( + + + + Feed URL + + + )} + + + ); +} + +const TimeDisplay = ({ date }: { date: string }) => ( + + + + {date} + + +); + +const useStyles = createStyles(({ colorScheme }) => ({ + backgroundImage: { + position: 'absolute', + width: '100%', + height: '100%', + filter: colorScheme === 'dark' ? 'blur(30px)' : 'blur(15px)', + transform: 'scaleX(-1)', + opacity: colorScheme === 'dark' ? 0.3 : 0.2, + transition: 'ease-in-out 0.2s', + + '&:hover': { + opacity: colorScheme === 'dark' ? 0.4 : 0.3, + filter: 'blur(40px) brightness(0.7)', + }, + }, +})); + +export default definition; diff --git a/yarn.lock b/yarn.lock index 719ff96f0..2d3338ba5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -131,7 +131,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.8.0": +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.19.0, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.20.2 resolution: "@babel/helper-plugin-utils@npm:7.20.2" checksum: f6cae53b7fdb1bf3abd50fa61b10b4470985b400cc794d92635da1e7077bb19729f626adc0741b69403d9b6e411cddddb9c0157a709cc7c4eeb41e663be5d74b @@ -263,17 +263,6 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.17.12": - version: 7.18.6 - resolution: "@babel/plugin-syntax-jsx@npm:7.18.6" - dependencies: - "@babel/helper-plugin-utils": ^7.18.6 - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 6d37ea972970195f1ffe1a54745ce2ae456e0ac6145fae9aa1480f297248b262ea6ebb93010eddb86ebfacb94f57c05a1fc5d232b9a67325b09060299d515c67 - languageName: node - linkType: hard - "@babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -507,12 +496,11 @@ __metadata: languageName: node linkType: hard -"@emotion/babel-plugin@npm:^11.10.5": - version: 11.10.5 - resolution: "@emotion/babel-plugin@npm:11.10.5" +"@emotion/babel-plugin@npm:^11.10.6": + version: 11.10.6 + resolution: "@emotion/babel-plugin@npm:11.10.6" dependencies: "@babel/helper-module-imports": ^7.16.7 - "@babel/plugin-syntax-jsx": ^7.17.12 "@babel/runtime": ^7.18.3 "@emotion/hash": ^0.9.0 "@emotion/memoize": ^0.8.0 @@ -523,9 +511,7 @@ __metadata: find-root: ^1.1.0 source-map: ^0.5.7 stylis: 4.1.3 - peerDependencies: - "@babel/core": ^7.0.0 - checksum: e3353499c76c4422d6e900c0dfab73607056d9da86161a3f27c3459c193c4908050c5d252c68fcde231e13f02a9d8e0dc07d260317ae0e5206841e331cc4caae + checksum: 3eed138932e8edf2598352e69ad949b9db3051a4d6fcff190dacbac9aa838d7ef708b9f3e6c48660625d9311dae82d73477ae4e7a31139feef5eb001a5528421 languageName: node linkType: hard @@ -573,11 +559,11 @@ __metadata: linkType: hard "@emotion/react@npm:^11.10.5": - version: 11.10.5 - resolution: "@emotion/react@npm:11.10.5" + version: 11.10.6 + resolution: "@emotion/react@npm:11.10.6" dependencies: "@babel/runtime": ^7.18.3 - "@emotion/babel-plugin": ^11.10.5 + "@emotion/babel-plugin": ^11.10.6 "@emotion/cache": ^11.10.5 "@emotion/serialize": ^1.1.1 "@emotion/use-insertion-effect-with-fallbacks": ^1.0.0 @@ -585,14 +571,11 @@ __metadata: "@emotion/weak-memoize": ^0.3.0 hoist-non-react-statics: ^3.3.1 peerDependencies: - "@babel/core": ^7.0.0 react: ">=16.8.0" peerDependenciesMeta: - "@babel/core": - optional: true "@types/react": optional: true - checksum: 32b67b28e9b6d6c53b970072680697f04c2521441050bdeb19a1a7f0164af549b4dad39ff375eda1b6a3cf1cc86ba2c6fa55460ec040e6ebbca3e9ec58353cf7 + checksum: 4762042e39126ffaffe76052dc65c9bb0ba6b8893013687ba3cc13ed4dd834c31597f1230684c3c078e90aecc13ab6cd0e3cde0dec8b7761affd2571f4d80019 languageName: node linkType: hard @@ -774,6 +757,16 @@ __metadata: languageName: node linkType: hard +"@jellyfin/sdk@npm:^0.7.0": + version: 0.7.0 + resolution: "@jellyfin/sdk@npm:0.7.0" + dependencies: + axios: 0.27.2 + compare-versions: 5.0.1 + checksum: d7ddae88464e0f2276ec7a793f7e525098a56d96491a450d5e859bf46d74e473a4b4bd5dce2a64ef80484e12e23c14934a4cb20429e89a3c41e52e817e3f31d4 + languageName: node + linkType: hard + "@jest/console@npm:^28.1.3": version: 28.1.3 resolution: "@jest/console@npm:28.1.3" @@ -1058,133 +1051,133 @@ __metadata: linkType: hard "@mantine/core@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/core@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/core@npm:5.10.4" dependencies: "@floating-ui/react": ^0.19.1 - "@mantine/styles": 5.10.3 - "@mantine/utils": 5.10.3 + "@mantine/styles": 5.10.4 + "@mantine/utils": 5.10.4 "@radix-ui/react-scroll-area": 1.0.2 react-textarea-autosize: 8.3.4 peerDependencies: - "@mantine/hooks": 5.10.3 + "@mantine/hooks": 5.10.4 react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 460e613e90ecd94a9414bb93a98046e076256fa54ec5cd0b43d20815755e7862826d912a516591d2a2e0356647d72b293564d2ccb9ca1fb8999da0e963d701bd + checksum: 98cba720fa9764a7a45b13d167ad2dce1af1b639c202a25cf60c8ca439e386dfddefae262e770df62348af90647ba6cbc095c074a8f0582968059450fbbc2ff0 languageName: node linkType: hard "@mantine/dates@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/dates@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/dates@npm:5.10.4" dependencies: - "@mantine/utils": 5.10.3 + "@mantine/utils": 5.10.4 peerDependencies: - "@mantine/core": 5.10.3 - "@mantine/hooks": 5.10.3 + "@mantine/core": 5.10.4 + "@mantine/hooks": 5.10.4 dayjs: ">=1.0.0" react: ">=16.8.0" - checksum: 80e22b0eb2235a373f0d35979bea7aeea671515962f4d119811145da7086f2258663ceb2d19a6ad23a02b6f08e1c53047778ec37cf559d8e81c9061d9f83a159 + checksum: 14a32aa4c16e030266629dfc5171e930d271682de742541b2298d448df11c9fab40e1d8003eabccfd5449a65ef14681993af6426197da4dcad1f509fb9fff932 languageName: node linkType: hard "@mantine/dropzone@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/dropzone@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/dropzone@npm:5.10.4" dependencies: - "@mantine/utils": 5.10.3 + "@mantine/utils": 5.10.4 react-dropzone: 14.2.3 peerDependencies: - "@mantine/core": 5.10.3 - "@mantine/hooks": 5.10.3 + "@mantine/core": 5.10.4 + "@mantine/hooks": 5.10.4 react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 550bf8a282220adb3f7564e2ffa7c617480a5ab9d2556c50072b614450ca1d7fc526e8755a635936c4cb76f9a090f91686f690b7e7704320972dd282e00ab1d6 + checksum: 0b837b5bc7c982ad9832142c880c1b0f2827de5c2163b76895cac2dbe6204f7c67e466468a3fb00f72482bad957c4b1209820025c4dfc2c25650734df79e208b languageName: node linkType: hard "@mantine/form@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/form@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/form@npm:5.10.4" dependencies: fast-deep-equal: ^3.1.3 klona: ^2.0.5 peerDependencies: react: ">=16.8.0" - checksum: 88bcf7b37d19e0648500e9da7111d961a3b130ef1164f4c4fc15f606cececdddad614887ff6659e0a0ce3896a4e52e62c2f72758f857e932e903cf412a69ca54 + checksum: 00ebc0011981f8dae5e96e833f7e0102067a7bc38b1f6208b377f029fdc8bb703a492f40a688eb015dea3147c3b6e5a7cd26c584b93cc8d2567f0dcb10319759 languageName: node linkType: hard "@mantine/hooks@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/hooks@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/hooks@npm:5.10.4" peerDependencies: react: ">=16.8.0" - checksum: 629554658e910dec1c14ecbdaf8e48c1ce2af022044269e73ff069d719a4f9b68428bb75ade5108ace40bd29253658e663c594a237e080226de28705c10c871e + checksum: 41ededb62ea9311303e4b8d577ec21c12ddb339e60e70c3a1f561cb1b2c66fb6e6f29a7a23a89322748779cc06797de749203afd546f2b13180781c0e2873fa6 languageName: node linkType: hard "@mantine/modals@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/modals@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/modals@npm:5.10.4" dependencies: - "@mantine/utils": 5.10.3 + "@mantine/utils": 5.10.4 peerDependencies: - "@mantine/core": 5.10.3 - "@mantine/hooks": 5.10.3 + "@mantine/core": 5.10.4 + "@mantine/hooks": 5.10.4 react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: b013eb3c68ee1c0f163c1bd4b786c59e4a7f77d596f175ef2a88b1922153c24a154f12a36b65138af778cdd47944869056df5beb921e5d07801f5246b19b8da9 + checksum: 82fce48fffbbce11526212b994e2763f0b36a276a86385f5b9ac1b5f1226f39f3f12aa171fe8b9181b755f95dbb869ef88f339e620bfd0ec28b7b0017f6b7fa7 languageName: node linkType: hard "@mantine/next@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/next@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/next@npm:5.10.4" dependencies: - "@mantine/ssr": 5.10.3 - "@mantine/styles": 5.10.3 + "@mantine/ssr": 5.10.4 + "@mantine/styles": 5.10.4 peerDependencies: next: "*" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 78ee3e7f7565318f40f367c8d53edfb164ae10c4fcc788b8792b27ae3f10fad81be8dbfaecd171fd66c43eafc50cc30ea5ab7f3da5421642b3a8c96d5a03d618 + checksum: 344590d09cfef4194187f7ea430082d9f22379e264b04fa3a9261a137abf9b1e4681eb7c610b678822b2ad3001f5611e201a73a4612ee8b13d93460d68a28b0c languageName: node linkType: hard "@mantine/notifications@npm:^5.9.3": - version: 5.10.3 - resolution: "@mantine/notifications@npm:5.10.3" + version: 5.10.4 + resolution: "@mantine/notifications@npm:5.10.4" dependencies: - "@mantine/utils": 5.10.3 + "@mantine/utils": 5.10.4 react-transition-group: 4.4.2 peerDependencies: - "@mantine/core": 5.10.3 - "@mantine/hooks": 5.10.3 + "@mantine/core": 5.10.4 + "@mantine/hooks": 5.10.4 react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: e4b735725fc7c0558cf0b084f677d432d6bd49220621ff20e32371621ba454a9d8b8eba0a36db7e99a0951cc174cb51b7ec91a9e127cba13330c93f3bcaecb10 + checksum: da439698331f09dd1f3efaa470df1f9717f7309c2a61b0bfaa14bd832185fbd0711fbaffe867b0d6978108db30d059909b59c2b7c0ed83d1bd55bc8b7a6d3e7f languageName: node linkType: hard -"@mantine/ssr@npm:5.10.3": - version: 5.10.3 - resolution: "@mantine/ssr@npm:5.10.3" +"@mantine/ssr@npm:5.10.4": + version: 5.10.4 + resolution: "@mantine/ssr@npm:5.10.4" dependencies: - "@mantine/styles": 5.10.3 + "@mantine/styles": 5.10.4 html-react-parser: 1.4.12 peerDependencies: "@emotion/react": ">=11.9.0" "@emotion/server": ">=11.4.0" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 8a6f0140f8fc853e3be8b0ec78908f71378ab76f36efe58ea2b1818139db60658bd12d3b1a56b9e61218262e85e2fbf9488393c3f0a64cb2d24125a27230397f + checksum: 74e5c56b85ea731597aba876941086306abb5ae24faa86d37a5210cfd743aa164e97b29e547563f810ef92b51b3c90bd02ce1a39fbee84cbec2abd2e6ad45ca7 languageName: node linkType: hard -"@mantine/styles@npm:5.10.3": - version: 5.10.3 - resolution: "@mantine/styles@npm:5.10.3" +"@mantine/styles@npm:5.10.4": + version: 5.10.4 + resolution: "@mantine/styles@npm:5.10.4" dependencies: clsx: 1.1.1 csstype: 3.0.9 @@ -1192,16 +1185,16 @@ __metadata: "@emotion/react": ">=11.9.0" react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 60fcfd5ccbfd7e1106a24a13c3f705ac4b995214ae038c2d8e7bf5e795cb7c1f5da9ee5e336caedf2a537e77272a05eb315e76df3edb4a466ec158ae6aeaf8b5 + checksum: 54af835dca68a457be758570c82eab2d602da19a24da599b2f2c02f451be136a400b2f8efbbdc7d1a677188b57515d3ace23df0b8aa8e37c4ddf3a2fdbce1630 languageName: node linkType: hard -"@mantine/utils@npm:5.10.3": - version: 5.10.3 - resolution: "@mantine/utils@npm:5.10.3" +"@mantine/utils@npm:5.10.4": + version: 5.10.4 + resolution: "@mantine/utils@npm:5.10.4" peerDependencies: react: ">=16.8.0" - checksum: 063237e49f3c52e3bbcd99e7a8383ac881718d9e1e039d32249231370a463a19e070cea7f90c3c0fea2933c3c162f5509a0f2add015db2aeb90f7945cd954a12 + checksum: 96e2602f8500c29b5979d4fe0b3456c8de911ff1bd2ef216d960b23a5370ff6828871aa859538a4004ad095fb63d7e0e76cdfb365bdb930f70f8076d730302c1 languageName: node linkType: hard @@ -2080,9 +2073,9 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:^18.11.18": - version: 18.13.0 - resolution: "@types/node@npm:18.13.0" - checksum: 4ea10f8802848b01672bce938f678b6774ca2cee0c9774f12275ab064ae07818419c3e2e41d6257ce7ba846d1ea26c63214aa1dfa4166fa3746291752b8c6416 + version: 18.14.0 + resolution: "@types/node@npm:18.14.0" + checksum: d83fcf5e4ed544755dd9028f5cbb6b9d46235043159111bb2ad62223729aee581c0144a9f6df8ba73d74011db9ed4ebd7af2fd5e0996714e3beb508a5da8ac5c languageName: node linkType: hard @@ -2674,7 +2667,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^0.27.2": +"axios@npm:0.27.2, axios@npm:^0.27.2": version: 0.27.2 resolution: "axios@npm:0.27.2" dependencies: @@ -3005,9 +2998,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001406, caniuse-lite@npm:^1.0.30001449": - version: 1.0.30001451 - resolution: "caniuse-lite@npm:1.0.30001451" - checksum: 48a06a7881093bb4d8a08ed5428f24a1cbdaa544b0a6f0c3614287d4f34b6c853e79a0f608a5bd901c27995f5e951825606fba11e7930251cc422bd61de9d849 + version: 1.0.30001456 + resolution: "caniuse-lite@npm:1.0.30001456" + checksum: c2cc479962149abd09a25b64699ee7484d9c433db2bad0a489f7b51b09a463c991f6efd7b8e201bc1a1ccf3294263f88503a3adf0a57db9046939ee7e58b76a6 languageName: node linkType: hard @@ -3198,6 +3191,13 @@ __metadata: languageName: node linkType: hard +"compare-versions@npm:5.0.1": + version: 5.0.1 + resolution: "compare-versions@npm:5.0.1" + checksum: 302a4e46224b47b9280cf894c6c87d8df912671fa391dcdbf0e63438d9b0a69fe20dd747fb439e8d54c43af016ff4eaaf0a4c9d8e7ca358bcd12dadf4ad2935e + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -3713,9 +3713,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.4.284": - version: 1.4.295 - resolution: "electron-to-chromium@npm:1.4.295" - checksum: 66fff1341d3c94c2ccd1f2a39cffdb92118304f4b949d3194427e7022d6a6bd8c482b5c4afd9dce210117ba20cac01c1a1466089f5a862fe9c563113b86ff829 + version: 1.4.302 + resolution: "electron-to-chromium@npm:1.4.302" + checksum: aa764494f9a5b6916ba9f311c0204b2c73449addba18cc55d43e84e8c4465732af9cd6560a8efeb32f3c5a928299030e41352e5b3a081e9e56b086d5be618f45 languageName: node linkType: hard @@ -3758,7 +3758,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^2.0.0": +"entities@npm:^2.0.0, entities@npm:^2.0.3": version: 2.2.0 resolution: "entities@npm:2.2.0" checksum: 19010dacaf0912c895ea262b4f6128574f9ccf8d4b3b65c7e8334ad0079b3706376360e28d8843ff50a78aabcb8f08f0a32dbfacdc77e47ed77ca08b713669b3 @@ -4101,13 +4101,13 @@ __metadata: linkType: hard "eslint-plugin-testing-library@npm:^5.5.1": - version: 5.10.1 - resolution: "eslint-plugin-testing-library@npm:5.10.1" + version: 5.10.2 + resolution: "eslint-plugin-testing-library@npm:5.10.2" dependencies: "@typescript-eslint/utils": ^5.43.0 peerDependencies: eslint: ^7.5.0 || ^8.0.0 - checksum: fbc24ce8cbd03bee283ae520f023bd52f10d21fb25bd66c5a09a0e0ad17997bae559450438ce0b4fcc38b4e14b6802f18482c41d2bc72bf1a8e1a12a91766572 + checksum: 3b2b330e62f4a6dc438050006f0d0c97605f6861828b153271dc6d2fafb1e60f4e86fbaa8166c7afd452e3b6cad39413738fd4c8e2eb2def1915c678154676da languageName: node linkType: hard @@ -4249,11 +4249,11 @@ __metadata: linkType: hard "esquery@npm:^1.4.0": - version: 1.4.0 - resolution: "esquery@npm:1.4.0" + version: 1.4.2 + resolution: "esquery@npm:1.4.2" dependencies: estraverse: ^5.1.0 - checksum: a0807e17abd7fbe5fbd4fab673038d6d8a50675cdae6b04fbaa520c34581be0c5fa24582990e8acd8854f671dd291c78bb2efb9e0ed5b62f33bac4f9cf820210 + checksum: 2f4ad89c5aafaca61cc2c15e256190f0d6deb4791cae6552d3cb4b1eb8867958cdf27a56aaa3272ff17435e3eaa19ee0d4129fac336ca6373d7354d7b5da7966 languageName: node linkType: hard @@ -4512,8 +4512,8 @@ __metadata: linkType: hard "framer-motion@npm:^9.0.2": - version: 9.0.2 - resolution: "framer-motion@npm:9.0.2" + version: 9.0.4 + resolution: "framer-motion@npm:9.0.4" dependencies: "@emotion/is-prop-valid": ^0.8.2 "@motionone/dom": ^10.15.3 @@ -4525,7 +4525,7 @@ __metadata: dependenciesMeta: "@emotion/is-prop-valid": optional: true - checksum: 5694d3a49acb9f753f0eed4a946c2b33efc237e0863b882cc4bed9a6022447ce2c9024134b5e9fd7b6f73602d9af046fa633c0f7235e8cbb24702b403299b55e + checksum: 9ef23d81c78785dd2f7f278bad133d96c22fd3a9dcc310521ea220873b0a923ca927dc2d3e8000e221edfcc5bfd6272f752435472e3a8f9b1ef476202ede1ce2 languageName: node linkType: hard @@ -4944,6 +4944,7 @@ __metadata: "@ctrl/transmission": ^4.1.1 "@emotion/react": ^11.10.5 "@emotion/server": ^11.10.0 + "@jellyfin/sdk": ^0.7.0 "@mantine/core": ^5.9.3 "@mantine/dates": ^5.9.3 "@mantine/dropzone": ^5.9.3 @@ -4985,6 +4986,7 @@ __metadata: eslint-plugin-unused-imports: ^2.0.0 fily-publish-gridstack: ^0.0.13 framer-motion: ^9.0.2 + html-entities: ^2.3.3 i18next: ^21.9.1 jest: ^28.1.3 js-file-download: ^0.4.12 @@ -4996,12 +4998,14 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 react-simple-code-editor: ^0.13.1 + rss-parser: ^3.12.0 sabnzbd-api: ^1.5.0 sass: ^1.56.1 turbo: ^1.7.4 typescript: ^4.7.4 uuid: ^8.3.2 video.js: ^8.0.3 + xml-js: ^1.6.11 yarn: ^1.22.19 zustand: ^4.1.4 languageName: unknown @@ -5017,6 +5021,13 @@ __metadata: languageName: node linkType: hard +"html-entities@npm:^2.3.3": + version: 2.3.3 + resolution: "html-entities@npm:2.3.3" + checksum: 92521501da8aa5f66fee27f0f022d6e9ceae62667dae93aa6a2f636afa71ad530b7fb24a18d4d6c124c9885970cac5f8a52dbf1731741161002816ae43f98196 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -6318,9 +6329,9 @@ __metadata: linkType: hard "lru-cache@npm:^7.7.1": - version: 7.14.1 - resolution: "lru-cache@npm:7.14.1" - checksum: d72c6713c6a6d86836a7a6523b3f1ac6764768cca47ec99341c3e76db06aacd4764620e5e2cda719a36848785a52a70e531822dc2b33fb071fa709683746c104 + version: 7.16.1 + resolution: "lru-cache@npm:7.16.1" + checksum: 64618e3ed4fd1203afedd9bbf5247921b1419f8e3100f20e58e5f04e741f8287bd7d04fefaad332411bb53b3a73445714b235de750cf5d310cba1fa23bd82795 languageName: node linkType: hard @@ -7656,6 +7667,16 @@ __metadata: languageName: node linkType: hard +"rss-parser@npm:^3.12.0": + version: 3.12.0 + resolution: "rss-parser@npm:3.12.0" + dependencies: + entities: ^2.0.3 + xml2js: ^0.4.19 + checksum: aa0f0eb2e3a5c70677a1c7cb6c2e96420f12c8963a8bed922ec2ff1bb9dbbb725fc5783be31ca8140154c3d5589ccd31580ced7d32ebd0dda7572f78ce242a41 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -7726,15 +7747,22 @@ __metadata: linkType: hard "sass@npm:^1.56.1": - version: 1.58.1 - resolution: "sass@npm:1.58.1" + version: 1.58.2 + resolution: "sass@npm:1.58.2" dependencies: chokidar: ">=3.0.0 <4.0.0" immutable: ^4.0.0 source-map-js: ">=0.6.2 <2.0.0" bin: sass: sass.js - checksum: ff079887d906b5c0dde99084d14ac36336d238c0c07935ff6381bad68f05de212c1ff12657ac1e8a0533523cd7a393126facdc2508d758e7d5700344a0e6ea51 + checksum: e0febe4d274af7b9490b9207ff7f05762d60df6b2ad307f7a823432cb4e1604eced6784ae635a6b80e4a6177c047f5a9623c53a15aaec3b9bf981ea86e8937a9 + languageName: node + linkType: hard + +"sax@npm:>=0.6.0, sax@npm:^1.2.4": + version: 1.2.4 + resolution: "sax@npm:1.2.4" + checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe languageName: node linkType: hard @@ -8166,9 +8194,9 @@ __metadata: linkType: hard "tabbable@npm:^6.0.1": - version: 6.0.1 - resolution: "tabbable@npm:6.0.1" - checksum: 65e378ad69a97416f2fdce34ade11b8ff68b33d9b2d978920a9d285c77e1bb88cb35113a8f00af8c4f0163d788d451a48840a216fa918d6a3f0c554951deb984 + version: 6.1.1 + resolution: "tabbable@npm:6.1.1" + checksum: 348639497262241ce8e0ccb0664ea582a386183107299ee8f27cf7b56bc84f36e09eaf667d3cb4201e789634012a91f7129bcbd49760abe874fbace35b4cf429 languageName: node linkType: hard @@ -8335,58 +8363,58 @@ __metadata: languageName: node linkType: hard -"turbo-darwin-64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-darwin-64@npm:1.7.4" +"turbo-darwin-64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-darwin-64@npm:1.8.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"turbo-darwin-arm64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-darwin-arm64@npm:1.7.4" +"turbo-darwin-arm64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-darwin-arm64@npm:1.8.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"turbo-linux-64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-linux-64@npm:1.7.4" +"turbo-linux-64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-linux-64@npm:1.8.0" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"turbo-linux-arm64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-linux-arm64@npm:1.7.4" +"turbo-linux-arm64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-linux-arm64@npm:1.8.0" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"turbo-windows-64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-windows-64@npm:1.7.4" +"turbo-windows-64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-windows-64@npm:1.8.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"turbo-windows-arm64@npm:1.7.4": - version: 1.7.4 - resolution: "turbo-windows-arm64@npm:1.7.4" +"turbo-windows-arm64@npm:1.8.0": + version: 1.8.0 + resolution: "turbo-windows-arm64@npm:1.8.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard "turbo@npm:^1.7.4": - version: 1.7.4 - resolution: "turbo@npm:1.7.4" + version: 1.8.0 + resolution: "turbo@npm:1.8.0" dependencies: - turbo-darwin-64: 1.7.4 - turbo-darwin-arm64: 1.7.4 - turbo-linux-64: 1.7.4 - turbo-linux-arm64: 1.7.4 - turbo-windows-64: 1.7.4 - turbo-windows-arm64: 1.7.4 + turbo-darwin-64: 1.8.0 + turbo-darwin-arm64: 1.8.0 + turbo-linux-64: 1.8.0 + turbo-linux-arm64: 1.8.0 + turbo-windows-64: 1.8.0 + turbo-windows-arm64: 1.8.0 dependenciesMeta: turbo-darwin-64: optional: true @@ -8402,7 +8430,7 @@ __metadata: optional: true bin: turbo: bin/turbo - checksum: c4387cfee36c57dd1490e9b2452888d8450faa390aec5bc148389b426bc079d03044891d61ad84a6a3a7454e66dfbe9e9cfba05f636b36fef7ece7af275ef9ce + checksum: 7f97068d7f9a155e088d3575b1f9922e68fa3015aae0c92625238d44b4e6c275bec2a281907702dedb402fca29a6cd4690499e916cb334d7c24c98099bc3d8b0 languageName: node linkType: hard @@ -8612,13 +8640,13 @@ __metadata: linkType: hard "v8-to-istanbul@npm:^9.0.1": - version: 9.0.1 - resolution: "v8-to-istanbul@npm:9.0.1" + version: 9.1.0 + resolution: "v8-to-istanbul@npm:9.1.0" dependencies: "@jridgewell/trace-mapping": ^0.3.12 "@types/istanbul-lib-coverage": ^2.0.1 convert-source-map: ^1.6.0 - checksum: a49c34bf0a3af0c11041a3952a2600913904a983bd1bc87148b5c033bc5c1d02d5a13620fcdbfa2c60bc582a2e2970185780f0c844b4c3a220abf405f8af6311 + checksum: 2069d59ee46cf8d83b4adfd8a5c1a90834caffa9f675e4360f1157ffc8578ef0f763c8f32d128334424159bb6b01f3876acd39cd13297b2769405a9da241f8d1 languageName: node linkType: hard @@ -8822,6 +8850,34 @@ __metadata: languageName: node linkType: hard +"xml-js@npm:^1.6.11": + version: 1.6.11 + resolution: "xml-js@npm:1.6.11" + dependencies: + sax: ^1.2.4 + bin: + xml-js: ./bin/cli.js + checksum: 24a55479919413687105fc2d8ab05e613ebedb1c1bc12258a108e07cff5ef793779297db854800a4edf0281303ebd1f177bc4a588442f5344e62b3dddda26c2b + languageName: node + linkType: hard + +"xml2js@npm:^0.4.19": + version: 0.4.23 + resolution: "xml2js@npm:0.4.23" + dependencies: + sax: ">=0.6.0" + xmlbuilder: ~11.0.0 + checksum: ca0cf2dfbf6deeaae878a891c8fbc0db6fd04398087084edf143cdc83d0509ad0fe199b890f62f39c4415cf60268a27a6aed0d343f0658f8779bd7add690fa98 + languageName: node + linkType: hard + +"xmlbuilder@npm:~11.0.0": + version: 11.0.1 + resolution: "xmlbuilder@npm:11.0.1" + checksum: 7152695e16f1a9976658215abab27e55d08b1b97bca901d58b048d2b6e106b5af31efccbdecf9b07af37c8377d8e7e821b494af10b3a68b0ff4ae60331b415b0 + languageName: node + linkType: hard + "xtend@npm:^4.0.0": version: 4.0.2 resolution: "xtend@npm:4.0.2" @@ -8874,8 +8930,8 @@ __metadata: linkType: hard "yargs@npm:^17.3.1": - version: 17.6.2 - resolution: "yargs@npm:17.6.2" + version: 17.7.0 + resolution: "yargs@npm:17.7.0" dependencies: cliui: ^8.0.1 escalade: ^3.1.1 @@ -8884,7 +8940,7 @@ __metadata: string-width: ^4.2.3 y18n: ^5.0.5 yargs-parser: ^21.1.1 - checksum: 47da1b0d854fa16d45a3ded57b716b013b2179022352a5f7467409da5a04a1eef5b3b3d97a2dfc13e8bbe5f2ffc0afe3bc6a4a72f8254e60f5a4bd7947138643 + checksum: e7d5f5b60e63b04ded7c27c3d4b194565565cac3ea19fffcdbb183bed973a83106822a04dda28ebba4811ce92949a9d9858d3935186ff8f343548bf98aab2120 languageName: node linkType: hard