package app import ( "context" "crypto/aes" "crypto/cipher" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" ) const ( uiStatusQueued = "queued" uiStatusParsing = "parsing" uiStatusRunning = "running" uiStatusSuccess = "success" uiStatusFailed = "failed" uiStatusCanceled = "canceled" uiStatusPaused = "paused" ) type UIApp struct { downloader *Downloader cfg Config mu sync.Mutex dramas []Drama selected map[string]bool tasks map[string]*UITask taskOrder []string merges map[string]*UIMergeState lastError string loadedAt time.Time libraryLoading chan struct{} librarySaved bool libraryDirty bool libraryAttempted bool libraryRevision uint64 librarySources map[string]librarySourceState libraryError string libraryCancel context.CancelFunc libraryWriteMu sync.Mutex libraryLastSave time.Time libraryApp *hongguoCatalogState libraryMore bool libraryLoadingSource string libraryMetadata libraryMetadataProgress metadataQueue []string metadataPending map[string]bool metadataCancel context.CancelFunc metadataDone chan struct{} metadataClosed bool rankingApplied map[string]time.Time cond *sync.Cond statePath string address string runningCancels map[string]context.CancelFunc mergeMu sync.Mutex mergeJobs map[string]*uiMergeJob mergeStopped bool parsingInFlight map[string]bool parsingCancels map[string]context.CancelFunc closed bool workerCount int activeDownloads int settingsMu sync.Mutex nextOutputDir string directoryPickerMu sync.Mutex directoryPicker func(context.Context, string) (string, error) playbackMu sync.Mutex playbacks map[string]*playbackSession playbackPrefetchSlots chan struct{} playbackResourcesOnce sync.Once playbackResources *playbackResources viewersOnce sync.Once viewers *viewerManager embyMu sync.Mutex embyKey []byte embySyncOnce sync.Once embySync *embySyncManager dramaRefreshes map[string]*dramaRefreshCall dramaRefreshSlots chan struct{} coverImages coverImageCache coverRepairs map[string]*coverRepairCall coverRepairSlots chan struct{} } type uiState struct { Dramas []Drama `json:"dramas,omitempty"` LoadedAt time.Time `json:"loadedAt,omitempty"` LastError string `json:"lastError,omitempty"` TaskOrder []string `json:"taskOrder"` Tasks []*UITask `json:"tasks"` MergeStates map[string]*UIMergeState `json:"merges,omitempty"` } type UIMergeState struct { PlaybackTaskID string `json:"playbackTaskId,omitempty"` DramaID string `json:"dramaId"` DramaTitle string `json:"dramaTitle,omitempty"` Status string `json:"status"` Progress int `json:"progress"` Merged int `json:"merged"` StartEpisode int `json:"startEpisode,omitempty"` EndEpisode int `json:"endEpisode,omitempty"` Total int `json:"total"` OutputPath string `json:"outputPath,omitempty"` Detail string `json:"detail,omitempty"` Error string `json:"error,omitempty"` Skipped bool `json:"skipped,omitempty"` DeleteEpisodes bool `json:"deleteEpisodes,omitempty"` UpdatedAt time.Time `json:"updatedAt"` } type uiMergeResult struct { DramaID string `json:"dramaId"` DramaTitle string `json:"dramaTitle,omitempty"` OK bool `json:"ok"` Skipped bool `json:"skipped,omitempty"` Merged int `json:"merged"` StartEpisode int `json:"startEpisode,omitempty"` EndEpisode int `json:"endEpisode,omitempty"` OutputPath string `json:"outputPath,omitempty"` Detail string `json:"detail,omitempty"` Error string `json:"error,omitempty"` } type UITask struct { ID string `json:"id"` DramaID string `json:"dramaId"` DramaTitle string `json:"dramaTitle"` Episode string `json:"episode"` Title string `json:"title"` Status string `json:"status"` Progress int `json:"progress"` Error string `json:"error,omitempty"` Path string `json:"path"` Attempt int `json:"attempt"` DownloadedBytes int64 `json:"downloadedBytes"` TotalBytes int64 `json:"totalBytes"` SpeedBytesPerSecond float64 `json:"speedBytesPerSecond"` ElapsedSeconds int64 `json:"elapsedSeconds"` RemainingSeconds int64 `json:"remainingSeconds"` MediaElapsedSeconds int64 `json:"mediaElapsedSeconds"` MediaTotalSeconds int64 `json:"mediaTotalSeconds"` Phase string `json:"phase"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` CancelRequested bool `json:"cancelRequested,omitempty"` RemoveRequested bool `json:"removeRequested,omitempty"` PauseRequested bool `json:"pauseRequested,omitempty"` Source Task `json:"task"` } type uiTaskView struct { DownloadQuality int `json:"downloadQuality"` ID string `json:"id"` DramaID string `json:"dramaId"` DramaTitle string `json:"dramaTitle"` Episode string `json:"episode"` Title string `json:"title"` Index int `json:"index"` Total int `json:"total"` Status string `json:"status"` Progress int `json:"progress"` Error string `json:"error,omitempty"` Path string `json:"path"` Attempt int `json:"attempt"` DownloadedBytes int64 `json:"downloadedBytes"` TotalBytes int64 `json:"totalBytes"` SpeedBytesPerSecond float64 `json:"speedBytesPerSecond"` ElapsedSeconds int64 `json:"elapsedSeconds"` RemainingSeconds int64 `json:"remainingSeconds"` MediaElapsedSeconds int64 `json:"mediaElapsedSeconds"` MediaTotalSeconds int64 `json:"mediaTotalSeconds"` Phase string `json:"phase"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` CancelRequested bool `json:"cancelRequested,omitempty"` RemoveRequested bool `json:"removeRequested,omitempty"` PauseRequested bool `json:"pauseRequested,omitempty"` ReleaseStatus string `json:"releaseStatus,omitempty"` Playable bool `json:"playable"` } func NewUIApp(d *Downloader) *UIApp { cfg := d.cfg if cfg.OutputDir == "" { cfg.OutputDir = defaultOutputDir() } if cfg.Concurrency <= 0 { cfg.Concurrency = 1 } a := &UIApp{ downloader: d, cfg: cfg, selected: map[string]bool{}, tasks: map[string]*UITask{}, merges: map[string]*UIMergeState{}, statePath: filepath.Join(cfg.dataDirectory(), "ui-state.json"), runningCancels: map[string]context.CancelFunc{}, parsingInFlight: map[string]bool{}, parsingCancels: map[string]context.CancelFunc{}, nextOutputDir: cfg.outputDirSetting, } a.cond = sync.NewCond(&a.mu) a.loadState() a.loadLibrary() a.startWorkers() return a } func (a *UIApp) ListenAndServe(addr string) error { if err := a.prepareBrowserViewers(); err != nil { return err } defer a.stopSortMetadata() a.embySyncer().start() defer a.embySyncer().stop() installer := a.downloader.ffmpegInstallation() defer installer.stop() go func() { path, err := installer.ensure(context.Background()) if err != nil { fmt.Printf("FFmpeg 准备失败:%v\n", publicError(err)) } else { fmt.Printf("FFmpeg 已就绪:%s\n", path) } }() a.mu.Lock() a.address = addr _ = a.saveStateLocked() a.mu.Unlock() defer a.closePlaybacks() defer a.stopMergeJobs() server := &http.Server{Addr: addr, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second} return server.ListenAndServe() } func (a *UIApp) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", a.handleIndex) mux.Handle("/assets/", webAssets()) mux.HandleFunc("/api/ui/viewer", a.handleViewer) mux.HandleFunc("/api/ui/viewer/legacy", a.handleViewerLegacy) mux.HandleFunc("/api/ui/account/register", a.handleAccountRegister) mux.HandleFunc("/api/ui/account/login", a.handleAccountLogin) mux.HandleFunc("/api/ui/account/logout", a.handleAccountLogout) mux.HandleFunc("/api/ui/account/password", a.handleAccountPassword) mux.HandleFunc("/api/ui/account/import", a.handleAccountImport) mux.HandleFunc("/api/ui/admin/settings", a.handleAdminSettings) mux.HandleFunc("/api/ui/admin/accounts", a.handleAdminAccounts) mux.HandleFunc("/api/ui/admin/accounts/sources", a.handleAdminAccountPermissions) mux.HandleFunc("/api/ui/admin/accounts/permissions", a.handleAdminAccountPermissions) mux.HandleFunc("/api/ui/dramas", a.handleDramas) mux.HandleFunc("/api/ui/cover/repair", a.handleCoverRepair) mux.HandleFunc("/api/ui/dramas/refresh", a.handleDramaRefresh) mux.HandleFunc("/api/ui/vip/metadata", a.handleVIPMetadata) mux.HandleFunc("/api/ui/search", a.handleLibrarySearch) mux.HandleFunc("/api/ui/following", a.handleFollowing) mux.HandleFunc("/api/ui/rankings", a.handleRankings) mux.HandleFunc("/api/ui/recommendations", a.handleRecommendations) mux.HandleFunc("/api/ui/download", a.handleDownload) mux.HandleFunc("/api/ui/tasks", a.handleTasks) mux.HandleFunc("/api/ui/update", a.handleUpdate) mux.HandleFunc("/api/ui/tasks/retry", a.handleRetry) mux.HandleFunc("/api/ui/tasks/cancel", a.handleCancel) mux.HandleFunc("/api/ui/tasks/pause", a.handlePause) mux.HandleFunc("/api/ui/tasks/resume", a.handleResume) mux.HandleFunc("/api/ui/tasks/clear", a.handleClear) mux.HandleFunc("/api/ui/merge", a.handleMerge) mux.HandleFunc("/api/ui/merge/cancel", a.handleMergeCancel) mux.HandleFunc("/api/ui/ffmpeg", a.handleFFmpeg) mux.HandleFunc("/api/ui/config", a.handleConfig) mux.HandleFunc("/api/ui/network/check", a.handleNetworkCheck) mux.HandleFunc("/api/ui/directory/pick", a.handleDirectoryPicker) mux.HandleFunc("/api/ui/image", a.handleImage) mux.HandleFunc("/api/ui/admin/emby", a.handleEmbySyncSettings) mux.HandleFunc("/api/ui/admin/emby/sync", a.handleEmbySyncNow) mux.HandleFunc("/api/emby/export", a.handleEmbyExport) mux.HandleFunc("/api/emby/cover", a.handleEmbyCover) mux.HandleFunc("/api/emby/stream.m3u8", a.handleEmbyStream) mux.HandleFunc("/api/emby/segment.ts", a.handleEmbySegment) a.registerPlaybackRoutes(mux) return a.withAccountAccess(a.withBrowserViewer(mux)) } func (a *UIApp) startWorkers() { a.mu.Lock() defer a.mu.Unlock() a.ensureWorkersLocked() } func (a *UIApp) ensureWorkersLocked() { for a.workerCount < a.cfg.Concurrency { a.workerCount++ go a.worker() } } func (a *UIApp) worker() { for { a.mu.Lock() var task *UITask for task == nil && !a.closed { if a.activeDownloads >= a.cfg.Concurrency { a.cond.Wait() continue } for _, id := range a.taskOrder { candidate := a.tasks[id] if candidate != nil && candidate.Status == uiStatusQueued { task = candidate break } } if task == nil && !a.closed { a.cond.Wait() } } if a.closed { a.mu.Unlock() return } now := time.Now() a.activeDownloads++ task.Status = uiStatusRunning task.Progress = 0 task.Error = "" task.CancelRequested = false task.PauseRequested = false task.SpeedBytesPerSecond = 0 task.DownloadedBytes = 0 task.ElapsedSeconds = 0 task.MediaElapsedSeconds = 0 task.RemainingSeconds = 0 task.Phase = "starting" task.Attempt++ task.UpdatedAt = now taskID := task.ID downloadTask := task.Source ctx, cancel := context.WithTimeout(context.Background(), uiTaskTimeout(downloadTask)) a.runningCancels[taskID] = cancel _ = a.saveStateLocked() a.mu.Unlock() err := a.downloader.DownloadEpisodeWithProgress(ctx, downloadTask, func(p DownloadProgress) { a.mu.Lock() current := a.tasks[taskID] if current != nil && current.Status == uiStatusRunning { current.Progress = p.Percent current.DownloadedBytes = p.DownloadedBytes current.TotalBytes = p.TotalBytes current.SpeedBytesPerSecond = p.SpeedBytesPerSecond current.ElapsedSeconds = int64(p.Elapsed / time.Second) current.RemainingSeconds = int64(p.Remaining / time.Second) current.MediaElapsedSeconds = int64(p.MediaElapsed / time.Second) current.MediaTotalSeconds = int64(p.MediaTotal / time.Second) current.Phase = p.Phase current.UpdatedAt = time.Now() } a.mu.Unlock() }) cancel() a.mu.Lock() a.activeDownloads-- delete(a.runningCancels, taskID) current := a.tasks[taskID] if current != nil && current.Status == uiStatusRunning { current.UpdatedAt = time.Now() current.RemainingSeconds = 0 current.SpeedBytesPerSecond = 0 if current.PauseRequested && !current.RemoveRequested { current.Status = uiStatusPaused current.PauseRequested = false current.CancelRequested = false current.Error = "" current.Phase = "paused" } else if current.CancelRequested || errors.Is(err, context.Canceled) { current.Status = uiStatusCanceled current.Progress = 0 current.Error = "已取消" current.Phase = "canceled" } else if err != nil { current.Status = uiStatusFailed current.Progress = 0 if errors.Is(err, context.DeadlineExceeded) { current.Error = "下载任务超时,已自动失败,可稍后重试" } else { current.Error = a.redactError(err) } current.Phase = "failed" } else { current.Status = uiStatusSuccess current.Progress = 100 current.Error = "" current.Phase = "completed" if st, statErr := os.Stat(current.Path); statErr == nil && !st.IsDir() { current.DownloadedBytes = st.Size() if current.TotalBytes <= 0 { current.TotalBytes = st.Size() } } } if current.RemoveRequested { a.removeTaskLocked(taskID) } _ = a.saveStateLocked() } a.mu.Unlock() a.cond.Broadcast() if err == nil { a.notifyEmbySync() } } } func (a *UIApp) migrateTaskPathLocked(task *UITask) bool { if task == nil { return false } if task.Path != "" || task.Source.OutPath != "" { return false } index := task.Source.Index if index <= 0 { index = episodeNumber(task.Episode) } if index <= 0 { return false } title := strings.TrimSpace(task.DramaTitle) if title == "" { title = strings.TrimSpace(task.Source.DramaTitle) } if title == "" { return false } dramaID := strings.TrimSpace(task.DramaID) if dramaID == "" { dramaID = strings.TrimSpace(task.Source.DramaID) } dramaDir := filepath.Join(a.cfg.OutputDir, safeFilename(title)) markerPath := filepath.Join(dramaDir, ".drama-id") if marker, err := os.ReadFile(markerPath); err == nil && strings.TrimSpace(string(marker)) != "" && dramaID != "" && strings.TrimSpace(string(marker)) != dramaID { dramaDir = filepath.Join(a.cfg.OutputDir, safeFilename(title+"_"+hashShort(dramaID))) markerPath = filepath.Join(dramaDir, ".drama-id") } if err := os.MkdirAll(dramaDir, 0o755); err != nil { return false } if dramaID != "" { _ = os.WriteFile(markerPath, []byte(dramaID), 0o644) } newPath := filepath.Join(dramaDir, fmt.Sprintf("%03d.mp4", index)) task.Path = newPath task.Source.OutPath = newPath task.DramaID = dramaID task.Source.DramaID = dramaID task.DramaTitle = title task.Source.DramaTitle = title return true } func episodeNumber(value string) int { value = strings.TrimSpace(value) if n, err := strconv.Atoi(value); err == nil && n > 0 { return n } return 0 } func isInvalidPersistedProviderTask(task *UITask) bool { if task == nil { return false } providerTask := isHuangguoProviderSource(task.Source.Chapter.Source) if !providerTask { _, _, providerTask = splitProviderDramaID(task.DramaID) } if !providerTask { _, _, providerTask = splitProviderDramaID(task.Source.DramaID) } if !providerTask { return false } total := task.Source.Total if total <= 0 || total > 300 { return false } ep := episodeNumber(task.Episode) if ep <= 0 { ep = episodeNumber(task.Source.Chapter.EpisodeString(task.Source.Index)) } return ep > total } func (a *UIApp) loadState() { b, err := os.ReadFile(a.statePath) if err != nil { if !errors.Is(err, os.ErrNotExist) { a.lastError = a.redactString(err.Error()) } return } var state uiState if err := json.Unmarshal(b, &state); err != nil { a.lastError = a.redactString(err.Error()) return } a.mu.Lock() defer a.mu.Unlock() a.dramas = append([]Drama(nil), state.Dramas...) a.normalizeDramaCovers(a.dramas) a.loadedAt = state.LoadedAt a.lastError = a.redactString(state.LastError) if state.MergeStates != nil { a.merges = state.MergeStates } seen := map[string]bool{} changed := false for _, state := range a.merges { if state != nil && (state.Status == "running" || state.Status == "queued") { state.Status, state.Detail, state.Error = "failed", "", "服务已重启,合并已中断;原分集已保留,可重新合并" changed = true } } for _, task := range state.Tasks { if task == nil || task.RemoveRequested { changed = true continue } if task.PauseRequested { task.Status = uiStatusPaused task.Phase = "paused" task.PauseRequested = false task.CancelRequested = false changed = true } if task.Status == uiStatusRunning || task.Status == "" { task.Status = uiStatusQueued task.Progress = 0 task.CancelRequested = false task.SpeedBytesPerSecond = 0 task.RemainingSeconds = 0 task.Phase = "queued" task.UpdatedAt = time.Now() changed = true } if task.Status == uiStatusParsing || task.Phase == "parsing" { task.Status = uiStatusFailed task.Title = "章节获取失败" task.Progress = 0 task.Error = "上次章节解析中断,请点击重试重新获取章节" task.Phase = "failed" source := firstNonEmpty(task.Source.Chapter.Source, sourceFromDramaID(task.DramaID), sourceFromDramaID(task.Source.DramaID), "unknown") sourceID := firstNonEmpty(task.Source.DramaID, task.DramaID) if _, parsedID, ok := splitProviderDramaID(sourceID); ok { sourceID = parsedID } task.Source.Chapter.Source = source task.Source.Chapter.ID = providerChapterID(source, sourceID, "fetch-failed") task.Source.Chapter.Title = "章节处理中" task.UpdatedAt = time.Now() changed = true } if task.Path == "" { task.Path = task.Source.OutPath changed = true } if task.Source.OutPath == "" { task.Source.OutPath = task.Path changed = true } if isInvalidPersistedProviderTask(task) { changed = true continue } if !isFetchFailedPlaceholderTask(task) { if a.migrateTaskPathLocked(task) { changed = true } } if newID := uiTaskID(task.Path); newID != "" && task.ID != newID { task.ID = newID changed = true } if task.Status == uiStatusRunning || task.Status == "" { task.Status = uiStatusQueued task.Progress = 0 task.CancelRequested = false task.SpeedBytesPerSecond = 0 task.RemainingSeconds = 0 task.Phase = "queued" task.UpdatedAt = time.Now() changed = true } task.Error = a.redactString(task.Error) if task.CreatedAt.IsZero() { task.CreatedAt = time.Now() changed = true } if task.UpdatedAt.IsZero() { task.UpdatedAt = task.CreatedAt changed = true } if task.ID != "" && !seen[task.ID] { seen[task.ID] = true a.tasks[task.ID] = task } } for _, id := range state.TaskOrder { if a.tasks[id] != nil && seen[id] { a.taskOrder = append(a.taskOrder, id) seen[id] = false } } ids := make([]string, 0, len(a.tasks)) for id := range a.tasks { if seen[id] { ids = append(ids, id) } } sort.Strings(ids) a.taskOrder = append(a.taskOrder, ids...) if changed { _ = a.saveStateLocked() } } func (a *UIApp) saveStateLocked() error { directory := filepath.Dir(a.statePath) if err := os.MkdirAll(directory, 0o755); err != nil { a.lastError = a.redactString(err.Error()) return err } state := uiState{ LoadedAt: a.loadedAt, LastError: a.lastError, TaskOrder: append([]string(nil), a.taskOrder...), Tasks: make([]*UITask, 0, len(a.tasks)), MergeStates: cloneMergeStates(a.merges), } if !a.librarySaved { state.Dramas = append([]Drama(nil), a.dramas...) } for _, id := range a.taskOrder { if task := a.tasks[id]; task != nil { state.Tasks = append(state.Tasks, task) } } tmp, err := os.CreateTemp(directory, ".ui-state-*.tmp") if err != nil { a.lastError = a.redactString(err.Error()) return err } tmpName := tmp.Name() enc := json.NewEncoder(tmp) enc.SetIndent("", " ") if err := enc.Encode(state); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) a.lastError = a.redactString(err.Error()) return err } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) a.lastError = a.redactString(err.Error()) return err } if err := os.Rename(tmpName, a.statePath); err != nil { _ = os.Remove(tmpName) a.lastError = a.redactString(err.Error()) return err } return nil } func (a *UIApp) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/login" { http.NotFound(w, r) return } if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write([]byte(uiHTML)) } func (a *UIApp) handleDramas(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } refresh := r.URL.Query().Get("refresh") == "1" more := r.URL.Query().Get("more") == "1" update := r.URL.Query().Get("update") == "1" if refresh && more || update && (refresh || more) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "更新操作不能同时指定多个模式"}) return } source := r.URL.Query().Get("source") switch source { case "", "huangguo", "cloudfront", sourceHuangguoAI, sourceHuangguoVideo, sourceHuangdou, sourceHongguo: default: writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不支持刷新该站源"}) return } if source != "" && !requireSource(w, r.Context(), source) { return } var priority []string if raw := r.URL.Query().Get("priority"); (update || more) && raw != "" { priority = strings.Split(raw, ",") if len(raw) > 6000 || len(priority) > sortMetadataBatchSize { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "优先补齐条目过多"}) return } for _, id := range priority { if len(id) > 120 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "无效的剧集 ID"}) return } } } if !a.requireDramaSources(w, r, priority) { return } a.mu.Lock() updateAccepted := update && a.libraryLoading == nil if a.libraryLoading == nil && (update || refresh || more || len(a.dramas) == 0 && !a.libraryAttempted) { mode := libraryLoadRefresh if update { mode = libraryLoadUpdate } else if more { mode = libraryLoadMore } a.startLibraryLoadLocked(source, mode, priority, r.Context()) } revision, _ := strconv.ParseUint(r.URL.Query().Get("revision"), 10, 64) resp := a.librarySnapshotForSourceLocked(r.Context(), revision) if update { resp["updateAccepted"] = updateAccepted } a.mu.Unlock() writeJSON(w, http.StatusOK, resp) } func (a *UIApp) normalizeDramaCovers(dramas []Drama) { for i := range dramas { normalizeDramaCover(&dramas[i]) } } func normalizeDramaCover(drama *Drama) { drama.OnlineDate = normalizeDate(drama.OnlineDate) drama.Views = normalizeViews(drama.Views) if p := bestDramaCover(*drama); p != "" { drama.Cover = "/api/ui/image?url=" + url.QueryEscape(p) } } func bestDramaCover(dr Drama) string { for _, v := range []any{dr.CoverURL, dr.CoverURLSnake, dr.Cover, dr.ImageURL, dr.ImageURLSnake, dr.Image, dr.Img, dr.Pic, dr.Picture, dr.Poster, dr.Thumb, dr.Thumbnail} { if p := coverPathFromAny(v); p != "" { return p } } return "" } func coverPathFromAny(v any) string { switch x := v.(type) { case nil: return "" case string: s := strings.TrimSpace(x) if s == "" { return "" } if strings.HasPrefix(s, "/api/ui/image") { if u, err := url.Parse(s); err == nil { if raw := strings.TrimSpace(u.Query().Get("url")); raw != "" { return raw } } } if u, err := url.Parse(s); err == nil && u.IsAbs() { return u.String() } return strings.TrimLeft(s, "/") case map[string]any: for _, key := range []string{"url", "src", "path", "cover", "coverUrl", "cover_url", "image", "pic", "poster"} { if s := coverPathFromAny(x[key]); s != "" { return s } } } return "" } func (a *UIApp) handleImage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } imgPath := strings.TrimSpace(r.URL.Query().Get("url")) if imgPath == "" { http.Error(w, "missing url", http.StatusBadRequest) return } remoteURL, ok := buildImageURL(imgPath) if !ok { http.Error(w, "invalid image url", http.StatusBadRequest) return } source, allowed := a.imageSource(r.Context(), remoteURL) if !allowed { writeSourceDenied(w) return } ctx, cancel := context.WithTimeout(context.WithValue(r.Context(), coverSourceKey{}, source), 90*time.Second) defer cancel() buf, err := a.loadCoverImage(ctx, remoteURL, decodeImageBytes) if err != nil { if r.Context().Err() == nil { remote, _ := url.Parse(remoteURL) a.downloader.recordDiagnostic(diagnosticEvent{Event: "cover.failed", Host: remote.Hostname(), Message: a.redactError(err)}) } http.Error(w, a.redactError(err), http.StatusBadGateway) return } w.Header().Set("Content-Type", imageContentType(buf)) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "private, max-age=86400") if sourceScopeRestricted(r.Context()) { w.Header().Set("Cache-Control", "private, no-store") } w.WriteHeader(http.StatusOK) _, _ = w.Write(buf) } func buildImageURL(imgPath string) (string, bool) { imgPath = strings.TrimSpace(imgPath) if imgPath == "" { return "", false } if u, err := url.Parse(imgPath); err == nil && u.IsAbs() { if !validImageURL(u) { return "", false } return u.String(), true } imgPath = strings.TrimLeft(imgPath, "/") if imgPath == "" || strings.Contains(imgPath, "..") || strings.Contains(imgPath, "://") { return "", false } if strings.HasPrefix(imgPath, "upload_01/") || strings.HasPrefix(imgPath, "upload/") { return "https://pic.zdmhyg.cn/" + imgPath, true } return "https://zzzznnn.lkkwip.cn/" + imgPath, true } func isHuangguoImageCDNHost(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) return host == "pic.tuafjz.cn" || strings.HasSuffix(host, ".zdmhyg.cn") } func sourceCoverReferer(source, remoteURL string) string { switch source { case sourceHongguo: return hongguoBaseURL + "/" case sourceHuangguoAI: return "https://huangguoai.com/" case sourceHuangguoVideo: return "https://huangguo.video/" case sourceHuangdou: if remote, err := url.Parse(remoteURL); err == nil { host := strings.ToLower(remote.Hostname()) if host == "tideember.cc" || host == "xqjurgek.top" { return remote.Scheme + "://" + remote.Host + "/home" } } return huangdouBaseURL + "/home" } return imageReferer(remoteURL) } func imageReferer(remoteURL string) string { if u, err := url.Parse(remoteURL); err == nil { if isHuangguoImageCDNHost(u.Hostname()) { return "https://huangguoai.com/" } switch strings.ToLower(u.Hostname()) { case "huangguoai.com", "www.huangguoai.com": return "https://huangguoai.com/" case "huangguo.video", "cdn.huangguo.video": return "https://huangguo.video/" case "tideember.cc", "xqjurgek.top": return u.Scheme + "://" + u.Host + "/home" case "d3rorc0p4i1kyz.cloudfront.net": return huangdouBaseURL + "/home" } if isHongguoImageHost(u.Hostname()) { return hongguoBaseURL + "/" } } return "https://d2pypzndaqisk.cloudfront.net/" } func decodeImageBytes(buf []byte, remoteURL string) []byte { if isKnownImage(buf) { return buf } if decoded := decryptHuangguoImage(buf); isKnownImage(decoded) || isHEICImage(decoded) { return decoded } copyBuf := append([]byte(nil), buf...) decryptImageHeader(copyBuf) if isKnownImage(copyBuf) { return copyBuf } return buf } func decryptHuangguoImage(buf []byte) []byte { if len(buf) == 0 || len(buf)%aes.BlockSize != 0 { return nil } block, err := aes.NewCipher([]byte("f5d965df75336270")) if err != nil { return nil } out := make([]byte, len(buf)) cipher.NewCBCDecrypter(block, []byte("97b60394abc2fbe1")).CryptBlocks(out, buf) if len(out) > 0 { pad := int(out[len(out)-1]) if pad > 0 && pad <= aes.BlockSize && pad <= len(out) { valid := true for _, b := range out[len(out)-pad:] { if int(b) != pad { valid = false break } } if valid { out = out[:len(out)-pad] } } } return out } func decryptImageHeader(buf []byte) { key := []byte("2019ysapp7527") limit := len(buf) if limit > 100 { limit = 100 } for i := 0; i < limit; i++ { buf[i] ^= key[i%len(key)] } } func imageContentType(buf []byte) string { if len(buf) >= 12 && buf[0] == 'R' && buf[1] == 'I' && buf[2] == 'F' && buf[3] == 'F' && buf[8] == 'W' && buf[9] == 'E' && buf[10] == 'B' && buf[11] == 'P' { return "image/webp" } if len(buf) >= 8 && buf[0] == 0x89 && buf[1] == 0x50 && buf[2] == 0x4e && buf[3] == 0x47 { return "image/png" } if len(buf) >= 3 && buf[0] == 0x47 && buf[1] == 0x49 && buf[2] == 0x46 { return "image/gif" } if len(buf) >= 2 && buf[0] == 0xff && buf[1] == 0xd8 { return "image/jpeg" } return http.DetectContentType(buf) } func isKnownImage(buf []byte) bool { return imageContentType(buf) == "image/jpeg" || imageContentType(buf) == "image/png" || imageContentType(buf) == "image/gif" || imageContentType(buf) == "image/webp" } func (a *UIApp) handleDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } ids, quality, ok := readDownloadRequest(w, r) if !ok { return } if !a.requireDramaSources(w, r, ids) { return } views := a.enqueueDramasAsync(ids, false, quality) writeJSON(w, http.StatusAccepted, map[string]any{"data": views, "pending": true}) } func (a *UIApp) handleUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } ids, quality, ok := readDownloadRequest(w, r) if !ok { return } if !a.requireDramaSources(w, r, ids) { return } views := a.enqueueDramasAsync(ids, true, quality) writeJSON(w, http.StatusAccepted, map[string]any{"data": views, "pending": true}) } func (a *UIApp) enqueueDramasAsync(ids []string, updateOnly bool, requestedQuality *int) []uiTaskView { a.mu.Lock() if a.parsingInFlight == nil { a.parsingInFlight = map[string]bool{} } dramaByID := make(map[string]Drama, len(a.dramas)) for _, dr := range a.dramas { if strings.TrimSpace(dr.ID) != "" { dramaByID[dr.ID] = dr } } views := make([]uiTaskView, 0, len(ids)) dramas := make([]Drama, 0, len(ids)) for _, id := range ids { drama, found := dramaByID[id] if !found { drama = Drama{ID: id, Title: "短剧"} } dramaKey := placeholderDramaKey(drama) if a.parsingInFlight[dramaKey] { if view, ok := a.existingParsingPlaceholderViewLocked(drama); ok { views = append(views, view) } continue } quality := 0 if updateOnly { for _, taskID := range a.taskOrder { if task := a.tasks[taskID]; task != nil && task.DramaID == drama.ID { quality = task.Source.DownloadQuality break } } } if requestedQuality != nil { quality = *requestedQuality } dramas = append(dramas, drama) a.parsingInFlight[dramaKey] = true if view, changed := a.addParsingPlaceholderTaskLocked(drama, quality); view.ID != "" { views = append(views, view) if changed { a.cond.Broadcast() } } } _ = a.saveStateLocked() a.mu.Unlock() for _, drama := range dramas { a.startDramaParse(drama, updateOnly) } return views } func placeholderDramaKey(drama Drama) string { if id := strings.TrimSpace(drama.ID); id != "" { return id } return strings.Join([]string{strings.TrimSpace(drama.Source), strings.TrimSpace(drama.SourceID), drama.DisplayTitle()}, "|") } func (a *UIApp) existingParsingPlaceholderViewLocked(drama Drama) (uiTaskView, bool) { task := a.placeholderTask(drama, "parsing") id := uiTaskID(task.OutPath) if existing := a.tasks[id]; existing != nil && existing.Status == uiStatusParsing && existing.Phase == "parsing" { return taskToView(existing), true } return uiTaskView{}, false } func (a *UIApp) markParsingFinished(dramas []Drama) { a.mu.Lock() defer a.mu.Unlock() if a.parsingInFlight == nil { return } for _, drama := range dramas { delete(a.parsingInFlight, placeholderDramaKey(drama)) delete(a.parsingCancels, placeholderDramaKey(drama)) } } func (a *UIApp) enqueueDramas(ctx context.Context, dramas []Drama, updateOnly bool, quality int) ([]uiTaskView, []uiTaskView, string) { views := []uiTaskView{} failedViews := []uiTaskView{} respErr := "" for _, drama := range dramas { a.mu.Lock() existingDirectory := "" for _, id := range a.taskOrder { if task := a.tasks[id]; task != nil && task.DramaID == drama.ID && !isChapterPlaceholderTask(task) && task.Path != "" { existingDirectory = filepath.Dir(task.Path) break } } a.mu.Unlock() built, err := a.downloader.buildDramaTasksInDirectory(ctx, drama, existingDirectory) if err != nil { msg := fmt.Sprintf("《%s》加入失败:%s", drama.DisplayTitle(), a.redactError(err)) if respErr != "" { respErr += "\n" } respErr += msg a.mu.Lock() if a.finishCanceledParseLocked(drama) { a.mu.Unlock() continue } a.lastError = respErr a.removeParsingPlaceholderTaskLocked(drama) if view, changed := a.addFailedPlaceholderTaskLocked(drama, err, quality); view.ID != "" { failedViews = append(failedViews, view) if changed { a.cond.Broadcast() } } _ = a.saveStateLocked() a.mu.Unlock() continue } if len(built) == 0 { msg := fmt.Sprintf("《%s》没有可下载章节", drama.DisplayTitle()) if respErr != "" { respErr += "\n" } respErr += msg a.mu.Lock() if a.finishCanceledParseLocked(drama) { a.mu.Unlock() continue } a.lastError = respErr a.removeParsingPlaceholderTaskLocked(drama) if view, changed := a.addFailedPlaceholderTaskLocked(drama, errors.New("没有可下载章节"), quality); view.ID != "" { failedViews = append(failedViews, view) if changed { a.cond.Broadcast() } } _ = a.saveStateLocked() a.mu.Unlock() continue } a.mu.Lock() if a.finishCanceledParseLocked(drama) { a.mu.Unlock() continue } a.removeParsingPlaceholderTaskLocked(drama) a.removeFailedPlaceholderTaskLocked(drama) pathIndex := a.pathIndexLocked() now := time.Now() changed := true for _, task := range built { task.DownloadQuality = quality if existing := pathIndex[task.OutPath]; existing != nil { task.DownloadQuality = existing.Source.DownloadQuality if existing.Status != uiStatusRunning { existing.Source = task } if existing.Status == uiStatusSuccess { if fileOK, _ := existingGood(existing.Path, a.cfg.SkipBytes); fileOK { views = append(views, taskToView(existing)) continue } if updateOnly { existing.Status = uiStatusQueued existing.Progress = 0 existing.Error = "" existing.Phase = "queued" existing.DownloadedBytes = 0 existing.TotalBytes = task.Chapter.MediaSize existing.UpdatedAt = now views = append(views, taskToView(existing)) changed = true continue } } views = append(views, taskToView(existing)) continue } uiTask := newUITask(task, now) if ok, size := existingGood(task.OutPath, a.cfg.SkipBytes); ok { uiTask.Status = uiStatusSuccess uiTask.Progress = 100 uiTask.DownloadedBytes = size if uiTask.TotalBytes <= 0 { uiTask.TotalBytes = size } uiTask.Phase = "completed" } a.tasks[uiTask.ID] = uiTask a.taskOrder = append(a.taskOrder, uiTask.ID) pathIndex[uiTask.Path] = uiTask views = append(views, taskToView(uiTask)) changed = true } if changed { _ = a.saveStateLocked() a.cond.Broadcast() } a.mu.Unlock() } return views, failedViews, respErr } func (a *UIApp) addParsingPlaceholderTaskLocked(drama Drama, quality int) (uiTaskView, bool) { task := a.placeholderTask(drama, "parsing") task.DownloadQuality = quality uiTask := newUITask(task, time.Now()) uiTask.Status = uiStatusParsing uiTask.Progress = 0 uiTask.Phase = "parsing" uiTask.Title = "正在解析章节" if existing := a.tasks[uiTask.ID]; existing != nil { existing.Source = task existing.Status = uiStatusParsing existing.CancelRequested = false existing.PauseRequested = false existing.RemoveRequested = false existing.Progress = 0 existing.Error = "" existing.Phase = "parsing" existing.Title = "正在解析章节" existing.UpdatedAt = uiTask.UpdatedAt return taskToView(existing), false } a.tasks[uiTask.ID] = uiTask a.taskOrder = append(a.taskOrder, uiTask.ID) return taskToView(uiTask), true } func (a *UIApp) removeParsingPlaceholderTaskLocked(drama Drama) { task := a.placeholderTask(drama, "parsing") id := uiTaskID(task.OutPath) if id == "" { return } if existing := a.tasks[id]; existing == nil || existing.Phase != "parsing" { return } delete(a.tasks, id) for i, taskID := range a.taskOrder { if taskID == id { a.taskOrder = append(a.taskOrder[:i], a.taskOrder[i+1:]...) break } } } func (a *UIApp) placeholderTask(drama Drama, suffix string) Task { title := drama.DisplayTitle() if title == "" { title = "短剧" } dramaID := strings.TrimSpace(drama.ID) if dramaID == "" { dramaID = title } dramaDir, err := safeJoin(a.cfg.OutputDir, safeFilename(title)) if err != nil { dramaDir = filepath.Join(a.cfg.OutputDir, safeFilename(title)) } outPath := filepath.Join(dramaDir, ".placeholder-"+hashShort(dramaID)+".txt") chapterSource := firstNonEmpty(drama.Source, sourceFromDramaID(dramaID), "unknown") return Task{DramaID: dramaID, DramaTitle: title, Chapter: Chapter{ID: providerChapterID(chapterSource, firstNonEmpty(drama.SourceID, dramaID), suffix), Source: chapterSource, Title: "章节处理中", CurrentEpisode: rawEpisode(1)}, Index: 1, Total: 1, OutPath: outPath} } func sourceFromDramaID(id string) string { if source, _, ok := splitProviderDramaID(id); ok { return source } return "" } func (a *UIApp) addFailedPlaceholderTaskLocked(drama Drama, cause error, quality int) (uiTaskView, bool) { task := a.placeholderTask(drama, "fetch-failed") task.DownloadQuality = quality uiTask := newUITask(task, time.Now()) uiTask.Title = "章节获取失败" uiTask.Status = uiStatusFailed uiTask.Progress = 0 uiTask.Error = a.redactError(cause) uiTask.Phase = "failed" if existing := a.tasks[uiTask.ID]; existing != nil { existing.Source = task existing.Status = uiStatusFailed existing.Title = "章节获取失败" existing.Progress = 0 existing.Error = uiTask.Error existing.Phase = "failed" existing.UpdatedAt = uiTask.UpdatedAt return taskToView(existing), false } a.tasks[uiTask.ID] = uiTask a.taskOrder = append(a.taskOrder, uiTask.ID) return taskToView(uiTask), true } func (a *UIApp) removeFailedPlaceholderTaskLocked(drama Drama) { task := a.placeholderTask(drama, "fetch-failed") id := uiTaskID(task.OutPath) if id == "" { return } if existing := a.tasks[id]; existing == nil || !isFetchFailedPlaceholderTask(existing) { return } delete(a.tasks, id) for i, taskID := range a.taskOrder { if taskID == id { a.taskOrder = append(a.taskOrder[:i], a.taskOrder[i+1:]...) break } } } func isFetchFailedPlaceholderTask(task *UITask) bool { return task != nil && task.Phase == "failed" && task.Title == "章节获取失败" && strings.Contains(task.Source.Chapter.ID, ":fetch-failed") } func dramaFromPlaceholderTask(task *UITask) Drama { if task == nil { return Drama{} } drama := Drama{ID: task.DramaID, Title: task.DramaTitle, Name: task.DramaTitle} drama.Source = firstNonEmpty(task.Source.Chapter.Source, sourceFromDramaID(task.DramaID)) if _, sourceID, ok := splitProviderDramaID(task.DramaID); ok { drama.SourceID = sourceID } else if task.Source.Chapter.ID != "" { parts := strings.Split(task.Source.Chapter.ID, ":") if len(parts) >= 2 { drama.SourceID = parts[1] } } return drama } func (a *UIApp) handleTasks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } a.mu.Lock() views := a.taskViewsForSourceLocked(r.Context()) merges := cloneMergeStates(a.merges) for id := range merges { if !dramaAllowed(r.Context(), id, "") { delete(merges, id) continue } merges[id].PlaybackTaskID = "" if _, _, err := a.mergedPlaybackTaskLocked(mergedPlaybackPrefix + id); err == nil { merges[id].PlaybackTaskID = mergedPlaybackPrefix + id } } a.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"data": views, "merges": merges, "ffmpeg": a.downloader.ffmpegInstallation().snapshot()}) } func (a *UIApp) handleRetry(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } selection, ok := readTaskSelection(w, r) if !ok { return } a.mu.Lock() if !a.requireTaskSourcesLocked(w, r, selection) { a.mu.Unlock() return } if a.parsingInFlight == nil { a.parsingInFlight = map[string]bool{} } now := time.Now() changed := false var reparses []Drama for _, id := range selection.idsLocked(a) { task := a.tasks[id] if task == nil || task.RemoveRequested || task.Status != uiStatusFailed && task.Status != uiStatusCanceled { continue } if isChapterPlaceholderTask(task) { drama := dramaFromPlaceholderTask(task) dramaKey := placeholderDramaKey(drama) if a.parsingInFlight[dramaKey] { continue } a.parsingInFlight[dramaKey] = true a.removeFailedPlaceholderTaskLocked(drama) if _, placeholderChanged := a.addParsingPlaceholderTaskLocked(drama, task.Source.DownloadQuality); placeholderChanged { changed = true } reparses = append(reparses, drama) changed = true continue } task.Status = uiStatusQueued task.Progress = 0 task.Error = "" task.CancelRequested = false task.PauseRequested = false task.DownloadedBytes = 0 task.ElapsedSeconds = 0 task.MediaElapsedSeconds = 0 task.SpeedBytesPerSecond = 0 task.RemainingSeconds = 0 task.Phase = "queued" task.UpdatedAt = now changed = true } if changed { _ = a.saveStateLocked() a.cond.Broadcast() } views := a.taskViewsForSourceLocked(r.Context()) a.mu.Unlock() for _, drama := range reparses { a.startDramaParse(drama, true) } writeJSON(w, http.StatusOK, map[string]any{"data": views, "pending": len(reparses) > 0}) } func (a *UIApp) handleCancel(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } selection, ok := readTaskSelection(w, r) if !ok { return } a.mu.Lock() if !a.requireTaskSourcesLocked(w, r, selection) { a.mu.Unlock() return } now := time.Now() changed := false for _, id := range selection.idsLocked(a) { task := a.tasks[id] if task == nil { continue } task.PauseRequested = false switch task.Status { case uiStatusParsing: task.CancelRequested = true if cancel := a.parsingCancels[task.DramaID]; cancel != nil { cancel() } changed = true case uiStatusQueued, uiStatusPaused: task.Status = uiStatusCanceled task.Progress = 0 task.Error = "已取消" task.CancelRequested = false task.RemainingSeconds = 0 task.SpeedBytesPerSecond = 0 task.Phase = "canceled" task.UpdatedAt = now changed = true case uiStatusRunning: task.CancelRequested = true task.UpdatedAt = now if cancel := a.runningCancels[id]; cancel != nil { cancel() } changed = true } } if changed { _ = a.saveStateLocked() } views := a.taskViewsForSourceLocked(r.Context()) a.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"data": views}) } func (a *UIApp) handleClear(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } ids, ok := readIDsRequest(w, r) if !ok { return } a.mu.Lock() if !a.requireTaskSourcesLocked(w, r, taskSelection{IDs: ids}) { a.mu.Unlock() return } removed, pending := 0, 0 for _, id := range ids { task := a.tasks[id] if task == nil { continue } if task.Status == uiStatusRunning || task.Status == uiStatusParsing { task.RemoveRequested = true task.CancelRequested = true if cancel := a.runningCancels[id]; cancel != nil { cancel() } if task.Status == uiStatusParsing { if cancel := a.parsingCancels[task.DramaID]; cancel != nil { cancel() } } pending++ } else { a.removeTaskLocked(id) removed++ } } _ = a.saveStateLocked() views := a.taskViewsForSourceLocked(r.Context()) a.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"data": views, "removed": removed, "pending": pending}) } func (a *UIApp) handleMerge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } ids, deleteEpisodes, async, ok := readMergeRequest(w, r) if !ok || !requireDownload(w, r) || !a.requireDramaSources(w, r, ids) { return } a.mu.Lock() if !a.requireTaskSourcesLocked(w, r, taskSelection{DramaIDs: ids}) { a.mu.Unlock() return } jobs, err := a.enqueueMergesLocked(ids, deleteEpisodes) a.mu.Unlock() if err != nil { writeJSON(w, http.StatusConflict, map[string]string{"error": a.redactError(err)}) return } if async { writeJSON(w, http.StatusAccepted, map[string]any{"accepted": len(jobs), "async": true}) return } results := make([]uiMergeResult, 0, len(jobs)) for _, job := range jobs { select { case <-r.Context().Done(): return case <-job.done: results = append(results, job.result) } } writeJSON(w, http.StatusOK, map[string]any{"data": results}) } func (a *UIApp) mergeDrama(ctx context.Context, dramaID string, tasks []*UITask, deleteEpisodes bool) uiMergeResult { res := uiMergeResult{DramaID: dramaID} if len(tasks) == 0 { res.Error = "没有已完成的分集可合并" a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].Source.Index < tasks[j].Source.Index }) valid := make([]*UITask, 0, len(tasks)) seenEp := map[int]bool{} startEp, endEp := 0, 0 for _, task := range tasks { if task == nil || task.Path == "" { continue } ep := task.Source.Index if ep <= 0 { ep = episodeNumber(task.Episode) } if ep <= 0 || seenEp[ep] { continue } if st, err := os.Stat(task.Path); err == nil && !st.IsDir() && st.Size() > 0 { seenEp[ep] = true valid = append(valid, task) if startEp == 0 || ep < startEp { startEp = ep } if ep > endEp { endEp = ep } } } if len(valid) == 0 { res.Error = "已完成任务的文件不存在" a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } for ep := startEp; ep <= endEp; ep++ { if !seenEp[ep] { res.DramaTitle = valid[0].DramaTitle res.StartEpisode = startEp res.EndEpisode = endEp res.Merged = len(valid) res.Error = fmt.Sprintf("缺少第%03d集,未执行合并", ep) a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } } res.DramaTitle = valid[0].DramaTitle res.Merged = len(valid) res.StartEpisode = startEp res.EndEpisode = endEp dir := filepath.Dir(valid[0].Path) outPath := filepath.Join(dir, mergeOutputName(res.DramaTitle, startEp, endEp)) res.OutputPath = outPath if ok, _ := existingGood(outPath, a.cfg.SkipBytes); ok { res.OK = true res.Skipped = true a.setMergeState(res, "success", 100, true, deleteEpisodes) return res } paths := make([]string, 0, len(valid)) for _, task := range valid { paths = append(paths, task.Path) } partPath := filepath.Join(dir, "."+strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))+".part.mp4") _ = os.Remove(partPath) defer os.Remove(partPath) cmdCtx, cancel := context.WithTimeout(ctx, 2*time.Hour) defer cancel() res.Detail = "正在准备 FFmpeg" a.setMergeState(res, "running", 0, false, deleteEpisodes) ffmpeg, err := a.downloader.ensureFFmpeg(cmdCtx) if err != nil { res.Error = a.redactError(err) a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } method, err := mergeMediaFiles(cmdCtx, ffmpeg, paths, partPath, func(progress int, detail string) { res.Detail = detail a.setMergeState(res, "running", progress, false, deleteEpisodes) }) if err != nil { res.Error = a.redactError(err) a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } res.Detail = method if err := cmdCtx.Err(); err != nil { res.Error = "合并已取消,原分集已保留" a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } if ok, _ := existingGood(partPath, a.cfg.SkipBytes); !ok { res.Error = "合并输出文件无效" a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } if err := os.Rename(partPath, outPath); err != nil { res.Error = a.redactError(err) a.setMergeState(res, "failed", 0, false, deleteEpisodes) return res } res.OK = true if deleteEpisodes { for _, task := range valid { _ = os.Remove(task.Path) } } a.setMergeState(res, "success", 100, false, deleteEpisodes) return res } func cloneMergeStates(in map[string]*UIMergeState) map[string]*UIMergeState { out := make(map[string]*UIMergeState, len(in)) for id, state := range in { if state == nil { continue } copyState := *state out[id] = ©State } return out } func mergeOutputName(title string, start, end int) string { if start <= 0 { start = 1 } if end < start { end = start } return fmt.Sprintf("%s%d-%d.mp4", safeFilename(title), start, end) } func (a *UIApp) setMergeState(res uiMergeResult, status string, progress int, skipped, deleteEpisodes bool) { state := &UIMergeState{ DramaID: res.DramaID, DramaTitle: res.DramaTitle, Status: status, Progress: progress, Merged: res.Merged, StartEpisode: res.StartEpisode, EndEpisode: res.EndEpisode, Total: res.Merged, OutputPath: res.OutputPath, Detail: res.Detail, Error: res.Error, Skipped: skipped, DeleteEpisodes: deleteEpisodes, UpdatedAt: time.Now(), } a.mu.Lock() if a.merges == nil { a.merges = map[string]*UIMergeState{} } a.merges[res.DramaID] = state _ = a.saveStateLocked() a.mu.Unlock() } func readMergeRequest(w http.ResponseWriter, r *http.Request) ([]string, bool, bool, bool) { var req struct { Async bool `json:"async"` DramaIDs []string `json:"dramaIds"` DeleteEpisodes bool `json:"deleteEpisodes"` } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) return nil, false, false, false } ids, ok := cleanIDList(w, req.DramaIDs) return ids, req.DeleteEpisodes, req.Async, ok } func (a *UIApp) handleConfig(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { if !a.updateRuntimeSettings(w, r) { return } } else if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } a.mu.Lock() addr := a.address cfg := a.cfg nextOutputDir := firstNonEmpty(a.nextOutputDir, cfg.outputDirSetting, cfg.OutputDir) a.mu.Unlock() proxyMode, proxyURL, proxyHasAuth := publicProxyConfig(cfg.ProxyURL) writeJSON(w, http.StatusOK, map[string]any{"outputDir": cfg.OutputDir, "outputDirSetting": nextOutputDir, "restartRequired": !sameDirectory(cfg.OutputDir, nextOutputDir), "dataDir": cfg.dataDirectory(), "concurrency": cfg.Concurrency, "ffmpeg": cfg.FFmpeg, "address": addr, "libraryCachePath": libraryCachePath(cfg.dataDirectory()), "requestConcurrency": cfg.RequestConcurrency, "requestIntervalMs": cfg.RequestIntervalMS, "network": a.downloader.proxyRouter.summary(), "proxyMode": proxyMode, "proxyURL": proxyURL, "proxyHasAuth": proxyHasAuth}) } func readIDsRequest(w http.ResponseWriter, r *http.Request) ([]string, bool) { var req struct { IDs []string `json:"ids"` } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) return nil, false } return cleanIDList(w, req.IDs) } func readDramaIDsRequest(w http.ResponseWriter, r *http.Request) ([]string, bool) { var req struct { DramaIDs []string `json:"dramaIds"` } r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) return nil, false } return cleanIDList(w, req.DramaIDs) } func cleanIDList(w http.ResponseWriter, in []string) ([]string, bool) { seen := map[string]bool{} ids := make([]string, 0, len(in)) for _, id := range in { id = strings.TrimSpace(id) if id == "" || seen[id] { continue } seen[id] = true ids = append(ids, id) } if len(ids) == 0 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ids is required"}) return nil, false } return ids, true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func uiTaskTimeout(task Task) time.Duration { total := task.Total if total <= 0 { total = 1 } timeout := time.Duration(20+total*3) * time.Minute if timeout < 30*time.Minute { return 30 * time.Minute } if timeout > 2*time.Hour { return 2 * time.Hour } return timeout } func newUITask(task Task, now time.Time) *UITask { episode := task.Chapter.EpisodeString(task.Index) title := strings.TrimSpace(task.Chapter.Title) if title == "" || strings.TrimSpace(title) == strings.TrimSpace(episode) { title = "第" + padEpisode(episode) + "集" } return &UITask{ ID: uiTaskID(task.OutPath), DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: episode, Title: title, Status: uiStatusQueued, Progress: 0, TotalBytes: task.Chapter.MediaSize, Phase: "queued", Path: task.OutPath, CreatedAt: now, UpdatedAt: now, Source: task, } } func uiTaskID(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } sum := sha256.Sum256([]byte(path)) return hex.EncodeToString(sum[:])[:16] } func cloneUITask(task *UITask) *UITask { if task == nil { return nil } cp := *task return &cp } func (a *UIApp) pathIndexLocked() map[string]*UITask { out := make(map[string]*UITask, len(a.tasks)) for _, task := range a.tasks { if task == nil { continue } if task.Path != "" { out[task.Path] = task } if task.Source.OutPath != "" { out[task.Source.OutPath] = task } } return out } func (a *UIApp) taskViewsLocked() []uiTaskView { views := make([]uiTaskView, 0, len(a.taskOrder)) releases := make(map[string]string, len(a.dramas)) for _, drama := range a.dramas { releases[drama.ID] = dramaReleaseStatus(drama) } for _, id := range a.taskOrder { if task := a.tasks[id]; task != nil { view := taskToView(task) if status := releases[task.DramaID]; status != "" { view.ReleaseStatus = status } views = append(views, view) } } return views } func taskToView(task *UITask) uiTaskView { return uiTaskView{ ID: task.ID, DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Episode, Title: task.Title, Index: task.Source.Index, Total: task.Source.Total, Status: task.Status, Progress: task.Progress, Error: task.Error, Path: task.Path, Attempt: task.Attempt, DownloadedBytes: task.DownloadedBytes, TotalBytes: task.TotalBytes, SpeedBytesPerSecond: task.SpeedBytesPerSecond, ElapsedSeconds: task.ElapsedSeconds, RemainingSeconds: task.RemainingSeconds, MediaElapsedSeconds: task.MediaElapsedSeconds, MediaTotalSeconds: task.MediaTotalSeconds, Phase: task.Phase, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, CancelRequested: task.CancelRequested, RemoveRequested: task.RemoveRequested, PauseRequested: task.PauseRequested, ReleaseStatus: task.Source.ReleaseStatus, Playable: isPlayableDownloadTask(task), DownloadQuality: task.Source.DownloadQuality, } } func (a *UIApp) redactError(err error) string { if err == nil { return "" } return a.redactString(err.Error()) } func (a *UIApp) redactString(s string) string { return redactConfiguredString(&a.cfg, s) }