diff --git a/src/backend/metadata/search.ts b/src/backend/metadata/search.ts index 6f6ecc99..a162dd3a 100644 --- a/src/backend/metadata/search.ts +++ b/src/backend/metadata/search.ts @@ -1,22 +1,27 @@ import { SimpleCache } from "@/utils/cache"; +import { MediaItem } from "@/utils/mediaTypes"; -import { formatTMDBMeta, formatTMDBSearchResult, multiSearch } from "./tmdb"; -import { MWMediaMeta, MWQuery } from "./types/mw"; +import { + formatTMDBMetaToMediaItem, + formatTMDBSearchResult, + multiSearch, +} from "./tmdb"; +import { MWQuery } from "./types/mw"; -const cache = new SimpleCache(); +const cache = new SimpleCache(); cache.setCompare((a, b) => { return a.searchQuery.trim() === b.searchQuery.trim(); }); cache.initialize(); -export async function searchForMedia(query: MWQuery): Promise { - if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; +export async function searchForMedia(query: MWQuery): Promise { + if (cache.has(query)) return cache.get(query) as MediaItem[]; const { searchQuery } = query; const data = await multiSearch(searchQuery); const results = data.map((v) => { const formattedResult = formatTMDBSearchResult(v, v.media_type); - return formatTMDBMeta(formattedResult); + return formatTMDBMetaToMediaItem(formattedResult); }); cache.set(query, results, 3600); // cache results for 1 hour diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index d4e75e57..9a87eb19 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -1,6 +1,7 @@ import slugify from "slugify"; import { conf } from "@/setup/config"; +import { MediaItem } from "@/utils/mediaTypes"; import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; import { @@ -24,12 +25,26 @@ export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes { throw new Error("unsupported type"); } +export function mediaItemTypeToMediaType(type: MediaItem["type"]): MWMediaType { + if (type === "movie") return MWMediaType.MOVIE; + if (type === "show") return MWMediaType.SERIES; + throw new Error("unsupported type"); +} + export function TMDBMediaToMediaType(type: TMDBContentTypes): MWMediaType { if (type === TMDBContentTypes.MOVIE) return MWMediaType.MOVIE; if (type === TMDBContentTypes.TV) return MWMediaType.SERIES; throw new Error("unsupported type"); } +export function TMDBMediaToMediaItemType( + type: TMDBContentTypes +): MediaItem["type"] { + if (type === TMDBContentTypes.MOVIE) return "movie"; + if (type === TMDBContentTypes.TV) return "show"; + throw new Error("unsupported type"); +} + export function formatTMDBMeta( media: TMDBMediaResult, season?: TMDBSeasonMetaResult @@ -72,6 +87,18 @@ export function formatTMDBMeta( }; } +export function formatTMDBMetaToMediaItem(media: TMDBMediaResult): MediaItem { + const type = TMDBMediaToMediaItemType(media.object_type); + + return { + title: media.title, + id: media.id.toString(), + year: media.original_release_year ?? 0, + poster: media.poster, + type, + }; +} + export function TMDBIdToUrlId( type: MWMediaType, tmdbId: string, @@ -89,6 +116,14 @@ export function TMDBMediaToId(media: MWMediaMeta): string { return TMDBIdToUrlId(media.type, media.id, media.title); } +export function mediaItemToId(media: MediaItem): string { + return TMDBIdToUrlId( + mediaItemTypeToMediaType(media.type), + media.id, + media.title + ); +} + export function decodeTMDBId( paramId: string ): { id: string; type: MWMediaType } | null { diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 608458a8..2cfea5a3 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -2,16 +2,16 @@ import c from "classnames"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { TMDBMediaToId } from "@/backend/metadata/tmdb"; -import { MWMediaMeta } from "@/backend/metadata/types/mw"; +import { mediaItemToId } from "@/backend/metadata/tmdb"; import { DotList } from "@/components/text/DotList"; import { Flare } from "@/components/utils/Flare"; +import { MediaItem } from "@/utils/mediaTypes"; import { IconPatch } from "../buttons/IconPatch"; import { Icons } from "../Icon"; export interface MediaCardProps { - media: MWMediaMeta; + media: MediaItem; linkable?: boolean; series?: { episode: number; @@ -38,7 +38,7 @@ function MediaCardContent({ const canLink = linkable && !closable; const dotListContent = [t(`media.${media.type}`)]; - if (media.year) dotListContent.push(media.year); + if (media.year) dotListContent.push(media.year.toFixed()); return ( void; } -function formatSeries( - obj: - | { episodeId: string; seasonId: string; episode: number; season: number } - | undefined -) { +function formatSeries(obj: ProgressMediaItem | undefined) { if (!obj) return undefined; + if (obj.type !== "show") return; + // TODO only show latest episode watched + const ep = Object.values(obj.episodes)[0]; + const season = obj.seasons[ep?.seasonId]; + if (!ep || !season) return; return { - season: obj.season, - episode: obj.episode, - episodeId: obj.episodeId, - seasonId: obj.seasonId, + season: season.number, + episode: ep.number, + episodeId: ep.id, + seasonId: ep.seasonId, + progress: ep.progress, }; } export function WatchedMediaCard(props: WatchedMediaCardProps) { - const { watched } = useWatchedContext(); - const watchedMedia = useMemo(() => { - return watched.items - .sort((a, b) => b.watchedAt - a.watchedAt) - .find((v) => v.item.meta.id === props.media.id); - }, [watched, props.media]); + const progressItems = useProgressStore((s) => s.items); + const item = useMemo(() => { + return progressItems[props.media.id]; + }, [progressItems, props.media]); + const series = useMemo(() => formatSeries(item), [item]); + const progress = item?.progress ?? series?.progress; + const percentage = progress + ? (progress.watched / progress.duration) * 100 + : undefined; return ( diff --git a/src/components/player/hooks/usePlayerMeta.ts b/src/components/player/hooks/usePlayerMeta.ts index 6aac4c1c..0e6513dd 100644 --- a/src/components/player/hooks/usePlayerMeta.ts +++ b/src/components/player/hooks/usePlayerMeta.ts @@ -23,6 +23,7 @@ export function usePlayerMeta() { type: "show", releaseYear: +(m.meta.year ?? 0), title: m.meta.title, + poster: m.meta.poster, tmdbId: m.tmdbId ?? "", imdbId: m.imdbId, episode: { @@ -41,6 +42,7 @@ export function usePlayerMeta() { type: "movie", releaseYear: +(m.meta.year ?? 0), title: m.meta.title, + poster: m.meta.poster, tmdbId: m.tmdbId ?? "", imdbId: m.imdbId, }; diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx deleted file mode 100644 index 94b3b40e..00000000 --- a/src/pages/SearchPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { searchForMedia } from "@/backend/metadata/search"; -import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw"; -import { IconPatch } from "@/components/buttons/IconPatch"; -import { Icons } from "@/components/Icon"; -import { SectionHeading } from "@/components/layout/SectionHeading"; -import { MediaGrid } from "@/components/media/MediaGrid"; -import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; -import { useLoading } from "@/hooks/useLoading"; -import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; - -function SearchSuffix(props: { failed?: boolean; results?: number }) { - const { t } = useTranslation(); - - const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH; - - return ( -
- - - {/* standard suffix */} - {!props.failed ? ( -
- {(props.results ?? 0) > 0 ? ( -

{t("search.allResults")}

- ) : ( -

{t("search.noResults")}

- )} -
- ) : null} - - {/* Error result */} - {props.failed ? ( -
-

{t("search.allFailed")}

-
- ) : null} -
- ); -} - -export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { - const { t } = useTranslation(); - - const [results, setResults] = useState([]); - const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => - searchForMedia(query) - ); - - useEffect(() => { - async function runSearch(query: MWQuery) { - const searchResults = await runSearchQuery(query); - if (!searchResults) return; - setResults(searchResults); - } - - if (searchQuery.searchQuery !== "") runSearch(searchQuery); - }, [searchQuery, runSearchQuery]); - - if (loading) return ; - if (error) return ; - if (!results) return null; - - return ( -
- {results.length > 0 ? ( -
- - - {results.map((v) => ( - - ))} - -
- ) : null} - - -
- ); -} diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx index 7c8dfeb6..a27150ff 100644 --- a/src/pages/parts/home/BookmarksPart.tsx +++ b/src/pages/parts/home/BookmarksPart.tsx @@ -45,12 +45,13 @@ export function BookmarksPart() { {bookmarksSorted.map((v) => ( - setItemBookmark(v, false)} - /> +
Bookmark
+ // setItemBookmark(v, false)} + // /> ))}
diff --git a/src/pages/parts/home/WatchingPart.tsx b/src/pages/parts/home/WatchingPart.tsx index be222398..9c86b39a 100644 --- a/src/pages/parts/home/WatchingPart.tsx +++ b/src/pages/parts/home/WatchingPart.tsx @@ -1,5 +1,5 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditButton } from "@/components/buttons/EditButton"; @@ -7,25 +7,29 @@ import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; -import { - getIfBookmarkedFromPortable, - useBookmarkContext, -} from "@/state/bookmark"; -import { useWatchedContext } from "@/state/watched"; +import { useProgressStore } from "@/stores/progress"; +import { MediaItem } from "@/utils/mediaTypes"; export function WatchingPart() { const { t } = useTranslation(); - const { getFilteredBookmarks } = useBookmarkContext(); - const { getFilteredWatched, removeProgress } = useWatchedContext(); + const progressItems = useProgressStore((s) => s.items); + const removeItem = useProgressStore((s) => s.removeItem); const [editing, setEditing] = useState(false); const [gridRef] = useAutoAnimate(); - const bookmarks = getFilteredBookmarks(); - const watchedItems = getFilteredWatched().filter( - (v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta) - ); + const sortedProgressItems = useMemo(() => { + const output: MediaItem[] = []; + Object.entries(progressItems).forEach((entry) => { + output.push({ + id: entry[0], + ...entry[1], + }); + }); + // TODO sort on last modified date + return output; + }, [progressItems]); - if (watchedItems.length === 0) return null; + if (sortedProgressItems.length === 0) return null; return (
@@ -36,12 +40,12 @@ export function WatchingPart() { - {watchedItems.map((v) => ( + {sortedProgressItems.map((v) => ( removeProgress(v.item.meta.id)} + onClose={() => removeItem(v.id)} /> ))} diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx index 5935c517..a82ac23e 100644 --- a/src/pages/parts/search/SearchListPart.tsx +++ b/src/pages/parts/search/SearchListPart.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { searchForMedia } from "@/backend/metadata/search"; -import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw"; +import { MWQuery } from "@/backend/metadata/types/mw"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; @@ -10,6 +10,7 @@ import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { useLoading } from "@/hooks/useLoading"; import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; +import { MediaItem } from "@/utils/mediaTypes"; function SearchSuffix(props: { failed?: boolean; results?: number }) { const { t } = useTranslation(); @@ -47,7 +48,7 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) { export function SearchListPart({ searchQuery }: { searchQuery: string }) { const { t } = useTranslation(); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => searchForMedia(query) ); diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index e530108e..1d5fc8c3 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -15,7 +15,7 @@ }, "media": { "movie": "Movie", - "series": "Series", + "show": "Show", "stopEditing": "Stop editing", "errors": { "genericTitle": "Whoops, it broke!", diff --git a/src/state/watched/migrations/v2.ts b/src/state/watched/migrations/v2.ts index 2e92224e..6a11d589 100644 --- a/src/state/watched/migrations/v2.ts +++ b/src/state/watched/migrations/v2.ts @@ -1,5 +1,6 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { searchForMedia } from "@/backend/metadata/search"; +import { mediaItemTypeToMediaType } from "@/backend/metadata/tmdb"; import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types/mw"; import { compareTitle } from "@/utils/titleMatch"; @@ -69,8 +70,8 @@ async function getMetas( 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 (item.data.type === "show") { + const meta = await getMetaFromId(MWMediaType.SERIES, item.data.id); if (!meta || !meta?.meta.seasons) return; const seasonNumbers = [ ...new Set( @@ -95,7 +96,7 @@ async function getMetas( keys.map(async ([key, id]) => { if (!key) return; mediaMetas[item.id][key] = await getMetaFromId( - item.data.type, + mediaItemTypeToMediaType(item.data.type), item.data.id, id === "0" || id === null ? undefined : id ); diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 8c6143a4..c8af29fc 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -22,6 +22,7 @@ export interface PlayerMeta { tmdbId: string; imdbId?: string; releaseYear: number; + poster?: string; episode?: { number: number; tmdbId: string; diff --git a/src/stores/progress/index.ts b/src/stores/progress/index.ts index b84be1ca..55b66df7 100644 --- a/src/stores/progress/index.ts +++ b/src/stores/progress/index.ts @@ -26,6 +26,7 @@ export interface ProgressEpisodeItem { export interface ProgressMediaItem { title: string; year: number; + poster?: string; type: "show" | "movie"; progress?: ProgressItem; seasons: Record; @@ -40,6 +41,7 @@ export interface UpdateItemOptions { export interface ProgressStore { items: Record; updateItem(ops: UpdateItemOptions): void; + removeItem(id: string): void; } // TODO add migration from previous progress store @@ -47,6 +49,11 @@ export const useProgressStore = create( persist( immer((set) => ({ items: {}, + removeItem(id) { + set((s) => { + delete s.items[id]; + }); + }, updateItem({ meta, progress }) { set((s) => { if (!s.items[meta.tmdbId]) @@ -56,6 +63,7 @@ export const useProgressStore = create( seasons: {}, title: meta.title, year: meta.releaseYear, + poster: meta.poster, }; const item = s.items[meta.tmdbId]; if (meta.type === "movie") { diff --git a/src/utils/mediaTypes.ts b/src/utils/mediaTypes.ts new file mode 100644 index 00000000..6165f016 --- /dev/null +++ b/src/utils/mediaTypes.ts @@ -0,0 +1,7 @@ +export interface MediaItem { + id: string; + title: string; + year: number; + poster?: string; + type: "show" | "movie"; +}