620 lines
13 KiB
Go
620 lines
13 KiB
Go
package usenet
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/puzpuzpuz/xsync/v4"
|
|
"github.com/rs/zerolog"
|
|
"github.com/sirrobot01/decypharr/internal/config"
|
|
"github.com/sourcegraph/conc/pool"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
type fileInfo struct {
|
|
id string
|
|
name string
|
|
size int64
|
|
mode os.FileMode
|
|
modTime time.Time
|
|
isDir bool
|
|
}
|
|
|
|
func (fi *fileInfo) Name() string { return fi.name }
|
|
func (fi *fileInfo) Size() int64 { return fi.size }
|
|
func (fi *fileInfo) Mode() os.FileMode { return fi.mode }
|
|
func (fi *fileInfo) ModTime() time.Time { return fi.modTime }
|
|
func (fi *fileInfo) IsDir() bool { return fi.isDir }
|
|
func (fi *fileInfo) ID() string { return fi.id }
|
|
func (fi *fileInfo) Sys() interface{} { return nil }
|
|
|
|
type Store interface {
|
|
Add(nzb *NZB) error
|
|
Get(nzoID string) *NZB
|
|
GetByName(name string) *NZB
|
|
Update(nzb *NZB) error
|
|
UpdateFile(nzoID string, file *NZBFile) error
|
|
Delete(nzoID string) error
|
|
Count() int
|
|
Filter(category string, limit int, status ...string) []*NZB
|
|
GetHistory(category string, limit int) []*NZB
|
|
UpdateStatus(nzoID string, status string) error
|
|
Close() error
|
|
GetListing(folder string) []os.FileInfo
|
|
Load() error
|
|
|
|
// GetQueueItem Queue management
|
|
|
|
GetQueueItem(nzoID string) *NZB
|
|
AddToQueue(nzb *NZB)
|
|
RemoveFromQueue(nzoID string)
|
|
GetQueue() []*NZB
|
|
AtomicDelete(nzoID string) error
|
|
|
|
RemoveFile(nzoID string, filename string) error
|
|
|
|
MarkAsCompleted(nzoID string, storage string) error
|
|
}
|
|
|
|
type store struct {
|
|
storePath string
|
|
listing atomic.Value
|
|
badListing atomic.Value
|
|
queue *xsync.Map[string, *NZB]
|
|
titles *xsync.Map[string, string] // title -> nzoID
|
|
config *config.Usenet
|
|
logger zerolog.Logger
|
|
}
|
|
|
|
func NewStore(config *config.Config, logger zerolog.Logger) Store {
|
|
err := os.MkdirAll(config.NZBsPath(), 0755)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
s := &store{
|
|
storePath: config.NZBsPath(),
|
|
queue: xsync.NewMap[string, *NZB](),
|
|
titles: xsync.NewMap[string, string](),
|
|
config: config.Usenet,
|
|
logger: logger,
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (ns *store) Load() error {
|
|
ids, err := ns.getAllIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
listing := make([]os.FileInfo, 0)
|
|
badListing := make([]os.FileInfo, 0)
|
|
|
|
for _, id := range ids {
|
|
nzb, err := ns.loadFromFile(id)
|
|
if err != nil {
|
|
continue // Skip if file cannot be loaded
|
|
}
|
|
|
|
ns.titles.Store(nzb.Name, nzb.ID)
|
|
|
|
fileInfo := &fileInfo{
|
|
id: nzb.ID,
|
|
name: nzb.Name,
|
|
size: nzb.TotalSize,
|
|
mode: 0644,
|
|
modTime: nzb.AddedOn,
|
|
isDir: true,
|
|
}
|
|
|
|
listing = append(listing, fileInfo)
|
|
if nzb.IsBad {
|
|
badListing = append(badListing, fileInfo)
|
|
}
|
|
}
|
|
|
|
ns.listing.Store(listing)
|
|
ns.badListing.Store(badListing)
|
|
|
|
return nil
|
|
}
|
|
|
|
// getFilePath returns the file path for an NZB
|
|
func (ns *store) getFilePath(nzoID string) string {
|
|
return filepath.Join(ns.storePath, nzoID+".json")
|
|
}
|
|
|
|
func (ns *store) loadFromFile(nzoID string) (*NZB, error) {
|
|
filePath := ns.getFilePath(nzoID)
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var compact CompactNZB
|
|
if err := json.Unmarshal(data, &compact); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return compact.toNZB(), nil
|
|
}
|
|
|
|
// saveToFile saves an NZB to file
|
|
func (ns *store) saveToFile(nzb *NZB) error {
|
|
filePath := ns.getFilePath(nzb.ID)
|
|
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(filePath)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
compact := nzb.toCompact()
|
|
data, err := json.Marshal(compact) // Use compact JSON
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(filePath, data, 0644)
|
|
}
|
|
|
|
func (ns *store) refreshListing() error {
|
|
ids, err := ns.getAllIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
listing := make([]os.FileInfo, 0, len(ids))
|
|
badListing := make([]os.FileInfo, 0, len(ids))
|
|
|
|
for _, id := range ids {
|
|
nzb, err := ns.loadFromFile(id)
|
|
if err != nil {
|
|
continue // Skip if file cannot be loaded
|
|
}
|
|
fileInfo := &fileInfo{
|
|
id: nzb.ID,
|
|
name: nzb.Name,
|
|
size: nzb.TotalSize,
|
|
mode: 0644,
|
|
modTime: nzb.AddedOn,
|
|
isDir: true,
|
|
}
|
|
listing = append(listing, fileInfo)
|
|
ns.titles.Store(nzb.Name, nzb.ID)
|
|
if nzb.IsBad {
|
|
badListing = append(badListing, fileInfo)
|
|
}
|
|
}
|
|
|
|
// Update all structures atomically
|
|
ns.listing.Store(listing)
|
|
ns.badListing.Store(badListing)
|
|
|
|
// Refresh rclone if configured
|
|
go func() {
|
|
if err := ns.refreshRclone(); err != nil {
|
|
ns.logger.Error().Err(err).Msg("Failed to refresh rclone")
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) Add(nzb *NZB) error {
|
|
if nzb == nil {
|
|
return fmt.Errorf("nzb cannot be nil")
|
|
}
|
|
if err := ns.saveToFile(nzb); err != nil {
|
|
return err
|
|
}
|
|
|
|
ns.titles.Store(nzb.Name, nzb.ID)
|
|
|
|
go func() {
|
|
_ = ns.refreshListing()
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) GetByName(name string) *NZB {
|
|
|
|
if nzoID, exists := ns.titles.Load(name); exists {
|
|
return ns.Get(nzoID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) GetQueueItem(nzoID string) *NZB {
|
|
if item, exists := ns.queue.Load(nzoID); exists {
|
|
return item
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) AddToQueue(nzb *NZB) {
|
|
if nzb == nil {
|
|
return
|
|
}
|
|
ns.queue.Store(nzb.ID, nzb)
|
|
}
|
|
|
|
func (ns *store) RemoveFromQueue(nzoID string) {
|
|
if nzoID == "" {
|
|
return
|
|
}
|
|
ns.queue.Delete(nzoID)
|
|
}
|
|
|
|
func (ns *store) GetQueue() []*NZB {
|
|
var queueItems []*NZB
|
|
ns.queue.Range(func(_ string, value *NZB) bool {
|
|
queueItems = append(queueItems, value)
|
|
return true // continue iteration
|
|
})
|
|
return queueItems
|
|
}
|
|
|
|
func (ns *store) Get(nzoID string) *NZB {
|
|
nzb, err := ns.loadFromFile(nzoID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return nzb
|
|
}
|
|
func (ns *store) Update(nzb *NZB) error {
|
|
if err := ns.saveToFile(nzb); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) Delete(nzoID string) error {
|
|
return ns.AtomicDelete(nzoID)
|
|
}
|
|
|
|
// AtomicDelete performs an atomic delete operation across all data structures
|
|
func (ns *store) AtomicDelete(nzoID string) error {
|
|
if nzoID == "" {
|
|
return fmt.Errorf("nzoID cannot be empty")
|
|
}
|
|
|
|
filePath := ns.getFilePath(nzoID)
|
|
|
|
// Get NZB info before deletion for cleanup
|
|
nzb := ns.Get(nzoID)
|
|
if nzb == nil {
|
|
// Check if file exists on disk even if not in cache
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
return nil // Already deleted
|
|
}
|
|
}
|
|
ns.queue.Delete(nzoID)
|
|
|
|
if nzb != nil {
|
|
ns.titles.Delete(nzb.Name)
|
|
}
|
|
|
|
if currentListing := ns.listing.Load(); currentListing != nil {
|
|
oldListing := currentListing.([]os.FileInfo)
|
|
newListing := make([]os.FileInfo, 0, len(oldListing))
|
|
for _, fi := range oldListing {
|
|
if fileInfo, ok := fi.(*fileInfo); ok && fileInfo.id != nzoID {
|
|
newListing = append(newListing, fi)
|
|
}
|
|
}
|
|
ns.listing.Store(newListing)
|
|
}
|
|
|
|
if currentListing := ns.badListing.Load(); currentListing != nil {
|
|
oldListing := currentListing.([]os.FileInfo)
|
|
newListing := make([]os.FileInfo, 0, len(oldListing))
|
|
for _, fi := range oldListing {
|
|
if fileInfo, ok := fi.(*fileInfo); ok && fileInfo.id != nzoID {
|
|
newListing = append(newListing, fi)
|
|
}
|
|
}
|
|
ns.badListing.Store(newListing)
|
|
}
|
|
|
|
// Remove file from disk
|
|
return os.Remove(filePath)
|
|
}
|
|
|
|
func (ns *store) RemoveFile(nzoID string, filename string) error {
|
|
if nzoID == "" || filename == "" {
|
|
return fmt.Errorf("nzoID and filename cannot be empty")
|
|
}
|
|
|
|
nzb := ns.Get(nzoID)
|
|
if nzb == nil {
|
|
return fmt.Errorf("nzb with nzoID %s not found", nzoID)
|
|
}
|
|
err := nzb.MarkFileAsRemoved(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ns.Update(nzb); err != nil {
|
|
return fmt.Errorf("failed to update nzb after removing file %s: %w", filename, err)
|
|
}
|
|
// Refresh listing after file removal
|
|
_ = ns.refreshListing()
|
|
// Remove file from rclone cache if configured
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) getAllIDs() ([]string, error) {
|
|
var ids []string
|
|
|
|
err := filepath.WalkDir(ns.storePath, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !d.IsDir() && strings.HasSuffix(d.Name(), ".json") {
|
|
id := strings.TrimSuffix(d.Name(), ".json")
|
|
ids = append(ids, id)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return ids, err
|
|
}
|
|
|
|
func (ns *store) Filter(category string, limit int, status ...string) []*NZB {
|
|
ids, err := ns.getAllIDs()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
statusSet := make(map[string]struct{})
|
|
for _, s := range status {
|
|
statusSet[s] = struct{}{}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
p := pool.New().WithContext(ctx).WithMaxGoroutines(10)
|
|
|
|
var results []*NZB
|
|
var mu sync.Mutex
|
|
var found atomic.Int32
|
|
|
|
for _, id := range ids {
|
|
id := id
|
|
p.Go(func(ctx context.Context) error {
|
|
// Early exit if limit reached
|
|
if limit > 0 && found.Load() >= int32(limit) {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
nzb := ns.Get(id)
|
|
if nzb == nil {
|
|
return nil
|
|
}
|
|
|
|
// Apply filters
|
|
if category != "" && nzb.Category != category {
|
|
return nil
|
|
}
|
|
|
|
if len(statusSet) > 0 {
|
|
if _, exists := statusSet[nzb.Status]; !exists {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Add to results with limit check
|
|
mu.Lock()
|
|
if limit == 0 || len(results) < limit {
|
|
results = append(results, nzb)
|
|
found.Add(1)
|
|
|
|
// Cancel if we hit the limit
|
|
if limit > 0 && len(results) >= limit {
|
|
cancel()
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
})
|
|
}
|
|
|
|
if err := p.Wait(); err != nil {
|
|
return nil
|
|
}
|
|
return results
|
|
}
|
|
|
|
func (ns *store) Count() int {
|
|
ids, err := ns.getAllIDs()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(ids)
|
|
}
|
|
|
|
func (ns *store) GetHistory(category string, limit int) []*NZB {
|
|
return ns.Filter(category, limit, "completed", "failed", "error")
|
|
}
|
|
|
|
func (ns *store) UpdateStatus(nzoID string, status string) error {
|
|
nzb := ns.Get(nzoID)
|
|
if nzb == nil {
|
|
return fmt.Errorf("nzb with nzoID %s not found", nzoID)
|
|
}
|
|
|
|
nzb.Status = status
|
|
nzb.LastActivity = time.Now()
|
|
|
|
if status == "completed" {
|
|
nzb.CompletedOn = time.Now()
|
|
nzb.Progress = 100
|
|
nzb.Percentage = 100
|
|
}
|
|
if status == "failed" {
|
|
// Remove from cache if failed
|
|
err := ns.Delete(nzb.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return ns.Update(nzb)
|
|
}
|
|
|
|
func (ns *store) Close() error {
|
|
// Clear cache
|
|
ns.queue = xsync.NewMap[string, *NZB]()
|
|
// Clear listings
|
|
ns.listing = atomic.Value{}
|
|
ns.badListing = atomic.Value{}
|
|
// Clear titles
|
|
ns.titles = xsync.NewMap[string, string]()
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) UpdateFile(nzoID string, file *NZBFile) error {
|
|
if nzoID == "" || file == nil {
|
|
return fmt.Errorf("nzoID and file cannot be empty")
|
|
}
|
|
|
|
nzb := ns.Get(nzoID)
|
|
if nzb == nil {
|
|
return fmt.Errorf("nzb with nzoID %s not found", nzoID)
|
|
}
|
|
|
|
// Update file in NZB
|
|
for i, f := range nzb.Files {
|
|
if f.Name == file.Name {
|
|
nzb.Files[i] = *file
|
|
break
|
|
}
|
|
}
|
|
|
|
if err := ns.Update(nzb); err != nil {
|
|
return fmt.Errorf("failed to update nzb after updating file %s: %w", file.Name, err)
|
|
}
|
|
|
|
// Refresh listing after file update
|
|
return ns.refreshListing()
|
|
}
|
|
|
|
func (ns *store) GetListing(folder string) []os.FileInfo {
|
|
switch folder {
|
|
case "__bad__":
|
|
if badListing, ok := ns.badListing.Load().([]os.FileInfo); ok {
|
|
return badListing
|
|
}
|
|
return []os.FileInfo{}
|
|
default:
|
|
if listing, ok := ns.listing.Load().([]os.FileInfo); ok {
|
|
return listing
|
|
}
|
|
return []os.FileInfo{}
|
|
}
|
|
}
|
|
|
|
func (ns *store) MarkAsCompleted(nzoID string, storage string) error {
|
|
if nzoID == "" {
|
|
return fmt.Errorf("nzoID cannot be empty")
|
|
}
|
|
|
|
// Get NZB from queue
|
|
queueNZB := ns.GetQueueItem(nzoID)
|
|
if queueNZB == nil {
|
|
return fmt.Errorf("NZB %s not found in queue", nzoID)
|
|
}
|
|
|
|
// Update NZB status
|
|
queueNZB.Status = "completed"
|
|
queueNZB.Storage = storage
|
|
queueNZB.CompletedOn = time.Now()
|
|
queueNZB.LastActivity = time.Now()
|
|
queueNZB.Progress = 100
|
|
queueNZB.Percentage = 100
|
|
|
|
// Atomically: remove from queue and add to storage
|
|
ns.queue.Delete(nzoID)
|
|
if err := ns.Add(queueNZB); err != nil {
|
|
// Rollback: add back to queue if storage fails
|
|
ns.queue.Store(nzoID, queueNZB)
|
|
return fmt.Errorf("failed to store completed NZB: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) refreshRclone() error {
|
|
|
|
if ns.config.RcUrl == "" {
|
|
return nil
|
|
}
|
|
|
|
client := http.DefaultClient
|
|
// Create form data
|
|
data := ns.buildRcloneRequestData()
|
|
|
|
if err := ns.sendRcloneRequest(client, "vfs/forget", data); err != nil {
|
|
ns.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request")
|
|
}
|
|
|
|
if err := ns.sendRcloneRequest(client, "vfs/refresh", data); err != nil {
|
|
ns.logger.Error().Err(err).Msg("Failed to send rclone vfs/refresh request")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ns *store) buildRcloneRequestData() string {
|
|
return "dir=__all__"
|
|
}
|
|
|
|
func (ns *store) sendRcloneRequest(client *http.Client, endpoint, data string) error {
|
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", ns.config.RcUrl, endpoint), strings.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
if ns.config.RcUser != "" && ns.config.RcPass != "" {
|
|
req.SetBasicAuth(ns.config.RcUser, ns.config.RcPass)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func(Body io.ReadCloser) {
|
|
err := Body.Close()
|
|
if err != nil {
|
|
ns.logger.Error().Err(err).Msg("Failed to close response body")
|
|
}
|
|
}(resp.Body)
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("failed to perform %s: %s - %s", endpoint, resp.Status, string(body))
|
|
}
|
|
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|