mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
286 lines
11 KiB
TypeScript
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
|
|
}
|