Compare commits

...

2 Commits

Author SHA1 Message Date
Adrian Castro
9694630cdf Merge 3fb2567ae1 into a3f184979e 2024-04-18 15:34:48 +00:00
Adrian Castro
3fb2567ae1 feat: finish api package 2024-04-18 17:34:40 +02:00
12 changed files with 615 additions and 82 deletions

View File

@@ -1,17 +1,6 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
export interface SessionResponse { import type { LoginResponse } from "./types";
id: string;
userId: string;
createdAt: string;
accessedAt: string;
device: string;
userAgent: string;
}
export interface LoginResponse {
session: SessionResponse;
token: string;
}
export function getAuthHeaders(token: string): Record<string, string> { export function getAuthHeaders(token: string): Record<string, string> {
return { return {

View File

@@ -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<BookmarkResponse>(
`/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,
},
);
}

View File

@@ -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<void>(`/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<void>(`/users/${account.userId}/bookmarks`, {
method: "PUT",
body: bookmarks,
baseURL: url,
headers: getAuthHeaders(account.token),
});
}

View File

@@ -1,3 +1,13 @@
export const name = "api"; export const name = "api";
export * from "./auth"; export * from "./auth";
export * from "./bookmarks";
export * from "./crypto"; 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";

View File

@@ -1,10 +1,10 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
import type { SessionResponse } from "./auth"; import type {
ChallengeTokenResponse,
export interface ChallengeTokenResponse { LoginInput,
challenge: string; LoginResponse,
} } from "./types";
export async function getLoginChallengeToken( export async function getLoginChallengeToken(
url: string, 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( export async function loginAccount(
url: string, url: string,
data: LoginInput, data: LoginInput,

View File

@@ -1,12 +1,6 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
export interface MetaResponse { import type { MetaResponse } from "./types";
version: string;
name: string;
description?: string;
hasCaptcha: boolean;
captchaClientKey?: string;
}
export async function getBackendMeta(url: string): Promise<MetaResponse> { export async function getBackendMeta(url: string): Promise<MetaResponse> {
return ofetch<MetaResponse>("/meta", { return ofetch<MetaResponse>("/meta", {

View File

@@ -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<ProgressResponse>(
`/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,
},
});
}

View File

@@ -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<ChallengeTokenResponse> {
return ofetch<ChallengeTokenResponse>("/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<RegisterResponse> {
return ofetch<RegisterResponse>("/auth/register/complete", {
method: "POST",
body: {
namespace: "movie-web",
...data,
},
baseURL: url,
});
}

View File

@@ -1,36 +1,8 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
import type { AccountWithToken, SessionResponse, SessionUpdate } from "./types";
import { getAuthHeaders } from "./auth"; 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) { export async function getSessions(url: string, account: AccountWithToken) {
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, { return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
headers: getAuthHeaders(account.token), headers: getAuthHeaders(account.token),

View File

@@ -1,22 +1,12 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
import type { AccountWithToken } from "./sessions"; import type {
AccountWithToken,
SettingsInput,
SettingsResponse,
} from "./types";
import { getAuthHeaders } from "./auth"; 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( export function updateSettings(
url: string, url: string,
account: AccountWithToken, account: AccountWithToken,

231
packages/api/src/types.ts Normal file
View File

@@ -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<string, ProgressSeasonItem>;
episodes: Record<string, ProgressEpisodeItem>;
}
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;
}

133
packages/api/src/user.ts Normal file
View File

@@ -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<string, ProgressMediaItem> = {};
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<UserResponse> {
return ofetch<UserResponse>(`/users/${account.userId}`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function getBookmarks(url: string, account: AccountWithToken) {
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function getProgress(url: string, account: AccountWithToken) {
return ofetch<ProgressResponse[]>(`/users/${account.userId}/progress`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}