// Package subscription manages clash RSS subscriptions with persistent storage. package subscription import ( "fmt" "hash/fnv" "io" "net/http" "os" "path/filepath" "time" "gitea.epss.net.cn/klesh/ss/internal/model" "gitea.epss.net.cn/klesh/ss/internal/storage" ) // Manager manages clash RSS subscriptions with persistent storage. type Manager struct { Storage *storage.Manager Data *model.SubscriptionsData Config map[string]interface{} SubscriptionsDir string } // New creates a Manager, loading existing subscriptions/config and ensuring // the subscriptions directory exists. func New(s *storage.Manager) (*Manager, error) { m := &Manager{ Storage: s, Data: s.LoadSubscriptions(), Config: s.LoadConfig(), SubscriptionsDir: filepath.Join(s.ConfigDir, "subscriptions"), } if err := os.MkdirAll(m.SubscriptionsDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create subscriptions directory: %w", err) } return m, nil } // AddSubscription adds a new subscription, then refreshes and activates it. func (m *Manager) AddSubscription(name, url string) { sub := m.Data.AddSubscription(name, url) if m.Storage.SaveSubscriptions(m.Data) { m.RefreshSubscription(sub.Name, false) m.ActivateSubscription(sub.Name) fmt.Printf("✅ Added subscription: %s -> %s\n", name, url) } else { fmt.Println("❌ Failed to save subscription") } } // RefreshSubscription downloads the subscription content from its URL and // saves it to disk, optionally backing up any existing file first. func (m *Manager) RefreshSubscription(name string, backup bool) { sub, ok := m.Data.Subscriptions[name] if !ok { fmt.Printf("❌ Subscription '%s' not found\n", name) return } fmt.Printf("🔄 Refreshing subscription: %s\n", name) resp, err := http.Get(sub.URL) if err != nil { m.recordRefreshError(sub, err) return } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { m.recordRefreshError(sub, err) return } if resp.StatusCode >= 400 { err := fmt.Errorf("HTTP %d", resp.StatusCode) m.recordRefreshError(sub, err) return } body := string(bodyBytes) filePath := filepath.Join(m.SubscriptionsDir, name+".yml") if backup { if info, statErr := os.Stat(filePath); statErr == nil { ctime := creationTime(info) backupName := fmt.Sprintf("%s_%s.yml", name, ctime.Format("20060102_150405")) backupPath := filepath.Join(m.SubscriptionsDir, backupName) if err := os.Rename(filePath, backupPath); err != nil { fmt.Printf("❌ Failed to save file: %v\n", err) m.recordRefreshError(sub, err) return } fmt.Printf(" 🔄 Backed up existing file to: %s\n", backupName) } } content := convertContent(body) if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { fmt.Printf("❌ Failed to save file: %v\n", err) m.recordRefreshError(sub, err) return } now := &model.Time{Time: time.Now()} sub.LastRefresh = now fileSize := int64(len(content)) sub.FileSize = &fileSize statusCode := resp.StatusCode sub.StatusCode = &statusCode contentHash := int64(stableHash(content)) sub.ContentHash = &contentHash sub.LastError = nil if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("✅ Subscription '%s' refreshed successfully\n", name) fmt.Printf(" 📁 Saved to: %s\n", filePath) fmt.Printf(" 📊 Size: %d bytes\n", len(body)) } else { fmt.Println("❌ Failed to save subscription metadata") } } func (m *Manager) recordRefreshError(sub *model.Subscription, err error) { fmt.Printf("❌ Failed to download subscription: %v\n", err) msg := err.Error() sub.LastError = &msg m.Storage.SaveSubscriptions(m.Data) } // stableHash returns a stable 64-bit hash of content. Unlike Python's // built-in hash() (randomized per-process via PYTHONHASHSEED and therefore // not actually stable across runs), this is deterministic. func stableHash(content string) uint64 { h := fnv.New64a() h.Write([]byte(content)) return h.Sum64() } // DeleteSubscription removes a subscription by name. func (m *Manager) DeleteSubscription(name string) { if m.Data.RemoveSubscription(name) { if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("🗑️ Deleted subscription: %s\n", name) } else { fmt.Println("❌ Failed to delete subscription") } } else { fmt.Printf("❌ Subscription '%s' not found\n", name) } } // RenameSubscription renames oldName to newName. func (m *Manager) RenameSubscription(oldName, newName string) { if m.Data.RenameSubscription(oldName, newName) { if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("✅ Renamed subscription: %s -> %s\n", oldName, newName) } else { fmt.Println("❌ Failed to rename subscription") } } else { fmt.Printf("❌ Failed to rename subscription: '%s' not found or '%s' already exists\n", oldName, newName) } } // SetSubscriptionURL updates the URL for a subscription. func (m *Manager) SetSubscriptionURL(name, url string) { if m.Data.SetSubscriptionURL(name, url) { if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("✅ Updated URL for subscription '%s': %s\n", name, url) } else { fmt.Println("❌ Failed to save subscription") } } else { fmt.Printf("❌ Subscription '%s' not found\n", name) } } // ActivateSubscription marks a subscription as active, in addition to any // other subscriptions already active. `config apply` merges every active // subscription's proxies together. func (m *Manager) ActivateSubscription(name string) { if m.Data.SetActive(name) { if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("✅ Activated subscription: %s (%d active)\n", name, len(m.Data.GetActiveSubscriptions())) } else { fmt.Println("❌ Failed to activate subscription") } } else { fmt.Printf("❌ Subscription '%s' not found\n", name) } } // DeactivateSubscription marks a subscription as inactive. func (m *Manager) DeactivateSubscription(name string) { if m.Data.SetInactive(name) { if m.Storage.SaveSubscriptions(m.Data) { fmt.Printf("✅ Deactivated subscription: %s\n", name) } else { fmt.Println("❌ Failed to deactivate subscription") } } else { fmt.Printf("❌ Subscription '%s' not found\n", name) } } // ListSubscriptions prints all subscriptions in display order (see // model.SubscriptionsData.OrderedNames / `subscription move`). func (m *Manager) ListSubscriptions() { if len(m.Data.Subscriptions) == 0 { fmt.Println("No subscriptions found") return } fmt.Println("📋 Subscriptions:") for _, name := range m.Data.OrderedNames() { sub := m.Data.Subscriptions[name] activeMarker := "⚪" if sub.Status == model.StatusActive { activeMarker = "✅" } lastRefreshStr := "" if sub.LastRefresh != nil { lastRefreshStr = fmt.Sprintf(" (last: %s)", sub.LastRefresh.Format("2006-01-02 15:04:05")) } fmt.Printf(" %s %s%s\n", activeMarker, name, lastRefreshStr) } } // MoveSubscription repositions name immediately before or after target in // the display/merge order. func (m *Manager) MoveSubscription(name, target string, before bool) { if m.Data.MoveSubscription(name, target, before) { if m.Storage.SaveSubscriptions(m.Data) { position := "after" if before { position = "before" } fmt.Printf("✅ Moved subscription '%s' %s '%s'\n", name, position, target) } else { fmt.Println("❌ Failed to move subscription") } } else if name == target { fmt.Println("❌ Cannot move a subscription relative to itself") } else if _, ok := m.Data.Subscriptions[name]; !ok { fmt.Printf("❌ Subscription '%s' not found\n", name) } else { fmt.Printf("❌ Subscription '%s' not found\n", target) } } // GetSubscriptionURL prints the URL for a subscription. func (m *Manager) GetSubscriptionURL(name string) { sub, ok := m.Data.Subscriptions[name] if !ok { fmt.Printf("❌ Subscription '%s' not found\n", name) return } fmt.Printf("%s: %s\n", name, sub.URL) } // ShowStorageInfo prints storage location information. func (m *Manager) ShowStorageInfo() { info := m.Storage.GetStorageInfo() fmt.Println("📁 Storage Information:") fmt.Printf(" Platform: %s\n", info.Platform) fmt.Printf(" Config Directory: %s\n", info.ConfigDir) fmt.Printf(" Config File: %s\n", info.ConfigFile) fmt.Printf(" Subscriptions File: %s\n", info.SubscriptionsFile) fmt.Printf(" Directory Exists: %v\n", info.Exists) }