10 changed files with 407 additions and 484 deletions
@ -0,0 +1,5 @@ |
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||||
|
|
||||||
|
export interface BookmarkStoreData { |
||||||
|
bookmarks: MWMediaMeta[]; |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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. |
interface StoreVersion<A> { |
||||||
also type safety is important, this is all spaghetti with "any" everywhere |
version: number; |
||||||
*/ |
migrate?(data: A): any; |
||||||
|
create?: () => A; |
||||||
function buildStoreObject(d: any) { |
} |
||||||
const data: any = { |
interface StoreRet<T> { |
||||||
versions: d.versions, |
save: (data: T) => void; |
||||||
currentVersion: d.maxVersion, |
get: () => T; |
||||||
id: d.storageString, |
_raw: () => any; |
||||||
|
onChange: (cb: (data: T) => void) => { |
||||||
|
destroy: () => void; |
||||||
}; |
}; |
||||||
|
|
||||||
function update(this: any, obj2: any) { |
|
||||||
let obj = obj2; |
|
||||||
if (!obj) throw new Error("object to update is not an object"); |
|
||||||
|
|
||||||
// repeat until object fully updated
|
|
||||||
if (obj["--version"] === undefined) obj["--version"] = 0; |
|
||||||
while (obj["--version"] !== this.currentVersion) { |
|
||||||
// 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
|
|
||||||
if (!this.versions[version]) { |
|
||||||
console.error( |
|
||||||
`Version not found for storage item in store ${this.id}, resetting` |
|
||||||
); |
|
||||||
obj = null; |
|
||||||
break; |
|
||||||
} |
} |
||||||
|
|
||||||
// update object
|
export interface StoreBuilder<T> { |
||||||
obj = this.versions[version].update(obj); |
setKey: (key: string) => StoreBuilder<T>; |
||||||
|
addVersion: <A>(ver: StoreVersion<A>) => StoreBuilder<T>; |
||||||
|
build: () => StoreRet<T>; |
||||||
} |
} |
||||||
|
|
||||||
// if resulting obj is null, use latest version as init object
|
interface InternalStoreData { |
||||||
if (obj === null) { |
versions: StoreVersion<any>[]; |
||||||
console.error( |
key: string | null; |
||||||
`Storage item for store ${this.id} has been reset due to faulty updates` |
|
||||||
); |
|
||||||
return this.versions[this.currentVersion.toString()].init(); |
|
||||||
} |
} |
||||||
|
|
||||||
// updates succesful, return
|
const storeCallbacks: Record<string, ((data: any) => void)[]> = {}; |
||||||
return obj; |
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {}; |
||||||
} |
|
||||||
|
|
||||||
function get(this: any) { |
export async function initializeStores() { |
||||||
// get from storage api
|
// migrate all stores
|
||||||
const store = this; |
for (const [store, internal] of Object.values(stores)) { |
||||||
let gottenData: any = localStorage.getItem(this.id); |
const versions = internal.versions.sort((a, b) => a.version - b.version); |
||||||
|
|
||||||
// parse json if item exists
|
const data = store._raw(); |
||||||
if (gottenData) { |
const dataVersion = |
||||||
try { |
data["--version"] && typeof data["--version"] === "number" |
||||||
gottenData = JSON.parse(gottenData); |
? data["--version"] |
||||||
if (!gottenData.constructor) { |
: 0; |
||||||
console.error( |
|
||||||
`Storage item for store ${this.id} has not constructor` |
// Find which versions need to be used for migrations
|
||||||
); |
const relevantVersions = versions.filter((v) => v.version >= dataVersion); |
||||||
throw new Error("storage item has no constructor"); |
|
||||||
} |
// Migrate over each version
|
||||||
if (gottenData.constructor !== Object) { |
let mostRecentData = data; |
||||||
console.error(`Storage item for store ${this.id} is not an object`); |
for (const version of relevantVersions) { |
||||||
throw new Error("storage item is not an object"); |
if (version.migrate) |
||||||
} |
mostRecentData = await version.migrate(mostRecentData); |
||||||
} 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
|
store.save(mostRecentData); |
||||||
if (!gottenData) { |
} |
||||||
gottenData = this.versions[this.currentVersion.toString()].init(); |
|
||||||
} |
} |
||||||
|
|
||||||
// update the data if needed
|
function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> { |
||||||
gottenData = this.update(gottenData); |
const key = store.key ?? ""; |
||||||
|
const latestVersion = store.versions.sort((a, b) => b.version - a.version)[0]; |
||||||
|
|
||||||
// add a save object to return value
|
function onChange(cb: (data: T) => void) { |
||||||
gottenData.save = function save(newData: any) { |
if (!storeCallbacks[key]) storeCallbacks[key] = []; |
||||||
const dataToStore = newData || gottenData; |
storeCallbacks[key].push(cb); |
||||||
localStorage.setItem(store.id, JSON.stringify(dataToStore)); |
return { |
||||||
|
destroy() { |
||||||
|
// remove function pointer from callbacks
|
||||||
|
storeCallbacks[key] = storeCallbacks[key].filter((v) => v === cb); |
||||||
|
}, |
||||||
}; |
}; |
||||||
|
} |
||||||
|
|
||||||
// add instance helpers
|
function makeRaw() { |
||||||
Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => { |
const data = latestVersion.create?.() ?? {}; |
||||||
if (gottenData[name] !== undefined) |
data["--version"] = latestVersion.version; |
||||||
throw new Error( |
return data; |
||||||
`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 getRaw() { |
||||||
data.get = get.bind(data); |
const item = localStorage.getItem(key); |
||||||
data.update = update.bind(data); |
if (!item) return makeRaw(); |
||||||
|
try { |
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
// add static helpers
|
function save(data: T) { |
||||||
Object.entries(d.staticHelpers).forEach(([name, helper]: any) => { |
const withVersion: any = { ...data }; |
||||||
if (data[name] !== undefined) |
withVersion["--version"] = latestVersion.version; |
||||||
throw new Error(`helper name: ${name} on store ${data.id} is reserved`); |
localStorage.setItem(key, JSON.stringify(withVersion)); |
||||||
data[name] = helper.bind({}); |
|
||||||
}); |
|
||||||
|
|
||||||
return data; |
if (!storeCallbacks[key]) storeCallbacks[key] = []; |
||||||
|
storeCallbacks[key].forEach((v) => v(structuredClone(data))); |
||||||
} |
} |
||||||
|
|
||||||
/* |
|
||||||
* Builds a versioned store |
|
||||||
* |
|
||||||
* manages versioning of localstorage items |
|
||||||
*/ |
|
||||||
export function versionedStoreBuilder(): any { |
|
||||||
return { |
return { |
||||||
_data: { |
get() { |
||||||
versionList: [], |
const data = getRaw(); |
||||||
maxVersion: 0, |
delete data["--version"]; |
||||||
versions: {}, |
return data as T; |
||||||
storageString: undefined, |
|
||||||
instanceHelpers: {}, |
|
||||||
staticHelpers: {}, |
|
||||||
}, |
}, |
||||||
|
_raw() { |
||||||
setKey(str: string) { |
return getRaw(); |
||||||
this._data.storageString = str; |
|
||||||
return this; |
|
||||||
}, |
}, |
||||||
|
onChange, |
||||||
addVersion({ version, migrate, create }: any) { |
save, |
||||||
// 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; |
|
||||||
}, |
|
||||||
|
|
||||||
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
|
function assertStore(store: InternalStoreData) { |
||||||
if (helperType === "instance") |
const versionListSorted = store.versions.sort( |
||||||
this._data.instanceHelpers[name as string] = helper; |
(a, b) => a.version - b.version |
||||||
else if (helperType === "static") |
|
||||||
this._data.staticHelpers[name as string] = helper; |
|
||||||
|
|
||||||
return this; |
|
||||||
}, |
|
||||||
|
|
||||||
build() { |
|
||||||
// check if version list doesnt skip versions
|
|
||||||
const versionListSorted = this._data.versionList.sort( |
|
||||||
(a: number, b: number) => a - b |
|
||||||
); |
); |
||||||
versionListSorted.forEach((v: any, i: number, arr: any[]) => { |
versionListSorted.forEach((v, i, arr) => { |
||||||
if (i === 0) return; |
if (i === 0) return; |
||||||
if (v !== arr[i - 1] + 1) |
if (v.version !== arr[i - 1].version + 1) |
||||||
throw new Error("Version list of store is not incremental"); |
throw new Error("Version list of store is not incremental"); |
||||||
}); |
}); |
||||||
|
versionListSorted.forEach((v) => { |
||||||
|
if (v.version < 0) throw new Error("Versions cannot be negative"); |
||||||
|
}); |
||||||
|
|
||||||
// version zero must exist
|
// version zero must exist
|
||||||
if (versionListSorted[0] !== 0) |
if (versionListSorted[0]?.version !== 0) |
||||||
throw new Error("Version 0 doesn't exist in version list of store"); |
throw new Error("Version 0 doesn't exist in version list of store"); |
||||||
|
|
||||||
// max version must have init function
|
// max version must have create function
|
||||||
if (!this._data.versions[this._data.maxVersion.toString()].init) |
if (!store.versions[store.versions.length - 1].create) |
||||||
throw new Error( |
throw new Error(`Missing create function on latest version of store`); |
||||||
`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)` |
|
||||||
); |
|
||||||
|
|
||||||
// check storage string
|
// check storage string
|
||||||
if (!this._data.storageString) |
if (!store.key) throw new Error("storage key not set in store"); |
||||||
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}`); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
// build versioned store
|
export function createVersionedStore<T>(): StoreBuilder<T> { |
||||||
return buildStoreObject(this._data); |
const _data: InternalStoreData = { |
||||||
|
versions: [], |
||||||
|
key: null, |
||||||
|
}; |
||||||
|
|
||||||
|
return { |
||||||
|
setKey(key) { |
||||||
|
_data.key = key; |
||||||
|
return this; |
||||||
|
}, |
||||||
|
addVersion(ver) { |
||||||
|
_data.versions.push(ver); |
||||||
|
return this; |
||||||
|
}, |
||||||
|
build() { |
||||||
|
assertStore(_data); |
||||||
|
const storageObject = buildStorageObject<T>(_data); |
||||||
|
stores[_data.key ?? ""] = [storageObject, _data]; |
||||||
|
return storageObject; |
||||||
}, |
}, |
||||||
}; |
}; |
||||||
} |
} |
||||||
|
|
||||||
|
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]; |
||||||
|
} |
||||||
|
Loading…
Reference in new issue