Files
MeBox/web/src/api/client.ts
T
2026-10-21 12:02:11 +08:00

332 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and refreshes it on 401. Only an explicit server-side rejection of
// the refresh token sends the user back to the login page; transient failures
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
// Serialize arrays as repeated params without brackets: genre=a&genre=b
// instead of axios's default genre[]=a&genre[]=b which Gin's QueryArray
// does not recognise.
paramsSerializer: { indexes: null },
})
export const LONG_REQUEST_TIMEOUT = 120_000
export const BATCH_REQUEST_TIMEOUT = 300_000
export const MIGRATION_REQUEST_TIMEOUT = 600_000
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
let refreshSubscribers: Array<{
resolve: (token: string) => void
reject: (error: unknown) => void
}> = []
// Subscribe to token refresh
function subscribeTokenRefresh(resolve: (token: string) => void, reject: (error: unknown) => void) {
refreshSubscribers.push({ resolve, reject })
}
// Notify all subscribers about new token
function onTokenRefreshed(newToken: string) {
refreshSubscribers.forEach((subscriber) => subscriber.resolve(newToken))
refreshSubscribers = []
}
function onTokenRefreshFailed(error: unknown) {
refreshSubscribers.forEach((subscriber) => subscriber.reject(error))
refreshSubscribers = []
}
function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
return Boolean(config?.url?.includes('/auth/refresh'))
}
// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自
// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出
// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器
// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。
const preflightAccessToken = createTokenPreflight({
readAccessToken: () => useAuthStore.getState().token,
refreshSession: () => useAuthStore.getState().refreshSession(),
})
// Add auth token to requests
api.interceptors.request.use(async (config) => {
// 刷新请求自己绝不能走预检,否则会递归刷新。
if (!isRefreshRequest(config)) {
try {
await preflightAccessToken()
} catch {
// 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。
// 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。
throw new axios.Cancel('session expired')
}
}
const token = useAuthStore.getState().token
if (token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
}
const activeProfileId = getActivePlayProfileId()
if (activeProfileId) {
config.headers = config.headers ?? {}
config.headers['X-Play-Profile-ID'] = activeProfileId
const pinToken = getActivePlayProfilePinToken()
if (pinToken) {
config.headers['X-Play-Profile-PIN-Token'] = pinToken
}
}
return config
})
// Handle 401 errors with token refresh
api.interceptors.response.use(
(resp) => resp,
async (err: AxiosError) => {
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
// If 401 and not already retried
if (
err.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isRefreshRequest(originalRequest)
) {
if (isRefreshing) {
// Wait for token refresh to complete
return new Promise((resolve, reject) => {
subscribeTokenRefresh((token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
resolve(api(originalRequest))
}, reject)
})
}
originalRequest._retry = true
isRefreshing = true
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
// 超时只是暂时不可用,保留本地令牌等下一次重试。
const outcome: RefreshOutcome = await useAuthStore
.getState()
.refreshSession()
.catch((): RefreshOutcome => 'transient')
isRefreshing = false
if (outcome === 'refreshed') {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
return api(originalRequest)
}
onTokenRefreshFailed(err)
if (outcome === 'invalid') {
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
// For other errors, just reject
return Promise.reject(err)
},
)
const tokenQuery = () => {
const t = useAuthStore.getState().token ?? ''
return `token=${encodeURIComponent(t)}`
}
const profileQuery = () => {
const id = getActivePlayProfileId()
if (!id) return ''
const pinToken = getActivePlayProfilePinToken()
return `&profile_id=${encodeURIComponent(id)}${
pinToken ? `&profile_pin_token=${encodeURIComponent(pinToken)}` : ''
}`
}
// streamURL returns a direct-play URL for <video src>. The JWT is added as
// a query parameter because <video> elements cannot send Authorization
// headers.
//
// proxy=true 时由服务端把网盘/STRM 直链转发为同源数据(画质与原文件一致、
// 不转码)。VR 全景渲染需要把视频帧读进 WebGL 纹理,跨域直链会被浏览器
// 判定为污染资源而禁止读取,因此只有这种场景才需要开启。
export function streamURL(mediaId: string, options: { proxy?: boolean } = {}): string {
const proxy = options.proxy ? '&proxy=1' : ''
return `/api/stream/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${proxy}`
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0, subtitleStream?: number, quality?: string, sessionID?: string): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
const start = `&start=${encodeURIComponent(String(safeStart))}`
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
// requests must not cancel the active ffmpeg job back to t=0.
const bust = `&_seek=${Date.now()}`
const subtitle =
subtitleStream !== undefined && subtitleStream >= 0
? `&subtitle=${encodeURIComponent(String(subtitleStream))}`
: ''
const qualityQuery = quality ? `&quality=${encodeURIComponent(quality)}` : ''
const session = sessionID ? `&session_id=${encodeURIComponent(sessionID)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}${session}`
}
// cloudHlsURL returns the MeBox-proxied 115 cloud HLS master playlist URL.
// A browser cannot fetch the 115 CDN directly because its CORS policy only
// allows https://115.com; the backend rewrites all child URLs to this
// same-origin proxy.
export function cloudHlsURL(mediaId: string, definition: string): string {
const quality = definition ? `&definition=${encodeURIComponent(definition)}` : ''
return `/api/cloud115/media/${encodeURIComponent(mediaId)}/master.m3u8?${tokenQuery()}${profileQuery()}${quality}&media_id=${encodeURIComponent(mediaId)}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
// navigation/tab close, where an axios promise can be discarded by browsers.
export function stopHLSJob(mediaId: string, sessionID?: string): void {
const session = sessionID ? `&session_id=${encodeURIComponent(sessionID)}` : ''
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}${session}`
void fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
}).catch(() => undefined)
}
// postPlaybackProgressKeepalive sends the final playback position without
// relying on an axios request surviving page navigation or tab close.
export function postPlaybackProgressKeepalive(payload: {
media_id: string
position_ms: number
duration_ms: number
session_id?: string
session_started_at_ms?: number
sequence?: number
}): void {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = useAuthStore.getState().token
if (token) headers.Authorization = `Bearer ${token}`
const activeProfileId = getActivePlayProfileId()
if (activeProfileId) {
headers['X-Play-Profile-ID'] = activeProfileId
const pinToken = getActivePlayProfilePinToken()
if (pinToken) headers['X-Play-Profile-PIN-Token'] = pinToken
}
void fetch('/api/history', {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
headers,
body: JSON.stringify(payload),
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
// can never be blocked by CORS / GFW. Empty strings pass through unchanged.
export type ImageURLOptions =
| boolean
| {
refreshCache?: boolean
retryFailed?: boolean
maxWidth?: number
maxHeight?: number
quality?: number
}
// ARTWORK is the shared set of thumbnail sizes the whole app requests.
//
// The server caches thumbnails per (source file, maxWidth, maxHeight, quality):
// every extra combination is another full decode of the original image (measured
// at 60-120ms for a 1920x1080 backdrop, up to ~800ms for a large poster) plus
// another cache file, so do not invent sizes at the call site — pick a preset.
// Keeping this list short is what stops a single page from triggering a dozen
// different decodes for the same artwork.
export const ARTWORK = {
/** 最小一档(160px):卡片的模糊占位图,以及 30-40px 级别的列表缩略图。 */
posterTiny: { maxWidth: 160, quality: 60 },
/** 海报列表与卡片(2:3 容器,紧凑布局共用同一份缓存)。 */
posterCard: { maxWidth: 480, maxHeight: 600, quality: 80 },
/** 海报详情页、剧集详情页头部与首页大图。 */
posterDetail: { maxWidth: 560, maxHeight: 840, quality: 80 },
/** 剧照大图:首页 hero、影片详情页背景。 */
backdropHero: { maxWidth: 1920, maxHeight: 1080, quality: 80 },
/** 剧照小图:媒体库封面、剧集条目、搜索结果等。 */
backdropStrip: { maxWidth: 480, maxHeight: 320, quality: 80 },
}
export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
if (!remote) return ''
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
const opts: Exclude<ImageURLOptions, boolean> = typeof options === 'boolean' ? {} : options
const retryFailed = typeof options === 'boolean' ? options : Boolean(opts.retryFailed)
const refreshCache = typeof options === 'boolean' ? false : Boolean(opts.refreshCache)
const retryQuery = retryFailed ? 'retry=1' : ''
const refreshQuery = refreshCache ? 'refresh=1' : ''
const resizeQuery = [
positiveDimensionQuery('maxWidth', opts.maxWidth),
positiveDimensionQuery('maxHeight', opts.maxHeight),
positiveDimensionQuery('quality', opts.quality, 100),
].filter(Boolean).join('&')
const imageQuery = [versionQuery, retryQuery, refreshQuery, resizeQuery].filter(Boolean).join('&')
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery)
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery)
}
function positiveDimensionQuery(name: string, value?: number, max = 10_000): string {
if (!Number.isFinite(value) || !value || value <= 0) return ''
return `${name}=${Math.min(Math.round(value), max)}`
}
function withQuery(url: string, query: string): string {
if (!query) return url
return `${url}${url.includes('?') ? '&' : '?'}${query}`
}
function withoutAuthQuery(url: string): string {
const hashIndex = url.indexOf('#')
const beforeHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url
const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''
const queryIndex = beforeHash.indexOf('?')
if (queryIndex < 0) return url
const path = beforeHash.slice(0, queryIndex)
const params = new URLSearchParams(beforeHash.slice(queryIndex + 1))
;['token', 'api_key', 'apiKey', 'ApiKey'].forEach((key) => params.delete(key))
const query = params.toString()
return `${path}${query ? `?${query}` : ''}${hash}`
}
// getToken returns the current auth token
export function getToken(): string | null {
return useAuthStore.getState().token
}
// getRefreshToken returns the current refresh token
export function getRefreshToken(): string | null {
return useAuthStore.getState().refreshToken
}