Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The media drive events (v0.313.0): a drive change makes the restart pending until the apps restart; a safe removal
|
||||
// keeps the drive registered and its paused torrents until the media apps restart after its return (R-1044).
|
||||
func TestMediaEvents_PendingUntilRestarted(t *testing.T) {
|
||||
s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_b", Label: "Media B"}})
|
||||
if err := s.MarkMediaChanged("/mnt/felhom-drives/media_b", "added"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("a drive just added must make the media restart pending")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if err := s.MarkMediaRestarted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("after the restart nothing is pending")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
_ = s.MarkMediaChanged("/mnt/felhom-drives/media_b", "returned")
|
||||
if !s.GetStoragePaths()[0].MediaRestartPending() {
|
||||
t.Fatal("a later change makes it pending again — the timestamps must compare correctly as text")
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (2026-10-15): make ClearEjected also clear EjectPaused → this test fails (nothing to resume after
|
||||
// the restart: the household's torrents stay stopped). Restored.
|
||||
func TestMediaEvents_SafeRemovalKeepsDriveAndTorrents(t *testing.T) {
|
||||
s := newTestSettings(t, []StoragePath{{Path: "/mnt/felhom-drives/media_c", Label: "Media C"}})
|
||||
if err := s.SetEjected("/mnt/felhom-drives/media_c", "uuid:abc", []string{"qbit:h1", "trans:h2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sp := s.GetStoragePaths()
|
||||
if len(sp) != 1 || !sp[0].Disconnected || sp[0].EjectedAt == "" {
|
||||
t.Fatalf("a safe removal must keep the drive registered and mark it removed: %+v", sp)
|
||||
}
|
||||
if got := s.TakeResumable(); len(got) != 0 {
|
||||
t.Fatalf("while the drive is away nothing may resume: %v", got)
|
||||
}
|
||||
if err := s.ClearEjected("/mnt/felhom-drives/media_c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := s.TakeResumable()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("after the return the two paused torrents must be handed to the restart: %v", got)
|
||||
}
|
||||
if again := s.TakeResumable(); len(again) != 0 {
|
||||
t.Fatalf("taken once, never twice: %v", again)
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,20 @@ type StoragePath struct {
|
||||
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
||||
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
||||
|
||||
// The media drive events (v0.313.0, R-1023/R-1038/R-1044, `09` §3 decisions 238/239/245). MediaChangedAt: the drive
|
||||
// was added, removed, or came back — the media apps must restart to see it; MediaRestartedAt: they were restarted
|
||||
// after that (the drive card's „apps restarted since this drive changed"). Ejected*: a SAFE removal keeps the drive
|
||||
// registered (R-1044): EjectedAt marks it, EjectDurableID finds it again, EjectSeenGone says the disk left after the
|
||||
// eject (so its return is a return, not the still-mounted raw disk), EjectPaused lists the torrents the removal
|
||||
// paused ("qbit:<hash>", "trans:<hash>") so its return resumes exactly those.
|
||||
MediaChangedAt string `json:"media_changed_at,omitempty"`
|
||||
MediaChange string `json:"media_change,omitempty"` // added | removed | returned
|
||||
MediaRestartedAt string `json:"media_restarted_at,omitempty"`
|
||||
EjectedAt string `json:"ejected_at,omitempty"`
|
||||
EjectDurableID string `json:"eject_durable_id,omitempty"`
|
||||
EjectSeenGone bool `json:"eject_seen_gone,omitempty"`
|
||||
EjectPaused []string `json:"eject_paused,omitempty"`
|
||||
|
||||
// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
|
||||
// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
@@ -2815,3 +2829,104 @@ func (s *Settings) ClearUnhealthyStopHold(stack string) (bool, error) {
|
||||
}
|
||||
return true, s.save()
|
||||
}
|
||||
|
||||
// MarkMediaChanged records a media drive event (added | removed | returned) — the media apps must restart to see it.
|
||||
func (s *Settings) MarkMediaChanged(path, change string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].MediaChangedAt = time.Now().UTC().Format(mediaTS)
|
||||
s.StoragePaths[i].MediaChange = change
|
||||
if s.log != nil {
|
||||
s.log.Printf("[INFO] [settings] media drive event: %s %s — the media apps must restart to see it", path, change)
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// MarkMediaRestarted records that the media apps were restarted now, after every pending drive event.
|
||||
func (s *Settings) MarkMediaRestarted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().UTC().Format(mediaTS)
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].MediaChangedAt != "" {
|
||||
s.StoragePaths[i].MediaRestartedAt = now
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// MediaRestartPending reports whether a drive changed after the media apps last restarted.
|
||||
func (sp StoragePath) MediaRestartPending() bool {
|
||||
return sp.MediaChangedAt != "" && sp.MediaRestartedAt < sp.MediaChangedAt
|
||||
}
|
||||
|
||||
// SetEjected records a SAFE removal: the drive stays registered and disconnected, with the torrents the removal paused.
|
||||
func (s *Settings) SetEjected(path, durableID string, paused []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
sp := &s.StoragePaths[i]
|
||||
sp.Disconnected, sp.DisconnectedAt = true, time.Now().UTC().Format(time.RFC3339)
|
||||
sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone, sp.EjectPaused = sp.DisconnectedAt, durableID, false, paused
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// SetEjectSeenGone records that a safely removed drive's disk left the host after the eject.
|
||||
func (s *Settings) SetEjectSeenGone(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
s.StoragePaths[i].EjectSeenGone = true
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// ClearEjected ends a safe removal (the drive came back): connected, the eject fields cleared.
|
||||
func (s *Settings) ClearEjected(path string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.StoragePaths {
|
||||
if s.StoragePaths[i].Path == path {
|
||||
sp := &s.StoragePaths[i]
|
||||
sp.Disconnected, sp.DisconnectedAt, sp.StoppedStacks = false, "", nil
|
||||
// EjectPaused is KEPT: the torrents resume after the media apps' restart (TakeResumable), when the download
|
||||
// apps see the drive again — resumed before, they would report their files missing.
|
||||
sp.EjectedAt, sp.EjectDurableID, sp.EjectSeenGone = "", "", false
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("storage path %q not found", path)
|
||||
}
|
||||
|
||||
// mediaTS is a FIXED-width timestamp, so two of them compare correctly as strings (RFC3339Nano trims trailing zeros).
|
||||
const mediaTS = "2006-01-02T15:04:05.000000000Z"
|
||||
|
||||
// TakeResumable returns, and clears, the torrents paused by a safe removal of drives that are back.
|
||||
func (s *Settings) TakeResumable() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []string
|
||||
for i := range s.StoragePaths {
|
||||
sp := &s.StoragePaths[i]
|
||||
if sp.EjectedAt == "" && len(sp.EjectPaused) > 0 {
|
||||
out = append(out, sp.EjectPaused...)
|
||||
sp.EjectPaused = nil
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
_ = s.save()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user