package app import ( "encoding/json" "errors" "io" "os" "path/filepath" "sort" "sync" "time" ) const followingLimit = 1000 var errFollowingLimit = errors.New("追剧清单已达到 1000 部,请先移除不再需要的内容") var errFollowingUnknown = errors.New("剧库中没有这部剧,请先搜索或更新剧库") type followingEntry struct { DramaID string `json:"dramaId"` Source string `json:"source"` Title string `json:"title"` Category string `json:"category,omitempty"` Saved bool `json:"saved"` Completed bool `json:"completed"` KnownEpisodes int `json:"knownEpisodes"` AddedAt time.Time `json:"addedAt"` UpdatedAt time.Time `json:"updatedAt"` } type followingFile struct { Version int `json:"version"` Entries []followingEntry `json:"entries"` } type followingStore struct { mu sync.Mutex path string entries map[string]followingEntry loadErr error } func newFollowingStore(directory string) *followingStore { store := &followingStore{path: filepath.Join(directory, "following.json"), entries: map[string]followingEntry{}} file, err := os.Open(store.path) if errors.Is(err, os.ErrNotExist) { return store } if err != nil { store.loadErr = err return store } defer file.Close() var saved followingFile decoder := json.NewDecoder(io.LimitReader(file, 8<<20)) if err = decoder.Decode(&saved); err == nil { if decoder.Decode(new(any)) != io.EOF || saved.Version != 1 || len(saved.Entries) > followingLimit { err = errors.New("追剧清单格式无效,原文件已保留") } } if err != nil { store.loadErr = err return store } for _, entry := range saved.Entries { entry, valid := normalizeFollowingEntry(entry) if !valid { continue } if old, exists := store.entries[entry.DramaID]; !exists || entry.UpdatedAt.After(old.UpdatedAt) { store.entries[entry.DramaID] = entry } } return store } func followingEntries(entries map[string]followingEntry) []followingEntry { result := make([]followingEntry, 0, len(entries)) for _, entry := range entries { result = append(result, entry) } sort.Slice(result, func(i, j int) bool { if result[i].UpdatedAt.Equal(result[j].UpdatedAt) { return result[i].DramaID < result[j].DramaID } return result[i].UpdatedAt.After(result[j].UpdatedAt) }) return result } func normalizeFollowingEntry(entry followingEntry) (followingEntry, bool) { id, source, valid := playbackHistoryIdentity(entry.DramaID) if !valid || canonicalProviderSource(entry.Source) != source || len(entry.Title) > 4096 || len(entry.Category) > 512 || entry.KnownEpisodes < 0 || entry.KnownEpisodes > 10000 || entry.AddedAt.IsZero() || entry.UpdatedAt.IsZero() || !entry.Saved && !entry.Completed { return followingEntry{}, false } entry.DramaID, entry.Source = id, source return entry, true } func (store *followingStore) list() ([]followingEntry, error) { store.mu.Lock() defer store.mu.Unlock() return followingEntries(store.entries), store.loadErr } func (store *followingStore) update(id string, change func(followingEntry, bool) (followingEntry, error)) (followingEntry, error) { store.mu.Lock() defer store.mu.Unlock() if store.loadErr != nil { return followingEntry{}, store.loadErr } previous, exists := store.entries[id] entry, err := change(previous, exists) if err != nil { return followingEntry{}, err } if !exists && (entry.Saved || entry.Completed) && len(store.entries) >= followingLimit { return followingEntry{}, errFollowingLimit } next := make(map[string]followingEntry, len(store.entries)+1) for key, value := range store.entries { next[key] = value } if entry.Saved || entry.Completed { next[id] = entry } else { delete(next, id) } if err = store.write(followingFile{Version: 1, Entries: followingEntries(next)}); err != nil { return followingEntry{}, err } store.entries = next return entry, nil } func (store *followingStore) merge(entries []followingEntry) (int, int, error) { store.mu.Lock() defer store.mu.Unlock() if store.loadErr != nil { return 0, 0, store.loadErr } next := make(map[string]followingEntry, len(store.entries)+len(entries)) for id, entry := range store.entries { next[id] = entry } imported := 0 skipped := 0 for _, entry := range entries { entry, valid := normalizeFollowingEntry(entry) if !valid { continue } current, exists := next[entry.DramaID] if exists && !entry.UpdatedAt.After(current.UpdatedAt) { continue } if !exists && len(next) >= followingLimit { skipped++ continue } next[entry.DramaID] = entry imported++ } if imported == 0 { return 0, skipped, nil } if err := store.write(followingFile{Version: 1, Entries: followingEntries(next)}); err != nil { return 0, skipped, err } store.entries = next return imported, skipped, nil } func (store *followingStore) write(data followingFile) error { if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil { return err } file, err := os.CreateTemp(filepath.Dir(store.path), ".following-*.json") if err != nil { return err } defer os.Remove(file.Name()) if err = json.NewEncoder(file).Encode(data); err == nil { err = file.Sync() } if closeErr := file.Close(); err == nil { err = closeErr } if err != nil { return err } return os.Rename(file.Name(), store.path) }