feat(app): add internal components batch 5
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user