mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
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
|
||
}
|