package service import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "unicode" "golang.org/x/sync/singleflight" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) // Legacy instance-level danmaku settings. Playback preferences now live on // model.User; these keys remain only as a fallback for unauthenticated/legacy // service paths and tests. const ( DanmakuEnabledKey = "danmaku.enabled" DanmakuSourceKey = "danmaku.source" DanmakuOpacityKey = "danmaku.opacity" DanmakuFontSizeKey = "danmaku.font_size" DanmakuAreaKey = "danmaku.area" // DanmakuAppIDKey / DanmakuAppKeyKey hold the optional dandanplay // DevCenter application credentials. When both are set they override the // built-in fallback pair (see danmaku_credentials.go). DanmakuAppIDKey = "danmaku.app_id" DanmakuAppKeyKey = "danmaku.app_key" ) // DanmakuDefaultSource is the official dandanplay endpoint used when the // admin leaves the source address empty. Any server implementing the // dandanplay protocol (search/episodes + comment/{episodeId}) may be used. const DanmakuDefaultSource = "https://api.dandanplay.net" // danmakuOfficialBase is where identification (/api/v2/match) and the // comment/search fallback always go, regardless of the configured source. // A package var (not a const) so tests can point it at a local server. var danmakuOfficialBase = DanmakuDefaultSource // DanmakuRenderConfig carries the renderer knobs to the web player. type DanmakuRenderConfig struct { Enabled bool `json:"enabled"` Source string `json:"source,omitempty"` AppID string `json:"app_id,omitempty"` Opacity string `json:"opacity"` FontSize string `json:"font_size"` Area string `json:"area"` Volume float64 `json:"volume"` // Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。 Vr360GuideSeen bool `json:"vr360_guide_seen"` // MergeSources 是当前用户的弹幕合并偏好(按用户存储)。 MergeSources bool `json:"merge_sources"` // AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。 AppKeyConfigured bool `json:"app_key_configured"` } // DanmakuFetchOptions 承载单次抓取的调用方偏好。 type DanmakuFetchOptions struct { // MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。 MergeSources bool // UserID 非空时使用该用户的弹幕源、凭据与渲染偏好。 UserID string } // DanmakuSettingsPatch 是播放器弹幕设置的局部更新。nil 字段保持不变。 type DanmakuSettingsPatch struct { Enabled *bool `json:"enabled"` Source *string `json:"source"` AppID *string `json:"app_id"` AppKey *string `json:"app_key"` ClearAppKey bool `json:"clear_app_key"` Opacity *float64 `json:"opacity"` FontSize *int `json:"font_size"` Area *float64 `json:"area"` MergeSources *bool `json:"merge_sources"` Volume *float64 `json:"volume"` // Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。 Vr360GuideSeen *bool `json:"vr360_guide_seen"` } type danmakuUserContextKey struct{} type danmakuUserContext struct { source string appID string appKey string } // ErrNoDanmakuSettings 表示更新请求没有包含任何可写字段。 var ErrNoDanmakuSettings = errors.New("no settings to update") // ErrInvalidDanmakuSettings 表示更新请求包含非法值。 var ErrInvalidDanmakuSettings = errors.New("invalid player settings") // DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream // comment payload; parsing happens client-side. The dandanplay protocol has // two payload shapes in the wild — the classic Bilibili-style XML and the // newer dandanplay JSON ({"count":N,"comments":[{p,m,t,...}]}) — so // source_type is sniffed from the body rather than fixed. // // Candidates is non-nil when multiple anime matched the search and the player // must ask the user which one to use (disambiguation); Raw is empty then. // AnimeTitle, EpisodeTitle, EpisodeID and MatchMode provide matched danmaku // metadata so the player UI can display which episode was loaded. type DanmakuFetchResult struct { DanmakuRenderConfig SourceType string `json:"source_type"` Raw string `json:"raw,omitempty"` Candidates []DanmakuAnime `json:"candidates,omitempty"` // Alternatives 是「同一集的其它可选来源」。与 Candidates 语义不同: // Candidates 表示自动匹配不唯一、必须由用户选择后才能加载弹幕; // Alternatives 表示弹幕已经自动加载好了,这里额外提供同集的其它来源 // (LogVar 聚合了多个视频网站,同一集常有多个库)供用户随时切换, // 不必再手动搜索一遍。 Alternatives []DanmakuAnime `json:"alternatives,omitempty"` AnimeTitle string `json:"anime_title,omitempty"` EpisodeTitle string `json:"episode_title,omitempty"` EpisodeID int64 `json:"episode_id,omitempty"` MatchMode string `json:"match_mode,omitempty"` // MergedSources 表示本次结果由多少个来源合并而成(未合并时为 0)。 MergedSources int `json:"merged_sources,omitempty"` } // DanmakuAnime is one search hit (an anime) with its episode list, mirroring // the dandanplay SearchEpisodesResponse shape. type DanmakuAnime struct { AnimeID int64 `json:"animeId"` AnimeTitle string `json:"animeTitle"` Episodes []DanmakuEpisode `json:"episodes"` } // DanmakuEpisode is one selectable danmaku library inside an anime. type DanmakuEpisode struct { EpisodeID int64 `json:"episodeId"` EpisodeTitle string `json:"episodeTitle"` } // DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id) // into a memory model.Media and a direct stream URL. type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error) // DanmakuService fetches danmaku for a media item through the dandanplay // protocol: match by 16MB-prefix hash, then search for an episode id by the // video's name, then fetch the comment library XML. The React player parses // and renders it. type DanmakuService struct { log *zap.Logger repo *repository.Container client *http.Client // strmResolve resolves a .strm play indirection into a fetchable target // (local path / redirect URL / proxied link). Wired by the builder to // StrmService.ResolvePlay; nil means strm sources are skipped. strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error) // remoteResolve resolves an Emby remote pseudo-ID into *model.Media and // direct stream URL for range hashing. remoteResolve DanmakuRemoteMediaResolver hashCacheMu sync.Mutex hashCache map[string]string // stamp → 16MB-prefix MD5 // resultCache 缓存整条弹幕抓取结果,避免同一集重复播放时重走 // 「16MB 哈希 + 上游搜索 + 评论拉取」这条高延迟链路。 resultCacheMu sync.Mutex resultCache map[string]danmakuResultCacheEntry fetchGroup singleflight.Group } func danmakuHTTPClient() *http.Client { return &http.Client{Timeout: 20 * time.Second} } func NewDanmakuService(log *zap.Logger, repo *repository.Container) *DanmakuService { if log == nil { log = zap.NewNop() } return &DanmakuService{ log: log, repo: repo, client: danmakuHTTPClient(), hashCache: make(map[string]string), resultCache: make(map[string]danmakuResultCacheEntry), } } // SetStrmResolver wires the strm play resolver used to fetch cloud video // bytes for hash computation. func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)) { if s != nil { s.strmResolve = resolve } } // SetRemoteMediaResolver wires the resolver used to fetch metadata and direct // stream URLs for Emby remote mounted media. func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) { if s != nil { s.remoteResolve = resolve } } // 弹幕抓取结果缓存:同一集在 TTL 内重复播放时直接返回,避免重复执行 // 「16MB 前置哈希 → 上游搜索 → 评论拉取」这条高延迟链路。弹幕库内容变化 // 很慢,而用户通常会在短时间内反复切集/回看,因此 TTL 取 6 小时。 const ( danmakuResultCacheTTL = 6 * time.Hour danmakuResultCacheMaxEntries = 64 // 候选列表由上游搜索决定,相对稳定但可能随源更新,缓存 10 分钟。 danmakuResultCacheCandidateTTL = 10 * time.Minute // 空结果可能只是上游临时抖动,只短暂缓存,避免长时间看不到弹幕。 danmakuResultCacheEmptyTTL = time.Minute ) type danmakuResultCacheEntry struct { result *DanmakuFetchResult expiresAt time.Time storedAt time.Time } func danmakuResultCacheKey(userID, source, credentialFingerprint, mediaID, keyword, episodeID string, merge bool) string { return strings.Join([]string{ strings.TrimSpace(userID), strings.TrimSpace(source), strings.TrimSpace(credentialFingerprint), mediaID, strings.TrimSpace(keyword), strings.TrimSpace(episodeID), strconv.FormatBool(merge), }, "\x00") } // danmakuCredentialFingerprint keeps credential changes in the result cache // key without persisting the AppSecret in plain text. func danmakuCredentialFingerprint(appID, appKey string) string { appID = strings.TrimSpace(appID) appKey = strings.TrimSpace(appKey) if appID == "" && appKey == "" { return "" } sum := md5.Sum([]byte(appID + "\x00" + appKey)) return hex.EncodeToString(sum[:]) } func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) { if s == nil || key == "" { return nil, false } now := time.Now() s.resultCacheMu.Lock() defer s.resultCacheMu.Unlock() entry, ok := s.resultCache[key] if !ok { return nil, false } if now.After(entry.expiresAt) { delete(s.resultCache, key) return nil, false } return cloneDanmakuFetchResult(entry.result), true } func (s *DanmakuService) resultCachePut(key string, result *DanmakuFetchResult) { if s == nil || key == "" || result == nil { return } now := time.Now() s.resultCacheMu.Lock() defer s.resultCacheMu.Unlock() if s.resultCache == nil { s.resultCache = make(map[string]danmakuResultCacheEntry) } ttl := danmakuResultCacheTTLFor(result) if ttl <= 0 { return } if _, exists := s.resultCache[key]; !exists && len(s.resultCache) >= danmakuResultCacheMaxEntries { oldestKey := "" var oldest time.Time for k, entry := range s.resultCache { if oldestKey == "" || entry.storedAt.Before(oldest) { oldestKey, oldest = k, entry.storedAt } } delete(s.resultCache, oldestKey) } s.resultCache[key] = danmakuResultCacheEntry{ result: cloneDanmakuFetchResult(result), expiresAt: now.Add(ttl), storedAt: now, } } // danmakuResultCacheTTLFor 按结果完整性选择缓存时长:拿到弹幕正文才值得 // 长缓存;只有候选列表时短缓存;空结果只缓存一分钟。 func danmakuResultCacheTTLFor(result *DanmakuFetchResult) time.Duration { if result == nil { return 0 } if strings.TrimSpace(result.Raw) != "" { return danmakuResultCacheTTL } if len(result.Candidates) > 0 { return danmakuResultCacheCandidateTTL } return danmakuResultCacheEmptyTTL } // cloneDanmakuFetchResult 深拷贝切片字段,避免缓存命中后调用方修改共享数据。 func cloneDanmakuFetchResult(in *DanmakuFetchResult) *DanmakuFetchResult { if in == nil { return nil } out := *in out.Candidates = cloneDanmakuAnimeList(in.Candidates) out.Alternatives = cloneDanmakuAnimeList(in.Alternatives) return &out } func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime { if in == nil { return nil } out := make([]DanmakuAnime, len(in)) for i, anime := range in { out[i] = anime out[i].Episodes = append([]DanmakuEpisode(nil), anime.Episodes...) } return out } // Config reads danmaku settings from the runtime settings table. func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { cfg := DanmakuRenderConfig{ Opacity: "1", FontSize: "24", Area: "1", Volume: 1, } if s == nil || s.repo == nil || s.repo.Setting == nil { return cfg } read := func(key, fallback string) string { v, err := s.repo.Setting.Get(ctx, key) if err != nil || v == "" { return fallback } return v } cfg.Enabled = ParseBoolSetting(read(DanmakuEnabledKey, "true"), true) cfg.Source = read(DanmakuSourceKey, "") cfg.Opacity = read(DanmakuOpacityKey, "1") cfg.FontSize = read(DanmakuFontSizeKey, "24") cfg.Area = read(DanmakuAreaKey, "1") return cfg } // ConfigForUser 返回当前用户的播放器与弹幕偏好。用户不存在时回退到旧版 // 全局配置,便于旧客户端和迁移期平滑工作。 func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig { if user, ok := s.findUser(ctx, userID); ok { return danmakuConfigFromUser(user) } return s.Config(ctx) } // MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。 func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool { if user, ok := s.findUser(ctx, userID); ok { return user.DanmakuMergeSources } return false } func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.User, bool) { if s == nil || s.repo == nil || s.repo.User == nil { return nil, false } userID = strings.TrimSpace(userID) if userID == "" { return nil, false } user, err := s.repo.User.FindByID(ctx, userID) return user, err == nil && user != nil } func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig { if user == nil { return DanmakuRenderConfig{ Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1, } } opacity := user.DanmakuOpacity if opacity < 0.1 || opacity > 1 { opacity = 1 } fontSize := user.DanmakuFontSize if fontSize < 10 || fontSize > 96 { fontSize = 24 } area := user.DanmakuArea if area < 0.1 || area > 1 { area = 1 } volume := user.PlayerVolume if volume < 0 || volume > 1 { volume = 1 } return DanmakuRenderConfig{ Enabled: user.DanmakuEnabled, Source: strings.TrimSpace(user.DanmakuSource), AppID: strings.TrimSpace(user.DanmakuAppID), Opacity: strconv.FormatFloat(opacity, 'f', -1, 64), FontSize: strconv.Itoa(fontSize), Area: strconv.FormatFloat(area, 'f', -1, 64), Volume: volume, Vr360GuideSeen: user.PlayerVr360GuideSeen, MergeSources: user.DanmakuMergeSources, AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "", } } // UpdateUserSettings 持久化当前用户的播放器与弹幕设置,并返回脱敏后的完整配置。 func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, patch DanmakuSettingsPatch) (DanmakuRenderConfig, error) { if s == nil || s.repo == nil || s.repo.User == nil { return DanmakuRenderConfig{}, errors.New("player settings unavailable") } userID = strings.TrimSpace(userID) if userID == "" { return DanmakuRenderConfig{}, errors.New("missing user") } updates := map[string]any{} if patch.Enabled != nil { updates["danmaku_enabled"] = *patch.Enabled } if patch.Source != nil { source, err := normalizeDanmakuSource(*patch.Source) if err != nil { return DanmakuRenderConfig{}, err } updates["danmaku_source"] = source } if patch.AppID != nil { appID := strings.TrimSpace(*patch.AppID) if len(appID) > 128 { return DanmakuRenderConfig{}, fmt.Errorf("%w: app_id is too long", ErrInvalidDanmakuSettings) } updates["danmaku_app_id"] = appID } if patch.ClearAppKey { updates["danmaku_app_key"] = "" } else if patch.AppKey != nil { if key := strings.TrimSpace(*patch.AppKey); key != "" { if len(key) > 256 { return DanmakuRenderConfig{}, fmt.Errorf("%w: app_key is too long", ErrInvalidDanmakuSettings) } updates["danmaku_app_key"] = key } } if patch.Opacity != nil { if *patch.Opacity < 0.1 || *patch.Opacity > 1 { return DanmakuRenderConfig{}, fmt.Errorf("%w: opacity must be between 0.1 and 1", ErrInvalidDanmakuSettings) } updates["danmaku_opacity"] = *patch.Opacity } if patch.FontSize != nil { if *patch.FontSize < 10 || *patch.FontSize > 96 { return DanmakuRenderConfig{}, fmt.Errorf("%w: font_size must be between 10 and 96", ErrInvalidDanmakuSettings) } updates["danmaku_font_size"] = *patch.FontSize } if patch.Area != nil { if *patch.Area < 0.1 || *patch.Area > 1 { return DanmakuRenderConfig{}, fmt.Errorf("%w: area must be between 0.1 and 1", ErrInvalidDanmakuSettings) } updates["danmaku_area"] = *patch.Area } if patch.MergeSources != nil { updates["danmaku_merge_sources"] = *patch.MergeSources } if patch.Volume != nil { if *patch.Volume < 0 || *patch.Volume > 1 { return DanmakuRenderConfig{}, fmt.Errorf("%w: volume must be between 0 and 1", ErrInvalidDanmakuSettings) } updates["player_volume"] = *patch.Volume } if patch.Vr360GuideSeen != nil { updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen } if len(updates) == 0 { return DanmakuRenderConfig{}, ErrNoDanmakuSettings } if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { return DanmakuRenderConfig{}, err } return s.ConfigForUser(ctx, userID), nil } // normalizeDanmakuSource validates a user-configured dandanplay endpoint. An // empty value means the official endpoint; only explicit HTTP(S) origins are // accepted so malformed values never reach the outbound request layer. func normalizeDanmakuSource(raw string) (string, error) { source := strings.TrimRight(strings.TrimSpace(raw), "/") if source == "" { return "", nil } if len(source) > 512 { return "", fmt.Errorf("%w: source is too long", ErrInvalidDanmakuSettings) } u, err := url.Parse(source) if err != nil || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.User != nil { return "", fmt.Errorf("%w: source must be an http(s) URL", ErrInvalidDanmakuSettings) } return source, nil } // SetMergeSources 持久化该用户的弹幕合并偏好,保留旧调用点兼容性。 func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error { _, err := s.UpdateUserSettings(ctx, userID, DanmakuSettingsPatch{MergeSources: &enabled}) return err } // Fetch retrieves danmaku for the given media. keyword overrides the // media-derived search term (empty = use the video's own name); pass it from // the player when the user searches for a custom title. episodeID forces a // specific danmaku library chosen by the user (from a previous disambiguation // response); empty means auto-resolution through the dandanplay protocol: // // 1. match: MD5 of the first 16MB of the video (local file read directly, // .strm resolved to a direct link and range-fetched) → /api/v2/match // against the official endpoint, which yields the episode library id. // 2. scraped metadata: title + episode number + episode title, filtered by // release year and subtitle to disambiguate same-name seasons. // 3. search by the playing file's name + episode number. // 4. current auto-identification (original name → title → file name + episode, // single hit used, several hits returned as candidates for the player). // 5. manual: the player picks from the returned candidates (episodeID / // keyword override). // // Comments are always fetched from the configured source first (when set) // and fall back to the official endpoint on failure. Only hash identification // always goes to the official endpoint. // // When danmaku is disabled the result carries Enabled=false so the player can // silently skip rendering. func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID string) (*DanmakuFetchResult, error) { return s.FetchWithOptions(ctx, mediaID, keyword, episodeID, DanmakuFetchOptions{}) } // FetchWithOptions 是 Fetch 的带偏好版本。结果按「配置源 + 媒体 + 关键词 + // 指定集 + 合并开关」缓存,并用 singleflight 合并并发请求,避免同一集被重复抓取。 func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { if s == nil { return nil, errors.New("danmaku service unavailable") } cfg := s.Config(ctx) credentialFingerprint := "" if user, ok := s.findUser(ctx, opts.UserID); ok { cfg = danmakuConfigFromUser(user) opts.MergeSources = cfg.MergeSources credentialFingerprint = danmakuCredentialFingerprint(user.DanmakuAppID, user.DanmakuAppKey) ctx = context.WithValue(ctx, danmakuUserContextKey{}, danmakuUserContext{ source: cfg.Source, appID: strings.TrimSpace(user.DanmakuAppID), appKey: strings.TrimSpace(user.DanmakuAppKey), }) } if !cfg.Enabled { return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil } key := danmakuResultCacheKey(opts.UserID, cfg.Source, credentialFingerprint, mediaID, keyword, episodeID, opts.MergeSources) if cached, ok := s.resultCacheGet(key); ok { cached.DanmakuRenderConfig = cfg return cached, nil } value, err, _ := s.fetchGroup.Do(key, func() (any, error) { // 等待期间可能已有同一 key 的请求写入缓存。 if cached, ok := s.resultCacheGet(key); ok { return cached, nil } res, err := s.fetchWithOptionsUncached(ctx, cfg, mediaID, keyword, episodeID, opts) if err != nil { // 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。 return res, err } s.resultCachePut(key, res) return res, nil }) res, _ := value.(*DanmakuFetchResult) if res != nil { res.DanmakuRenderConfig = cfg } if err != nil { return cloneDanmakuFetchResult(res), err } return cloneDanmakuFetchResult(res), nil } // fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。 func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, cfg DanmakuRenderConfig, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) { res := &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"} if !res.Enabled { return res, nil } configured := strings.TrimRight(strings.TrimSpace(res.Source), "/") official := danmakuOfficialBase // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 if target := strings.TrimSpace(episodeID); target != "" { raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) if err != nil { s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) return res, err } res.Raw, res.SourceType = raw, st if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil { res.EpisodeID = id } res.MatchMode = "manual" return res, nil } term, media, err := s.searchTerms(ctx, mediaID) if err != nil { return res, err } manualKeyword := strings.TrimSpace(keyword) != "" if kw := strings.TrimSpace(keyword); kw != "" { term.name = kw } if strings.TrimSpace(term.name) == "" { return res, nil } target := "" // targetBase 是 target 所属的源。各源的 episodeId 空间互相独立,必须用 // 产生该 ID 的源去请求弹幕,否则会拿到 404;默认沿用「配置源优先」行为。 targetBase := configured // hashOfficialID 记录 hash 层的官方 episodeId。仅当配置源重定位成功时 // 才填,用于「配置源该集无弹幕」时回官方兜底。 hashOfficialID := int64(0) // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) { if hash, ok := s.mediaHash(ctx, media); ok { fileSize := media.SizeBytes if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") { fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小 } matchName := danmakuMatchFileName(media.Path) if matchName == "" { matchName = term.name } matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec) if err != nil { s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err)) } else if len(matches) > 0 { match := matches[0] res.AnimeTitle = match.AnimeTitle res.EpisodeTitle = match.EpisodeTitle res.EpisodeID = match.EpisodeID res.MatchMode = "hash" // match 返回的是官方 ID 空间的 episodeId,直接拿去问第三方源 // 只会 404(实测各源 ID 空间独立)。先用官方给到的剧名+集数 // 在配置源里重定位到它自己的 episodeId;定位不到就整条走官方。 if configured != "" && !sameDanmakuBase(configured, official) { if matched, ok := s.lookupConfiguredEpisodes(ctx, configured, match); ok { configuredID := firstDanmakuEpisodeID(matched) target = strconv.FormatInt(configuredID, 10) targetBase = configured res.EpisodeID = configuredID hashOfficialID = match.EpisodeID // 同集有多个来源时全部带上,供面板里直接切换。 if len(matched) > 1 { res.Alternatives = matched } } } if target == "" { target = strconv.FormatInt(match.EpisodeID, 10) targetBase = official } } } } // 2) hash 未命中时,优先使用刮削后的剧名、集数和集标题匹配。 // 该层能解决官方 hash 库未收录、但本地已经刮削出准确季度和单集标题的情况。 if target == "" && !manualKeyword && media != nil { if matched, base, ok := s.lookupScrapedEpisodes(ctx, configured, official, media); ok { configuredID := firstDanmakuEpisodeID(matched) target = strconv.FormatInt(configuredID, 10) targetBase = base res.AnimeTitle = matched[0].AnimeTitle res.EpisodeTitle = matched[0].Episodes[0].EpisodeTitle res.EpisodeID = configuredID res.MatchMode = "metadata" if len(matched) > 1 { res.Alternatives = matched } } } // 3) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 4 层)。 if target == "" && !manualKeyword && media != nil && media.Path != "" { if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { if candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, fileName, term.episode); err == nil && len(candidates) == 1 && len(candidates[0].Episodes) > 0 { target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) targetBase = base res.AnimeTitle = candidates[0].AnimeTitle res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle res.EpisodeID = candidates[0].Episodes[0].EpisodeID res.MatchMode = "filename" } } } // 4) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, // 多结果返回候选列表交给播放器(歧义处理)。 if target == "" { candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, term.name, term.episode) if err != nil { s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err)) return res, err } if len(candidates) != 1 { res.Candidates = candidates return res, nil } if len(candidates[0].Episodes) == 0 { return res, errors.New("no danmaku library found for this video") } target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) targetBase = base res.AnimeTitle = candidates[0].AnimeTitle res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle res.EpisodeID = candidates[0].Episodes[0].EpisodeID res.MatchMode = "search" } raw, st, err := s.fetchCommentWithFallback(ctx, targetBase, official, target) if err != nil { s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) return res, err } // 第三方目录里存在该集,不代表它真的收录了弹幕(实测部分条目返回 // count=0)。这种「拿到空库」的情况要用官方 episodeId 再试一次,否则 // 重定位后反而会静默变成无弹幕 —— 旧写法是靠官方 ID 撞 404 才走到官方 // 兜底的,改用重定位就必须显式补上这一步。 if hashOfficialID != 0 && danmakuCommentCount(raw) == 0 { officialTarget := strconv.FormatInt(hashOfficialID, 10) if officialRaw, officialType, officialErr := s.fetchCommentFromBase(ctx, official, officialTarget); officialErr == nil && danmakuCommentCount(officialRaw) > 0 { raw, st = officialRaw, officialType res.EpisodeID = hashOfficialID } else if officialErr != nil { s.log.Debug("danmaku official fallback for empty configured library failed", zap.String("episode_id", officialTarget), zap.Error(officialErr)) } } // 合并多来源:仅在上游确实返回了多个同集来源时才有意义。 if opts.MergeSources && len(res.Alternatives) > 1 { if mergedRaw, mergedCount, ok := s.mergeAlternativeSources(ctx, targetBase, res.Alternatives, res.EpisodeID, raw); ok { raw, st = mergedRaw, "json" res.MergedSources = mergedCount } } res.Raw, res.SourceType = raw, st return res, nil } // mergeAlternativeSources 并发抓取同一集的多个来源,按「时间 + 内容」去重后 // 合并成单个载荷。已有载荷(existingRaw)会被复用,避免重复请求。 // 返回合并后的载荷、实际参与合并的来源数与是否成功。 func (s *DanmakuService) mergeAlternativeSources(ctx context.Context, base string, alternatives []DanmakuAnime, existingID int64, existingRaw string) (string, int, bool) { ids := make([]int64, 0, len(alternatives)+1) seen := map[int64]bool{} if existingID > 0 { seen[existingID] = true ids = append(ids, existingID) } for _, anime := range alternatives { for _, ep := range anime.Episodes { if ep.EpisodeID <= 0 || seen[ep.EpisodeID] { continue } seen[ep.EpisodeID] = true ids = append(ids, ep.EpisodeID) if len(ids) >= danmakuMergeMaxSources { break } } if len(ids) >= danmakuMergeMaxSources { break } } // 少于两个来源时无需合并。 if len(ids) < 2 { return "", 0, false } var ( mu sync.Mutex collected [][]danmakuComment wg sync.WaitGroup sem = make(chan struct{}, danmakuMergeConcurrency) ) collect := func(raw string) { comments := parseDanmakuComments(raw) if len(comments) == 0 { return } mu.Lock() collected = append(collected, comments) mu.Unlock() } for _, id := range ids { if id == existingID && existingRaw != "" { collect(existingRaw) continue } wg.Add(1) go func(id int64) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() body, _, err := s.fetchCommentFromBase(ctx, base, strconv.FormatInt(id, 10)) if err != nil { // 单个来源失败不影响整体合并,静默跳过。 return } collect(body) }(id) } wg.Wait() if len(collected) < 2 { return "", 0, false } merged := mergeDanmakuComments(collected) if len(merged) == 0 { return "", 0, false } encoded := encodeDanmakuComments(merged) if encoded == "" { return "", 0, false } return encoded, len(collected), true } // fetchCommentFromBase 从单个源拉取弹幕,不做任何回退。 func (s *DanmakuService) fetchCommentFromBase(ctx context.Context, base, target string) (raw, sourceType string, err error) { raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true) if err != nil { return "", "auto", err } return raw, detectDanmakuSourceType(raw), nil } // danmakuCommentCount 估算弹幕条数,用于判断某个源是否真的返回了内容。 // 同时兼容 dandanplay JSON 与 Bilibili XML 两种载荷。 func danmakuCommentCount(raw string) int { trimmed := strings.TrimSpace(raw) if trimmed == "" { return 0 } switch { case strings.HasPrefix(trimmed, "{"): var payload struct { Count int `json:"count"` Comments []json.RawMessage `json:"comments"` } if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { return 0 } if payload.Count > 0 { return payload.Count } return len(payload.Comments) case strings.HasPrefix(trimmed, "<"): return strings.Count(trimmed, " 0 { term.episode = strconv.Itoa(m.EpisodeNum) } return term, m, nil } if s == nil || s.repo == nil || s.repo.Media == nil { return term, nil, errors.New("media repository unavailable") } m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil { return term, nil, err } if m == nil { return term, nil, errors.New("media not found") } if name := strings.TrimSpace(m.OriginalName); name != "" { term.name = name } else if name := strings.TrimSpace(m.Title); name != "" { term.name = name } else { term.name = mediaSidecarBase(m.Path) if term.name == "" { term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)) } } if m.EpisodeNum > 0 { term.episode = strconv.Itoa(m.EpisodeNum) } return term, m, nil } // searchCandidates returns every anime hit for a name via the dandanplay // search endpoint, with the episode list per anime. The optional episode // number filters the result to that specific episode. Responses follow // SearchEpisodesResponse: { animes: [{ animeId, animeTitle, episodes: // [{ episodeId, episodeTitle }] }] }. func (s *DanmakuService) searchCandidates(ctx context.Context, base, name, episode string) ([]DanmakuAnime, error) { u := fmt.Sprintf("%s/api/v2/search/episodes?anime=%s&v2=true", base, url.QueryEscape(name)) if episode != "" { u += "&episode=" + url.QueryEscape(episode) } body, err := s.fetchBody(ctx, u, false) if err != nil { return nil, err } var resp struct { Animes []struct { AnimeID int64 `json:"animeId"` AnimeTitle string `json:"animeTitle"` Episodes []struct { EpisodeID int64 `json:"episodeId"` EpisodeTitle string `json:"episodeTitle"` } `json:"episodes"` } `json:"animes"` } if err := json.Unmarshal([]byte(body), &resp); err != nil { return nil, fmt.Errorf("danmaku search returned invalid JSON: %w", err) } var out []DanmakuAnime for _, anime := range resp.Animes { item := DanmakuAnime{AnimeID: anime.AnimeID, AnimeTitle: anime.AnimeTitle} for _, ep := range anime.Episodes { if ep.EpisodeID <= 0 { continue } item.Episodes = append(item.Episodes, DanmakuEpisode{ EpisodeID: ep.EpisodeID, EpisodeTitle: ep.EpisodeTitle, }) } if len(item.Episodes) == 0 { continue } out = append(out, item) } return out, nil } func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, followRedirect bool) (string, error) { client := s.client if !followRedirect { // Search returns plain JSON; the comment endpoint 302-redirects to a // danmaku accelerator CDN which the default client follows. copied := *s.client copied.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } client = &copied } req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "MeBox/danmaku (+https://github.com/truewhile/MeBox)") req.Header.Set("Accept", "application/json, application/xml, */*") if appID, appKey, ok := s.danmakuCredentials(ctx, sourceURL); ok { // 签名认证:base64(sha256(AppId+Timestamp+Path+Secret)),密钥不出服务器。 ts := time.Now().Unix() path := "/" if u, err := url.Parse(sourceURL); err == nil && u.Path != "" { path = u.Path } req.Header.Set("X-AppId", appID) req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10)) req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path)) } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("danmaku source returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 8 MiB cap if err != nil { return "", err } return string(body), nil } // danmakuCredentials resolves application credentials. Per-user explicit // credentials are used for any configured source. Legacy global credentials // and the built-in fallback pair are only sent to the official host, never to // third-party dandanplay protocol mirrors. func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) { u, err := url.Parse(sourceURL) if err != nil { return "", "", false } official, err := url.Parse(danmakuOfficialBase) if err != nil { return "", "", false } isOfficial := strings.EqualFold(u.Hostname(), official.Hostname()) if userCtx, ok := ctx.Value(danmakuUserContextKey{}).(danmakuUserContext); ok { userSource := strings.TrimSpace(userCtx.source) if userSource == "" { userSource = danmakuOfficialBase } if sameDanmakuBase(sourceURL, userSource) { id, key := strings.TrimSpace(userCtx.appID), strings.TrimSpace(userCtx.appKey) if id != "" && key != "" { return id, key, true } if id != "" || key != "" { s.log.Warn("danmaku user credentials incomplete, ignoring them", zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != "")) } } } if !isOfficial { return "", "", false } var id, key string if s != nil && s.repo != nil && s.repo.Setting != nil { id, _ = s.repo.Setting.Get(ctx, DanmakuAppIDKey) key, _ = s.repo.Setting.Get(ctx, DanmakuAppKeyKey) } id, key = strings.TrimSpace(id), strings.TrimSpace(key) if id != "" && key != "" { return id, key, true } if id != "" || key != "" { s.log.Warn("danmaku credentials incomplete, using built-in fallback", zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != "")) } embedID, embedKey := danmakuEmbeddedCredentials() return embedID, embedKey, true } // danmakuHashPrefixBytes 是 dandanplay match 规格要求的前 16MB 数据。 const danmakuHashPrefixBytes = 16 << 20 const danmakuHashCacheMax = 256 func (s *DanmakuService) hashCacheGet(stamp string) (string, bool) { s.hashCacheMu.Lock() defer s.hashCacheMu.Unlock() h, ok := s.hashCache[stamp] return h, ok } func (s *DanmakuService) hashCachePut(stamp, hash string) { s.hashCacheMu.Lock() defer s.hashCacheMu.Unlock() if len(s.hashCache) >= danmakuHashCacheMax { // 简单淘汰:满了整体清空;哈希只用于重复播放时的缓存命中。 s.hashCache = make(map[string]string) } s.hashCache[stamp] = hash } // danmakuMatchFileName derives the /api/v2/match fileName: base name without // the final extension. .strm items are covered too — MeBox strm files drop the // video extension ("xxx.strm") while pre-existing ones may keep it // ("xxx.mkv.strm") — so a second strip removes a real video extension only // (filepath.Ext would misread names like "xxx.第01话" as having an extension). func danmakuMatchFileName(path string) string { return mediaSidecarBase(path) } // mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the // video). Local videos are hashed straight from disk; .strm indirections and // remote Emby streams are range-fetched and only the 16MB prefix is downloaded. func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) { if media == nil { return "", false } if IsEmbyRemoteID(media.ID) { return s.hashEmbyRemote(ctx, media) } if media.Path == "" { return "", false } if strings.EqualFold(filepath.Ext(media.Path), ".strm") { target := media.STRMURL if target == "" { parsed, err := readLocalSTRMTarget(media.Path) if err != nil || parsed == "" { return "", false } target = parsed } return s.hashStrmTarget(ctx, target) } return s.hashLocalFile(media.Path) } // hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range. func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) { if media == nil || media.ID == "" { return "", false } if h, ok := s.hashCacheGet("e|" + media.ID); ok { return h, true } if s.remoteResolve == nil { return "", false } _, streamURL, err := s.remoteResolve(ctx, media.ID) if err != nil || strings.TrimSpace(streamURL) == "" { if err != nil { s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped", zap.String("media_id", media.ID), zap.Error(err)) } return "", false } body, err := s.openRangeBody(ctx, streamURL, nil) if err != nil || body == nil { if err != nil { s.log.Warn("danmaku emby range fetch failed, hash layer skipped", zap.String("media_id", media.ID), zap.Error(err)) } return "", false } defer body.Close() h := md5.New() if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil { return "", false } hash := hex.EncodeToString(h.Sum(nil)) s.hashCachePut("e|"+media.ID, hash) return hash, true } // hashLocalFile computes the MD5 of the first 16MB of a local video, cached // by path+size+mtime so repeated danmaku loads skip the disk read. func (s *DanmakuService) hashLocalFile(path string) (string, bool) { info, err := os.Stat(path) if err != nil || info.IsDir() { return "", false } stamp := fmt.Sprintf("f|%s|%d|%d", path, info.Size(), info.ModTime().UnixNano()) if h, ok := s.hashCacheGet(stamp); ok { return h, true } f, err := os.Open(path) // #nosec G304 -- path 来自已入库的媒体行 if err != nil { return "", false } defer f.Close() h := md5.New() if _, err := io.Copy(h, io.LimitReader(f, danmakuHashPrefixBytes)); err != nil { return "", false } hash := hex.EncodeToString(h.Sum(nil)) s.hashCachePut(stamp, hash) return hash, true } // hashStrmTarget computes the video hash behind a .strm indirection: // MeBox-internal /api/strm/play URLs are resolved through strmResolve (local // path read directly, cloud links range-fetched); plain http(s) links are // fetched directly. Only the 16MB prefix is ever downloaded. func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string, bool) { if h, ok := s.hashCacheGet("s|" + raw); ok { return h, true } u, err := url.Parse(raw) if err != nil { return "", false } var ( src *StrmPlayResult body io.ReadCloser ) switch { case strings.HasPrefix(u.Path, "/api/strm/play/"): // /api/strm/play/{provider}/video{ext}?acct=..&pickcode=.. segs := strings.Split(strings.TrimPrefix(u.Path, "/api/strm/play/"), "/") if len(segs) < 2 || s.strmResolve == nil { return "", false } src, err = s.strmResolve(ctx, segs[0], u.Query()) if err != nil || src == nil { s.log.Warn("danmaku strm resolve failed, hash layer skipped", zap.String("provider", segs[0]), zap.Error(err)) return "", false } case u.Scheme == "http" || u.Scheme == "https": src = &StrmPlayResult{RedirectURL: raw} default: // webdav/alist 等协议无法直接用标准 HTTP 拉取,交给搜索层兜底。 return "", false } switch { case src.LocalPath != "": return s.hashLocalFile(src.LocalPath) case src.RedirectURL != "": var headers map[string]string if src.Link != nil { headers = src.Link.Headers // 115 直链防盗链要求携带绑定 UA } body, err = s.openRangeBody(ctx, src.RedirectURL, headers) case src.Link != nil && src.Link.URL != "": body, err = s.openRangeBody(ctx, src.Link.URL, src.Link.Headers) default: return "", false } if err != nil || body == nil { if err != nil { s.log.Warn("danmaku hash prefix fetch failed, hash layer skipped", zap.String("target", raw), zap.Error(err)) } return "", false } defer body.Close() h := md5.New() if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil { return "", false } hash := hex.EncodeToString(h.Sum(nil)) s.hashCachePut("s|"+raw, hash) return hash, true } // openRangeBody issues a Range request for the 16MB video prefix. Range is a // suggestion — servers that ignore it are capped by the caller's LimitReader. func (s *DanmakuService) openRangeBody(ctx context.Context, target string, headers map[string]string) (io.ReadCloser, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "MeBox/danmaku (+https://github.com/truewhile/MeBox)") req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", danmakuHashPrefixBytes-1)) for k, v := range headers { req.Header.Set(k, v) } client := s.client if client == nil { client = danmakuHTTPClient() } resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { resp.Body.Close() return nil, fmt.Errorf("hash range fetch returned HTTP %d", resp.StatusCode) } return resp.Body, nil } // danmakuMatch mirrors one hit of the /api/v2/match response. type danmakuMatch struct { EpisodeID int64 `json:"episodeId"` AnimeID int64 `json:"animeId"` AnimeTitle string `json:"animeTitle"` EpisodeTitle string `json:"episodeTitle"` } // matchOfficial identifies the video via POST /api/v2/match on the official // endpoint (always official, signed with the app credentials). Returns the // candidate list only when the upstream explicitly reports a confident match; // empty means nothing matched. The API may return fuzzy suggestions together // with isMatched=false; those are not safe to auto-select because their first // item can belong to an unrelated anime. // // fileName must be URL-escaped: the official API rejects raw non-ASCII file // names with errorCode 2 (verified against the live API — QueryEscape's // percent-encoding with "+" for space is accepted). func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash string, fileSize int64, durationSec int) ([]danmakuMatch, error) { appID, appKey, ok := s.danmakuCredentials(ctx, danmakuOfficialBase) if !ok { return nil, errors.New("danmaku credentials unavailable") } payload := struct { FileName string `json:"fileName"` FileHash string `json:"fileHash"` FileSize int64 `json:"fileSize"` VideoDuration int `json:"videoDuration"` MatchMode string `json:"matchMode"` }{ FileName: url.QueryEscape(fileName), FileHash: fileHash, FileSize: fileSize, VideoDuration: durationSec, MatchMode: "hashAndFileName", } body, err := json.Marshal(payload) if err != nil { return nil, err } path := "/api/v2/match" ts := time.Now().Unix() req, err := http.NewRequestWithContext(ctx, http.MethodPost, danmakuOfficialBase+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-AppId", appID) req.Header.Set("X-Timestamp", strconv.FormatInt(ts, 10)) req.Header.Set("X-Signature", dandanplaySignature(appID, appKey, ts, path)) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode) } var out struct { Success bool `json:"success"` IsMatched bool `json:"isMatched"` Matches []danmakuMatch `json:"matches"` } if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("danmaku match returned invalid JSON: %w", err) } if !out.Success || !out.IsMatched { return nil, nil } return out.Matches, nil } // sameDanmakuBase reports whether two source bases point at the same origin // (host, including port) so the fallback does not call the same server twice. func sameDanmakuBase(a, b string) bool { ua, errA := url.Parse(a) ub, errB := url.Parse(b) return errA == nil && errB == nil && strings.EqualFold(ua.Host, ub.Host) } // fetchCommentWithFallback fetches a comment library from primary first (when // set and different from official), falling back to the official endpoint on // failure. primary must be the source that issued target: episode ids are only // meaningful inside the source that produced them. func (s *DanmakuService) fetchCommentWithFallback(ctx context.Context, primary, official, target string) (raw, sourceType string, err error) { var bases []string if primary != "" && !sameDanmakuBase(primary, official) { bases = append(bases, primary) } bases = append(bases, official) var lastErr error for i, base := range bases { raw, err = s.fetchBody(ctx, fmt.Sprintf("%s/api/v2/comment/%s?withRelated=true", base, target), true) if err == nil { return raw, detectDanmakuSourceType(raw), nil } lastErr = err if i < len(bases)-1 { s.log.Warn("danmaku comment fetch failed on configured source, falling back to official", zap.String("source", redactSensitiveURL(base)), zap.Error(redactSensitiveError(err))) } } return "", "auto", lastErr } // searchCandidatesWithSource searches the configured source first (when set // and different from official), falling back to the official endpoint on // failure. It also reports which base produced the hits, because the caller // must fetch comments from that same base — episode ids are source-local. func (s *DanmakuService) searchCandidatesWithSource(ctx context.Context, configured, official, name, episode string) ([]DanmakuAnime, string, error) { if configured != "" && !sameDanmakuBase(configured, official) { candidates, err := s.searchCandidates(ctx, configured, name, episode) if err == nil { return candidates, configured, nil } s.log.Warn("danmaku search failed on configured source, falling back to official", zap.String("source", redactSensitiveURL(configured)), zap.Error(redactSensitiveError(err))) } candidates, err := s.searchCandidates(ctx, official, name, episode) return candidates, official, err } // danmakuEpisodeNumberRE 从「第N话 / 第N話 / 第N集」里取出集数。各源标题格式 // 不一(有的只有集数、有的带副标题),正则只认集数标记本身。 var danmakuEpisodeNumberRE = regexp.MustCompile(`第\s*(\d+(?:\.\d+)?)\s*[话話集]`) // danmakuTitleYearRE 匹配搜索源标题里的年份,例如「命运石之门(2011)」。 var danmakuTitleYearRE = regexp.MustCompile(`\((?:19|20)\d{2}\)`) // danmakuEpisodeNumber 返回标题中的集数,取不到时返回空串。 func danmakuEpisodeNumber(title string) string { m := danmakuEpisodeNumberRE.FindStringSubmatch(title) if len(m) < 2 { return "" } return m[1] } // danmakuEpisodeSubtitle 返回「第N话」之后的副标题,用于区分同名前作/续作。 func danmakuEpisodeSubtitle(title string) string { loc := danmakuEpisodeNumberRE.FindStringIndex(title) if loc == nil { return "" } return strings.TrimSpace(title[loc[1]:]) } // normalizeDanmakuText 去掉标点与空白,便于跨源比对副标题。注意它只做字符 // 归一化,不剥离集数标记 —— 调用方传入的可能已经是剥离后的副标题。 func normalizeDanmakuText(s string) []rune { out := make([]rune, 0, 32) for _, r := range strings.ToLower(s) { if unicode.IsLetter(r) || unicode.IsDigit(r) { out = append(out, r) } } return out } // danmakuTitleYear 返回标题中显式标注的年份;没有年份时返回 0。 func danmakuTitleYear(title string) int { m := danmakuTitleYearRE.FindString(title) if len(m) != 6 { return 0 } year, _ := strconv.Atoi(m[1:5]) return year } // danmakuSubtitleMatches 判断两个源的副标题是否指向同一集。 // // 完全一致直接判定相同,且不设长度门槛 —— 中文副标题常常只有两三个字 // (「辛」「序曲」),但它们本身就是很强的标识。只有在做包含/前缀这类模糊 // 比对时才要求足够长度,避免短串误配到别的集。同一集在不同源的副标题长度 // 也可能不同(一侧带 "-Fractal Androgynous-" 之类后缀),故保留模糊分支。 func danmakuSubtitleMatches(candidateTitle, subtitle string) bool { // candidateTitle 是完整标题,subtitle 已经剥离过集数标记,两者处理方式不同。 a := normalizeDanmakuText(danmakuEpisodeSubtitle(candidateTitle)) b := normalizeDanmakuText(subtitle) if len(a) == 0 || len(b) == 0 { return false } if string(a) == string(b) { return true } const fuzzyMinRunes = 4 if len(a) < fuzzyMinRunes || len(b) < fuzzyMinRunes { return false } if strings.Contains(string(a), string(b)) || strings.Contains(string(b), string(a)) { return true } if danmakuRuneSimilarity(a, b) >= 0.7 { return true } n := len(a) if len(b) < n { n = len(b) } if n > 12 { n = 12 } return string(a[:n]) == string(b[:n]) } // danmakuRuneSimilarity 返回两个字符串的 LCS 相似度,分母取较短长度。 // 弹幕源的日文/英文副标题常比刮削标题更长,因此不能直接用整体编辑距离; // 例如「起始与终结的序章」和「始与终的序章-Turning Point-」应被视为同一集。 func danmakuRuneSimilarity(a, b []rune) float64 { if len(a) == 0 || len(b) == 0 { return 0 } prev := make([]int, len(b)+1) for _, ra := range a { cur := make([]int, len(b)+1) for j, rb := range b { if ra == rb { cur[j+1] = prev[j] + 1 } else if cur[j] > prev[j+1] { cur[j+1] = cur[j] } else { cur[j+1] = prev[j+1] } } prev = cur } minLen := len(a) if len(b) < minLen { minLen = len(b) } return float64(prev[len(b)]) / float64(minLen) } // lookupConfiguredEpisodes 用官方 match 给出的「剧名 + 集数」在配置源里重新 // 定位同一集,返回配置源里所有指向该集的结果。 // // 必须重定位:各源 episodeId 空间互相独立,官方 ID(如 135500001)在第三方 // 源上只是一个不存在的编号,直接请求必然 404。官方 match 返回的 animeTitle / // episodeTitle 正好提供了跨源检索所需的剧名、集数与副标题。 // // 返回列表而非单条:LogVar 这类源聚合了多个视频网站,同一集常有多个库, // 调用方取第一条自动加载,其余作为可切换来源交给用户。 func (s *DanmakuService) lookupConfiguredEpisodes(ctx context.Context, configured string, m danmakuMatch) ([]DanmakuAnime, bool) { episodeNum := danmakuEpisodeNumber(m.EpisodeTitle) if episodeNum == "" || strings.TrimSpace(m.AnimeTitle) == "" { return nil, false } candidates, err := s.searchCandidates(ctx, configured, m.AnimeTitle, episodeNum) if err != nil { s.log.Debug("danmaku configured lookup failed", zap.String("source", redactSensitiveURL(configured)), zap.String("anime", m.AnimeTitle), zap.Error(redactSensitiveError(err))) return nil, false } matched := matchDanmakuEpisodes(candidates, episodeNum, m.EpisodeTitle) if len(matched) == 0 { return nil, false } return matched, true } // lookupScrapedEpisodes uses already-scraped media metadata to locate the // correct library when hash identification missed. Unlike the generic title // search, this path requires an explicit episode-title match and, when known, // a matching release year. That prevents Steins;Gate (2011) from being // confused with Steins;Gate 0 (2018) or other same-name entries. func (s *DanmakuService) lookupScrapedEpisodes(ctx context.Context, configured, official string, media *model.Media) ([]DanmakuAnime, string, bool) { title, episodeNum, episodeTitle, ok := scrapedDanmakuMetadata(media) if !ok { return nil, "", false } candidates, base, err := s.searchCandidatesWithSource(ctx, configured, official, title, episodeNum) if err != nil { s.log.Debug("danmaku scraped metadata lookup failed", zap.String("title", title), zap.String("episode", episodeNum), zap.Error(err)) return nil, "", false } matched := matchScrapedDanmakuEpisodes(candidates, title, media.Year, episodeNum, episodeTitle) if len(matched) == 0 { return nil, "", false } return matched, base, true } // scrapedDanmakuMetadata returns the query fields only when the media has // enough scraped information to identify a specific episode. func scrapedDanmakuMetadata(media *model.Media) (title, episodeNum, episodeTitle string, ok bool) { if media == nil { return "", "", "", false } title = strings.TrimSpace(media.Title) if title == "" { title = strings.TrimSpace(media.OriginalName) } episodeTitle = strings.TrimSpace(media.EpisodeTitle) if title == "" || media.EpisodeNum <= 0 || episodeTitle == "" { return "", "", "", false } return title, strconv.Itoa(media.EpisodeNum), episodeTitle, true } // matchScrapedDanmakuEpisodes filters title search hits by release year and // episode subtitle. The returned list keeps the source grouping so multiple // libraries for the same episode remain switchable in the player. func matchScrapedDanmakuEpisodes(candidates []DanmakuAnime, title string, year int, episodeNum, episodeTitle string) []DanmakuAnime { out := make([]DanmakuAnime, 0, len(candidates)) for _, anime := range candidates { if !danmakuAnimeTitleMatches(anime.AnimeTitle, title, year) { continue } hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle) if len(hits) == 0 { continue } anime.Episodes = hits out = append(out, anime) } return out } // danmakuAnimeTitleMatches checks the scraped title and, when present, the // candidate's year. A candidate with a conflicting explicit year is rejected // even if its title contains the scraped title. func danmakuAnimeTitleMatches(animeTitle, mediaTitle string, year int) bool { candidate := string(normalizeDanmakuText(animeTitle)) target := string(normalizeDanmakuText(mediaTitle)) if candidate == "" || target == "" || !strings.Contains(candidate, target) { return false } if year > 0 { if candidateYear := danmakuTitleYear(animeTitle); candidateYear > 0 && candidateYear != year { return false } } return true } // firstDanmakuEpisodeID 返回匹配列表里的第一条 episodeId,作为自动选中的库。 func firstDanmakuEpisodeID(matched []DanmakuAnime) int64 { for _, anime := range matched { for _, ep := range anime.Episodes { if ep.EpisodeID > 0 { return ep.EpisodeID } } } return 0 } // pickDanmakuEpisodeID 从配置源的搜索结果里挑出与目标集最匹配的一条。 // // 搜索按「剧名+集数」返回,但同名不同季/不同版本会同时命中,且顺序不保证 // 正确:实测「命运石之门 第18话」首条是《命运石之门 0(2018)》、《战区88 OVA // 第1话》首条是《战区88(2004) TV》,两者内容都不对,只有副标题能区分。因此: // - 目标带副标题时,必须找到副标题一致的候选,否则放弃(宁可回退官方, // 也不能给用户放错番的弹幕); // - 目标没有副标题(如「第11话」)时,退而要求集数一致。 func pickDanmakuEpisodeID(candidates []DanmakuAnime, episodeNum, episodeTitle string) (int64, bool) { if id := firstDanmakuEpisodeID(matchDanmakuEpisodes(candidates, episodeNum, episodeTitle)); id != 0 { return id, true } return 0, false } // matchDanmakuEpisodes 在搜索结果里筛出所有指向目标集的结果,保留原有的番剧 // 分组结构(只留下命中的集数),因此调用方既能取第一条自动加载,也能把整个 // 列表作为可切换来源展示。 func matchDanmakuEpisodes(candidates []DanmakuAnime, episodeNum, episodeTitle string) []DanmakuAnime { out := make([]DanmakuAnime, 0, len(candidates)) for _, anime := range candidates { hits := matchDanmakuEpisodeHits(anime.Episodes, episodeNum, episodeTitle) if len(hits) == 0 { continue } anime.Episodes = hits out = append(out, anime) } return out } // matchDanmakuEpisodeHits 返回同一番剧里指向目标集的弹幕库。 func matchDanmakuEpisodeHits(episodes []DanmakuEpisode, episodeNum, episodeTitle string) []DanmakuEpisode { subtitle := danmakuEpisodeSubtitle(episodeTitle) // 刮削后的 episode_title 通常只保存副标题本身(例如「起始与终结的序章」), // 不带「第1话」前缀。此时把它整体作为副标题参与比对。 if subtitle == "" && danmakuEpisodeNumber(episodeTitle) == "" { subtitle = strings.TrimSpace(episodeTitle) } hits := make([]DanmakuEpisode, 0, len(episodes)) for _, ep := range episodes { if ep.EpisodeID <= 0 || danmakuEpisodeNumber(ep.EpisodeTitle) != episodeNum { continue } // 目标带副标题时必须副标题一致;没有副标题时仅凭集数匹配。 if subtitle != "" && !danmakuSubtitleMatches(ep.EpisodeTitle, subtitle) { continue } hits = append(hits, ep) } return hits }