From 3fb2567ae10b1e2afee8c69a1854fb0ec7ad9447 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Thu, 18 Apr 2024 17:34:40 +0200 Subject: [PATCH] feat: finish api package --- packages/api/src/auth.ts | 13 +- packages/api/src/bookmarks.ts | 55 ++++++++ packages/api/src/import.ts | 30 +++++ packages/api/src/index.ts | 10 ++ packages/api/src/login.ts | 24 +--- packages/api/src/meta.ts | 8 +- packages/api/src/progress.ts | 102 +++++++++++++++ packages/api/src/register.ts | 41 ++++++ packages/api/src/sessions.ts | 30 +---- packages/api/src/settings.ts | 20 +-- packages/api/src/types.ts | 231 ++++++++++++++++++++++++++++++++++ packages/api/src/user.ts | 133 ++++++++++++++++++++ 12 files changed, 615 insertions(+), 82 deletions(-) create mode 100644 packages/api/src/bookmarks.ts create mode 100644 packages/api/src/import.ts create mode 100644 packages/api/src/progress.ts create mode 100644 packages/api/src/register.ts create mode 100644 packages/api/src/types.ts create mode 100644 packages/api/src/user.ts diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 71b2317..99b6448 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -1,17 +1,6 @@ import { ofetch } from "ofetch"; -export interface SessionResponse { - id: string; - userId: string; - createdAt: string; - accessedAt: string; - device: string; - userAgent: string; -} -export interface LoginResponse { - session: SessionResponse; - token: string; -} +import type { LoginResponse } from "./types"; export function getAuthHeaders(token: string): Record { return { diff --git a/packages/api/src/bookmarks.ts b/packages/api/src/bookmarks.ts new file mode 100644 index 0000000..b9194b3 --- /dev/null +++ b/packages/api/src/bookmarks.ts @@ -0,0 +1,55 @@ +import { ofetch } from "ofetch"; + +import type { + AccountWithToken, + BookmarkInput, + BookmarkMediaItem, + BookmarkResponse, +} from "./types"; +import { getAuthHeaders } from "./auth"; + +export function bookmarkMediaToInput( + tmdbId: string, + item: BookmarkMediaItem, +): BookmarkInput { + return { + meta: { + title: item.title, + type: item.type, + poster: item.poster, + year: item.year ?? 0, + }, + tmdbId, + }; +} + +export async function addBookmark( + url: string, + account: AccountWithToken, + input: BookmarkInput, +) { + return ofetch( + `/users/${account.userId}/bookmarks/${input.tmdbId}`, + { + method: "POST", + headers: getAuthHeaders(account.token), + baseURL: url, + body: input, + }, + ); +} + +export async function removeBookmark( + url: string, + account: AccountWithToken, + id: string, +) { + return ofetch<{ tmdbId: string }>( + `/users/${account.userId}/bookmarks/${id}`, + { + method: "DELETE", + headers: getAuthHeaders(account.token), + baseURL: url, + }, + ); +} diff --git a/packages/api/src/import.ts b/packages/api/src/import.ts new file mode 100644 index 0000000..7d6dcca --- /dev/null +++ b/packages/api/src/import.ts @@ -0,0 +1,30 @@ +import { ofetch } from "ofetch"; + +import type { AccountWithToken, BookmarkInput, ProgressInput } from "./types"; +import { getAuthHeaders } from "./auth"; + +export function importProgress( + url: string, + account: AccountWithToken, + progressItems: ProgressInput[], +) { + return ofetch(`/users/${account.userId}/progress/import`, { + method: "PUT", + body: progressItems, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} + +export function importBookmarks( + url: string, + account: AccountWithToken, + bookmarks: BookmarkInput[], +) { + return ofetch(`/users/${account.userId}/bookmarks`, { + method: "PUT", + body: bookmarks, + baseURL: url, + headers: getAuthHeaders(account.token), + }); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 222676f..0718415 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,3 +1,13 @@ export const name = "api"; export * from "./auth"; +export * from "./bookmarks"; export * from "./crypto"; +export * from "./import"; +export * from "./login"; +export * from "./meta"; +export * from "./progress"; +export * from "./register"; +export * from "./sessions"; +export * from "./settings"; +export * from "./types"; +export * from "./user"; diff --git a/packages/api/src/login.ts b/packages/api/src/login.ts index 14d6fc4..5408b10 100644 --- a/packages/api/src/login.ts +++ b/packages/api/src/login.ts @@ -1,10 +1,10 @@ import { ofetch } from "ofetch"; -import type { SessionResponse } from "./auth"; - -export interface ChallengeTokenResponse { - challenge: string; -} +import type { + ChallengeTokenResponse, + LoginInput, + LoginResponse, +} from "./types"; export async function getLoginChallengeToken( url: string, @@ -19,20 +19,6 @@ export async function getLoginChallengeToken( }); } -export interface LoginResponse { - session: SessionResponse; - token: string; -} - -export interface LoginInput { - publicKey: string; - challenge: { - code: string; - signature: string; - }; - device: string; -} - export async function loginAccount( url: string, data: LoginInput, diff --git a/packages/api/src/meta.ts b/packages/api/src/meta.ts index 0886dc1..b2f8620 100644 --- a/packages/api/src/meta.ts +++ b/packages/api/src/meta.ts @@ -1,12 +1,6 @@ import { ofetch } from "ofetch"; -export interface MetaResponse { - version: string; - name: string; - description?: string; - hasCaptcha: boolean; - captchaClientKey?: string; -} +import type { MetaResponse } from "./types"; export async function getBackendMeta(url: string): Promise { return ofetch("/meta", { diff --git a/packages/api/src/progress.ts b/packages/api/src/progress.ts new file mode 100644 index 0000000..84fb818 --- /dev/null +++ b/packages/api/src/progress.ts @@ -0,0 +1,102 @@ +import { ofetch } from "ofetch"; + +import type { + AccountWithToken, + ProgressInput, + ProgressMediaItem, + ProgressResponse, + ProgressUpdateItem, +} from "./types"; +import { getAuthHeaders } from "./auth"; + +export function progressUpdateItemToInput( + item: ProgressUpdateItem, +): ProgressInput { + return { + duration: item.progress?.duration ?? 0, + watched: item.progress?.watched ?? 0, + tmdbId: item.tmdbId, + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + episodeId: item.episodeId, + seasonId: item.seasonId, + episodeNumber: item.episodeNumber, + seasonNumber: item.seasonNumber, + }; +} + +export function progressMediaItemToInputs( + tmdbId: string, + item: ProgressMediaItem, +): ProgressInput[] { + if (item.type === "show") { + return Object.entries(item.episodes).flatMap(([_, episode]) => ({ + duration: item.progress?.duration ?? episode.progress.duration, + watched: item.progress?.watched ?? episode.progress.watched, + tmdbId, + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + episodeId: episode.id, + seasonId: episode.seasonId, + episodeNumber: episode.number, + seasonNumber: item.seasons[episode.seasonId]?.number, + updatedAt: new Date(episode.updatedAt).toISOString(), + })); + } + return [ + { + duration: item.progress?.duration ?? 0, + watched: item.progress?.watched ?? 0, + tmdbId, + updatedAt: new Date(item.updatedAt).toISOString(), + meta: { + title: item.title ?? "", + type: item.type ?? "", + year: item.year ?? NaN, + poster: item.poster, + }, + }, + ]; +} + +export async function setProgress( + url: string, + account: AccountWithToken, + input: ProgressInput, +) { + return ofetch( + `/users/${account.userId}/progress/${input.tmdbId}`, + { + method: "PUT", + headers: getAuthHeaders(account.token), + baseURL: url, + body: input, + }, + ); +} + +export async function removeProgress( + url: string, + account: AccountWithToken, + id: string, + episodeId?: string, + seasonId?: string, +) { + await ofetch(`/users/${account.userId}/progress/${id}`, { + method: "DELETE", + headers: getAuthHeaders(account.token), + baseURL: url, + body: { + episodeId, + seasonId, + }, + }); +} diff --git a/packages/api/src/register.ts b/packages/api/src/register.ts new file mode 100644 index 0000000..d66444c --- /dev/null +++ b/packages/api/src/register.ts @@ -0,0 +1,41 @@ +import { ofetch } from "ofetch"; + +import type { + ChallengeTokenResponse, + RegisterInput, + SessionResponse, + UserResponse, +} from "./types"; + +export async function getRegisterChallengeToken( + url: string, + captchaToken?: string, +): Promise { + return ofetch("/auth/register/start", { + method: "POST", + body: { + captchaToken, + }, + baseURL: url, + }); +} + +export interface RegisterResponse { + user: UserResponse; + session: SessionResponse; + token: string; +} + +export async function registerAccount( + url: string, + data: RegisterInput, +): Promise { + return ofetch("/auth/register/complete", { + method: "POST", + body: { + namespace: "movie-web", + ...data, + }, + baseURL: url, + }); +} diff --git a/packages/api/src/sessions.ts b/packages/api/src/sessions.ts index c3869b4..79bb9c7 100644 --- a/packages/api/src/sessions.ts +++ b/packages/api/src/sessions.ts @@ -1,36 +1,8 @@ import { ofetch } from "ofetch"; +import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types"; import { getAuthHeaders } from "./auth"; -export interface SessionResponse { - id: string; - userId: string; - createdAt: string; - accessedAt: string; - device: string; - userAgent: string; -} - -export interface SessionUpdate { - deviceName: string; -} - -interface Account { - profile: { - colorA: string; - colorB: string; - icon: string; - }; -} - -export type AccountWithToken = Account & { - sessionId: string; - userId: string; - token: string; - seed: string; - deviceName: string; -}; - export async function getSessions(url: string, account: AccountWithToken) { return ofetch(`/users/${account.userId}/sessions`, { headers: getAuthHeaders(account.token), diff --git a/packages/api/src/settings.ts b/packages/api/src/settings.ts index 669a80d..b28b715 100644 --- a/packages/api/src/settings.ts +++ b/packages/api/src/settings.ts @@ -1,22 +1,12 @@ import { ofetch } from "ofetch"; -import type { AccountWithToken } from "./sessions"; +import type { + AccountWithToken, + SettingsInput, + SettingsResponse, +} from "./types"; import { getAuthHeaders } from "./auth"; -export interface SettingsInput { - applicationLanguage?: string; - applicationTheme?: string | null; - defaultSubtitleLanguage?: string; - proxyUrls?: string[] | null; -} - -export interface SettingsResponse { - applicationTheme?: string | null; - applicationLanguage?: string | null; - defaultSubtitleLanguage?: string | null; - proxyUrls?: string[] | null; -} - export function updateSettings( url: string, account: AccountWithToken, diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts new file mode 100644 index 0000000..fd5fba0 --- /dev/null +++ b/packages/api/src/types.ts @@ -0,0 +1,231 @@ +export interface SessionResponse { + id: string; + userId: string; + createdAt: string; + accessedAt: string; + device: string; + userAgent: string; +} +export interface LoginResponse { + session: SessionResponse; + token: string; +} + +export interface BookmarkMetaInput { + title: string; + year: number; + poster?: string; + type: string; +} + +export interface BookmarkInput { + tmdbId: string; + meta: BookmarkMetaInput; +} + +export interface ChallengeTokenResponse { + challenge: string; +} + +export interface LoginResponse { + session: SessionResponse; + token: string; +} + +export interface LoginInput { + publicKey: string; + challenge: { + code: string; + signature: string; + }; + device: string; +} + +export interface MetaResponse { + version: string; + name: string; + description?: string; + hasCaptcha: boolean; + captchaClientKey?: string; +} + +export interface RegisterInput { + publicKey: string; + challenge: { + code: string; + signature: string; + }; + device: string; + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export interface ProgressInput { + meta?: { + title: string; + year: number; + poster?: string; + type: string; + }; + tmdbId: string; + watched: number; + duration: number; + seasonId?: string; + episodeId?: string; + seasonNumber?: number; + episodeNumber?: number; + updatedAt?: string; +} + +export interface SessionResponse { + id: string; + userId: string; + createdAt: string; + accessedAt: string; + device: string; + userAgent: string; +} + +export interface SessionUpdate { + deviceName: string; +} + +interface Account { + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export type AccountWithToken = Account & { + sessionId: string; + userId: string; + token: string; + seed: string; + deviceName: string; +}; + +export interface SettingsInput { + applicationLanguage?: string; + applicationTheme?: string | null; + defaultSubtitleLanguage?: string; + proxyUrls?: string[] | null; +} + +export interface SettingsResponse { + applicationTheme?: string | null; + applicationLanguage?: string | null; + defaultSubtitleLanguage?: string | null; + proxyUrls?: string[] | null; +} + +export interface UserResponse { + id: string; + namespace: string; + name: string; + roles: string[]; + createdAt: string; + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export interface UserEdit { + profile?: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export interface BookmarkMediaItem { + title: string; + year?: number; + poster?: string; + type: "show" | "movie"; + updatedAt: number; +} + +export interface BookmarkResponse { + tmdbId: string; + meta: { + title: string; + year: number; + poster?: string; + type: "show" | "movie"; + }; + updatedAt: string; +} + +export interface ProgressItem { + watched: number; + duration: number; +} + +export interface ProgressSeasonItem { + title: string; + number: number; + id: string; +} + +export interface ProgressEpisodeItem { + title: string; + number: number; + id: string; + seasonId: string; + updatedAt: number; + progress: ProgressItem; +} + +export interface ProgressMediaItem { + title: string; + year?: number; + poster?: string; + type: "show" | "movie"; + progress?: ProgressItem; + updatedAt: number; + seasons: Record; + episodes: Record; +} + +export interface ProgressUpdateItem { + title?: string; + year?: number; + poster?: string; + type?: "show" | "movie"; + progress?: ProgressItem; + tmdbId: string; + id: string; + episodeId?: string; + seasonId?: string; + episodeNumber?: number; + seasonNumber?: number; + action: "upsert" | "delete"; +} + +export interface ProgressResponse { + tmdbId: string; + season: { + id?: string; + number?: number; + }; + episode: { + id?: string; + number?: number; + }; + meta: { + title: string; + year: number; + poster?: string; + type: "show" | "movie"; + }; + duration: string; + watched: string; + updatedAt: string; +} diff --git a/packages/api/src/user.ts b/packages/api/src/user.ts new file mode 100644 index 0000000..8d2e957 --- /dev/null +++ b/packages/api/src/user.ts @@ -0,0 +1,133 @@ +import { ofetch } from "ofetch"; + +import type { + AccountWithToken, + BookmarkMediaItem, + BookmarkResponse, + ProgressMediaItem, + ProgressResponse, + SessionResponse, + UserEdit, + UserResponse, +} from "./types"; +import { getAuthHeaders } from "./auth"; + +export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) { + const entries = responses.map((bookmark) => { + const item: BookmarkMediaItem = { + ...bookmark.meta, + updatedAt: new Date(bookmark.updatedAt).getTime(), + }; + return [bookmark.tmdbId, item] as const; + }); + + return Object.fromEntries(entries); +} + +export function progressResponsesToEntries(responses: ProgressResponse[]) { + const items: Record = {}; + + responses.forEach((v) => { + if (!items[v.tmdbId]) { + items[v.tmdbId] = { + title: v.meta.title, + poster: v.meta.poster, + type: v.meta.type, + updatedAt: new Date(v.updatedAt).getTime(), + episodes: {}, + seasons: {}, + year: v.meta.year, + }; + } + + const item = items[v.tmdbId]; + if (!item) return; + + // Since each watched episode is a single array entry but with the same tmdbId, the root item updatedAt will only have the first episode's timestamp (which is not the newest). + // Here, we are setting it explicitly so the updatedAt always has the highest updatedAt from the episodes. + if (new Date(v.updatedAt).getTime() > item.updatedAt) { + item.updatedAt = new Date(v.updatedAt).getTime(); + } + + if (item.type === "movie") { + item.progress = { + duration: Number(v.duration), + watched: Number(v.watched), + }; + } + + if (item.type === "show" && v.season.id && v.episode.id) { + item.seasons[v.season.id] = { + id: v.season.id, + number: v.season.number ?? 0, + title: "", + }; + item.episodes[v.episode.id] = { + id: v.episode.id, + number: v.episode.number ?? 0, + title: "", + progress: { + duration: Number(v.duration), + watched: Number(v.watched), + }, + seasonId: v.season.id, + updatedAt: new Date(v.updatedAt).getTime(), + }; + } + }); + + return items; +} + +export async function getUser( + url: string, + token: string, +): Promise<{ user: UserResponse; session: SessionResponse }> { + return ofetch<{ user: UserResponse; session: SessionResponse }>( + "/users/@me", + { + headers: getAuthHeaders(token), + baseURL: url, + }, + ); +} + +export async function editUser( + url: string, + account: AccountWithToken, + object: UserEdit, +): Promise<{ user: UserResponse; session: SessionResponse }> { + return ofetch<{ user: UserResponse; session: SessionResponse }>( + `/users/${account.userId}`, + { + method: "PATCH", + headers: getAuthHeaders(account.token), + body: object, + baseURL: url, + }, + ); +} + +export async function deleteUser( + url: string, + account: AccountWithToken, +): Promise { + return ofetch(`/users/${account.userId}`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +} + +export async function getBookmarks(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/bookmarks`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +} + +export async function getProgress(url: string, account: AccountWithToken) { + return ofetch(`/users/${account.userId}/progress`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +}