diff --git a/index.html b/index.html index 1d1c3577..4555b17a 100644 --- a/index.html +++ b/index.html @@ -162,4 +162,4 @@ - \ No newline at end of file + diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index dc4d6ed1..bc042462 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -148,6 +148,7 @@ }, "media": { "episodeDisplay": "S{{season}} E{{episode}}", + "unreleased": "Unreleased", "types": { "movie": "Movie", "show": "Show" @@ -294,6 +295,7 @@ "enableSubtitles": "Enable Subtitles", "experienceSection": "Viewing experience", "playbackItem": "Playback settings", + "audioItem": "Audio", "qualityItem": "Quality", "sourceItem": "Video sources", "subtitleItem": "Subtitle settings", @@ -316,7 +318,7 @@ "unknownOption": "Unknown" }, "subtitles": { - "customChoice": "Select subtitle from file", + "customChoice": "Drop or upload file", "customizeLabel": "Customize", "offChoice": "Off", "settings": { @@ -325,7 +327,8 @@ "fixCapitals": "Fix capitalization" }, "title": "Subtitles", - "unknownLanguage": "Unknown" + "unknownLanguage": "Unknown", + "dropSubtitleFile": "Drop subtitle file here" } }, "metadata": { @@ -386,6 +389,13 @@ "homeButton": "Go home", "text": "We have searched through our providers and cannot find the media you are looking for! We do not host the media and have no control over what is available. Please click 'Show details' below for more details.", "title": "We couldn't find that" + }, + "extensionFailure": { + "badge": "Extension disabled", + "homeButton": "Go home", + "enableExtension": "Enable extension", + "title": "Please enable the extension", + "text": "You've installed the movie-web extension. To start using it, you need to enable the extension for this site." } }, "time": { diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index add1089e..3da79ac2 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -43,7 +43,7 @@ export function formatTMDBMetaResult( title: movie.title, object_type: mediaTypeToTMDB(type), poster: getMediaPoster(movie.poster_path) ?? undefined, - original_release_year: new Date(movie.release_date).getFullYear(), + original_release_date: new Date(movie.release_date), }; } if (type === MWMediaType.SERIES) { @@ -58,7 +58,7 @@ export function formatTMDBMetaResult( title: v.name, })), poster: getMediaPoster(show.poster_path) ?? undefined, - original_release_year: new Date(show.first_air_date).getFullYear(), + original_release_date: new Date(show.first_air_date), }; } diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index b143b312..67c7d56f 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -66,7 +66,7 @@ export function formatTMDBMeta( return { title: media.title, id: media.id.toString(), - year: media.original_release_year?.toString(), + year: media.original_release_date?.getFullYear()?.toString(), poster: media.poster, type, seasons: seasons as any, @@ -94,7 +94,8 @@ export function formatTMDBMetaToMediaItem(media: TMDBMediaResult): MediaItem { return { title: media.title, id: media.id.toString(), - year: media.original_release_year ?? 0, + year: media.original_release_date?.getFullYear() ?? 0, + release_date: media.original_release_date, poster: media.poster, type, }; @@ -260,7 +261,7 @@ export function formatTMDBSearchResult( title: show.name, poster: getMediaPoster(show.poster_path), id: show.id, - original_release_year: new Date(show.first_air_date).getFullYear(), + original_release_date: new Date(show.first_air_date), object_type: mediatype, }; } @@ -271,7 +272,7 @@ export function formatTMDBSearchResult( title: movie.title, poster: getMediaPoster(movie.poster_path), id: movie.id, - original_release_year: new Date(movie.release_date).getFullYear(), + original_release_date: new Date(movie.release_date), object_type: mediatype, }; } diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts index 1071d96c..5d082f55 100644 --- a/src/backend/metadata/types/tmdb.ts +++ b/src/backend/metadata/types/tmdb.ts @@ -20,7 +20,7 @@ export type TMDBMediaResult = { title: string; poster?: string; id: number; - original_release_year?: number; + original_release_date?: Date; object_type: TMDBContentTypes; seasons?: TMDBSeasonShort[]; }; diff --git a/src/components/DropFile.tsx b/src/components/DropFile.tsx new file mode 100644 index 00000000..8b0ab84e --- /dev/null +++ b/src/components/DropFile.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import type { DragEvent, ReactNode } from "react"; + +interface FileDropHandlerProps { + children: ReactNode; + className: string; + onDrop: (event: DragEvent) => void; + onDraggingChange: (isDragging: boolean) => void; +} + +export function FileDropHandler(props: FileDropHandlerProps) { + const [dragging, setDragging] = useState(false); + + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + setDragging(true); + }; + + const handleDragLeave = (event: DragEvent) => { + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + setDragging(false); + } + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + + props.onDrop(event); + }; + + useEffect(() => { + props.onDraggingChange(dragging); + }, [dragging, props]); + + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index ec5e26cb..500b408a 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -64,6 +64,7 @@ export enum Icons { DONATION = "donation", CIRCLE_QUESTION = "circle_question", BRUSH = "brush", + UPLOAD = "upload", } export interface IconProps { @@ -134,6 +135,7 @@ const iconList: Record = { donation: ``, circle_question: ``, brush: ``, + upload: ``, }; function ChromeCastButton() { diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index cad3ae6a..17ada085 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,4 +1,5 @@ import classNames from "classnames"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -24,6 +25,20 @@ export interface MediaCardProps { onClose?: () => void; } +function checkReleased(media: MediaItem): boolean { + const isReleasedYear = Boolean( + media.year && media.year <= new Date().getFullYear(), + ); + const isReleasedDate = Boolean( + media.release_date && media.release_date <= new Date(), + ); + + // If the media has a release date, use that, otherwise use the year + const isReleased = media.release_date ? isReleasedDate : isReleasedYear; + + return isReleased; +} + function MediaCardContent({ media, linkable, @@ -35,10 +50,19 @@ function MediaCardContent({ const { t } = useTranslation(); const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; - const canLink = linkable && !closable; + const isReleased = useCallback(() => checkReleased(media), [media]); + + const canLink = linkable && !closable && isReleased(); const dotListContent = [t(`media.types.${media.type}`)]; - if (media.year) dotListContent.push(media.year.toFixed()); + + if (media.year) { + dotListContent.push(media.year.toFixed()); + } + + if (!isReleased()) { + dotListContent.push(t("media.unreleased")); + } return (
; - const canLink = props.linkable && !props.closable; + const isReleased = useCallback( + () => checkReleased(props.media), + [props.media], + ); + + const canLink = props.linkable && !props.closable && isReleased(); let link = canLink ? `/media/${encodeURIComponent(mediaItemToId(props.media))}` @@ -157,7 +186,7 @@ export function MediaCard(props: MediaCardProps) { } } - if (!props.linkable) return {content}; + if (!canLink) return {content}; return ( + + + + + diff --git a/src/components/player/atoms/settings/AudioView.tsx b/src/components/player/atoms/settings/AudioView.tsx new file mode 100644 index 00000000..a2c9c7f6 --- /dev/null +++ b/src/components/player/atoms/settings/AudioView.tsx @@ -0,0 +1,65 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { FlagIcon } from "@/components/FlagIcon"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { AudioTrack } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; +import { getPrettyLanguageNameFromLocale } from "@/utils/language"; + +import { SelectableLink } from "../../internals/ContextMenu/Links"; + +export function AudioOption(props: { + langCode?: string; + children: React.ReactNode; + selected?: boolean; + onClick?: () => void; +}) { + return ( + + + + + + {props.children} + + + ); +} + +export function AudioView({ id }: { id: string }) { + const { t } = useTranslation(); + const unknownChoice = t("player.menus.subtitles.unknownLanguage"); + + const router = useOverlayRouter(id); + const audioTracks = usePlayerStore((s) => s.audioTracks); + const currentAudioTrack = usePlayerStore((s) => s.currentAudioTrack); + const changeAudioTrack = usePlayerStore((s) => s.display?.changeAudioTrack); + + const change = useCallback( + (track: AudioTrack) => { + changeAudioTrack?.(track); + router.close(); + }, + [router, changeAudioTrack], + ); + + return ( + <> + router.navigate("/")}>Audio + + {audioTracks.map((v) => ( + change(v) : undefined} + > + {getPrettyLanguageNameFromLocale(v.language) ?? unknownChoice} + + ))} + + + ); +} diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 8524ecc8..035567e2 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -1,11 +1,14 @@ +import classNames from "classnames"; import Fuse from "fuse.js"; -import { useMemo, useRef, useState } from "react"; +import { type DragEvent, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsyncFn } from "react-use"; import { convert } from "subsrt-ts"; import { subtitleTypeList } from "@/backend/helpers/subs"; +import { FileDropHandler } from "@/components/DropFile"; import { FlagIcon } from "@/components/FlagIcon"; +import { Icon, Icons } from "@/components/Icon"; import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; import { Input } from "@/components/player/internals/ContextMenu/Input"; @@ -123,6 +126,34 @@ export function CaptionsView({ id }: { id: string }) { const { selectCaptionById, disable } = useCaptions(); const captionList = usePlayerStore((s) => s.captionList); const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList); + const [dragging, setDragging] = useState(false); + const setCaption = usePlayerStore((s) => s.setCaption); + + function onDrop(event: DragEvent) { + const files = event.dataTransfer.files; + const firstFile = files[0]; + if (!files || !firstFile) return; + + const fileExtension = `.${firstFile.name.split(".").pop()}`; + if (!fileExtension || !subtitleTypeList.includes(fileExtension)) { + return; + } + + const reader = new FileReader(); + reader.addEventListener("load", (e) => { + if (!e.target || typeof e.target.result !== "string") return; + + const converted = convert(e.target.result, "srt"); + + setCaption({ + language: "custom", + srtData: converted, + id: "custom-caption", + }); + }); + + reader.readAsText(firstFile); + } const captions = useMemo( () => @@ -164,6 +195,20 @@ export function CaptionsView({ id }: { id: string }) { return ( <>
+
+
+ + + {t("player.menus.subtitles.dropSubtitleFile")} + +
+
+ router.navigate("/")} rightSide={ @@ -178,17 +223,28 @@ export function CaptionsView({ id }: { id: string }) { > {t("player.menus.subtitles.title")} +
+ { + setDragging(isDragging); + }} + onDrop={(event) => onDrop(event)} + >
-
- - disable()} selected={!selectedCaptionId}> - {t("player.menus.subtitles.offChoice")} - - - {content} - + + disable()} + selected={!selectedCaptionId} + > + {t("player.menus.subtitles.offChoice")} + + + {content} + + ); } diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 8321c562..8198d87b 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -16,6 +16,7 @@ export function SettingsMenu({ id }: { id: string }) { const { t } = useTranslation(); const router = useOverlayRouter(id); const currentQuality = usePlayerStore((s) => s.currentQuality); + const currentAudioTrack = usePlayerStore((s) => s.currentAudioTrack); const selectedCaptionLanguage = usePlayerStore( (s) => s.caption.selected?.language, ); @@ -35,6 +36,11 @@ export function SettingsMenu({ id }: { id: string }) { t("player.menus.subtitles.unknownLanguage") : undefined; + const selectedAudioLanguagePretty = currentAudioTrack + ? getPrettyLanguageNameFromLocale(currentAudioTrack.language) ?? + t("player.menus.subtitles.unknownLanguage") + : undefined; + const source = usePlayerStore((s) => s.source); const downloadable = source?.type === "file" || source?.type === "hls"; @@ -51,6 +57,15 @@ export function SettingsMenu({ id }: { id: string }) { > {t("player.menus.settings.qualityItem")} + {currentAudioTrack && ( + router.navigate("/audio")} + rightText={selectedAudioLanguagePretty ?? undefined} + > + {t("player.menus.settings.audioItem")} + + )} + router.navigate("/source")} rightText={sourceName} diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 51e6d7bb..8e155f69 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -81,6 +81,24 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { emit("qualities", convertedLevels); } + function reportAudioTracks() { + if (!hls) return; + const currentTrack = hls.audioTracks[hls.audioTrack]; + emit("changedaudiotrack", { + id: currentTrack.id.toString(), + label: currentTrack.name, + language: currentTrack.lang ?? "unknown", + }); + emit( + "audiotracks", + hls.audioTracks.map((v) => ({ + id: v.id.toString(), + label: v.name, + language: v.lang ?? "unknown", + })), + ); + } + function setupQualityForHls() { if (videoElement && canPlayHlsNatively(videoElement)) { return; // nothing to change @@ -155,6 +173,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { if (!hls) return; reportLevels(); setupQualityForHls(); + reportAudioTracks(); if (isExtensionActiveCached()) { hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => { @@ -464,5 +483,18 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { hls?.setSubtitleOption({ lang }); return promise; }, + changeAudioTrack(track) { + if (!hls) return; + const audioTrack = hls?.audioTracks.find( + (t) => t.id.toString() === track.id, + ); + if (!audioTrack) return; + hls.audioTrack = hls.audioTracks.indexOf(audioTrack); + emit("changedaudiotrack", { + id: audioTrack.id.toString(), + label: audioTrack.name, + language: audioTrack.lang ?? "unknown", + }); + }, }; } diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts index 1a318f16..48f8b2ab 100644 --- a/src/components/player/display/chromecast.ts +++ b/src/components/player/display/chromecast.ts @@ -283,5 +283,8 @@ export function makeChromecastDisplayInterface( async setSubtitlePreference() { return Promise.resolve(); }, + changeAudioTrack() { + // cant change audio tracks + }, }; } diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 134bef44..2f17aaed 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -1,7 +1,7 @@ import { MediaPlaylist } from "hls.js"; import { MWMediaType } from "@/backend/metadata/types/mw"; -import { CaptionListItem } from "@/stores/player/slices/source"; +import { AudioTrack, CaptionListItem } from "@/stores/player/slices/source"; import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; import { Listener } from "@/utils/events"; @@ -25,6 +25,8 @@ export type DisplayInterfaceEvents = { loading: boolean; qualities: SourceQuality[]; changedquality: SourceQuality | null; + audiotracks: AudioTrack[]; + changedaudiotrack: AudioTrack | null; needstrack: boolean; canairplay: boolean; playbackrate: number; @@ -60,6 +62,7 @@ export interface DisplayInterface extends Listener { automaticQuality: boolean, preferredQuality: SourceQuality | null, ): void; + changeAudioTrack(audioTrack: AudioTrack): void; processVideoElement(video: HTMLVideoElement): void; processContainerElement(container: HTMLElement): void; toggleFullscreen(): void; diff --git a/src/pages/onboarding/OnboardingExtension.tsx b/src/pages/onboarding/OnboardingExtension.tsx index db351dda..66e662e2 100644 --- a/src/pages/onboarding/OnboardingExtension.tsx +++ b/src/pages/onboarding/OnboardingExtension.tsx @@ -2,8 +2,7 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useAsyncFn, useInterval } from "react-use"; -import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; -import { extensionInfo, sendPage } from "@/backend/extension/messaging"; +import { sendPage } from "@/backend/extension/messaging"; import { Button } from "@/components/buttons/Button"; import { Icon, Icons } from "@/components/Icon"; import { Loading } from "@/components/layout/Loading"; @@ -22,24 +21,8 @@ import { ExtensionDetectionResult, detectExtensionInstall, } from "@/utils/detectFeatures"; - -type ExtensionStatus = - | "unknown" - | "failed" - | "disallowed" - | "noperms" - | "outdated" - | "success"; - -async function getExtensionState(): Promise { - const info = await extensionInfo(); - if (!info) return "unknown"; // cant talk to extension - if (!info.success) return "failed"; // extension failed to respond - if (!info.allowed) return "disallowed"; // extension is not enabled on this page - if (!info.hasPermission) return "noperms"; // extension has no perms to do it's tasks - if (!isAllowedExtensionVersion(info.version)) return "outdated"; // extension is too old - return "success"; // no problems -} +import { getExtensionState } from "@/utils/extension"; +import type { ExtensionStatus } from "@/utils/extension"; function RefreshBar() { const { t } = useTranslation(); diff --git a/src/pages/parts/admin/WorkerTestPart.tsx b/src/pages/parts/admin/WorkerTestPart.tsx index 5f8b9853..bb68fced 100644 --- a/src/pages/parts/admin/WorkerTestPart.tsx +++ b/src/pages/parts/admin/WorkerTestPart.tsx @@ -83,10 +83,12 @@ export function WorkerTestPart() { status: "success", }); } catch (err) { + const error = err as Error; + error.message = error.message.replace(worker.url, "WORKER_URL"); updateWorker(worker.id, { id: worker.id, status: "error", - error: err as Error, + error, }); } }); diff --git a/src/pages/parts/player/ScrapeErrorPart.tsx b/src/pages/parts/player/ScrapeErrorPart.tsx index 127a69a6..bc1f78e6 100644 --- a/src/pages/parts/player/ScrapeErrorPart.tsx +++ b/src/pages/parts/player/ScrapeErrorPart.tsx @@ -1,7 +1,8 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; +import { useEffect, useMemo, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; +import { sendPage } from "@/backend/extension/messaging"; import { Button } from "@/components/buttons/Button"; import { Icons } from "@/components/Icon"; import { IconPill } from "@/components/layout/IconPill"; @@ -10,6 +11,8 @@ import { Paragraph } from "@/components/text/Paragraph"; import { Title } from "@/components/text/Title"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; +import { getExtensionState } from "@/utils/extension"; +import type { ExtensionStatus } from "@/utils/extension"; import { getProviderApiUrls } from "@/utils/proxyUrls"; import { ErrorCardInModal } from "../errors/ErrorCard"; @@ -25,6 +28,8 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) { const { t } = useTranslation(); const modal = useModal("error"); const location = useLocation(); + const [extensionState, setExtensionState] = + useState("unknown"); const error = useMemo(() => { const data = props.data; @@ -42,6 +47,58 @@ export function ScrapeErrorPart(props: ScrapeErrorPartProps) { return str; }, [props, location]); + useEffect(() => { + getExtensionState().then((state: ExtensionStatus) => { + setExtensionState(state); + }); + }, [t]); + + if (extensionState === "disallowed") { + return ( + + + + {t("player.scraping.extensionFailure.badge")} + + {t("player.scraping.extensionFailure.title")} + + + ), + }} + /> + +
+ + +
+
+
+ ); + } + return ( diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts index 86743ccd..63403376 100644 --- a/src/stores/player/slices/display.ts +++ b/src/stores/player/slices/display.ts @@ -75,6 +75,16 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({ s.currentQuality = quality; }); }); + newDisplay.on("audiotracks", (audioTracks) => { + set((s) => { + s.audioTracks = audioTracks; + }); + }); + newDisplay.on("changedaudiotrack", (audioTrack) => { + set((s) => { + s.currentAudioTrack = audioTrack; + }); + }); newDisplay.on("needstrack", (needsTrack) => { set((s) => { s.caption.asTrack = needsTrack; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 5d04ef49..eb2ce9e1 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -56,12 +56,20 @@ export interface CaptionListItem { hls?: boolean; } +export interface AudioTrack { + id: string; + label: string; + language: string; +} + export interface SourceSlice { status: PlayerStatus; source: SourceSliceSource | null; sourceId: string | null; qualities: SourceQuality[]; + audioTracks: AudioTrack[]; currentQuality: SourceQuality | null; + currentAudioTrack: AudioTrack | null; captionList: CaptionListItem[]; caption: { selected: Caption | null; @@ -109,8 +117,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ source: null, sourceId: null, qualities: [], + audioTracks: [], captionList: [], currentQuality: null, + currentAudioTrack: null, status: playerStatus.IDLE, meta: null, caption: { diff --git a/src/utils/extension.ts b/src/utils/extension.ts new file mode 100644 index 00000000..8874146b --- /dev/null +++ b/src/utils/extension.ts @@ -0,0 +1,20 @@ +import { isAllowedExtensionVersion } from "@/backend/extension/compatibility"; +import { extensionInfo } from "@/backend/extension/messaging"; + +export type ExtensionStatus = + | "unknown" + | "failed" + | "disallowed" + | "noperms" + | "outdated" + | "success"; + +export async function getExtensionState(): Promise { + const info = await extensionInfo(); + if (!info) return "unknown"; // cant talk to extension + if (!info.success) return "failed"; // extension failed to respond + if (!info.allowed) return "disallowed"; // extension is not enabled on this page + if (!info.hasPermission) return "noperms"; // extension has no perms to do it's tasks + if (!isAllowedExtensionVersion(info.version)) return "outdated"; // extension is too old + return "success"; // no problems +} diff --git a/src/utils/mediaTypes.ts b/src/utils/mediaTypes.ts index f577ca5f..c81b1ac0 100644 --- a/src/utils/mediaTypes.ts +++ b/src/utils/mediaTypes.ts @@ -2,6 +2,7 @@ export interface MediaItem { id: string; title: string; year?: number; + release_date?: Date; poster?: string; type: "show" | "movie"; }