refactor: split modules and harden scraping workflows

This commit is contained in:
ShukeBta
2026-06-24 11:59:18 +08:00
parent efca3cbe69
commit 192f35d9fa
470 changed files with 47957 additions and 33839 deletions
+1
View File
@@ -24,6 +24,7 @@ export interface ExternalMediaResult {
bangumi_id?: number
douban_id?: string
subscribe_keyword: string
subscribe_aliases?: string[]
total_episodes?: number
downloaded_episodes?: number
local_media_count?: number
-58
View File
@@ -1,58 +0,0 @@
// API 配置 API 模块
import { api } from './client'
import type { ApiConfig, ApiProvider } from '../types'
// 获取所有 API 配置
export async function listApiConfigs(): Promise<ApiConfig[]> {
const resp = await api.get<ApiConfig[]>('/api-config')
return resp.data as unknown as ApiConfig[]
}
// 获取提供者列表
export async function getProviders(): Promise<ApiProvider[]> {
const resp = await api.get<ApiProvider[]>('/api-config/providers/list')
return resp.data as unknown as ApiProvider[]
}
// 获取指定提供者的配置
export async function getApiConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}`)
return resp.data as unknown as ApiConfig
}
// 获取生效的配置
export async function getEffectiveConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}/effective`)
return resp.data as unknown as ApiConfig
}
// 更新 API 配置
export interface UpdateApiConfigRequest {
api_key?: string
base_url?: string
extra?: string
enabled?: boolean
}
export async function updateApiConfig(
provider: string,
data: UpdateApiConfigRequest
): Promise<ApiConfig> {
const resp = await api.post<ApiConfig>(`/api-config/${provider}`, data)
return resp.data as unknown as ApiConfig
}
// 删除 API 配置
export async function deleteApiConfig(provider: string): Promise<void> {
await api.delete(`/api-config/${provider}`)
}
// 测试 API 连接
export interface TestApiConfigResponse {
result: 'success' | 'error' | 'invalid' | 'unknown'
}
export async function testApiConfig(provider: string): Promise<TestApiConfigResponse> {
const resp = await api.post<TestApiConfigResponse>(`/api-config/${provider}/test`)
return resp.data as unknown as TestApiConfigResponse
}
+20 -4
View File
@@ -151,11 +151,13 @@ export function hlsURL(mediaId: string): string {
// 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 function imageURL(remote?: string): string {
export function imageURL(remote?: string, version?: string): string {
if (!remote) return ''
if (remote.startsWith('/api/img')) return remote
if (remote.startsWith('/api/')) return withQuery(remote, tokenQuery())
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), versionQuery)
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), versionQuery)
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), versionQuery)
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, versionQuery)
}
function withQuery(url: string, query: string): string {
@@ -163,6 +165,20 @@ function withQuery(url: string, query: string): string {
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
+1
View File
@@ -16,6 +16,7 @@ export interface DiscoverItem extends Partial<Media> {
year?: number
rating?: number
subscribe_keyword?: string
subscribe_aliases?: string[]
}
export interface DiscoverSection {
-17
View File
@@ -1,17 +0,0 @@
import { api } from './client'
import type { DiscoverItem, DiscoverSection } from '../types'
// discoverExtraAPI wraps the Vue-style multi-section feed used by the
// React DiscoverPage rails. Use the existing /discover/trending and
// /discover/popular helpers for the simple cases.
export const discoverExtraAPI = {
sections: () =>
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
feed: (sectionKeys: string[]) =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
})
.then((r) => r.data),
}
+66 -10
View File
@@ -1,5 +1,6 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, Media, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage {
items: Media[]
@@ -15,6 +16,13 @@ export interface MediaSearchPage {
page_size?: number
}
export interface SeriesPage {
items: SeriesCard[]
total: number
page: number
page_size: number
}
export interface ManualScrapeCandidate {
source: string
media_type?: string
@@ -35,6 +43,15 @@ export interface ManualScrapeCandidate {
nsfw?: boolean
}
export interface ScrapeOptions {
episode_artwork?: boolean
episode_images?: boolean
refresh_matched?: boolean
include_matched?: boolean
}
export type ManualScrapeApplyOptions = ScrapeOptions
export interface MediaMetadataUpdate {
title?: string
original_name?: string
@@ -71,26 +88,51 @@ export const libraryAPI = {
scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string) =>
api.post(`/libraries/${id}/scrape`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50) =>
listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
params: {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeries: (id: string, page = 1, pageSize = 500) =>
api
.get<SeriesPage>(`/libraries/${id}/series`, {
params: { page, page_size: pageSize },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeriesEpisodes: (id: string, key: string) =>
api
.get<{ items: Media[]; total: number }>(`/libraries/${id}/series/episodes`, {
params: { key },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
}
export const mediaAPI = {
search: (q: string, limit = 50) =>
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50) =>
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: { q, page, page_size: pageSize },
params: {
q,
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
@@ -105,11 +147,25 @@ export const mediaAPI = {
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
.then((r) => r.data.items),
applyManualScrape: (id: string, match: ManualScrapeCandidate) =>
api.post<Media>(`/media/${id}/scrape/apply`, match, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate) =>
applyManualScrape: (id: string, match: ManualScrapeCandidate, options?: ManualScrapeApplyOptions) =>
api
.post<{ applied: number; errors?: string[] }>('/media/scrape/apply', { media_ids: mediaIDs, match }, { timeout: BATCH_REQUEST_TIMEOUT })
.post<Media>(
`/media/${id}/scrape/apply`,
episodeImageOption(options) === undefined ? match : { ...match, episode_images: episodeImageOption(options) },
{ timeout: LONG_REQUEST_TIMEOUT },
)
.then((r) => r.data),
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate, options?: ManualScrapeApplyOptions) =>
api
.post<{ applied: number; errors?: string[] }>(
'/media/scrape/apply',
{ media_ids: mediaIDs, match, episode_images: episodeImageOption(options) },
{ timeout: BATCH_REQUEST_TIMEOUT },
)
.then((r) => r.data),
}
function episodeImageOption(options?: ScrapeOptions): boolean | undefined {
return options?.episode_images ?? options?.episode_artwork
}
-19
View File
@@ -1,19 +0,0 @@
import { api } from './client'
import type { Media } from '../types'
// Auxiliary media surfaces used by the home page rails and the admin
// dashboard "library composition" card.
export const mediaExtraAPI = {
recent: (limit = 12) =>
api.get<Media[]>('/media/recent', { params: { limit } }).then((r) => r.data),
stats: () =>
api
.get<{
by_type: { movies: number; tv: number; anime: number; music: number; unscraped: number }
total: number
total_size: number
total_seconds: number
}>('/media/stats')
.then((r) => r.data),
}
-53
View File
@@ -1,53 +0,0 @@
import { api } from './client'
export interface UserPermission {
user_id: string
can_play_media: boolean
can_favorite: boolean
can_view_history: boolean
can_view_dashboard: boolean
can_view_discover: boolean
can_manage_downloads: boolean
can_manage_subscriptions: boolean
can_manage_sites: boolean
can_manage_files: boolean
can_manage_strm: boolean
can_cast: boolean
can_use_ai_assistant: boolean
can_access_settings: boolean
updated_at: string
}
type ApiEnvelope<T> = {
code?: number
message?: string
data?: T
}
function unwrap<T>(raw: T | ApiEnvelope<T>): T {
if (raw && typeof raw === 'object' && 'data' in raw && (raw as ApiEnvelope<T>).data !== undefined) {
return (raw as ApiEnvelope<T>).data as T
}
return raw as T
}
export const permissionsAPI = {
// Caller's effective permissions; admins always get the all-true set.
mine: () => api.get<UserPermission | ApiEnvelope<UserPermission>>('/auth/permissions').then((r) => unwrap(r.data)),
// Admin endpoints
get: (userID: string) =>
api
.get<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions`)
.then((r) => unwrap(r.data)),
save: (userID: string, p: UserPermission) =>
api
.put<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions`, p)
.then((r) => unwrap(r.data)),
reset: (userID: string) =>
api
.post<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions/reset`)
.then((r) => unwrap(r.data)),
}
+13 -2
View File
@@ -27,6 +27,11 @@ export interface ExternalPlayer {
url: string
}
function publicOriginHeader() {
if (typeof window === 'undefined' || !window.location?.origin) return undefined
return { 'X-MediaStation-Public-Origin': window.location.origin }
}
export const playbackAPI = {
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
@@ -70,9 +75,15 @@ export const playbackAPI = {
externalPlayers: (mediaId: string) =>
api
.get<{ players: ExternalPlayer[]; url?: string }>(`/playback/${mediaId}/external-players`)
.get<{ players: ExternalPlayer[]; url?: string }>(`/playback/${mediaId}/external-players`, {
headers: publicOriginHeader(),
})
.then((r) => r.data),
externalURL: (mediaId: string) =>
api.get<{ url: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data),
api
.get<{ url: string }>(`/playback/${mediaId}/external-url`, {
headers: publicOriginHeader(),
})
.then((r) => r.data),
}
-14
View File
@@ -1,14 +0,0 @@
import { api } from './client'
import type { Media } from '../types'
export interface SeasonGroup {
season: number
episodes: Media[]
}
export const seriesAPI = {
seasons: (libraryID: string) =>
api
.get<{ seasons: SeasonGroup[] }>(`/libraries/${libraryID}/seasons`)
.then((r) => r.data.seasons),
}
-40
View File
@@ -1,40 +0,0 @@
import { api } from './client'
import type { Hardware, Library, Media } from '../types'
// statsExtraAPI exposes the admin dashboard surfaces beyond /stats.
export const statsExtraAPI = {
overview: () =>
api
.get<{
libraries: number
media_count: number
users_count: number
total_size: number
total_seconds: number
generated_at: string
}>('/stats/overview')
.then((r) => r.data),
trend: (days = 14) =>
api
.get<{ trend: { day: string; count: number }[]; days: number }>('/stats/trend', {
params: { days },
})
.then((r) => r.data),
topContent: (limit = 10) =>
api
.get<{
items: { media: Media; play_count: number; last_played: string }[]
}>('/stats/top-content', { params: { limit } })
.then((r) => r.data),
libraries: () =>
api
.get<{
libraries: { library: Library; item_count: number; total_size: number }[]
}>('/stats/libraries')
.then((r) => r.data),
monitor: () => api.get<Hardware>('/stats/monitor').then((r) => r.data),
}
+15 -1
View File
@@ -1,6 +1,6 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
export type StorageType = 'alist' | 'openlist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2'
export type StorageType = 'alist' | 'openlist' | 'webdav' | 'cloud115' | 'clouddrive2'
export interface CloudEntry {
id: string
@@ -141,6 +141,20 @@ export const cloudAPI = {
})
.then((r) => r.data),
mkdir: (type: StorageType, dir: string, name: string) =>
api
.post<{ entry: CloudEntry }>(`/admin/cloud/${type}/mkdir`, { dir, name }, {
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
rename: (type: StorageType, ref: string, name: string) =>
api
.put<{ entry: CloudEntry }>(`/admin/cloud/${type}/rename`, { ref, name }, {
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
import: (type: StorageType, ref: string, name: string, size: number) =>
api
.post(`/admin/cloud/${type}/import`, { ref, name, size })
+1
View File
@@ -15,6 +15,7 @@ export type GenerateSTRMResult = {
generated: number
updated: number
skipped: number
cleaned: number
errors?: string[]
items?: Array<{
media_id: string
+24
View File
@@ -18,6 +18,28 @@ export function buildSiteSearchFeedURL(keyword: string, source?: string, aliases
return `site-search://search?${params.toString()}`
}
export function buildSubscriptionAliases(item: {
title?: string
original_name?: string
subscribe_keyword?: string
subscribe_aliases?: string[]
year?: number
}) {
const withYear = (value?: string) => {
const title = (value || '').trim()
if (!title) return ''
return item.year && item.year > 0 ? `${title} ${item.year}` : title
}
return [
...(item.subscribe_aliases || []),
item.title || '',
item.original_name || '',
withYear(item.title),
withYear(item.original_name),
item.subscribe_keyword || '',
]
}
export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
@@ -38,6 +60,8 @@ export const subscriptionsAPI = {
poster_url?: string
backdrop_url?: string
overview?: string
original_name?: string
year?: number
resolution?: string
quality?: string
effects?: string
+6 -4
View File
@@ -1,4 +1,5 @@
import { api } from './client'
import type { ScrapeOptions } from './library'
// toolsAPI groups admin-only endpoints that don't fit the other domain
// modules: organizing media files into the canonical naming layout, and
@@ -55,6 +56,7 @@ export const toolsAPI = {
organized: number
skipped: number
replaced?: number
reclassified?: number
source_path?: string
dest_path?: string
errors?: string[]
@@ -101,15 +103,15 @@ export const toolsAPI = {
// repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的
// {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。
// 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。
repairAndRescrapeAll: () =>
repairAndRescrapeAll: (options?: ScrapeOptions) =>
api
.post<{ status: string }>('/admin/media/repair-rescrape', {})
.post<{ status: string }>('/admin/media/repair-rescrape', options ?? {})
.then((r) => r.data),
// repairAndRescrapeLibrary 触发「单库修复+重刮」:只对指定媒体库回填占位符
// 外部 ID 并重刮,不影响其它库。后端异步执行,进度通过 WS "scrape" topic 推送。
repairAndRescrapeLibrary: (libraryID: string) =>
repairAndRescrapeLibrary: (libraryID: string, options?: ScrapeOptions) =>
api
.post<{ status: string }>(`/admin/libraries/${libraryID}/repair-rescrape`, {})
.post<{ status: string }>(`/admin/libraries/${libraryID}/repair-rescrape`, options ?? {})
.then((r) => r.data),
}