feat(app): add internal components batch 5

This commit is contained in:
吴璨
2026-09-30 10:20:38 +08:00
parent 4ec1f85bbc
commit 5bd126e026
10 changed files with 1956 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
type librarySourceState struct {
Status string `json:"status"`
Count int `json:"count"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (state librarySourceState) withPhase(phase string) librarySourceState {
phase = strings.TrimSpace(phase)
if state.Status == "loading" {
state.Phase = phase
}
return state
}
type libraryProgressKey struct{}
type libraryCategoryKey struct{}
type libraryProgressFunc func(string, []Drama, error, bool) error
type libraryLoadMode int
const (
libraryLoadRefresh libraryLoadMode = iota
libraryLoadMore
libraryLoadUpdate
)
func reportLibraryProgress(ctx context.Context, source string, items []Drama, err error, done bool) error {
if callback, ok := ctx.Value(libraryProgressKey{}).(libraryProgressFunc); ok && (done || len(items) > 0) {
return callback(source, items, err, done)
}
return nil
}
func (a *UIApp) setLibraryPhaseLocked(phase string) {
phase = strings.TrimSpace(phase)
a.libraryLoadingPhase = phase
if a.librarySources == nil {
a.librarySources = map[string]librarySourceState{}
}
for source, state := range a.librarySources {
if state.Status == "loading" {
a.librarySources[source] = state.withPhase(phase)
}
}
}
func (a *UIApp) handleLibraryCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
a.mu.Lock()
cancel := a.libraryCancel
running := a.libraryLoading != nil
if running {
a.setLibraryPhaseLocked("正在停止,已保留已入库内容")
a.libraryRevision++
}
resp := a.librarySnapshotForSourceLocked(r.Context(), 0)
a.mu.Unlock()
if cancel != nil {
cancel()
}
resp["canceled"] = running
writeJSON(w, http.StatusOK, resp)
}
func (a *UIApp) startLibraryRefreshLocked(source string) {
a.startLibraryLoadLocked(source, libraryLoadRefresh, nil, "")
}
func (a *UIApp) startLibraryLoadLocked(source string, mode libraryLoadMode, priority []string, category string, scopes ...context.Context) {
category = strings.TrimSpace(category)
base := context.Background()
if len(scopes) > 0 && sourceScope(scopes[0]) != nil {
base = context.WithValue(base, accountSourceContextKey{}, sourceScope(scopes[0]))
}
if mode == libraryLoadUpdate || mode == libraryLoadMore {
if source == "" && sourceScopeRestricted(base) {
for _, allowed := range sourceScope(base).Sources {
a.enqueueHistoricalMetadataLocked(allowed, priority)
}
} else {
a.enqueueHistoricalMetadataLocked(source, priority)
}
}
more := mode == libraryLoadMore
if more {
if !matchesSourceFilter(sourceHongguo, source) || !hongguoCatalogCategoryHasMore(a.libraryApp, category) {
return
}
source = sourceHongguo
}
previousCount := len(a.dramas)
a.libraryAttempted = true
a.libraryLoading = make(chan struct{})
a.libraryMore = more
a.libraryLoadingSource = source
a.libraryLoadingPhase = "等待开始"
if a.librarySources == nil {
a.librarySources = map[string]librarySourceState{}
}
for _, provider := range []string{sourceHongguo} {
if matchesSourceFilter(provider, source) {
state := a.librarySources[provider]
state.Status = "loading"
state.Error = ""
state.Phase = "等待开始"
a.librarySources[provider] = state
}
}
a.libraryRevision++
ctx, cancel := context.WithTimeout(base, 12*time.Minute)
a.libraryCancel = cancel
ctx = context.WithValue(ctx, libraryProgressKey{}, libraryProgressFunc(a.acceptLibraryProgress))
ctx = context.WithValue(ctx, libraryMoreKey{}, more)
ctx = context.WithValue(ctx, libraryUpdateKey{}, mode == libraryLoadUpdate)
if category != "" {
ctx = context.WithValue(ctx, libraryCategoryKey{}, category)
}
ctx = withKnownHongguoDramas(ctx, a.dramas)
go func() {
defer cancel()
var loadErr error
a.mu.Lock()
a.setLibraryPhaseLocked("保存当前剧库")
a.libraryRevision++
a.mu.Unlock()
if err := a.persistLibraryIfDirty(); err != nil {
loadErr = err
} else if more {
a.mu.Lock()
a.setLibraryPhaseLocked("继续加载目录")
a.libraryRevision++
a.mu.Unlock()
items, err := a.downloader.fetchHongguoCategoryDramas(ctx, category)
loadErr = errors.Join(err, reportLibraryProgress(ctx, sourceHongguo, items, err, true))
} else {
a.mu.Lock()
a.setLibraryPhaseLocked("读取站源目录")
a.libraryRevision++
a.mu.Unlock()
_, loadErr = a.downloader.fetchAllDramas(ctx, source)
}
a.mu.Lock()
if ctx.Err() == context.Canceled {
loadErr = nil
a.markLibrarySourcesCanceledLocked(base, source)
} else if loadErr != nil && a.libraryLoading != nil {
a.markLibrarySourcesFailedLocked(base, source, loadErr)
}
a.libraryDirty = true
a.libraryRevision++
a.mu.Unlock()
_ = a.persistLibrary()
a.mu.Lock()
close(a.libraryLoading)
a.libraryLoading = nil
a.libraryCancel = nil
a.libraryLoadingPhase = ""
a.libraryRevision++
count := len(a.dramas)
a.mu.Unlock()
fmt.Printf("本地剧库:%d 部,本次新增 %d 部\n", count, count-previousCount)
}()
}
func (a *UIApp) persistLibraryIfDirty() error {
a.mu.Lock()
dirty := a.libraryDirty
a.mu.Unlock()
if !dirty {
return nil
}
return a.persistLibrary()
}
func (a *UIApp) markLibrarySourcesFailedLocked(ctx context.Context, source string, err error) {
if a.librarySources == nil {
a.librarySources = map[string]librarySourceState{}
}
if matchesSourceFilter(sourceHongguo, source) {
state := a.librarySources[sourceHongguo]
if state.Status == "loading" {
state.Status = "failed"
if state.Count > 0 {
state.Status = "partial"
}
state.Error = a.redactError(err)
state.Phase = ""
a.librarySources[sourceHongguo] = state
}
}
a.libraryError = librarySourceErrors(a.librarySources)
}
func (a *UIApp) markLibrarySourcesCanceledLocked(ctx context.Context, source string) {
if a.librarySources == nil {
a.librarySources = map[string]librarySourceState{}
}
for _, provider := range []string{sourceHongguo} {
if !matchesSourceFilter(provider, source) || !sourceAllowed(ctx, provider) {
continue
}
state := a.librarySources[provider]
if state.Status == "loading" {
state.Status = "canceled"
state.Error = ""
state.Phase = ""
a.librarySources[provider] = state
}
}
a.libraryError = librarySourceErrors(a.librarySources)
}
func (a *UIApp) acceptLibraryProgress(source string, items []Drama, loadErr error, done bool) error {
if canonicalProviderSource(source) != sourceHongguo {
return nil
}
source = sourceHongguo
a.mu.Lock()
items = onlyHongguoDramas(items)
a.normalizeDramaCovers(items)
newDramas := a.newSortMetadataDramasLocked(items)
if source == sourceHongguo {
a.libraryApp = a.downloader.hongguoCatalogSnapshot()
}
if done {
var mergeErr error
if loadErr != nil {
mergeErr = &libraryLoadError{failures: map[string]error{source: loadErr}}
}
a.dramas = mergeSourceDramas(a.dramas, items, mergeErr, source)
} else {
positions := make(map[string]int, len(a.dramas))
for index, drama := range a.dramas {
positions[drama.ID] = index
}
for _, drama := range items {
if drama.ID == "" {
continue
}
if position, found := positions[drama.ID]; found {
a.dramas[position] = mergeDramaMetadata(drama, a.dramas[position])
} else {
positions[drama.ID] = len(a.dramas)
a.dramas = append(a.dramas, drama)
}
}
}
a.enqueueSortMetadataLocked(newDramas, false)
state := a.librarySources[source]
state.Count = 0
for _, drama := range a.dramas {
if dramaProvider(drama) == source {
state.Count++
}
}
if len(items) > 0 {
a.loadedAt = time.Now()
state.UpdatedAt = a.loadedAt
}
if done {
state.Status = "ready"
if loadErr != nil {
state.Status = "failed"
if state.Count > 0 {
state.Status = "partial"
}
state.Error = a.redactError(loadErr)
}
state.Phase = ""
}
a.librarySources[source] = state
a.libraryError = librarySourceErrors(a.librarySources)
a.libraryDirty = true
a.libraryRevision++
persist := done || len(items) > 0 && source == sourceHongguo || time.Since(a.libraryLastSave) >= 3*time.Second
if persist {
a.libraryLastSave = time.Now()
}
a.mu.Unlock()
if persist {
return a.persistLibrary()
}
return nil
}
func librarySourceErrors(sources map[string]librarySourceState) string {
var messages []string
for source, state := range sources {
if state.Error != "" {
messages = append(messages, fmt.Sprintf("%s 获取失败: %s", source, state.Error))
}
}
sort.Strings(messages)
return strings.Join(messages, "\n")
}
func cloneLibrarySources(sources map[string]librarySourceState) map[string]librarySourceState {
copy := make(map[string]librarySourceState, len(sources))
for source, state := range sources {
copy[source] = state
}
return copy
}
func (a *UIApp) persistLibrary() error {
a.libraryWriteMu.Lock()
defer a.libraryWriteMu.Unlock()
a.mu.Lock()
cache := libraryCache{Dramas: append([]Drama{}, a.dramas...), LoadedAt: a.loadedAt, LastError: librarySourceErrors(a.librarySources), Sources: cloneLibrarySources(a.librarySources), HongguoApp: cloneHongguoCatalogState(a.libraryApp)}
revision := a.libraryRevision
a.mu.Unlock()
err := writeLibraryCache(a.cfg.dataDirectory(), cache)
a.mu.Lock()
defer a.mu.Unlock()
if err != nil {
a.libraryError = strings.TrimSpace(cache.LastError + "\n剧库缓存保存失败: " + a.redactError(err))
a.libraryRevision++
return err
}
a.librarySaved = true
if revision == a.libraryRevision {
a.libraryDirty = false
}
return nil
}
func (a *UIApp) librarySnapshotLocked(revision uint64) map[string]any {
remaining := sortMetadataRemaining(a.dramas)
var unqueued []Drama
for _, drama := range a.dramas {
if !a.metadataPending[drama.ID] {
unqueued = append(unqueued, drama)
}
}
more := map[string]bool{}
for source, count := range sortMetadataRemaining(unqueued) {
more[source] = count > 0
}
more["hongguo"] = more["hongguo"] || hongguoCatalogHasMore(a.libraryApp)
more[""] = more[""] || more["hongguo"]
response := map[string]any{
"revision": a.libraryRevision, "loadedAt": a.loadedAt, "loading": a.libraryLoading != nil,
"cached": a.librarySaved && !a.libraryDirty, "error": a.libraryError,
"sources": cloneLibrarySources(a.librarySources), "total": len(a.dramas),
"hasMore": more["hongguo"], "hasMoreBySource": more, "metadataRemaining": remaining,
"loadingMore": a.libraryMore && a.libraryLoading != nil, "loadingSource": a.libraryLoadingSource, "loadingPhase": a.libraryLoadingPhase, "metadata": a.libraryMetadata,
}
if revision != 0 && revision == a.libraryRevision {
response["unchanged"] = true
} else {
response["data"] = append([]Drama{}, a.dramas...)
}
return response
}