276 lines
8.2 KiB
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)
|
|
}
|