error handling for video + bookmark migration + last watched episode shown + progress migrations

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-30 21:29:02 +01:00
parent 4c43208deb
commit 023a850e4f
15 changed files with 269 additions and 10 deletions

View file

@ -14,8 +14,9 @@ export interface WatchedMediaCardProps {
function formatSeries(obj: ProgressMediaItem | undefined) { function formatSeries(obj: ProgressMediaItem | undefined) {
if (!obj) return undefined; if (!obj) return undefined;
if (obj.type !== "show") return; if (obj.type !== "show") return;
// TODO only show latest episode watched const ep = Object.values(obj.episodes).sort(
const ep = Object.values(obj.episodes)[0]; (a, b) => b.updatedAt - a.updatedAt
)[0];
const season = obj.seasons[ep?.seasonId]; const season = obj.seasons[ep?.seasonId];
if (!ep || !season) return; if (!ep || !season) return;
return { return {

View file

@ -6,6 +6,7 @@ import {
DisplayInterfaceEvents, DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface"; } from "@/components/player/display/displayInterface";
import { handleBuffered } from "@/components/player/utils/handleBuffered"; import { handleBuffered } from "@/components/player/utils/handleBuffered";
import { getMediaErrorDetails } from "@/components/player/utils/mediaErrorDetails";
import { import {
LoadableSource, LoadableSource,
SourceQuality, SourceQuality,
@ -119,9 +120,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
console.error("HLS error", data); console.error("HLS error", data);
if (data.fatal) { if (data.fatal) {
throw new Error( emit("error", {
`HLS ERROR:${data.error?.message ?? "Something went wrong"}` message: data.error.message,
); stackTrace: data.error.stack,
errorName: data.error.name,
type: "hls",
});
} }
}); });
hls.on(Hls.Events.MANIFEST_LOADED, () => { hls.on(Hls.Events.MANIFEST_LOADED, () => {
@ -154,6 +158,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
emit("play", undefined); emit("play", undefined);
emit("loading", false); emit("loading", false);
}); });
videoElement.addEventListener("error", () => {
const err = videoElement?.error ?? null;
const errorDetails = getMediaErrorDetails(err);
emit("error", {
errorName: errorDetails.name,
message: errorDetails.message,
type: "htmlvideo",
});
});
videoElement.addEventListener("playing", () => emit("play", undefined)); videoElement.addEventListener("playing", () => emit("play", undefined));
videoElement.addEventListener("pause", () => emit("pause", undefined)); videoElement.addEventListener("pause", () => emit("pause", undefined));
videoElement.addEventListener("canplay", () => emit("loading", false)); videoElement.addEventListener("canplay", () => emit("loading", false));

View file

@ -1,6 +1,14 @@
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
import { Listener } from "@/utils/events"; import { Listener } from "@/utils/events";
export type DisplayErrorType = "hls" | "htmlvideo";
export type DisplayError = {
stackTrace?: string;
message: string;
errorName: string;
type: DisplayErrorType;
};
export type DisplayInterfaceEvents = { export type DisplayInterfaceEvents = {
play: void; play: void;
pause: void; pause: void;
@ -15,6 +23,7 @@ export type DisplayInterfaceEvents = {
needstrack: boolean; needstrack: boolean;
canairplay: boolean; canairplay: boolean;
playbackrate: number; playbackrate: number;
error: DisplayError;
}; };
export interface qualityChangeOptions { export interface qualityChangeOptions {

View file

@ -0,0 +1,36 @@
const mediaErrorMap: Record<number, { name: string; message: string }> = {
1: {
name: "MEDIA_ERR_ABORTED",
message:
"The fetching of the associated resource was aborted by the user's request.",
},
2: {
name: "MEDIA_ERR_NETWORK",
message:
"Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
},
3: {
name: "MEDIA_ERR_DECODE",
message:
"Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.",
},
4: {
name: "MEDIA_ERR_SRC_NOT_SUPPORTED",
message:
"The associated resource or media provider object has been found to be unsuitable.",
},
};
export function getMediaErrorDetails(err: MediaError | null): {
name: string;
message: string;
} {
const item = mediaErrorMap[err?.code ?? -1];
if (!item) {
return {
name: "MediaError",
message: "Unknown media error occured",
};
}
return item;
}

View file

@ -10,6 +10,7 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou
import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape";
import { useQueryParam } from "@/hooks/useQueryParams"; import { useQueryParam } from "@/hooks/useQueryParams";
import { MetaPart } from "@/pages/parts/player/MetaPart"; import { MetaPart } from "@/pages/parts/player/MetaPart";
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
@ -108,6 +109,7 @@ export function PlayerView() {
{status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? (
<ScrapeErrorPart data={errorData} /> <ScrapeErrorPart data={errorData} />
) : null} ) : null}
{status === playerStatus.PLAYBACK_ERROR ? <PlaybackErrorPart /> : null}
</PlayerPart> </PlayerPart>
); );
} }

View file

@ -5,6 +5,7 @@ import { Dropdown } from "@/components/Dropdown";
import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayer } from "@/components/player/hooks/usePlayer";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { TextInputControl } from "@/components/text-inputs/TextInputControl"; import { TextInputControl } from "@/components/text-inputs/TextInputControl";
import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities"; import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities";
@ -105,6 +106,7 @@ export default function VideoTesterView() {
</div> </div>
</div> </div>
) : null} ) : null}
{status === playerStatus.PLAYBACK_ERROR ? <PlaybackErrorPart /> : null}
</PlayerPart> </PlayerPart>
); );
} }

View file

@ -0,0 +1,56 @@
import { Button } from "@/components/Button";
import { Icon, Icons } from "@/components/Icon";
import { IconPill } from "@/components/layout/IconPill";
import { Paragraph } from "@/components/text/Paragraph";
import { Title } from "@/components/text/Title";
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
import { usePlayerStore } from "@/stores/player/store";
export function PlaybackErrorPart() {
const playbackError = usePlayerStore((s) => s.interface.error);
return (
<ErrorLayout>
<ErrorContainer>
<IconPill icon={Icons.WAND}>Not found</IconPill>
<Title>Goo goo gaa gaa</Title>
<Paragraph>
Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost
bestest, but alas, no wucky videos to be spotted anywhere (´ω`)
Please don&apos;t be angwy, wittle movie-web ish twying so hard. Can
you find it in your heart to forgive? UwU 💖
</Paragraph>
<Button
href="/"
theme="purple"
padding="md:px-12 p-2.5"
className="mt-6"
>
Go home
</Button>
</ErrorContainer>
<ErrorContainer maxWidth="max-w-[45rem]">
{/* Error */}
{playbackError ? (
<div className="w-full bg-errors-card p-6 rounded-lg">
<div className="flex justify-between items-center pb-2 border-b border-errors-border">
<span className="text-white font-medium">Error details</span>
<div className="flex justify-center items-center gap-3">
<Button theme="secondary" padding="p-2 md:px-4">
<Icon icon={Icons.COPY} className="text-2xl mr-3" />
Copy
</Button>
<Button theme="secondary" padding="p-2 md:px-2">
<Icon icon={Icons.X} className="text-2xl" />
</Button>
</div>
</div>
<div className="mt-4 h-60 overflow-y-auto text-left whitespace-pre pointer-events-auto">
{playbackError.message}
</div>
</div>
) : null}
</ErrorContainer>
</ErrorLayout>
);
}

View file

@ -1,9 +1,17 @@
import { MWMediaType } from "@/backend/metadata/types/mw";
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
import { createVersionedStore } from "@/utils/storage"; import { createVersionedStore } from "@/utils/storage";
import { BookmarkStoreData } from "./types"; import { BookmarkStoreData } from "./types";
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2"; import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
import { migrateV2Bookmarks } from "../watched/migrations/v3"; import { migrateV2Bookmarks } from "../watched/migrations/v3";
const typeMap: Record<MWMediaType, "show" | "movie" | null> = {
[MWMediaType.ANIME]: null,
[MWMediaType.MOVIE]: "movie",
[MWMediaType.SERIES]: "show",
};
export const BookmarkStore = createVersionedStore<BookmarkStoreData>() export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
.setKey("mw-bookmarks") .setKey("mw-bookmarks")
.addVersion({ .addVersion({
@ -20,6 +28,28 @@ export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
}) })
.addVersion({ .addVersion({
version: 2, version: 2,
migrate(old: BookmarkStoreData): BookmarkStoreData {
const newItems: Record<string, BookmarkMediaItem> = {};
for (const oldBookmark of old.bookmarks) {
const type = typeMap[oldBookmark.type];
if (!type) continue;
newItems[oldBookmark.id] = {
title: oldBookmark.title,
year: oldBookmark.year ? Number(oldBookmark.year) : undefined,
poster: oldBookmark.poster,
type,
updatedAt: Date.now(),
};
}
useBookmarkStore.getState().replaceBookmarks(newItems);
return { bookmarks: [] };
},
})
.addVersion({
version: 3,
create() { create() {
return { return {
bookmarks: [], bookmarks: [],

View file

@ -1,3 +1,5 @@
import { MWMediaType } from "@/backend/metadata/types/mw";
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
import { createVersionedStore } from "@/utils/storage"; import { createVersionedStore } from "@/utils/storage";
import { OldData, migrateV2Videos } from "./migrations/v2"; import { OldData, migrateV2Videos } from "./migrations/v2";
@ -28,6 +30,93 @@ export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
}) })
.addVersion({ .addVersion({
version: 3, version: 3,
migrate(old: WatchedStoreData): WatchedStoreData {
console.log(old);
// Convert items
const newItems: Record<string, ProgressMediaItem> = {};
for (const oldItem of old.items) {
if (oldItem.item.meta.type === MWMediaType.SERIES) {
// Upsert
if (!newItems[oldItem.item.meta.id]) {
newItems[oldItem.item.meta.id] = {
type: "show",
episodes: {},
seasons: {},
title: oldItem.item.meta.title,
updatedAt: oldItem.watchedAt,
poster: oldItem.item.meta.poster,
year: Number(oldItem.item.meta.year),
};
}
// Add episodes
if (
oldItem.item.series &&
!newItems[oldItem.item.meta.id].episodes[
oldItem.item.series.episodeId
]
) {
// Find episode ID (barely ever works)
const episodeTitle = oldItem.item.meta.seasonData.episodes.find(
(ep) => ep.id === oldItem.item.series?.episodeId
)?.title;
// Add season to season data
newItems[oldItem.item.meta.id].seasons[
oldItem.item.series.seasonId
] = {
id: oldItem.item.series.seasonId,
number: oldItem.item.series.season,
title:
oldItem.item.meta.seasons.find(
(s) => s.number === oldItem.item.series?.season
)?.title || "Unknown season",
};
// Populate episode data
newItems[oldItem.item.meta.id].episodes[
oldItem.item.series.episodeId
] = {
title: episodeTitle || "Unknown",
id: oldItem.item.series.episodeId,
number: oldItem.item.series.episode,
seasonId: oldItem.item.series.seasonId,
updatedAt: oldItem.watchedAt,
progress: {
duration: (100 / oldItem.percentage) * oldItem.progress,
watched: oldItem.progress,
},
};
}
} else {
newItems[oldItem.item.meta.id] = {
type: "movie",
episodes: {},
seasons: {},
title: oldItem.item.meta.title,
updatedAt: oldItem.watchedAt,
year: Number(oldItem.item.meta.year),
poster: oldItem.item.meta.poster,
progress: {
duration: (100 / oldItem.percentage) * oldItem.progress,
watched: oldItem.progress,
},
};
}
}
console.log(newItems);
useProgressStore.getState().replaceItems(newItems);
return {
items: [],
};
},
})
.addVersion({
version: 4,
create() { create() {
return { return {
items: [], items: [],

View file

@ -6,7 +6,7 @@ import { PlayerMeta } from "@/stores/player/slices/source";
export interface BookmarkMediaItem { export interface BookmarkMediaItem {
title: string; title: string;
year: number; year?: number;
poster?: string; poster?: string;
type: "show" | "movie"; type: "show" | "movie";
updatedAt: number; updatedAt: number;
@ -16,9 +16,9 @@ export interface ProgressStore {
bookmarks: Record<string, BookmarkMediaItem>; bookmarks: Record<string, BookmarkMediaItem>;
addBookmark(meta: PlayerMeta): void; addBookmark(meta: PlayerMeta): void;
removeBookmark(id: string): void; removeBookmark(id: string): void;
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
} }
// TODO add migration from previous bookmark store
export const useBookmarkStore = create( export const useBookmarkStore = create(
persist( persist(
immer<ProgressStore>((set) => ({ immer<ProgressStore>((set) => ({
@ -39,6 +39,11 @@ export const useBookmarkStore = create(
}; };
}); });
}, },
replaceBookmarks(items: Record<string, BookmarkMediaItem>) {
set((s) => {
s.bookmarks = items;
});
},
})), })),
{ {
name: "__MW::bookmarks", name: "__MW::bookmarks",

View file

@ -90,6 +90,12 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
s.mediaPlaying.playbackRate = rate; s.mediaPlaying.playbackRate = rate;
}); });
}); });
newDisplay.on("error", (err) => {
set((s) => {
s.status = playerStatus.PLAYBACK_ERROR;
s.interface.error = err;
});
});
set((s) => { set((s) => {
s.display = newDisplay; s.display = newDisplay;

View file

@ -1,3 +1,4 @@
import { DisplayError } from "@/components/player/display/displayInterface";
import { MakeSlice } from "@/stores/player/slices/types"; import { MakeSlice } from "@/stores/player/slices/types";
export enum VideoPlayerTimeFormat { export enum VideoPlayerTimeFormat {
@ -23,6 +24,7 @@ export interface InterfaceSlice {
isCasting: boolean; isCasting: boolean;
hideNextEpisodeBtn: boolean; hideNextEpisodeBtn: boolean;
shouldStartFromBeginning: boolean; shouldStartFromBeginning: boolean;
error?: DisplayError;
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently? volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"

View file

@ -14,6 +14,7 @@ export const playerStatus = {
SCRAPING: "scraping", SCRAPING: "scraping",
PLAYING: "playing", PLAYING: "playing",
SCRAPE_NOT_FOUND: "scrapeNotFound", SCRAPE_NOT_FOUND: "scrapeNotFound",
PLAYBACK_ERROR: "playbackError",
} as const; } as const;
export type PlayerStatus = ValuesOf<typeof playerStatus>; export type PlayerStatus = ValuesOf<typeof playerStatus>;

View file

@ -20,12 +20,13 @@ export interface ProgressEpisodeItem {
number: number; number: number;
id: string; id: string;
seasonId: string; seasonId: string;
updatedAt: number;
progress: ProgressItem; progress: ProgressItem;
} }
export interface ProgressMediaItem { export interface ProgressMediaItem {
title: string; title: string;
year: number; year?: number;
poster?: string; poster?: string;
type: "show" | "movie"; type: "show" | "movie";
progress?: ProgressItem; progress?: ProgressItem;
@ -43,9 +44,9 @@ export interface ProgressStore {
items: Record<string, ProgressMediaItem>; items: Record<string, ProgressMediaItem>;
updateItem(ops: UpdateItemOptions): void; updateItem(ops: UpdateItemOptions): void;
removeItem(id: string): void; removeItem(id: string): void;
replaceItems(items: Record<string, ProgressMediaItem>): void;
} }
// TODO add migration from previous progress store
export const useProgressStore = create( export const useProgressStore = create(
persist( persist(
immer<ProgressStore>((set) => ({ immer<ProgressStore>((set) => ({
@ -55,6 +56,11 @@ export const useProgressStore = create(
delete s.items[id]; delete s.items[id];
}); });
}, },
replaceItems(items: Record<string, ProgressMediaItem>) {
set((s) => {
s.items = items;
});
},
updateItem({ meta, progress }) { updateItem({ meta, progress }) {
set((s) => { set((s) => {
if (!s.items[meta.tmdbId]) if (!s.items[meta.tmdbId])
@ -95,6 +101,7 @@ export const useProgressStore = create(
number: meta.episode.number, number: meta.episode.number,
title: meta.episode.title, title: meta.episode.title,
seasonId: meta.season.tmdbId, seasonId: meta.season.tmdbId,
updatedAt: Date.now(),
progress: { progress: {
duration: 0, duration: 0,
watched: 0, watched: 0,

View file

@ -1,7 +1,7 @@
export interface MediaItem { export interface MediaItem {
id: string; id: string;
title: string; title: string;
year: number; year?: number;
poster?: string; poster?: string;
type: "show" | "movie"; type: "show" | "movie";
} }