57 changed files with 1021 additions and 179 deletions
@ -1,6 +1,3 @@
@@ -1,6 +1,3 @@
|
||||
# make sure the cors proxy url does NOT have a slash at the end |
||||
VITE_CORS_PROXY_URL=... |
||||
|
||||
# the keys below are optional - defaults are provided |
||||
VITE_TMDB_API_KEY=... |
||||
VITE_OMDB_API_KEY=... |
||||
VITE_TMDB_READ_API_KEY=... |
||||
|
@ -1,6 +1,5 @@
@@ -1,6 +1,5 @@
|
||||
window.__CONFIG__ = { |
||||
// url must NOT end with a slash
|
||||
VITE_CORS_PROXY_URL: "", |
||||
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3", |
||||
VITE_OMDB_API_KEY: "aa0937c0", |
||||
VITE_TMDB_READ_API_KEY: "" |
||||
}; |
||||
|
@ -0,0 +1,239 @@
@@ -0,0 +1,239 @@
|
||||
import { conf } from "@/setup/config"; |
||||
|
||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw"; |
||||
import { |
||||
ExternalIdMovieSearchResult, |
||||
TMDBContentTypes, |
||||
TMDBEpisodeShort, |
||||
TMDBExternalIds, |
||||
TMDBMediaResult, |
||||
TMDBMovieData, |
||||
TMDBMovieExternalIds, |
||||
TMDBMovieResponse, |
||||
TMDBMovieResult, |
||||
TMDBSeason, |
||||
TMDBSeasonMetaResult, |
||||
TMDBShowData, |
||||
TMDBShowExternalIds, |
||||
TMDBShowResponse, |
||||
TMDBShowResult, |
||||
} from "./types/tmdb"; |
||||
import { mwFetch } from "../helpers/fetch"; |
||||
|
||||
export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes { |
||||
if (type === MWMediaType.MOVIE) return "movie"; |
||||
if (type === MWMediaType.SERIES) return "show"; |
||||
throw new Error("unsupported type"); |
||||
} |
||||
|
||||
export function TMDBMediaToMediaType(type: string): MWMediaType { |
||||
if (type === "movie") return MWMediaType.MOVIE; |
||||
if (type === "show") return MWMediaType.SERIES; |
||||
throw new Error("unsupported type"); |
||||
} |
||||
|
||||
export function formatTMDBMeta( |
||||
media: TMDBMediaResult, |
||||
season?: TMDBSeasonMetaResult |
||||
): MWMediaMeta { |
||||
const type = TMDBMediaToMediaType(media.object_type); |
||||
let seasons: undefined | MWSeasonMeta[]; |
||||
if (type === MWMediaType.SERIES) { |
||||
seasons = media.seasons |
||||
?.sort((a, b) => a.season_number - b.season_number) |
||||
.map( |
||||
(v): MWSeasonMeta => ({ |
||||
title: v.title, |
||||
id: v.id.toString(), |
||||
number: v.season_number, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
return { |
||||
title: media.title, |
||||
id: media.id.toString(), |
||||
year: media.original_release_year?.toString(), |
||||
poster: media.poster, |
||||
type, |
||||
seasons: seasons as any, |
||||
seasonData: season |
||||
? ({ |
||||
id: season.id.toString(), |
||||
number: season.season_number, |
||||
title: season.title, |
||||
episodes: season.episodes |
||||
.sort((a, b) => a.episode_number - b.episode_number) |
||||
.map((v) => ({ |
||||
id: v.id.toString(), |
||||
number: v.episode_number, |
||||
title: v.title, |
||||
})), |
||||
} as any) |
||||
: (undefined as any), |
||||
}; |
||||
} |
||||
|
||||
export function TMDBMediaToId(media: MWMediaMeta): string { |
||||
return ["tmdb", mediaTypeToTMDB(media.type), media.id].join("-"); |
||||
} |
||||
|
||||
export function decodeTMDBId( |
||||
paramId: string |
||||
): { id: string; type: MWMediaType } | null { |
||||
const [prefix, type, id] = paramId.split("-", 3); |
||||
if (prefix !== "tmdb") return null; |
||||
let mediaType; |
||||
try { |
||||
mediaType = TMDBMediaToMediaType(type); |
||||
} catch { |
||||
return null; |
||||
} |
||||
return { |
||||
type: mediaType, |
||||
id, |
||||
}; |
||||
} |
||||
|
||||
const baseURL = "https://api.themoviedb.org/3"; |
||||
|
||||
const headers = { |
||||
accept: "application/json", |
||||
Authorization: `Bearer ${conf().TMDB_READ_API_KEY}`, |
||||
}; |
||||
|
||||
async function get<T>(url: string, params?: object): Promise<T> { |
||||
const res = await mwFetch<any>(encodeURI(url), { |
||||
headers, |
||||
baseURL, |
||||
params: { |
||||
...params, |
||||
}, |
||||
}); |
||||
return res; |
||||
} |
||||
|
||||
export async function searchMedia( |
||||
query: string, |
||||
type: TMDBContentTypes |
||||
): Promise<TMDBMovieResponse | TMDBShowResponse> { |
||||
let data; |
||||
|
||||
switch (type) { |
||||
case "movie": |
||||
data = await get<TMDBMovieResponse>("search/movie", { |
||||
query, |
||||
include_adult: false, |
||||
language: "en-US", |
||||
page: 1, |
||||
}); |
||||
break; |
||||
case "show": |
||||
data = await get<TMDBShowResponse>("search/tv", { |
||||
query, |
||||
include_adult: false, |
||||
language: "en-US", |
||||
page: 1, |
||||
}); |
||||
break; |
||||
default: |
||||
throw new Error("Invalid media type"); |
||||
} |
||||
|
||||
return data; |
||||
} |
||||
|
||||
// Conditional type which for inferring the return type based on the content type
|
||||
type MediaDetailReturn<T extends TMDBContentTypes> = T extends "movie" |
||||
? TMDBMovieData |
||||
: T extends "show" |
||||
? TMDBShowData |
||||
: never; |
||||
|
||||
export function getMediaDetails< |
||||
T extends TMDBContentTypes, |
||||
TReturn = MediaDetailReturn<T> |
||||
>(id: string, type: T): Promise<TReturn> { |
||||
if (type === "movie") { |
||||
return get<TReturn>(`/movie/${id}`); |
||||
} |
||||
if (type === "show") { |
||||
return get<TReturn>(`/tv/${id}`); |
||||
} |
||||
throw new Error("Invalid media type"); |
||||
} |
||||
|
||||
export function getMediaPoster(posterPath: string | null): string | undefined { |
||||
if (posterPath) return `https://image.tmdb.org/t/p/w185/${posterPath}`; |
||||
} |
||||
|
||||
export async function getEpisodes( |
||||
id: string, |
||||
season: number |
||||
): Promise<TMDBEpisodeShort[]> { |
||||
const data = await get<TMDBSeason>(`/tv/${id}/season/${season}`); |
||||
return data.episodes.map((e) => ({ |
||||
id: e.id, |
||||
episode_number: e.episode_number, |
||||
title: e.name, |
||||
})); |
||||
} |
||||
|
||||
export async function getExternalIds( |
||||
id: string, |
||||
type: TMDBContentTypes |
||||
): Promise<TMDBExternalIds> { |
||||
let data; |
||||
|
||||
switch (type) { |
||||
case "movie": |
||||
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`); |
||||
break; |
||||
case "show": |
||||
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`); |
||||
break; |
||||
default: |
||||
throw new Error("Invalid media type"); |
||||
} |
||||
|
||||
return data; |
||||
} |
||||
|
||||
export async function getMovieFromExternalId( |
||||
imdbId: string |
||||
): Promise<string | undefined> { |
||||
const data = await get<ExternalIdMovieSearchResult>(`/find/${imdbId}`, { |
||||
external_source: "imdb_id", |
||||
}); |
||||
|
||||
const movie = data.movie_results[0]; |
||||
if (!movie) return undefined; |
||||
|
||||
return movie.id.toString(); |
||||
} |
||||
|
||||
export function formatTMDBSearchResult( |
||||
result: TMDBShowResult | TMDBMovieResult, |
||||
mediatype: TMDBContentTypes |
||||
): TMDBMediaResult { |
||||
const type = TMDBMediaToMediaType(mediatype); |
||||
if (type === MWMediaType.SERIES) { |
||||
const show = result as TMDBShowResult; |
||||
return { |
||||
title: show.name, |
||||
poster: getMediaPoster(show.poster_path), |
||||
id: show.id, |
||||
original_release_year: new Date(show.first_air_date).getFullYear(), |
||||
object_type: mediatype, |
||||
}; |
||||
} |
||||
const movie = result as TMDBMovieResult; |
||||
|
||||
return { |
||||
title: movie.title, |
||||
poster: getMediaPoster(movie.poster_path), |
||||
id: movie.id, |
||||
original_release_year: new Date(movie.release_date).getFullYear(), |
||||
object_type: mediatype, |
||||
}; |
||||
} |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
export type JWContentTypes = "movie" | "show"; |
||||
|
||||
export type JWSearchQuery = { |
||||
content_types: JWContentTypes[]; |
||||
page: number; |
||||
page_size: number; |
||||
query: string; |
||||
}; |
||||
|
||||
export type JWPage<T> = { |
||||
items: T[]; |
||||
page: number; |
||||
page_size: number; |
||||
total_pages: number; |
||||
total_results: number; |
||||
}; |
||||
|
||||
export const JW_API_BASE = "https://apis.justwatch.com"; |
||||
export const JW_IMAGE_BASE = "https://images.justwatch.com"; |
||||
|
||||
export type JWSeasonShort = { |
||||
title: string; |
||||
id: number; |
||||
season_number: number; |
||||
}; |
||||
|
||||
export type JWEpisodeShort = { |
||||
title: string; |
||||
id: number; |
||||
episode_number: number; |
||||
}; |
||||
|
||||
export type JWMediaResult = { |
||||
title: string; |
||||
poster?: string; |
||||
id: number; |
||||
original_release_year?: number; |
||||
jw_entity_id: string; |
||||
object_type: JWContentTypes; |
||||
seasons?: JWSeasonShort[]; |
||||
}; |
||||
|
||||
export type JWSeasonMetaResult = { |
||||
title: string; |
||||
id: string; |
||||
season_number: number; |
||||
episodes: JWEpisodeShort[]; |
||||
}; |
@ -0,0 +1,308 @@
@@ -0,0 +1,308 @@
|
||||
export type TMDBContentTypes = "movie" | "show"; |
||||
|
||||
export type TMDBSeasonShort = { |
||||
title: string; |
||||
id: number; |
||||
season_number: number; |
||||
}; |
||||
|
||||
export type TMDBEpisodeShort = { |
||||
title: string; |
||||
id: number; |
||||
episode_number: number; |
||||
}; |
||||
|
||||
export type TMDBMediaResult = { |
||||
title: string; |
||||
poster?: string; |
||||
id: number; |
||||
original_release_year?: number; |
||||
object_type: TMDBContentTypes; |
||||
seasons?: TMDBSeasonShort[]; |
||||
}; |
||||
|
||||
export type TMDBSeasonMetaResult = { |
||||
title: string; |
||||
id: string; |
||||
season_number: number; |
||||
episodes: TMDBEpisodeShort[]; |
||||
}; |
||||
|
||||
export interface TMDBShowData { |
||||
adult: boolean; |
||||
backdrop_path: string | null; |
||||
created_by: { |
||||
id: number; |
||||
credit_id: string; |
||||
name: string; |
||||
gender: number; |
||||
profile_path: string | null; |
||||
}[]; |
||||
episode_run_time: number[]; |
||||
first_air_date: string; |
||||
genres: { |
||||
id: number; |
||||
name: string; |
||||
}[]; |
||||
homepage: string; |
||||
id: number; |
||||
in_production: boolean; |
||||
languages: string[]; |
||||
last_air_date: string; |
||||
last_episode_to_air: { |
||||
id: number; |
||||
name: string; |
||||
overview: string; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
air_date: string; |
||||
episode_number: number; |
||||
production_code: string; |
||||
runtime: number | null; |
||||
season_number: number; |
||||
show_id: number; |
||||
still_path: string | null; |
||||
} | null; |
||||
name: string; |
||||
next_episode_to_air: { |
||||
id: number; |
||||
name: string; |
||||
overview: string; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
air_date: string; |
||||
episode_number: number; |
||||
production_code: string; |
||||
runtime: number | null; |
||||
season_number: number; |
||||
show_id: number; |
||||
still_path: string | null; |
||||
} | null; |
||||
networks: { |
||||
id: number; |
||||
logo_path: string; |
||||
name: string; |
||||
origin_country: string; |
||||
}[]; |
||||
number_of_episodes: number; |
||||
number_of_seasons: number; |
||||
origin_country: string[]; |
||||
original_language: string; |
||||
original_name: string; |
||||
overview: string; |
||||
popularity: number; |
||||
poster_path: string | null; |
||||
production_companies: { |
||||
id: number; |
||||
logo_path: string | null; |
||||
name: string; |
||||
origin_country: string; |
||||
}[]; |
||||
production_countries: { |
||||
iso_3166_1: string; |
||||
name: string; |
||||
}[]; |
||||
seasons: { |
||||
air_date: string; |
||||
episode_count: number; |
||||
id: number; |
||||
name: string; |
||||
overview: string; |
||||
poster_path: string | null; |
||||
season_number: number; |
||||
}[]; |
||||
spoken_languages: { |
||||
english_name: string; |
||||
iso_639_1: string; |
||||
name: string; |
||||
}[]; |
||||
status: string; |
||||
tagline: string; |
||||
type: string; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
} |
||||
|
||||
export interface TMDBMovieData { |
||||
adult: boolean; |
||||
backdrop_path: string | null; |
||||
belongs_to_collection: { |
||||
id: number; |
||||
name: string; |
||||
poster_path: string | null; |
||||
backdrop_path: string | null; |
||||
} | null; |
||||
budget: number; |
||||
genres: { |
||||
id: number; |
||||
name: string; |
||||
}[]; |
||||
homepage: string | null; |
||||
id: number; |
||||
imdb_id: string | null; |
||||
original_language: string; |
||||
original_title: string; |
||||
overview: string | null; |
||||
popularity: number; |
||||
poster_path: string | null; |
||||
production_companies: { |
||||
id: number; |
||||
logo_path: string | null; |
||||
name: string; |
||||
origin_country: string; |
||||
}[]; |
||||
production_countries: { |
||||
iso_3166_1: string; |
||||
name: string; |
||||
}[]; |
||||
release_date: string; |
||||
revenue: number; |
||||
runtime: number | null; |
||||
spoken_languages: { |
||||
english_name: string; |
||||
iso_639_1: string; |
||||
name: string; |
||||
}[]; |
||||
status: string; |
||||
tagline: string | null; |
||||
title: string; |
||||
video: boolean; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
} |
||||
|
||||
export interface TMDBEpisodeResult { |
||||
season: number; |
||||
number: number; |
||||
title: string; |
||||
ids: { |
||||
trakt: number; |
||||
tvdb: number; |
||||
imdb: string; |
||||
tmdb: number; |
||||
}; |
||||
} |
||||
|
||||
export interface TMDBShowResult { |
||||
adult: boolean; |
||||
backdrop_path: string | null; |
||||
genre_ids: number[]; |
||||
id: number; |
||||
origin_country: string[]; |
||||
original_language: string; |
||||
original_name: string; |
||||
overview: string; |
||||
popularity: number; |
||||
poster_path: string | null; |
||||
first_air_date: string; |
||||
name: string; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
} |
||||
|
||||
export interface TMDBShowResponse { |
||||
page: number; |
||||
results: TMDBShowResult[]; |
||||
total_pages: number; |
||||
total_results: number; |
||||
} |
||||
|
||||
export interface TMDBMovieResult { |
||||
adult: boolean; |
||||
backdrop_path: string | null; |
||||
genre_ids: number[]; |
||||
id: number; |
||||
original_language: string; |
||||
original_title: string; |
||||
overview: string; |
||||
popularity: number; |
||||
poster_path: string | null; |
||||
release_date: string; |
||||
title: string; |
||||
video: boolean; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
} |
||||
|
||||
export interface TMDBMovieResponse { |
||||
page: number; |
||||
results: TMDBMovieResult[]; |
||||
total_pages: number; |
||||
total_results: number; |
||||
} |
||||
|
||||
export interface TMDBEpisode { |
||||
air_date: string; |
||||
episode_number: number; |
||||
id: number; |
||||
name: string; |
||||
overview: string; |
||||
production_code: string; |
||||
runtime: number; |
||||
season_number: number; |
||||
show_id: number; |
||||
still_path: string | null; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
crew: any[]; |
||||
guest_stars: any[]; |
||||
} |
||||
|
||||
export interface TMDBSeason { |
||||
_id: string; |
||||
air_date: string; |
||||
episodes: TMDBEpisode[]; |
||||
name: string; |
||||
overview: string; |
||||
id: number; |
||||
poster_path: string | null; |
||||
season_number: number; |
||||
} |
||||
|
||||
export interface TMDBShowExternalIds { |
||||
id: number; |
||||
imdb_id: null | string; |
||||
freebase_mid: null | string; |
||||
freebase_id: null | string; |
||||
tvdb_id: number; |
||||
tvrage_id: null | string; |
||||
wikidata_id: null | string; |
||||
facebook_id: null | string; |
||||
instagram_id: null | string; |
||||
twitter_id: null | string; |
||||
} |
||||
|
||||
export interface TMDBMovieExternalIds { |
||||
id: number; |
||||
imdb_id: null | string; |
||||
wikidata_id: null | string; |
||||
facebook_id: null | string; |
||||
instagram_id: null | string; |
||||
twitter_id: null | string; |
||||
} |
||||
|
||||
export type TMDBExternalIds = TMDBShowExternalIds | TMDBMovieExternalIds; |
||||
|
||||
export interface ExternalIdMovieSearchResult { |
||||
movie_results: { |
||||
adult: boolean; |
||||
backdrop_path: string; |
||||
id: number; |
||||
title: string; |
||||
original_language: string; |
||||
original_title: string; |
||||
overview: string; |
||||
poster_path: string; |
||||
media_type: string; |
||||
genre_ids: number[]; |
||||
popularity: number; |
||||
release_date: string; |
||||
video: boolean; |
||||
vote_average: number; |
||||
vote_count: number; |
||||
}[]; |
||||
person_results: any[]; |
||||
tv_results: any[]; |
||||
tv_episode_results: any[]; |
||||
tv_season_results: any[]; |
||||
} |
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
import { getLegacyMetaFromId } from "@/backend/metadata/getmeta"; |
||||
import { |
||||
getEpisodes, |
||||
getMediaDetails, |
||||
getMovieFromExternalId, |
||||
} from "@/backend/metadata/tmdb"; |
||||
import { MWMediaType } from "@/backend/metadata/types/mw"; |
||||
import { BookmarkStoreData } from "@/state/bookmark/types"; |
||||
import { isNotNull } from "@/utils/typeguard"; |
||||
|
||||
import { WatchedStoreData } from "../types"; |
||||
|
||||
async function migrateId( |
||||
id: string, |
||||
type: MWMediaType |
||||
): Promise<string | undefined> { |
||||
const meta = await getLegacyMetaFromId(type, id); |
||||
|
||||
if (!meta) return undefined; |
||||
const { tmdbId, imdbId } = meta; |
||||
if (!tmdbId && !imdbId) return undefined; |
||||
|
||||
// movies always have an imdb id on tmdb
|
||||
if (imdbId && type === MWMediaType.MOVIE) { |
||||
const movieId = await getMovieFromExternalId(imdbId); |
||||
if (movieId) return movieId; |
||||
} |
||||
|
||||
if (tmdbId) { |
||||
return tmdbId; |
||||
} |
||||
} |
||||
|
||||
export async function migrateV2Bookmarks(old: BookmarkStoreData) { |
||||
const updatedBookmarks = old.bookmarks.map(async (item) => ({ |
||||
...item, |
||||
id: await migrateId(item.id, item.type).catch(() => undefined), |
||||
})); |
||||
|
||||
return { |
||||
bookmarks: (await Promise.all(updatedBookmarks)).filter((item) => item.id), |
||||
}; |
||||
} |
||||
|
||||
export async function migrateV3Videos( |
||||
old: WatchedStoreData |
||||
): Promise<WatchedStoreData> { |
||||
const updatedItems = await Promise.all( |
||||
old.items.map(async (progress) => { |
||||
try { |
||||
const migratedId = await migrateId( |
||||
progress.item.meta.id, |
||||
progress.item.meta.type |
||||
); |
||||
|
||||
if (!migratedId) return null; |
||||
|
||||
const clone = structuredClone(progress); |
||||
clone.item.meta.id = migratedId; |
||||
if (clone.item.series) { |
||||
const series = clone.item.series; |
||||
const details = await getMediaDetails(migratedId, "show"); |
||||
|
||||
const season = details.seasons.find( |
||||
(v) => v.season_number === series.season |
||||
); |
||||
if (!season) return null; |
||||
|
||||
const episodes = await getEpisodes(migratedId, season.season_number); |
||||
const episode = episodes.find( |
||||
(v) => v.episode_number === series.episode |
||||
); |
||||
if (!episode) return null; |
||||
|
||||
clone.item.series.episodeId = episode.id.toString(); |
||||
clone.item.series.seasonId = season.id.toString(); |
||||
} |
||||
|
||||
return clone; |
||||
} catch (err) { |
||||
return null; |
||||
} |
||||
}) |
||||
); |
||||
|
||||
return { |
||||
items: updatedItems.filter(isNotNull), |
||||
}; |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
export function isNotNull<T>(obj: T | null): obj is T { |
||||
return obj != null; |
||||
} |
Loading…
Reference in new issue