Files
mediahive/frontend/src/api.ts
T

311 lines
8.6 KiB
TypeScript

export interface PlayerStatus {
remote: boolean
}
export interface PlayerInfo {
id: string
name: string
family: string
path: string | null
}
export interface RootStatus {
root_id: string
path: string
status: string
error: string | null
snapshot_loaded: boolean
movies: number
series: number
}
export interface RootsResponse {
roots: RootStatus[]
}
export function normalizeMediaPath(input: string): string {
return input
.replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, "")
}
export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path))
}
export interface VideoSourceAttributes {
type: string
codecs: string
}
export function isSafariBrowser(): boolean {
if (typeof navigator === "undefined") {
return false
}
const ua = navigator.userAgent
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua)
}
export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) {
return url
}
if (url.includes("#")) {
return url
}
// Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`
}
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.webm$/i.test(path)) {
return { type: "video/webm", codecs: "av1" }
}
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: "video/mp4", codecs: "hvc1" }
}
if (/\.mov$/i.test(path)) {
return { type: "video/quicktime", codecs: "hvc1" }
}
if (/\.avi$/i.test(path)) {
return { type: "video/x-msvideo", codecs: "" }
}
if (/\.mkv$/i.test(path)) {
return { type: "video/x-matroska", codecs: "" }
}
return { type: "video/mp4", codecs: "hvc1" }
}
function encodePathSegments(path: string): string {
return path
.split("/")
.filter((segment) => segment.length > 0)
.map((segment) => encodeURIComponent(segment))
.join("/")
}
function normalizeCoverPath(path: string): string {
return path
.replace(/\\/g, "/")
.replace(/^[A-Za-z]:\//, "")
.replace(/^\/+/, "")
}
function toRootAssetPath(path: string): string | null {
const normalized = normalizeCoverPath(path)
const marker = ".mediahive/"
const idx = normalized.toLowerCase().indexOf(marker)
if (idx < 0) return null
const logical = normalized.slice(idx + marker.length)
return logical.length > 0 ? logical : null
}
function splitAssetTypePath(assetPath: string): { assetType: string; relativePath: string } | null {
const parts = assetPath.split("/").filter((segment) => segment.length > 0)
if (parts.length < 2) return null
const [assetType, ...rest] = parts
if (!assetType || !["movies", "series", "people"].includes(assetType.toLowerCase())) {
return null
}
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") }
}
/**
* Fetch active roots and their statuses
*/
export async function fetchRoots(): Promise<RootStatus[]> {
const response = await fetch("/api/roots")
if (!response.ok) {
throw new Error(`Failed to load roots: ${response.statusText}`)
}
const data = await response.json()
return data.roots || []
}
/**
* Fetch merged resume positions from all roots.
*/
export async function fetchResumePositions(): Promise<Record<string, number>> {
try {
const roots = await fetchRoots()
const merged: Record<string, number> = {}
await Promise.all(
roots.map(async (root) => {
const response = await fetch(`/api/meta/${encodeURIComponent(root.root_id)}/playback-state`)
if (!response.ok) return
const data = await response.json().catch(() => ({}))
const positions = data?.data?.resume_positions
if (positions && typeof positions === "object") {
Object.assign(merged, positions)
}
}),
)
return merged
} catch {
return {}
}
}
/**
* Replace the full root set atomically
*/
export async function replaceRoots(
roots: Record<string, string>,
): Promise<{ accepted: RootStatus[]; failed: unknown[] }> {
const response = await fetch("/api/roots", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roots }),
})
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(err.detail || response.statusText)
}
return response.json()
}
/**
* Fetch detected media players from the backend.
*/
export async function fetchPlayers(): Promise<PlayerInfo[]> {
const response = await fetch("/api/players")
if (!response.ok) {
throw new Error(`Failed to load players: ${response.statusText}`)
}
const data = await response.json()
return data.players || []
}
/**
* Play a media file with the selected player.
*/
export async function playMedia(
rootId: string,
filePath: string,
playerId?: string | null,
playerCustomCmd?: string | null,
): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { file_path: normalizedPath }
if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
try {
const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || response.statusText)
}
} catch (e) {
console.error("Play media error:", e)
alert(`Failed to play media.\n\n${e}`)
}
}
/**
* Open a folder in the system file manager
*/
export async function openFolder(rootId: string, folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath)
try {
const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder_path: normalizedPath }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || response.statusText)
}
} catch (e) {
console.error("Open folder error:", e)
alert(`Failed to open folder.\n\n${e}`)
}
}
/**
* Return whether MPC-BE local web control is currently reachable.
* @param port - Optional custom port (default 13579)
*/
export async function isMpcBeReachable(port?: number | null): Promise<boolean> {
try {
const url = port ? `/api/mpcbe/status?port=${port}` : "/api/mpcbe/status"
const response = await fetch(url)
if (!response.ok) return false
const data = await response.json().catch(() => ({}))
return Boolean(data.reachable)
} catch {
return false
}
}
/**
* Return player integration capabilities for the current OS.
* @param port - Optional custom MPC-BE port (default 13579)
*/
export async function getPlayerStatus(port?: number | null): Promise<PlayerStatus> {
const url = port ? `/api/player/status?port=${port}` : "/api/player/status"
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to load player status: ${response.statusText}`)
}
return response.json()
}
/**
* Convert a cover path to a displayable URL.
* Uses FastAPI server for async file serving.
*
* The path comes from the server already converted to Windows format (Z:\...)
* Paths starting with '/' are TMDB relative paths that weren't fetched - ignore them
*/
export function getCoverUrl(coverPath: string | null, rootId?: string | null): string {
if (!coverPath) {
return ""
}
// Ignore TMDB relative paths (start with /) - these are bugs in the index
if (coverPath.startsWith("/")) {
return ""
}
const rid = rootId || "unknown"
const assetPath = toRootAssetPath(coverPath)
if (assetPath) {
const split = splitAssetTypePath(assetPath)
if (split) {
return `/api/assets/${encodeURIComponent(rid)}/${encodeURIComponent(split.assetType)}/${encodePathSegments(split.relativePath)}`
}
}
const mediaPath = normalizeCoverPath(coverPath)
return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
}
/**
* Invoke the native OS folder picker via pywebview, then add the selected
* folder to the server's root list. Only works inside the packaged desktop app.
*/
export async function pickFolderAndAddRoot(): Promise<string | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).pywebview?.api
if (!api) return null
const folder: string | null = await api.pick_folder()
return folder
}