Files
ss/internal/subscription/manager.go

276 lines
8.2 KiB
Go

// 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)
}