Files
MeBox/web/src/api/client.ts
T
2026-09-17 01:08:28 +08:00

286 lines
11 KiB
TypeScript

import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../stores/auth'
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and routes 401s back to the login page.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
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'))
}
// Add auth token to requests
api.interceptors.request.use((config) => {
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
try {
const refreshed = await useAuthStore.getState().tokenRefresh()
if (refreshed) {
const newToken = useAuthStore.getState().token
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
}
onTokenRefreshed(newToken || '')
isRefreshing = false
return api(originalRequest)
}
} catch (refreshError) {
isRefreshing = false
onTokenRefreshFailed(refreshError)
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
return Promise.reject(refreshError)
}
// Refresh failed, logout
isRefreshing = false
onTokenRefreshFailed(err)
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): 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)}` : ''
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${subtitle}${qualityQuery}${bust}`
}
// 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): void {
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
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
}
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
}