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

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-15 09:54:49 +02:00
parent 78c894234c
commit f580d88a14
46 changed files with 3700 additions and 8 deletions
@@ -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)
}
}
+115
View File
@@ -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
}