diff --git a/internal/dav/delete.go b/internal/dav/delete.go index 1db718b..7a239cf 100644 --- a/internal/dav/delete.go +++ b/internal/dav/delete.go @@ -1,6 +1,7 @@ package dav import ( + "context" "fmt" "github.com/debridmediamanager/zurg/internal/config" @@ -29,14 +30,17 @@ func HandleDeleteFile(directory, torrentName, fileName string, torMgr *torrent.T return fmt.Errorf("cannot find torrent %s", torrentName) } file, ok := torrent.SelectedFiles.Get(fileName) - if !ok || file.IsDeleted { + if !ok || !file.State.Is("ok") { return fmt.Errorf("cannot find file %s", fileName) } dirCfg := torMgr.Config.(*config.ZurgConfigV1).GetDirectoryConfig(directory) if dirCfg.OnlyShowTheBiggestFile { torMgr.Delete(torrentName, true) } else { - file.IsDeleted = true + err := file.State.Event(context.Background(), "delete") + if err != nil { + return fmt.Errorf("cannot delete file %s: %v", fileName, err) + } if torMgr.CheckDeletedStatus(torrent) { torMgr.Delete(torrentName, true) } diff --git a/internal/dav/infuse.go b/internal/dav/infuse.go index a2ec364..efa27f4 100644 --- a/internal/dav/infuse.go +++ b/internal/dav/infuse.go @@ -75,7 +75,7 @@ func ServeFilesListForInfuse(directory, torrentName string, torMgr *torrent.Torr sort.Strings(filenames) for _, filename := range filenames { file, _ := tor.SelectedFiles.Get(filename) - if file.IsDeleted { + if !file.State.Is("ok") { continue } if dirCfg.OnlyShowTheBiggestFile && file.Bytes < biggestFileSize { diff --git a/internal/dav/listing.go b/internal/dav/listing.go index 6270b34..08075e5 100644 --- a/internal/dav/listing.go +++ b/internal/dav/listing.go @@ -79,7 +79,7 @@ func ServeFilesList(directory, torrentName string, torMgr *torrent.TorrentManage sort.Strings(filenames) for _, filename := range filenames { file, _ := tor.SelectedFiles.Get(filename) - if file.IsDeleted { + if !file.State.Is("ok") { continue } if dirCfg.OnlyShowTheBiggestFile && file.Bytes < biggestFileSize { @@ -107,7 +107,7 @@ func HandleSingleFile(directory, torrentName, fileName string, torMgr *torrent.T return nil, fmt.Errorf("cannot find torrent %s", torrentName) } file, ok := tor.SelectedFiles.Get(fileName) - if !ok || file.IsDeleted { + if !ok || !file.State.Is("ok") { return nil, fmt.Errorf("cannot find file %s", fileName) } diff --git a/internal/dav/rename.go b/internal/dav/rename.go index aac6d6e..0413d56 100644 --- a/internal/dav/rename.go +++ b/internal/dav/rename.go @@ -31,7 +31,7 @@ func HandleRenameFile(directory, torrentName, fileName, newName string, torMgr * return fmt.Errorf("cannot find torrent %s", torrentName) } file, ok := torrent.SelectedFiles.Get(fileName) - if !ok || file.IsDeleted { + if !ok || !file.State.Is("ok") { return fmt.Errorf("cannot find file %s", fileName) } oldName := torMgr.GetPath(file) diff --git a/internal/http/listing.go b/internal/http/listing.go index 2eb481f..e2ca243 100644 --- a/internal/http/listing.go +++ b/internal/http/listing.go @@ -75,7 +75,7 @@ func ServeFilesList(directory, torrentName string, torMgr *torrent.TorrentManage sort.Strings(filenames) for _, filename := range filenames { file, _ := tor.SelectedFiles.Get(filename) - if file.IsDeleted { + if !file.State.Is("ok") { continue } if dirCfg.OnlyShowTheBiggestFile && file.Bytes < biggestFileSize { diff --git a/internal/torrent/delete.go b/internal/torrent/delete.go index aebe70f..abdffde 100644 --- a/internal/torrent/delete.go +++ b/internal/torrent/delete.go @@ -6,7 +6,7 @@ import cmap "github.com/orcaman/concurrent-map/v2" func (t *TorrentManager) CheckDeletedStatus(torrent *Torrent) bool { var deletedIDs []int torrent.SelectedFiles.IterCb(func(_ string, file *File) { - if file.IsDeleted { + if file.State.Is("deleted") { deletedIDs = append(deletedIDs, file.ID) } }) diff --git a/internal/torrent/file_types.go b/internal/torrent/file_types.go index 6fac6d5..89ac9bb 100644 --- a/internal/torrent/file_types.go +++ b/internal/torrent/file_types.go @@ -1,6 +1,8 @@ package torrent import ( + stdjson "encoding/json" + "github.com/debridmediamanager/zurg/pkg/realdebrid" "github.com/looplab/fsm" ) @@ -10,9 +12,39 @@ type File struct { Link string `json:"Link"` Ended string `json:"Ended"` - IsBroken bool `json:"IsBroken"` - IsDeleted bool `json:"IsDeleted"` - State *fsm.FSM `json:"-"` + State *fsm.FSM `json:"-"` Rename string `json:"Rename"` } + +func (f *File) MarshalJSON() ([]byte, error) { + type Alias File + temp := &struct { + StateJson stdjson.RawMessage `json:"State"` + *Alias + }{ + Alias: (*Alias)(f), + } + + temp.StateJson = []byte(`"` + f.State.Current() + `"`) + + return json.Marshal(temp) +} + +func (f *File) UnmarshalJSON(data []byte) error { + type Alias File + temp := &struct { + StateJson string `json:"State"` + *Alias + }{ + Alias: (*Alias)(f), + } + + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + + f.State = NewFileState(temp.StateJson) + + return nil +} diff --git a/internal/torrent/manager.go b/internal/torrent/manager.go index 3f00462..ccabab0 100644 --- a/internal/torrent/manager.go +++ b/internal/torrent/manager.go @@ -106,7 +106,7 @@ func (t *TorrentManager) UnrestrictLinkUntilOk(link string) *realdebrid.Download } func (t *TorrentManager) UnrestrictFileUntilOk(file *File) *realdebrid.Download { - if file.IsBroken || file.IsDeleted { + if !file.State.Is("ok") { return nil } return t.UnrestrictLinkUntilOk(file.Link) diff --git a/internal/torrent/refresh.go b/internal/torrent/refresh.go index 58df22b..70af538 100644 --- a/internal/torrent/refresh.go +++ b/internal/torrent/refresh.go @@ -159,6 +159,7 @@ func (t *TorrentManager) getMoreInfo(rdTorrent realdebrid.Torrent) *Torrent { OriginalName: info.OriginalName, Added: info.Added, Hash: info.Hash, + State: NewTorrentState("ok"), } // SelectedFiles is a subset of Files with only the selected ones @@ -171,17 +172,17 @@ func (t *TorrentManager) getMoreInfo(rdTorrent realdebrid.Torrent) *Torrent { continue } selectedFiles = append(selectedFiles, &File{ - File: file, - Ended: info.Ended, - Link: "", // no link yet, consider it broken - IsBroken: true, + File: file, + Ended: info.Ended, + Link: "", // no link yet, consider it broken + State: NewFileState("broken"), }) } if len(selectedFiles) == len(info.Links) { // all links are still intact! good! for i, file := range selectedFiles { file.Link = info.Links[i] - file.IsBroken = false + file.State.SetState("ok") } torrent.UnassignedLinks = mapset.NewSet[string]() } else { @@ -254,6 +255,8 @@ func (t *TorrentManager) mergeToMain(existing, toMerge *Torrent) *Torrent { Components: mergedComponents, UnassignedLinks: newer.UnassignedLinks.Union(older.UnassignedLinks), UnrepairableReason: newer.UnrepairableReason, + + State: older.State, } // unrepairable reason @@ -268,32 +271,17 @@ func (t *TorrentManager) mergeToMain(existing, toMerge *Torrent) *Torrent { // 3. empty - the file is not available mainTorrent.SelectedFiles = cmap.New[*File]() newer.SelectedFiles.IterCb(func(key string, newerFile *File) { - if !newerFile.IsBroken { - mainTorrent.SelectedFiles.Set(key, newerFile) - return - } - olderFile, ok := older.SelectedFiles.Get(key) - if ok && !olderFile.IsBroken { - mainTorrent.SelectedFiles.Set(key, olderFile) - return - } mainTorrent.SelectedFiles.Set(key, newerFile) }) - inconsistentDeletes := false older.SelectedFiles.IterCb(func(key string, olderFile *File) { if !mainTorrent.SelectedFiles.Has(key) { mainTorrent.SelectedFiles.Set(key, olderFile) - return - } - newerFile, _ := mainTorrent.SelectedFiles.Get(key) - if olderFile.IsDeleted && !newerFile.IsDeleted { - newerFile.IsDeleted = true - inconsistentDeletes = true + } else if olderFile.State.Is("deleted") { + newerFile, _ := mainTorrent.SelectedFiles.Get(key) + newerFile.State.SetState("deleted") } }) - if inconsistentDeletes { - t.CheckDeletedStatus(&mainTorrent) - } + t.CheckDeletedStatus(&mainTorrent) return &mainTorrent } diff --git a/internal/torrent/repair.go b/internal/torrent/repair.go index a1905a3..97cd9fb 100644 --- a/internal/torrent/repair.go +++ b/internal/torrent/repair.go @@ -1,6 +1,7 @@ package torrent import ( + "context" "fmt" "math" "strings" @@ -100,7 +101,7 @@ func (t *TorrentManager) repairAll(torrent *Torrent) { // check 1: for broken files brokenFileIDs := mapset.NewSet[int]() torrent.SelectedFiles.IterCb(func(_ string, file *File) { - if file.IsBroken && !file.IsDeleted { + if file.State.Is("broken") { brokenFileIDs.Add(file.ID) } }) @@ -266,7 +267,7 @@ func (t *TorrentManager) assignUnassignedLinks(torrent *Torrent) bool { // base it on size because why not? if (unrestrict.Filesize > 1_000_000 && file.Bytes == unrestrict.Filesize) || strings.HasSuffix(strings.ToLower(file.Path), strings.ToLower(unrestrict.Filename)) { file.Link = link - file.IsBroken = false + file.State.SetState("ok") assigned = true assignedCount++ } @@ -308,9 +309,9 @@ func (t *TorrentManager) assignUnassignedLinks(torrent *Torrent) bool { Bytes: unassigned.Filesize, Selected: 0, }, - Ended: torrent.Added, - Link: unassigned.Link, - IsBroken: false, + Ended: torrent.Added, + Link: unassigned.Link, + State: NewFileState("ok"), } torrent.SelectedFiles.Set(unassigned.Filename, newFile) }) @@ -473,6 +474,11 @@ func (t *TorrentManager) canCapacityHandle() bool { func (t *TorrentManager) markAsUnplayable(torrent *Torrent, reason string) { t.log.Warnf("Marking torrent %s as unplayable - %s", t.GetKey(torrent), reason) + err := torrent.State.Event(context.Background(), "mark_as_unplayable") + if err != nil { + t.log.Errorf("Failed to mark torrent %s as unplayable: %v", t.GetKey(torrent), err) + return + } t.DirectoryMap.IterCb(func(directory string, torrents cmap.ConcurrentMap[string, *Torrent]) { torrents.Remove(t.GetKey(torrent)) }) @@ -491,7 +497,7 @@ func getBrokenFiles(torrent *Torrent) ([]*File, bool) { var brokenFiles []*File allBroken := true torrent.SelectedFiles.IterCb(func(_ string, file *File) { - if file.IsBroken && !file.IsDeleted { + if file.State.Is("broken") { brokenFiles = append(brokenFiles, file) } else { allBroken = false @@ -509,17 +515,17 @@ func (t *TorrentManager) isStillBroken(info *realdebrid.TorrentInfo, brokenFiles continue } selectedFiles = append(selectedFiles, &File{ - File: file, - Ended: info.Ended, - Link: "", // no link yet - IsBroken: true, + File: file, + Ended: info.Ended, + Link: "", // no link yet + State: NewFileState("broken"), }) } if len(selectedFiles) == len(info.Links) { // all links are still intact! good! for i, file := range selectedFiles { file.Link = info.Links[i] - file.IsBroken = false + file.State.SetState("ok") } } else { // if we can't assign links, it's still broken diff --git a/internal/torrent/fsm.go b/internal/torrent/states.go similarity index 61% rename from internal/torrent/fsm.go rename to internal/torrent/states.go index 5536fdc..1050221 100644 --- a/internal/torrent/fsm.go +++ b/internal/torrent/states.go @@ -4,22 +4,32 @@ import ( "github.com/looplab/fsm" ) -func NewFileState() *fsm.FSM { +func NewTorrentState(initial string) *fsm.FSM { + // ok + // broken + // under_repair + // deleted + // unplayable return fsm.NewFSM( - "ok", + initial, fsm.Events{ - {Name: "break", Src: []string{"ok"}, Dst: "broken"}, + {Name: "break", Src: []string{"ok", "unplayable"}, Dst: "broken"}, {Name: "repair", Src: []string{"broken"}, Dst: "under_repair"}, {Name: "repair_done", Src: []string{"under_repair"}, Dst: "ok"}, - {Name: "delete", Src: []string{"ok", "broken", "under_repair"}, Dst: "deleted"}, + {Name: "delete", Src: []string{"ok", "broken", "under_repair", "unplayable"}, Dst: "deleted"}, + {Name: "mark_as_unplayable", Src: []string{"ok", "under_repair"}, Dst: "unplayable"}, }, fsm.Callbacks{}, ) } -func NewTorrentState() *fsm.FSM { +func NewFileState(initial string) *fsm.FSM { + // ok + // broken + // under_repair + // deleted return fsm.NewFSM( - "ok", + initial, fsm.Events{ {Name: "break", Src: []string{"ok"}, Dst: "broken"}, {Name: "repair", Src: []string{"broken"}, Dst: "under_repair"}, diff --git a/internal/torrent/torrent_types.go b/internal/torrent/torrent_types.go index 0ae240d..98b3dc8 100644 --- a/internal/torrent/torrent_types.go +++ b/internal/torrent/torrent_types.go @@ -35,6 +35,7 @@ func (t *Torrent) MarshalJSON() ([]byte, error) { temp := &struct { SelectedFilesJson stdjson.RawMessage `json:"SelectedFiles"` UnassignedLinksJson stdjson.RawMessage `json:"UnassignedLinks"` + StateJson stdjson.RawMessage `json:"State"` *Alias }{ Alias: (*Alias)(t), @@ -53,6 +54,8 @@ func (t *Torrent) MarshalJSON() ([]byte, error) { temp.UnassignedLinksJson = []byte(unassignedLinksStr) } + temp.StateJson = []byte(`"` + t.State.Current() + `"`) + return json.Marshal(temp) } @@ -61,6 +64,7 @@ func (t *Torrent) UnmarshalJSON(data []byte) error { temp := &struct { SelectedFilesJson stdjson.RawMessage `json:"SelectedFiles"` UnassignedLinksJson stdjson.RawMessage `json:"UnassignedLinks"` + StateJson string `json:"State"` *Alias }{ Alias: (*Alias)(t), @@ -83,6 +87,8 @@ func (t *Torrent) UnmarshalJSON(data []byte) error { t.UnassignedLinks = mapset.NewSet[string]() } + t.State = NewFileState(temp.StateJson) + return nil } diff --git a/internal/universal/check.go b/internal/universal/check.go index c0fe3ce..6f955f8 100644 --- a/internal/universal/check.go +++ b/internal/universal/check.go @@ -27,7 +27,7 @@ func CheckFile(directory, torrentName, fileName string, w http.ResponseWriter, r } file, ok := torrent.SelectedFiles.Get(fileName) - if !ok || file.IsDeleted { + if !ok || !file.State.Is("ok") { log.Warnf("Cannot find file %s from path %s", fileName, req.URL.Path) http.Error(w, "File not found", http.StatusNotFound) return diff --git a/internal/universal/downloader.go b/internal/universal/downloader.go index 51b5da0..cf81a4d 100644 --- a/internal/universal/downloader.go +++ b/internal/universal/downloader.go @@ -1,6 +1,7 @@ package universal import ( + "context" "io" "net/http" "path/filepath" @@ -49,20 +50,25 @@ func (dl *Downloader) DownloadFile( } file, ok := torrent.SelectedFiles.Get(fileName) - if !ok || file.IsDeleted { + if !ok || !file.State.Is("ok") { log.Warnf("Cannot find file %s from path %s", fileName, req.URL.Path) http.Error(resp, "File not found", http.StatusNotFound) return } - if file.IsBroken { + if !file.State.Is("ok") { http.Error(resp, "File is not available", http.StatusNotFound) return } unrestrict := torMgr.UnrestrictFileUntilOk(file) if unrestrict == nil { - file.IsBroken = true + err := file.State.Event(context.Background(), "break") + if err != nil { + log.Errorf("File %s is stale: %v", fileName, err) + http.Error(resp, "File is stale, please try again", http.StatusLocked) + return + } if cfg.EnableRepair() { log.Warnf("File %s cannot be unrestricted (link=%s) (repairing...)", fileName, file.Link) torMgr.TriggerRepair(torrent) @@ -148,7 +154,12 @@ func (dl *Downloader) streamFileToResponse( downloadResp, err := dl.client.Do(dlReq) if err != nil { if file != nil && unrestrict.Streamable == 1 { - file.IsBroken = true + err := file.State.Event(context.Background(), "break") + if err != nil { + log.Errorf("File %s is stale: %v", file.Path, err) + http.Error(resp, "File is stale, please try again", http.StatusLocked) + return + } if cfg.EnableRepair() && torrent != nil { log.Warnf("Cannot download file %s: %v (repairing...)", unrestrict.Download, err) torMgr.TriggerRepair(torrent) @@ -166,7 +177,12 @@ func (dl *Downloader) streamFileToResponse( // Check if the download was not successful if downloadResp.StatusCode/100 != 2 { if file != nil && unrestrict.Streamable == 1 { - file.IsBroken = true + err := file.State.Event(context.Background(), "break") + if err != nil { + log.Errorf("File %s is stale: %v", file.Path, err) + http.Error(resp, "File is stale, please try again", http.StatusLocked) + return + } if cfg.EnableRepair() && torrent != nil { log.Warnf("Received a %s status code for file %s (repairing...)", downloadResp.Status, file.Path) torMgr.TriggerRepair(torrent)