Files
91/backend/internal/config/config.go
T
nianzhibai 7e5e67697e feat: add GuangYaPan drive support
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow.

Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation.

Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition.

Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
2026-06-14 15:44:50 +08:00

302 lines
9.1 KiB
Go
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.
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "admin123"
)
var (
legacyDefaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
defaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"}
)
type Config struct {
Server Server `yaml:"server"`
Storage Storage `yaml:"storage"`
Scanner Scanner `yaml:"scanner"`
Preview Preview `yaml:"preview"`
Nightly Nightly `yaml:"nightly"`
Drives []Drive `yaml:"drives"`
}
type Server struct {
Listen string `yaml:"listen"`
Admin Admin `yaml:"admin"`
SessionSecret string `yaml:"session_secret"`
// AllowedOrigins 是允许跨源访问的前端 Origin 白名单(如 "https://video.example.com")。
// 默认空 → 不开启 CORS 跨源;同源部署(前后端在同一个域名 + 端口下)不需要配置此项。
// 浏览器对不在列表里的 Origin 不会拿到 Access-Control-Allow-Origin 头,自然就读不到响应。
// 不要写 "*";带 cookie 的 CORS 必须是具体 Origin。
AllowedOrigins []string `yaml:"allowed_origins"`
}
type Admin struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
func RequiresAdminSetup(c *Config) bool {
if c == nil {
return true
}
username := strings.TrimSpace(c.Server.Admin.Username)
password := c.Server.Admin.Password
if username == "" || password == "" {
return true
}
return username == DefaultAdminUsername && password == DefaultAdminPassword
}
func WriteAdminCredentials(path, username, password string) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
var root yaml.Node
if err := yaml.Unmarshal(b, &root); err != nil {
return fmt.Errorf("parse config: %w", err)
}
doc := ensureDocumentMapping(&root)
server := ensureMappingValue(doc, "server")
admin := ensureMappingValue(server, "admin")
setScalarValue(admin, "username", username)
setScalarValue(admin, "password", password)
var out bytes.Buffer
enc := yaml.NewEncoder(&out)
enc.SetIndent(2)
if err := enc.Encode(&root); err != nil {
_ = enc.Close()
return fmt.Errorf("encode config: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("encode config: %w", err)
}
mode := os.FileMode(0o644)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, out.Bytes(), mode); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace config: %w", err)
}
return nil
}
func ensureDocumentMapping(root *yaml.Node) *yaml.Node {
if root.Kind == 0 {
root.Kind = yaml.DocumentNode
root.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
}
if root.Kind != yaml.DocumentNode {
clone := *root
root.Kind = yaml.DocumentNode
root.Content = []*yaml.Node{&clone}
}
if len(root.Content) == 0 || root.Content[0] == nil {
root.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
}
if root.Content[0].Kind != yaml.MappingNode {
root.Content[0].Kind = yaml.MappingNode
root.Content[0].Content = nil
}
return root.Content[0]
}
func ensureMappingValue(parent *yaml.Node, key string) *yaml.Node {
if parent.Kind != yaml.MappingNode {
parent.Kind = yaml.MappingNode
parent.Content = nil
}
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
if parent.Content[i+1].Kind != yaml.MappingNode {
parent.Content[i+1].Kind = yaml.MappingNode
parent.Content[i+1].Content = nil
}
return parent.Content[i+1]
}
}
value := &yaml.Node{Kind: yaml.MappingNode}
parent.Content = append(parent.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
value,
)
return value
}
func setScalarValue(parent *yaml.Node, key, value string) {
if parent.Kind != yaml.MappingNode {
parent.Kind = yaml.MappingNode
parent.Content = nil
}
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
parent.Content[i+1].Kind = yaml.ScalarNode
parent.Content[i+1].Tag = "!!str"
parent.Content[i+1].Value = value
parent.Content[i+1].Content = nil
return
}
}
parent.Content = append(parent.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
)
}
type Storage struct {
DBPath string `yaml:"db_path"`
LocalPreviewDir string `yaml:"local_preview_dir"`
}
type Scanner struct {
// IntervalSeconds 已废弃。旧版每天 02:00–07:00 窗口内按这个间隔重复扫盘;
// 新版统一由 nightly.cron_hour 调度,此字段被忽略,保留仅为兼容旧 yaml。
IntervalSeconds int `yaml:"interval_seconds"`
MaxDepth int `yaml:"max_depth"`
VideoExtensions []string `yaml:"video_extensions"`
}
type Preview struct {
Enabled bool `yaml:"enabled"`
FFmpegPath string `yaml:"ffmpeg_path"`
FFprobePath string `yaml:"ffprobe_path"`
DurationSeconds int `yaml:"duration_seconds"`
Width int `yaml:"width"`
Segments int `yaml:"segments"`
}
// Nightly 是凌晨流水线(扫盘 → 91 爬虫 → 迁移)的调度配置。
//
// 一个进程只跑一条 nightly 流水线;该 cron 时间到达且当天还没跑过时触发,
// 也可被管理后台「扫描所有网盘」按钮手动触发。MaxDuration 是软超时,超过
// 后当前 phase 完成、后续 phase 不再启动。
type Nightly struct {
// CronHour 是每日触发整点(023);默认 1 表示 01:00。
CronHour int `yaml:"cron_hour"`
// MaxDuration 是单次流水线总耗时上限;默认 6h。
MaxDuration time.Duration `yaml:"max_duration"`
}
// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
type Drive struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage
Name string `yaml:"name"`
RootID string `yaml:"root_id"`
Params map[string]string `yaml:"params,omitempty"`
}
// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回
func Load(path string) (*Config, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
example := filepath.Join(filepath.Dir(path), "config.example.yaml")
data, err := os.ReadFile(example)
if err != nil {
return nil, fmt.Errorf("config not found and example missing: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return nil, fmt.Errorf("write default config: %w", err)
}
}
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var c Config
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
c.applyDefaults()
return &c, nil
}
func (c *Config) applyDefaults() {
if c.Server.Listen == "" {
c.Server.Listen = ":8080"
}
if c.Storage.DBPath == "" {
c.Storage.DBPath = "./data/video-site.db"
}
if c.Storage.LocalPreviewDir == "" {
c.Storage.LocalPreviewDir = "./data/previews"
}
if c.Scanner.MaxDepth == 0 {
c.Scanner.MaxDepth = 5
}
if len(c.Scanner.VideoExtensions) == 0 {
c.Scanner.VideoExtensions = append([]string{}, defaultVideoExtensions...)
} else if isLegacyDefaultVideoExtensions(c.Scanner.VideoExtensions) {
c.Scanner.VideoExtensions = append(c.Scanner.VideoExtensions, ".strm")
}
if c.Preview.FFmpegPath == "" {
c.Preview.FFmpegPath = "ffmpeg"
}
if c.Preview.FFprobePath == "" {
c.Preview.FFprobePath = "ffprobe"
}
if c.Preview.DurationSeconds != 3 {
c.Preview.DurationSeconds = 3
}
if c.Preview.Width == 0 {
c.Preview.Width = 480
}
if c.Preview.Segments == 0 {
c.Preview.Segments = 3
}
// Nightly defaults。CronHour=0 是合法值(午夜),没法用 zero-value 单独
// 区分"未设"和"显式 0"。把整个 nightly 块当 sentinel —— MaxDuration==0
// 视为整个块缺失,重置成 (cron_hour=1, max_duration=6h)。代价:用户想配
// CronHour=0(午夜)必须同时显式写 max_duration(任何 >0 的值即可)。
// 收益:默认部署(yaml 没 nightly 块)得到 01:00 + 6h,与用户预期一致。
if c.Nightly.MaxDuration <= 0 {
c.Nightly.CronHour = 1
c.Nightly.MaxDuration = 6 * time.Hour
} else if c.Nightly.CronHour < 0 || c.Nightly.CronHour > 23 {
c.Nightly.CronHour = 1
}
}
func isLegacyDefaultVideoExtensions(exts []string) bool {
if len(exts) != len(legacyDefaultVideoExtensions) {
return false
}
seen := make(map[string]struct{}, len(exts))
for _, ext := range exts {
seen[strings.ToLower(strings.TrimSpace(ext))] = struct{}{}
}
for _, ext := range legacyDefaultVideoExtensions {
if _, ok := seen[ext]; !ok {
return false
}
}
return true
}