372 lines
11 KiB
Go
372 lines
11 KiB
Go
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
|
|
}
|