feat(mediaserver): Plex/Jellyfin/Emby auto-refresh + .strm instant mode
Sprint 1 — Auto-refresh after download:
- New [[mediaserver]] TOML section with kind/url/token/sections
- mediaserver.Refresh() fans out to Plex (partial via section ID auto-mapping
from file path prefix) and Jellyfin/Emby (full library scan)
- Manager.OnFinalized callback wired in daemon to trigger refresh after
organize() completes — keeps engine package free of mediaserver dep
- New unarr mediaserver {setup,list,remove,test} commands
- unarr init wizard offers to configure refresh when a server is detected
Sprint 2 — .strm instant mode (cloud + agent):
- Mode strm-to-library handled in daemon dispatch: writes a one-line .strm
file pointing to the cloud-resolved debrid HTTPS URL, then triggers refresh
- engine.WriteStrm + StrmDestForTask mirror organize()'s naming so Plex/Jellyfin
see the expected folder structure (Movies/Title (Year)/, TV Shows/Show/Season XX/)
- Atomic write (temp + rename) so partial files never get indexed
- Reports completed/failed status to the cloud via existing agent client
This commit is contained in:
parent
6955b6144b
commit
6adf1e2c4c
13 changed files with 1065 additions and 16 deletions
149
internal/mediaserver/refresh_test.go
Normal file
149
internal/mediaserver/refresh_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package mediaserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParsePlexSectionsFull(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"MediaContainer": {
|
||||
"Directory": [
|
||||
{ "key": "1", "title": "Movies", "Location": [{"path": "/data/media/movies"}] },
|
||||
{ "key": "2", "title": "TV Shows", "Location": [{"path": "/data/media/tv"}, {"path": "/mnt/tv2"}] },
|
||||
{ "key": "0", "title": "Bogus", "Location": [{"path": "/skip"}] }
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sections, err := parsePlexSectionsFull(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePlexSectionsFull error: %v", err)
|
||||
}
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("got %d sections, want 2 (id=0 should be skipped)", len(sections))
|
||||
}
|
||||
if sections[0].ID != 1 || sections[0].Title != "Movies" {
|
||||
t.Errorf("section[0] = %+v", sections[0])
|
||||
}
|
||||
if sections[1].ID != 2 || len(sections[1].Locations) != 2 {
|
||||
t.Errorf("section[1] = %+v", sections[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSectionByPath(t *testing.T) {
|
||||
sections := []Section{
|
||||
{ID: 1, Title: "Movies", Locations: []string{"/data/media/movies"}},
|
||||
{ID: 2, Title: "TV", Locations: []string{"/data/media/tv"}},
|
||||
{ID: 3, Title: "TV-HD", Locations: []string{"/data/media/tv/hd"}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
wantID int
|
||||
wantOK bool
|
||||
}{
|
||||
{"/data/media/movies/Inception (2010)/Inception.mkv", 1, true},
|
||||
{"/data/media/tv/Show/Season 01/ep.mkv", 2, true},
|
||||
{"/data/media/tv/hd/Show/Season 01/ep.mkv", 3, true}, // most specific wins
|
||||
{"/data/media/movies", 1, true}, // exact
|
||||
{"/elsewhere/foo.mkv", 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
gotID, gotOK := matchSectionByPath(sections, tc.path)
|
||||
if gotID != tc.wantID || gotOK != tc.wantOK {
|
||||
t.Errorf("matchSectionByPath(%q) = (%d,%v), want (%d,%v)",
|
||||
tc.path, gotID, gotOK, tc.wantID, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPlex_PartialRefreshWithPath(t *testing.T) {
|
||||
var refreshHits int32
|
||||
var gotPath, gotToken string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/library/sections":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Directory": [
|
||||
{ "key": "7", "title": "Movies", "Location": [{"path": "/m"}] }
|
||||
]
|
||||
}
|
||||
}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/library/sections/7/refresh"):
|
||||
atomic.AddInt32(&refreshHits, 1)
|
||||
gotPath = r.URL.Query().Get("path")
|
||||
gotToken = r.URL.Query().Get("X-Plex-Token")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Reset cache so the test server is hit fresh.
|
||||
plexSectionMu.Lock()
|
||||
plexSectionCache = map[string][]Section{}
|
||||
plexSectionMu.Unlock()
|
||||
|
||||
cfg := ServerConfig{Kind: "plex", URL: srv.URL, Token: "tk-1"}
|
||||
Refresh([]ServerConfig{cfg}, "/m/Inception/Inception.mkv")
|
||||
|
||||
// Refresh fans out goroutines — give it time.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&refreshHits) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if atomic.LoadInt32(&refreshHits) != 1 {
|
||||
t.Fatalf("refresh endpoint hit %d times, want 1", refreshHits)
|
||||
}
|
||||
if gotPath != "/m/Inception/Inception.mkv" {
|
||||
t.Errorf("path query = %q", gotPath)
|
||||
}
|
||||
if gotToken != "tk-1" {
|
||||
t.Errorf("token query = %q", gotToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshJellyfin(t *testing.T) {
|
||||
var hits int32
|
||||
var gotToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/Library/Refresh" {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
gotToken = r.Header.Get("X-Emby-Token")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := ServerConfig{Kind: "jellyfin", URL: srv.URL, Token: "jf-key"}
|
||||
Refresh([]ServerConfig{cfg}, "")
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt32(&hits) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 1 {
|
||||
t.Fatalf("Jellyfin hits = %d, want 1", hits)
|
||||
}
|
||||
if gotToken != "jf-key" {
|
||||
t.Errorf("X-Emby-Token = %q", gotToken)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue