mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 19:31:49 +01:00
Made type-safe versioned store, migrated to it
Co-authored-by: mrjvs <mistrjvs@gmail.com>
This commit is contained in:
parent
dd14b575eb
commit
942a6cc9c0
@ -9,6 +9,7 @@ import "@/setup/i18n";
|
|||||||
import "@/setup/index.css";
|
import "@/setup/index.css";
|
||||||
import "@/backend";
|
import "@/backend";
|
||||||
import { initializeChromecast } from "./setup/chromecast";
|
import { initializeChromecast } from "./setup/chromecast";
|
||||||
|
import { initializeStores } from "./utils/storage";
|
||||||
|
|
||||||
// initialize
|
// initialize
|
||||||
const key =
|
const key =
|
||||||
@ -42,12 +43,19 @@ initializeChromecast();
|
|||||||
// TODO general todos:
|
// TODO general todos:
|
||||||
// - localize everything (fix loading screen text (series vs movies))
|
// - localize everything (fix loading screen text (series vs movies))
|
||||||
|
|
||||||
|
const LazyLoadedApp = React.lazy(async () => {
|
||||||
|
await initializeStores();
|
||||||
|
return {
|
||||||
|
default: App,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Suspense fallback="">
|
<Suspense fallback="">
|
||||||
<App />
|
<LazyLoadedApp />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
@ -1,17 +1,8 @@
|
|||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import {
|
import { useStore } from "@/utils/storage";
|
||||||
createContext,
|
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { BookmarkStore } from "./store";
|
import { BookmarkStore } from "./store";
|
||||||
|
import { BookmarkStoreData } from "./types";
|
||||||
interface BookmarkStoreData {
|
|
||||||
bookmarks: MWMediaMeta[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookmarkStoreDataWrapper {
|
interface BookmarkStoreDataWrapper {
|
||||||
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void;
|
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void;
|
||||||
@ -36,25 +27,7 @@ function getBookmarkIndexFromMedia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookmarkContextProvider(props: { children: ReactNode }) {
|
export function BookmarkContextProvider(props: { children: ReactNode }) {
|
||||||
const bookmarkLocalstorage = BookmarkStore.get();
|
const [bookmarkStorage, setBookmarked] = useStore(BookmarkStore);
|
||||||
const [bookmarkStorage, setBookmarkStore] = useState<BookmarkStoreData>(
|
|
||||||
bookmarkLocalstorage as BookmarkStoreData
|
|
||||||
);
|
|
||||||
|
|
||||||
const setBookmarked = useCallback(
|
|
||||||
(data: any) => {
|
|
||||||
setBookmarkStore((old) => {
|
|
||||||
const old2 = JSON.parse(JSON.stringify(old));
|
|
||||||
let newData = data;
|
|
||||||
if (data.constructor === Function) {
|
|
||||||
newData = data(old2);
|
|
||||||
}
|
|
||||||
bookmarkLocalstorage.save(newData);
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[bookmarkLocalstorage, setBookmarkStore]
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import { versionedStoreBuilder } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
|
import { BookmarkStoreData } from "./types";
|
||||||
|
|
||||||
export const BookmarkStore = versionedStoreBuilder()
|
export const BookmarkStore = createVersionedStore<BookmarkStoreData>()
|
||||||
.setKey("mw-bookmarks")
|
.setKey("mw-bookmarks")
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 0,
|
version: 0,
|
||||||
|
migrate() {
|
||||||
|
return {
|
||||||
|
bookmarks: [], // TODO migrate bookmarks
|
||||||
|
};
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 1,
|
version: 1,
|
||||||
migrate() {
|
|
||||||
return {
|
|
||||||
bookmarks: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
create() {
|
create() {
|
||||||
return {
|
return {
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
|
5
src/state/bookmark/types.ts
Normal file
5
src/state/bookmark/types.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
|
|
||||||
|
export interface BookmarkStoreData {
|
||||||
|
bookmarks: MWMediaMeta[];
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
import { MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { useStore } from "@/utils/storage";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
@ -7,9 +8,9 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { VideoProgressStore } from "./store";
|
import { VideoProgressStore } from "./store";
|
||||||
|
import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types";
|
||||||
|
|
||||||
const FIVETEEN_MINUTES = 15 * 60;
|
const FIVETEEN_MINUTES = 15 * 60;
|
||||||
const FIVE_MINUTES = 5 * 60;
|
const FIVE_MINUTES = 5 * 60;
|
||||||
@ -34,29 +35,8 @@ function shouldSave(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaItem {
|
|
||||||
meta: MWMediaMeta;
|
|
||||||
series?: {
|
|
||||||
episodeId: string;
|
|
||||||
seasonId: string;
|
|
||||||
episode: number;
|
|
||||||
season: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WatchedStoreItem {
|
|
||||||
item: MediaItem;
|
|
||||||
progress: number;
|
|
||||||
percentage: number;
|
|
||||||
watchedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WatchedStoreData {
|
|
||||||
items: WatchedStoreItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WatchedStoreDataWrapper {
|
interface WatchedStoreDataWrapper {
|
||||||
updateProgress(media: MediaItem, progress: number, total: number): void;
|
updateProgress(media: StoreMediaItem, progress: number, total: number): void;
|
||||||
getFilteredWatched(): WatchedStoreItem[];
|
getFilteredWatched(): WatchedStoreItem[];
|
||||||
removeProgress(id: string): void;
|
removeProgress(id: string): void;
|
||||||
watched: WatchedStoreData;
|
watched: WatchedStoreData;
|
||||||
@ -72,7 +52,7 @@ const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
|||||||
});
|
});
|
||||||
WatchedContext.displayName = "WatchedContext";
|
WatchedContext.displayName = "WatchedContext";
|
||||||
|
|
||||||
function isSameEpisode(media: MediaItem, v: MediaItem) {
|
function isSameEpisode(media: StoreMediaItem, v: StoreMediaItem) {
|
||||||
return (
|
return (
|
||||||
media.meta.id === v.meta.id &&
|
media.meta.id === v.meta.id &&
|
||||||
(!media.series ||
|
(!media.series ||
|
||||||
@ -82,24 +62,7 @@ function isSameEpisode(media: MediaItem, v: MediaItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WatchedContextProvider(props: { children: ReactNode }) {
|
export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||||
const watchedLocalstorage = VideoProgressStore.get();
|
const [watched, setWatched] = useStore(VideoProgressStore);
|
||||||
const [watched, setWatchedReal] = useState<WatchedStoreData>(
|
|
||||||
watchedLocalstorage as WatchedStoreData
|
|
||||||
);
|
|
||||||
|
|
||||||
const setWatched = useCallback(
|
|
||||||
(data: any) => {
|
|
||||||
setWatchedReal((old) => {
|
|
||||||
let newData = data;
|
|
||||||
if (data.constructor === Function) {
|
|
||||||
newData = data(old);
|
|
||||||
}
|
|
||||||
watchedLocalstorage.save(newData);
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setWatchedReal, watchedLocalstorage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -110,7 +73,11 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
updateProgress(media: MediaItem, progress: number, total: number): void {
|
updateProgress(
|
||||||
|
media: StoreMediaItem,
|
||||||
|
progress: number,
|
||||||
|
total: number
|
||||||
|
): void {
|
||||||
setWatched((data: WatchedStoreData) => {
|
setWatched((data: WatchedStoreData) => {
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
let item = newData.items.find((v) => isSameEpisode(media, v.item));
|
let item = newData.items.find((v) => isSameEpisode(media, v.item));
|
||||||
@ -176,7 +143,7 @@ export function useWatchedContext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSameEpisodeMeta(
|
function isSameEpisodeMeta(
|
||||||
media: MediaItem,
|
media: StoreMediaItem,
|
||||||
mediaTwo: DetailedMeta | null,
|
mediaTwo: DetailedMeta | null,
|
||||||
episodeId?: string
|
episodeId?: string
|
||||||
) {
|
) {
|
||||||
|
170
src/state/watched/migrations/v2.ts
Normal file
170
src/state/watched/migrations/v2.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
|
import { searchForMedia } from "@/backend/metadata/search";
|
||||||
|
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
||||||
|
import { WatchedStoreData, WatchedStoreItem } from "../types";
|
||||||
|
|
||||||
|
interface OldMediaBase {
|
||||||
|
mediaId: number;
|
||||||
|
mediaType: MWMediaType;
|
||||||
|
percentage: number;
|
||||||
|
progress: number;
|
||||||
|
providerId: string;
|
||||||
|
title: string;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OldMovie extends OldMediaBase {
|
||||||
|
mediaType: MWMediaType.MOVIE;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OldSeries extends OldMediaBase {
|
||||||
|
mediaType: MWMediaType.SERIES;
|
||||||
|
episodeId: number;
|
||||||
|
seasonId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OldData {
|
||||||
|
items: (OldMovie | OldSeries)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateV2(old: OldData) {
|
||||||
|
const oldData = old;
|
||||||
|
if (!oldData) return;
|
||||||
|
|
||||||
|
const uniqueMedias: Record<string, any> = {};
|
||||||
|
oldData.items.forEach((item: any) => {
|
||||||
|
if (uniqueMedias[item.mediaId]) return;
|
||||||
|
uniqueMedias[item.mediaId] = item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearsAreClose = (a: number, b: number) => {
|
||||||
|
return Math.abs(a - b) <= 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaMetas: Record<string, Record<string, DetailedMeta | null>> = {};
|
||||||
|
|
||||||
|
const relevantItems = await Promise.all(
|
||||||
|
Object.values(uniqueMedias).map(async (item) => {
|
||||||
|
const year = Number(item.year.toString().split("-")[0]);
|
||||||
|
const data = await searchForMedia({
|
||||||
|
searchQuery: `${item.title} ${year}`,
|
||||||
|
type: item.mediaType,
|
||||||
|
});
|
||||||
|
const relevantItem = data.find((res) =>
|
||||||
|
yearsAreClose(Number(res.year), year)
|
||||||
|
);
|
||||||
|
if (!relevantItem) {
|
||||||
|
console.error("No item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.mediaId,
|
||||||
|
data: relevantItem,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of relevantItems.filter(Boolean)) {
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
let keys: (string | null)[][] = [["0", "0"]];
|
||||||
|
if (item.data.type === "series") {
|
||||||
|
// TODO sort episodes by season & episode so it shows the "highest" episode as last
|
||||||
|
const meta = await getMetaFromId(item.data.type, item.data.id);
|
||||||
|
if (!meta || !meta?.meta.seasons) return;
|
||||||
|
const seasonNumbers = [
|
||||||
|
...new Set(
|
||||||
|
oldData.items
|
||||||
|
.filter((watchedEntry: any) => watchedEntry.mediaId === item.id)
|
||||||
|
.map((watchedEntry: any) => watchedEntry.seasonId)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const seasons = seasonNumbers.map((num) => ({
|
||||||
|
num,
|
||||||
|
season: meta.meta?.seasons?.[(num as number) - 1],
|
||||||
|
}));
|
||||||
|
keys = seasons
|
||||||
|
.map((season) => (season ? [season.num, season?.season?.id] : []))
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mediaMetas[item.id]) mediaMetas[item.id] = {};
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(async ([key, id]) => {
|
||||||
|
if (!key) return;
|
||||||
|
mediaMetas[item.id][key] = await getMetaFromId(
|
||||||
|
item.data.type,
|
||||||
|
item.data.id,
|
||||||
|
id === "0" || id === null ? undefined : id
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've got all the metadata you can dream of now
|
||||||
|
// Now let's convert stuff into the new format.
|
||||||
|
const newData: WatchedStoreData = {
|
||||||
|
...oldData,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const oldWatched of oldData.items) {
|
||||||
|
if (oldWatched.mediaType === "movie") {
|
||||||
|
if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue;
|
||||||
|
|
||||||
|
const newItem: WatchedStoreItem = {
|
||||||
|
item: {
|
||||||
|
meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta,
|
||||||
|
},
|
||||||
|
progress: oldWatched.progress,
|
||||||
|
percentage: oldWatched.percentage,
|
||||||
|
watchedAt: Date.now(), // There was no watchedAt in V2
|
||||||
|
};
|
||||||
|
|
||||||
|
oldData.items = oldData.items.filter(
|
||||||
|
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
|
||||||
|
);
|
||||||
|
newData.items.push(newItem);
|
||||||
|
} else if (oldWatched.mediaType === "series") {
|
||||||
|
if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue;
|
||||||
|
|
||||||
|
const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId]
|
||||||
|
?.meta as MWMediaMeta;
|
||||||
|
|
||||||
|
if (meta.type !== "series") return;
|
||||||
|
|
||||||
|
const newItem: WatchedStoreItem = {
|
||||||
|
item: {
|
||||||
|
meta,
|
||||||
|
series: {
|
||||||
|
episode: Number(oldWatched.episodeId),
|
||||||
|
season: Number(oldWatched.seasonId),
|
||||||
|
seasonId: meta.seasonData.id,
|
||||||
|
episodeId:
|
||||||
|
meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
progress: oldWatched.progress,
|
||||||
|
percentage: oldWatched.percentage,
|
||||||
|
watchedAt: Date.now(), // There was no watchedAt in V2
|
||||||
|
// Put watchedAt in the future to show last episode as most recently
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
newData.items.find(
|
||||||
|
(item) =>
|
||||||
|
item.item.meta.id === newItem.item.meta.id &&
|
||||||
|
item.item.series?.episodeId === newItem.item.series?.episodeId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
oldData.items = oldData.items.filter(
|
||||||
|
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
|
||||||
|
);
|
||||||
|
newData.items.push(newItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
}
|
@ -1,57 +1,25 @@
|
|||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
import { searchForMedia } from "@/backend/metadata/search";
|
import { migrateV2, OldData } from "./migrations/v2";
|
||||||
import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types";
|
import { WatchedStoreData } from "./types";
|
||||||
import { versionedStoreBuilder } from "@/utils/storage";
|
|
||||||
import { WatchedStoreData, WatchedStoreItem } from "./context";
|
|
||||||
|
|
||||||
interface OldMediaBase {
|
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
|
||||||
mediaId: number;
|
|
||||||
mediaType: MWMediaType;
|
|
||||||
percentage: number;
|
|
||||||
progress: number;
|
|
||||||
providerId: string;
|
|
||||||
title: string;
|
|
||||||
year: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OldMovie extends OldMediaBase {
|
|
||||||
mediaType: MWMediaType.MOVIE;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OldSeries extends OldMediaBase {
|
|
||||||
mediaType: MWMediaType.SERIES;
|
|
||||||
episodeId: number;
|
|
||||||
seasonId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OldData {
|
|
||||||
items: (OldMovie | OldSeries)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoProgressStore = versionedStoreBuilder()
|
|
||||||
.setKey("video-progress")
|
.setKey("video-progress")
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 0,
|
version: 0,
|
||||||
|
migrate() {
|
||||||
|
return {
|
||||||
|
items: [], // dont migrate from version 0 to version 1, unmigratable
|
||||||
|
};
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 1,
|
version: 1,
|
||||||
migrate() {
|
async migrate(old: OldData) {
|
||||||
return {
|
return migrateV2(old);
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 2,
|
version: 2,
|
||||||
migrate(old: OldData) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// eslint-disable-next-line no-use-before-define
|
|
||||||
migrateV2(old);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
create() {
|
create() {
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
@ -59,153 +27,3 @@ export const VideoProgressStore = versionedStoreBuilder()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
async function migrateV2(old: OldData) {
|
|
||||||
const oldData = old;
|
|
||||||
if (!oldData) return;
|
|
||||||
|
|
||||||
const uniqueMedias: Record<string, any> = {};
|
|
||||||
oldData.items.forEach((item: any) => {
|
|
||||||
if (uniqueMedias[item.mediaId]) return;
|
|
||||||
uniqueMedias[item.mediaId] = item;
|
|
||||||
});
|
|
||||||
|
|
||||||
const yearsAreClose = (a: number, b: number) => {
|
|
||||||
return Math.abs(a - b) <= 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mediaMetas: Record<string, Record<string, DetailedMeta | null>> = {};
|
|
||||||
|
|
||||||
const relevantItems = await Promise.all(
|
|
||||||
Object.values(uniqueMedias).map(async (item) => {
|
|
||||||
const year = Number(item.year.toString().split("-")[0]);
|
|
||||||
const data = await searchForMedia({
|
|
||||||
searchQuery: `${item.title} ${year}`,
|
|
||||||
type: item.mediaType,
|
|
||||||
});
|
|
||||||
const relevantItem = data.find((res) =>
|
|
||||||
yearsAreClose(Number(res.year), year)
|
|
||||||
);
|
|
||||||
if (!relevantItem) {
|
|
||||||
console.error("No item");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: item.mediaId,
|
|
||||||
data: relevantItem,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const item of relevantItems.filter(Boolean)) {
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
let keys: (string | null)[][] = [["0", "0"]];
|
|
||||||
if (item.data.type === "series") {
|
|
||||||
const meta = await getMetaFromId(item.data.type, item.data.id);
|
|
||||||
if (!meta || !meta?.meta.seasons) return;
|
|
||||||
const seasonNumbers = [
|
|
||||||
...new Set(
|
|
||||||
oldData.items
|
|
||||||
.filter((watchedEntry: any) => watchedEntry.mediaId === item.id)
|
|
||||||
.map((watchedEntry: any) => watchedEntry.seasonId)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const seasons = seasonNumbers
|
|
||||||
.map((num) => ({
|
|
||||||
num,
|
|
||||||
season: meta.meta?.seasons?.[(num as number) - 1],
|
|
||||||
}))
|
|
||||||
.filter(Boolean);
|
|
||||||
keys = seasons
|
|
||||||
.map((season) => (season ? [season.num, season?.season?.id] : []))
|
|
||||||
.filter((entry) => entry.length > 0); // Stupid TypeScript
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mediaMetas[item.id]) mediaMetas[item.id] = {};
|
|
||||||
await Promise.all(
|
|
||||||
keys.map(async ([key, id]) => {
|
|
||||||
if (!key) return;
|
|
||||||
mediaMetas[item.id][key] = await getMetaFromId(
|
|
||||||
item.data.type,
|
|
||||||
item.data.id,
|
|
||||||
id === "0" || id === null ? undefined : id
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We've got all the metadata you can dream of now
|
|
||||||
// Now let's convert stuff into the new format.
|
|
||||||
interface WatchedStoreDataWithVersion extends WatchedStoreData {
|
|
||||||
"--version": number;
|
|
||||||
}
|
|
||||||
const newData: WatchedStoreDataWithVersion = {
|
|
||||||
...oldData,
|
|
||||||
items: [],
|
|
||||||
"--version": 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const oldWatched of oldData.items) {
|
|
||||||
if (oldWatched.mediaType === "movie") {
|
|
||||||
if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue;
|
|
||||||
|
|
||||||
const newItem: WatchedStoreItem = {
|
|
||||||
item: {
|
|
||||||
meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta,
|
|
||||||
},
|
|
||||||
progress: oldWatched.progress,
|
|
||||||
percentage: oldWatched.percentage,
|
|
||||||
watchedAt: Date.now(), // There was no watchedAt in V2
|
|
||||||
};
|
|
||||||
|
|
||||||
oldData.items = oldData.items.filter(
|
|
||||||
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
|
|
||||||
);
|
|
||||||
newData.items.push(newItem);
|
|
||||||
} else if (oldWatched.mediaType === "series") {
|
|
||||||
if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue;
|
|
||||||
|
|
||||||
const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId]
|
|
||||||
?.meta as MWMediaMeta;
|
|
||||||
|
|
||||||
if (meta.type !== "series") return;
|
|
||||||
|
|
||||||
const newItem: WatchedStoreItem = {
|
|
||||||
item: {
|
|
||||||
meta,
|
|
||||||
series: {
|
|
||||||
episode: Number(oldWatched.episodeId),
|
|
||||||
season: Number(oldWatched.seasonId),
|
|
||||||
seasonId: meta.seasonData.id,
|
|
||||||
episodeId:
|
|
||||||
meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
progress: oldWatched.progress,
|
|
||||||
percentage: oldWatched.percentage,
|
|
||||||
watchedAt: Date.now(), // There was no watchedAt in V2
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
newData.items.find(
|
|
||||||
(item) =>
|
|
||||||
item.item.meta.id === newItem.item.meta.id &&
|
|
||||||
item.item.series?.episodeId === newItem.item.series?.episodeId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
oldData.items = oldData.items.filter(
|
|
||||||
(item) => JSON.stringify(item) !== JSON.stringify(oldWatched)
|
|
||||||
);
|
|
||||||
newData.items.push(newItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify(old), JSON.stringify(newData));
|
|
||||||
if (JSON.stringify(old.items) !== JSON.stringify(newData.items)) {
|
|
||||||
console.log(newData);
|
|
||||||
VideoProgressStore.get().save(newData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
22
src/state/watched/types.ts
Normal file
22
src/state/watched/types.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
|
|
||||||
|
export interface StoreMediaItem {
|
||||||
|
meta: MWMediaMeta;
|
||||||
|
series?: {
|
||||||
|
episodeId: string;
|
||||||
|
seasonId: string;
|
||||||
|
episode: number;
|
||||||
|
season: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedStoreItem {
|
||||||
|
item: StoreMediaItem;
|
||||||
|
progress: number;
|
||||||
|
percentage: number;
|
||||||
|
watchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchedStoreData {
|
||||||
|
items: WatchedStoreItem[];
|
||||||
|
}
|
@ -1,232 +1,188 @@
|
|||||||
// TODO make type and react safe!!
|
import { useEffect, useState } from "react";
|
||||||
/*
|
|
||||||
it needs to be react-ified by having a save function not on the instance itself.
|
|
||||||
also type safety is important, this is all spaghetti with "any" everywhere
|
|
||||||
*/
|
|
||||||
|
|
||||||
function buildStoreObject(d: any) {
|
interface StoreVersion<A> {
|
||||||
const data: any = {
|
version: number;
|
||||||
versions: d.versions,
|
migrate?(data: A): any;
|
||||||
currentVersion: d.maxVersion,
|
create?: () => A;
|
||||||
id: d.storageString,
|
}
|
||||||
|
interface StoreRet<T> {
|
||||||
|
save: (data: T) => void;
|
||||||
|
get: () => T;
|
||||||
|
_raw: () => any;
|
||||||
|
onChange: (cb: (data: T) => void) => {
|
||||||
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function update(this: any, obj2: any) {
|
export interface StoreBuilder<T> {
|
||||||
let obj = obj2;
|
setKey: (key: string) => StoreBuilder<T>;
|
||||||
if (!obj) throw new Error("object to update is not an object");
|
addVersion: <A>(ver: StoreVersion<A>) => StoreBuilder<T>;
|
||||||
|
build: () => StoreRet<T>;
|
||||||
|
}
|
||||||
|
|
||||||
// repeat until object fully updated
|
interface InternalStoreData {
|
||||||
if (obj["--version"] === undefined) obj["--version"] = 0;
|
versions: StoreVersion<any>[];
|
||||||
while (obj["--version"] !== this.currentVersion) {
|
key: string | null;
|
||||||
// get version
|
}
|
||||||
let version: any = obj["--version"] || 0;
|
|
||||||
if (version.constructor !== Number || version < 0) version = -42;
|
|
||||||
// invalid on purpose so it will reset
|
|
||||||
else {
|
|
||||||
version = ((version as number) + 1).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if version exists
|
const storeCallbacks: Record<string, ((data: any) => void)[]> = {};
|
||||||
if (!this.versions[version]) {
|
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
|
||||||
console.error(
|
|
||||||
`Version not found for storage item in store ${this.id}, resetting`
|
|
||||||
);
|
|
||||||
obj = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update object
|
export async function initializeStores() {
|
||||||
obj = this.versions[version].update(obj);
|
// migrate all stores
|
||||||
|
for (const [store, internal] of Object.values(stores)) {
|
||||||
|
const versions = internal.versions.sort((a, b) => a.version - b.version);
|
||||||
|
|
||||||
|
const data = store._raw();
|
||||||
|
const dataVersion =
|
||||||
|
data["--version"] && typeof data["--version"] === "number"
|
||||||
|
? data["--version"]
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Find which versions need to be used for migrations
|
||||||
|
const relevantVersions = versions.filter((v) => v.version >= dataVersion);
|
||||||
|
|
||||||
|
// Migrate over each version
|
||||||
|
let mostRecentData = data;
|
||||||
|
for (const version of relevantVersions) {
|
||||||
|
if (version.migrate)
|
||||||
|
mostRecentData = await version.migrate(mostRecentData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if resulting obj is null, use latest version as init object
|
store.save(mostRecentData);
|
||||||
if (obj === null) {
|
|
||||||
console.error(
|
|
||||||
`Storage item for store ${this.id} has been reset due to faulty updates`
|
|
||||||
);
|
|
||||||
return this.versions[this.currentVersion.toString()].init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// updates succesful, return
|
|
||||||
return obj;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function get(this: any) {
|
function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
|
||||||
// get from storage api
|
const key = store.key ?? "";
|
||||||
const store = this;
|
const latestVersion = store.versions.sort((a, b) => b.version - a.version)[0];
|
||||||
let gottenData: any = localStorage.getItem(this.id);
|
|
||||||
|
|
||||||
// parse json if item exists
|
function onChange(cb: (data: T) => void) {
|
||||||
if (gottenData) {
|
if (!storeCallbacks[key]) storeCallbacks[key] = [];
|
||||||
try {
|
storeCallbacks[key].push(cb);
|
||||||
gottenData = JSON.parse(gottenData);
|
return {
|
||||||
if (!gottenData.constructor) {
|
destroy() {
|
||||||
console.error(
|
// remove function pointer from callbacks
|
||||||
`Storage item for store ${this.id} has not constructor`
|
storeCallbacks[key] = storeCallbacks[key].filter((v) => v === cb);
|
||||||
);
|
},
|
||||||
throw new Error("storage item has no constructor");
|
|
||||||
}
|
|
||||||
if (gottenData.constructor !== Object) {
|
|
||||||
console.error(`Storage item for store ${this.id} is not an object`);
|
|
||||||
throw new Error("storage item is not an object");
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// if errored, set to null so it generates new one, see below
|
|
||||||
console.error(`Failed to parse storage item for store ${this.id}`);
|
|
||||||
gottenData = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if item doesnt exist, generate from version init
|
|
||||||
if (!gottenData) {
|
|
||||||
gottenData = this.versions[this.currentVersion.toString()].init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the data if needed
|
|
||||||
gottenData = this.update(gottenData);
|
|
||||||
|
|
||||||
// add a save object to return value
|
|
||||||
gottenData.save = function save(newData: any) {
|
|
||||||
const dataToStore = newData || gottenData;
|
|
||||||
localStorage.setItem(store.id, JSON.stringify(dataToStore));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// add instance helpers
|
|
||||||
Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => {
|
|
||||||
if (gottenData[name] !== undefined)
|
|
||||||
throw new Error(
|
|
||||||
`helper name: ${name} on instance of store ${this.id} is reserved`
|
|
||||||
);
|
|
||||||
gottenData[name] = helper.bind(gottenData);
|
|
||||||
});
|
|
||||||
|
|
||||||
// return data
|
|
||||||
return gottenData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// add functions to store
|
function makeRaw() {
|
||||||
data.get = get.bind(data);
|
const data = latestVersion.create?.() ?? {};
|
||||||
data.update = update.bind(data);
|
data["--version"] = latestVersion.version;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// add static helpers
|
function getRaw() {
|
||||||
Object.entries(d.staticHelpers).forEach(([name, helper]: any) => {
|
const item = localStorage.getItem(key);
|
||||||
if (data[name] !== undefined)
|
if (!item) return makeRaw();
|
||||||
throw new Error(`helper name: ${name} on store ${data.id} is reserved`);
|
try {
|
||||||
data[name] = helper.bind({});
|
return JSON.parse(item);
|
||||||
|
} catch (err) {
|
||||||
|
// we assume user has fucked with the data, give them a fresh store
|
||||||
|
console.error(`FAILED TO PARSE LOCALSTORAGE FOR KEY ${key}`, err);
|
||||||
|
return makeRaw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(data: T) {
|
||||||
|
const withVersion: any = { ...data };
|
||||||
|
withVersion["--version"] = latestVersion.version;
|
||||||
|
localStorage.setItem(key, JSON.stringify(withVersion));
|
||||||
|
|
||||||
|
if (!storeCallbacks[key]) storeCallbacks[key] = [];
|
||||||
|
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get() {
|
||||||
|
const data = getRaw();
|
||||||
|
delete data["--version"];
|
||||||
|
return data as T;
|
||||||
|
},
|
||||||
|
_raw() {
|
||||||
|
return getRaw();
|
||||||
|
},
|
||||||
|
onChange,
|
||||||
|
save,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStore(store: InternalStoreData) {
|
||||||
|
const versionListSorted = store.versions.sort(
|
||||||
|
(a, b) => a.version - b.version
|
||||||
|
);
|
||||||
|
versionListSorted.forEach((v, i, arr) => {
|
||||||
|
if (i === 0) return;
|
||||||
|
if (v.version !== arr[i - 1].version + 1)
|
||||||
|
throw new Error("Version list of store is not incremental");
|
||||||
|
});
|
||||||
|
versionListSorted.forEach((v) => {
|
||||||
|
if (v.version < 0) throw new Error("Versions cannot be negative");
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
// version zero must exist
|
||||||
|
if (versionListSorted[0]?.version !== 0)
|
||||||
|
throw new Error("Version 0 doesn't exist in version list of store");
|
||||||
|
|
||||||
|
// max version must have create function
|
||||||
|
if (!store.versions[store.versions.length - 1].create)
|
||||||
|
throw new Error(`Missing create function on latest version of store`);
|
||||||
|
|
||||||
|
// check storage string
|
||||||
|
if (!store.key) throw new Error("storage key not set in store");
|
||||||
|
|
||||||
|
// check if all parts have migratio
|
||||||
|
const migrations = [...versionListSorted];
|
||||||
|
migrations.pop();
|
||||||
|
migrations.forEach((v) => {
|
||||||
|
if (!v.migrate)
|
||||||
|
throw new Error(`Migration missing on version ${v.version}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export function createVersionedStore<T>(): StoreBuilder<T> {
|
||||||
* Builds a versioned store
|
const _data: InternalStoreData = {
|
||||||
*
|
versions: [],
|
||||||
* manages versioning of localstorage items
|
key: null,
|
||||||
*/
|
};
|
||||||
export function versionedStoreBuilder(): any {
|
|
||||||
return {
|
return {
|
||||||
_data: {
|
setKey(key) {
|
||||||
versionList: [],
|
_data.key = key;
|
||||||
maxVersion: 0,
|
|
||||||
versions: {},
|
|
||||||
storageString: undefined,
|
|
||||||
instanceHelpers: {},
|
|
||||||
staticHelpers: {},
|
|
||||||
},
|
|
||||||
|
|
||||||
setKey(str: string) {
|
|
||||||
this._data.storageString = str;
|
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
addVersion(ver) {
|
||||||
addVersion({ version, migrate, create }: any) {
|
_data.versions.push(ver);
|
||||||
// input checking
|
|
||||||
if (version < 0) throw new Error("Cannot add version below 0 in store");
|
|
||||||
if (version > 0 && !migrate)
|
|
||||||
throw new Error(
|
|
||||||
`Missing migration on version ${version} (needed for any version above 0)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// update max version list
|
|
||||||
if (version > this._data.maxVersion) this._data.maxVersion = version;
|
|
||||||
// add to version list
|
|
||||||
this._data.versionList.push(version);
|
|
||||||
|
|
||||||
// register version
|
|
||||||
this._data.versions[version.toString()] = {
|
|
||||||
version, // version number
|
|
||||||
update: migrate
|
|
||||||
? (data: any) => {
|
|
||||||
// update function, and increment version
|
|
||||||
const newData = migrate(data);
|
|
||||||
newData["--version"] = version; // eslint-disable-line no-param-reassign
|
|
||||||
return newData;
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
init: create
|
|
||||||
? () => {
|
|
||||||
// return an initial object
|
|
||||||
const data = create();
|
|
||||||
data["--version"] = version;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
|
||||||
registerHelper({ name, helper, type }: any) {
|
|
||||||
// type
|
|
||||||
let helperType: string = type;
|
|
||||||
if (!helperType) helperType = "instance";
|
|
||||||
|
|
||||||
// input checking
|
|
||||||
if (!name || name.constructor !== String) {
|
|
||||||
throw new Error("helper name is not a string");
|
|
||||||
}
|
|
||||||
if (!helper || helper.constructor !== Function) {
|
|
||||||
throw new Error("helper function is not a function");
|
|
||||||
}
|
|
||||||
if (!["instance", "static"].includes(helperType)) {
|
|
||||||
throw new Error("helper type must be either 'instance' or 'static'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// register helper
|
|
||||||
if (helperType === "instance")
|
|
||||||
this._data.instanceHelpers[name as string] = helper;
|
|
||||||
else if (helperType === "static")
|
|
||||||
this._data.staticHelpers[name as string] = helper;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
// check if version list doesnt skip versions
|
assertStore(_data);
|
||||||
const versionListSorted = this._data.versionList.sort(
|
const storageObject = buildStorageObject<T>(_data);
|
||||||
(a: number, b: number) => a - b
|
stores[_data.key ?? ""] = [storageObject, _data];
|
||||||
);
|
return storageObject;
|
||||||
versionListSorted.forEach((v: any, i: number, arr: any[]) => {
|
|
||||||
if (i === 0) return;
|
|
||||||
if (v !== arr[i - 1] + 1)
|
|
||||||
throw new Error("Version list of store is not incremental");
|
|
||||||
});
|
|
||||||
|
|
||||||
// version zero must exist
|
|
||||||
if (versionListSorted[0] !== 0)
|
|
||||||
throw new Error("Version 0 doesn't exist in version list of store");
|
|
||||||
|
|
||||||
// max version must have init function
|
|
||||||
if (!this._data.versions[this._data.maxVersion.toString()].init)
|
|
||||||
throw new Error(
|
|
||||||
`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// check storage string
|
|
||||||
if (!this._data.storageString)
|
|
||||||
throw new Error("storage key not set in store");
|
|
||||||
|
|
||||||
// build versioned store
|
|
||||||
return buildStoreObject(this._data);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useStore<T>(
|
||||||
|
store: StoreRet<T>
|
||||||
|
): [T, (cb: (old: T) => T) => void] {
|
||||||
|
const [data, setData] = useState<T>(store.get());
|
||||||
|
useEffect(() => {
|
||||||
|
const { destroy } = store.onChange((newData) => {
|
||||||
|
setData(newData);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
destroy();
|
||||||
|
};
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
function setNewData(cb: (old: T) => T) {
|
||||||
|
const newData = cb(data);
|
||||||
|
store.save(newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [data, setNewData];
|
||||||
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { versionedStoreBuilder } from "@/utils/storage";
|
import { createVersionedStore } from "@/utils/storage";
|
||||||
|
|
||||||
export const volumeStore = versionedStoreBuilder()
|
interface VolumeStoreData {
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const volumeStore = createVersionedStore<VolumeStoreData>()
|
||||||
.setKey("mw-volume")
|
.setKey("mw-volume")
|
||||||
.addVersion({
|
.addVersion({
|
||||||
version: 0,
|
version: 0,
|
||||||
@ -18,8 +22,7 @@ export function getStoredVolume(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setStoredVolume(volume: number) {
|
export function setStoredVolume(volume: number) {
|
||||||
const store = volumeStore.get();
|
volumeStore.save({
|
||||||
store.save({
|
|
||||||
volume,
|
volume,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user