diff --git a/src/lib/Icon.svelte b/src/lib/Icon.svelte index 349c4e83..0c63d809 100644 --- a/src/lib/Icon.svelte +++ b/src/lib/Icon.svelte @@ -295,6 +295,43 @@ /> +{:else if i === "ryot"} + + + + + {:else if i === "gamepad"} span:first-of-type { font-weight: bold; font-size: 20px; + margin-top: auto; &.large { font-size: 32px; } } - > span:last-child { - margin-top: auto; - } - .disclaimer { position: absolute; top: 5px; diff --git a/src/routes/(app)/import/+page.svelte b/src/routes/(app)/import/+page.svelte index 889d9c5e..4257f596 100644 --- a/src/routes/(app)/import/+page.svelte +++ b/src/routes/(app)/import/+page.svelte @@ -18,7 +18,8 @@ MovaryHistory, MovaryRatings, MovaryWatchlist, - Watched + Watched, + WatchedStatus } from "@/types"; let isDragOver = false; @@ -369,6 +370,127 @@ } } + async function processRyotFile(files?: FileList | null) { + try { + console.log("processRyotFile", files); + if (!files || files?.length <= 0) { + console.error("processRyotFile", "No files to process!"); + notify({ + type: "error", + text: "File not found in dropped items. Please try again or refresh.", + time: 6000 + }); + isDragOver = false; + return; + } + isLoading = true; + if (files.length > 1) { + notify({ + type: "error", + text: "Only one file at a time is supported. Continuing with the first.", + time: 6000 + }); + } + + // Currently only support for importing one file at a time + const file = files[0]; + if (file.type !== "application/json") { + notify({ + type: "error", + text: "Must be a Ryot JSON export file" + }); + isLoading = false; + isDragOver = false; + return; + } + + // Build toImport array + const toImport: ImportedList[] = []; + const fileText = await readFile(new FileReader(), file); + const jsonData = JSON.parse(fileText)["media"] as any[]; + for (const v of jsonData) { + if (!v.source_id || !v.identifier || !(v.lot == "show" || v.lot == "movie")) { + notify({ + type: "error", + text: "Item in export either has no title, TMDB identifier or is not a movie/tv show! Look in console for more details." + }); + console.error( + "Can't add export item to import table! It has title, TMDB identifier or is not a movie/tv show! Item:", + v + ); + continue; + } + + // Define the main general status of the movie/show + // In Ryot, it can be marked as multiple of the following, so choose the most relevant + const statusRanks: [string, WatchedStatus][] = [ + ["", "DROPPED"], + ["Watchlist", "PLANNED"], + ["Monitoring", "PLANNED"], + ["In Progress", "WATCHING"], + ["Completed", "FINISHED"] + ]; + let rank = 0; + for (const s of v.collections) { + rank = Math.max( + rank, + statusRanks.findIndex((pair) => pair[0] == s) + ); + } + + const t: ImportedList = { + tmdbId: Number(v.identifier), + name: v.source_id, + type: v.lot === "show" ? "tv" : v.lot, + status: statusRanks[rank][1], + + // In Ryot, shows can have one review for each episode - Not supported in Watcharr + // Will ignore the episodes' reviews + thoughts: v.lot === "movie" && v.reviews.length ? v.reviews[0].review.text : "", + + // Ryot does not support overall rating for shows + rating: v.lot === "movie" && v.reviews.length ? Number(v.reviews[0].rating) : undefined, + + datesWatched: + v.lot === "movie" && v.seen_history.length + ? v.seen_history.map((seen: any) => new Date(seen.ended_on)) + : [], + + // Episode ratings are on a separate field: "reviews" + watchedEpisodes: v.seen_history.map((episode: any) => ({ + status: episode.progress === "100" ? "FINISHED" : "WATCHING", + + // Linear :( search the reviews for a match + rating: + Number( + ( + v.reviews.find( + (review: any) => + review.show_season_number === episode.show_season_number && + review.show_episode_number === episode.show_episode_number + ) || {} + ).rating + ) || null, + + seasonNumber: episode.show_season_number, + episodeNumber: episode.show_episode_number + })) + }; + toImport.push(t); + } + console.log("toImport:", toImport); + importedList.set({ + data: JSON.stringify(toImport), + type: "ryot" + }); + goto("/import/process"); + } catch (err) { + isLoading = false; + notify({ type: "error", text: "Failed to read file!" }); + console.error("import: Failed to read file!", err); + } + } + onMount(() => { if (!localStorage.getItem("token")) { goto("/login"); @@ -407,6 +529,8 @@ text="MyAnimeList Export" filesSelected={(f) => processFilesMyAnimeList(f)} /> + + processRyotFile(f)} /> {/if} diff --git a/src/routes/(app)/import/process/+page.svelte b/src/routes/(app)/import/process/+page.svelte index 792a431b..7d4a1d4b 100644 --- a/src/routes/(app)/import/process/+page.svelte +++ b/src/routes/(app)/import/process/+page.svelte @@ -253,6 +253,19 @@ text: "Failed to process import data!" }); } + } else if (list?.type === "ryot") { + importText = "Ryot"; + try { + const s = JSON.parse(list.data); + // Builds imported list in previous step for ease. + rList = s; + } catch (err) { + console.error("Ryot import processing failed!", err); + notify({ + type: "error", + text: "Processing failed!. Please report this issue if it persists." + }); + } } // TODO: remove duplicate names in list return list; diff --git a/src/routes/(app)/movie/[id]/+page.svelte b/src/routes/(app)/movie/[id]/+page.svelte index 5d8cf5d0..fe61509c 100644 --- a/src/routes/(app)/movie/[id]/+page.svelte +++ b/src/routes/(app)/movie/[id]/+page.svelte @@ -146,7 +146,7 @@ /> - {movie.runtime}m + {movie.runtime} min
{#each movie.genres as g, i} diff --git a/src/routes/(app)/profile/+page.svelte b/src/routes/(app)/profile/+page.svelte index 58bc47da..4d83b570 100644 --- a/src/routes/(app)/profile/+page.svelte +++ b/src/routes/(app)/profile/+page.svelte @@ -134,12 +134,28 @@ /** * Takes in number of minutes and converts to readable. - * eg into hours and minutes. + * eg into months, weeks, days, hours and minutes. */ - function toFormattedMinutes(m: number) { - const hours = Math.floor(m / 60); - const minutes = m % 60; - return `${hours ? `${hours}h ` : ""}${minutes}m`; + function toFormattedTimeLong(m: number) { + // Considers a 30 days long month + const countInMinutes = [ + ["month", 43200], + ["week", 10080], + ["day", 1440], + ["hour", 60] + ]; + + let ansString = ""; + let tmp; + for (const c of countInMinutes) { + tmp = Math.floor(m / (c[1] as number)); + + // Ignore fields with fewer than 1 unit + if (tmp) ansString += `${tmp} ${c[0]}${tmp >= 2 ? "s, " : ", "}`; + m -= tmp * (c[1] as number); + } + + return ansString.slice(0, -2); } @@ -163,10 +179,10 @@ - + {:catch err} diff --git a/src/routes/(app)/tv/[id]/+page.svelte b/src/routes/(app)/tv/[id]/+page.svelte index d7f53986..d927bbf5 100644 --- a/src/routes/(app)/tv/[id]/+page.svelte +++ b/src/routes/(app)/tv/[id]/+page.svelte @@ -145,7 +145,7 @@ {#if show?.episode_run_time?.length > 0} - {show.episode_run_time.join(",")}m + {show.episode_run_time.join(",")} min {/if}
diff --git a/src/store.ts b/src/store.ts index f812332b..e188fa9d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -24,7 +24,8 @@ export const activeSort = writable(defaultSort); export const activeFilters = writable({ type: [], status: [] }); export const appTheme = writable(); export const importedList = writable< - { data: string; type: "text-list" | "tmdb" | "movary" | "watcharr" | "myanimelist" } | undefined + | { data: string; type: "text-list" | "tmdb" | "movary" | "watcharr" | "myanimelist" | "ryot" } + | undefined >(); export const parsedImportedList = writable(); export const searchQuery = writable(""); diff --git a/src/types.ts b/src/types.ts index 2548b0e0..7c046896 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export type Icon = | "eye" | "star" | "movary" + | "ryot" | "refresh" | "gamepad" | "film"