fix(jellycompat): tolerate autoscan sidecar updates
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
@@ -110,21 +111,64 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required")
|
||||
return
|
||||
}
|
||||
scanRequests := make([]scantrigger.Request, 0, len(req.Updates))
|
||||
resolver := scantrigger.NewResolver(h.folders)
|
||||
targets := make([]scantrigger.Target, 0, len(req.Updates))
|
||||
seenTargets := make(map[autoscanTargetKey]struct{}, len(req.Updates))
|
||||
for _, update := range req.Updates {
|
||||
path := strings.TrimSpace(update.Path)
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Update path is required")
|
||||
return
|
||||
}
|
||||
scanRequests = append(scanRequests, scantrigger.Request{
|
||||
|
||||
target, err := resolver.Resolve(r.Context(), scantrigger.Request{
|
||||
Path: path,
|
||||
Trigger: autoscanTrigger,
|
||||
})
|
||||
if err != nil {
|
||||
if parentTarget, handled, fallbackErr := resolveAutoscanParentTarget(r.Context(), resolver, path, err); handled {
|
||||
if fallbackErr != nil {
|
||||
slog.Warn("jellycompat autoscan: media update parent path rejected",
|
||||
"path", path,
|
||||
"parent_path", filepath.Dir(filepath.Clean(path)),
|
||||
"update_type", update.UpdateType,
|
||||
"error", fallbackErr,
|
||||
)
|
||||
writeScanTriggerError(w, fallbackErr)
|
||||
return
|
||||
}
|
||||
if parentTarget != nil {
|
||||
slog.Debug("jellycompat autoscan: media update falling back to parent scan",
|
||||
"path", path,
|
||||
"parent_path", parentTarget.Path,
|
||||
"parent_mode", parentTarget.Mode,
|
||||
"update_type", update.UpdateType,
|
||||
)
|
||||
targets = appendAutoscanTarget(targets, seenTargets, parentTarget)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if softAutoscanUpdateError(err) {
|
||||
slog.Debug("jellycompat autoscan: media update ignored",
|
||||
"path", path,
|
||||
"update_type", update.UpdateType,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
slog.Warn("jellycompat autoscan: media update path rejected",
|
||||
"path", path,
|
||||
"update_type", update.UpdateType,
|
||||
"error", err,
|
||||
)
|
||||
writeScanTriggerError(w, err)
|
||||
return
|
||||
}
|
||||
targets = appendAutoscanTarget(targets, seenTargets, target)
|
||||
}
|
||||
targets, err := scantrigger.NewResolver(h.folders).ResolveAll(r.Context(), scanRequests)
|
||||
if err != nil {
|
||||
writeScanTriggerError(w, err)
|
||||
targets = compactAutoscanTargets(targets)
|
||||
if len(targets) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil {
|
||||
@@ -134,6 +178,133 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type autoscanTargetKey struct {
|
||||
folderID int
|
||||
mode string
|
||||
path string
|
||||
trigger string
|
||||
}
|
||||
|
||||
func appendAutoscanTarget(
|
||||
targets []scantrigger.Target,
|
||||
seen map[autoscanTargetKey]struct{},
|
||||
target *scantrigger.Target,
|
||||
) []scantrigger.Target {
|
||||
if target == nil {
|
||||
return targets
|
||||
}
|
||||
folderID := 0
|
||||
if target.Folder != nil {
|
||||
folderID = target.Folder.ID
|
||||
}
|
||||
key := autoscanTargetKey{
|
||||
folderID: folderID,
|
||||
mode: target.Mode,
|
||||
path: target.Path,
|
||||
trigger: target.Trigger,
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return targets
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
return append(targets, *target)
|
||||
}
|
||||
|
||||
func compactAutoscanTargets(targets []scantrigger.Target) []scantrigger.Target {
|
||||
if len(targets) < 2 {
|
||||
return targets
|
||||
}
|
||||
compacted := make([]scantrigger.Target, 0, len(targets))
|
||||
for i, target := range targets {
|
||||
if autoscanTargetCoveredByOther(target, targets, i) {
|
||||
continue
|
||||
}
|
||||
compacted = append(compacted, target)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
func autoscanTargetCoveredByOther(target scantrigger.Target, targets []scantrigger.Target, index int) bool {
|
||||
if target.Folder == nil || target.Mode == scantrigger.ModeLibrary {
|
||||
return false
|
||||
}
|
||||
for i, other := range targets {
|
||||
if i == index || other.Folder == nil || other.Folder.ID != target.Folder.ID || other.Trigger != target.Trigger {
|
||||
continue
|
||||
}
|
||||
switch other.Mode {
|
||||
case scantrigger.ModeLibrary:
|
||||
return true
|
||||
case scantrigger.ModeSubtree:
|
||||
if target.Path != "" && scantrigger.PathWithinRoot(target.Path, other.Path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveAutoscanParentTarget(
|
||||
ctx context.Context,
|
||||
resolver *scantrigger.Resolver,
|
||||
path string,
|
||||
err error,
|
||||
) (*scantrigger.Target, bool, error) {
|
||||
if !parentFallbackAutoscanUpdateError(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
cleanPath := filepath.Clean(path)
|
||||
parentPath := filepath.Dir(cleanPath)
|
||||
if parentPath == "." || parentPath == cleanPath {
|
||||
return nil, true, nil
|
||||
}
|
||||
target, parentErr := resolver.Resolve(ctx, scantrigger.Request{
|
||||
Path: parentPath,
|
||||
Trigger: autoscanTrigger,
|
||||
})
|
||||
if parentErr == nil {
|
||||
if target.Mode == scantrigger.ModeLibrary {
|
||||
return nil, true, nil
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
if softAutoscanUpdateError(parentErr) {
|
||||
return nil, true, nil
|
||||
}
|
||||
return nil, true, parentErr
|
||||
}
|
||||
|
||||
func parentFallbackAutoscanUpdateError(err error) bool {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
switch reqErr.Message {
|
||||
case "Path does not exist",
|
||||
"Path must be a file or directory",
|
||||
"Unsupported media file extension":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func softAutoscanUpdateError(err error) bool {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if !errors.As(err, &reqErr) || reqErr.Status != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
switch reqErr.Message {
|
||||
case "No library matches the given path",
|
||||
"Path does not exist",
|
||||
"Path must be a file or directory",
|
||||
"Unsupported media file extension":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeScanTriggerError(w http.ResponseWriter, err error) {
|
||||
var reqErr *scantrigger.RequestError
|
||||
if errors.As(err, &reqErr) {
|
||||
|
||||
@@ -137,24 +137,45 @@ func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) {
|
||||
func TestAutoscanMediaUpdatedRejectsAmbiguousLibraryWithoutPartialEnqueue(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ambiguousRoot := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
ambiguousPath := filepath.Join(ambiguousRoot, "Other.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(ambiguousPath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 4,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{
|
||||
{
|
||||
ID: 4,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Name: "Movies A",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{ambiguousRoot},
|
||||
},
|
||||
{
|
||||
ID: 6,
|
||||
Name: "Movies B",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{ambiguousRoot},
|
||||
},
|
||||
}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
{"path": filepath.Join(root, "missing.mkv"), "updateType": "Modified"},
|
||||
{"path": ambiguousPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -173,6 +194,224 @@ func TestAutoscanMediaUpdatedAllOrFail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedIgnoresUnsupportedSidecars(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(movieDir, "Movie.mkv")
|
||||
nfoPath := filepath.Join(movieDir, "Movie.nfo")
|
||||
posterPath := filepath.Join(movieDir, "poster.jpg")
|
||||
for _, path := range []string{filePath, nfoPath, posterPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 5,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": nfoPath, "updateType": "Modified"},
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
{"path": posterPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one parent scan, got %#v", queue.calls)
|
||||
}
|
||||
if queue.calls[0].libraryID != 5 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedSidecarsScanParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfoPath := filepath.Join(movieDir, "Movie.nfo")
|
||||
posterPath := filepath.Join(movieDir, "poster.jpg")
|
||||
for _, path := range []string{nfoPath, posterPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 6,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": nfoPath, "updateType": "Modified"},
|
||||
{"path": posterPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one parent scan, got %#v", queue.calls)
|
||||
}
|
||||
if queue.calls[0].libraryID != 6 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected parent scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedRootSidecarDoesNotScanLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
anchorPath := filepath.Join(root, ".plexignore")
|
||||
if err := os.WriteFile(anchorPath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 7,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": anchorPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 0 {
|
||||
t.Fatalf("expected no queued scans, got %#v", queue.calls)
|
||||
}
|
||||
if len(queue.batches) != 0 {
|
||||
t.Fatalf("expected no batch enqueue, got %#v", queue.batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedFallsBackToParentForMissingFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
movieDir := filepath.Join(root, "Movie (2024)")
|
||||
if err := os.Mkdir(movieDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(movieDir, "Movie.mkv")
|
||||
missingPath := filepath.Join(movieDir, "pollermovie.mkv")
|
||||
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 8,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": missingPath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
|
||||
}
|
||||
if queue.calls[0].libraryID != 8 || queue.calls[0].mode != "subtree" || queue.calls[0].path != movieDir || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedIgnoresUnmatchedLibraryPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
unmatchedRoot := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
unmatchedPath := filepath.Join(unmatchedRoot, "Other.mkv")
|
||||
for _, path := range []string{filePath, unmatchedPath} {
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
queue := &fakeAutoscanQueue{}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 9,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
Paths: []string{root},
|
||||
}}}, queue, NewResourceIDCodec(), nil)
|
||||
|
||||
payload := map[string]any{"Updates": []map[string]string{
|
||||
{"path": unmatchedPath, "updateType": "Modified"},
|
||||
{"path": filePath, "updateType": "Modified"},
|
||||
}}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(data))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleMediaUpdated(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(queue.calls) != 1 {
|
||||
t.Fatalf("expected one queued scan, got %d", len(queue.calls))
|
||||
}
|
||||
if queue.calls[0].libraryID != 9 || queue.calls[0].mode != "file" || queue.calls[0].path != filePath || queue.calls[0].trigger != "jellyfin_autoscan" {
|
||||
t.Fatalf("unexpected queued scan: %#v", queue.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
filePath := filepath.Join(root, "Movie.mkv")
|
||||
@@ -181,7 +420,7 @@ func TestAutoscanMediaUpdatedHidesInternalQueueError(t *testing.T) {
|
||||
}
|
||||
queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")}
|
||||
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
|
||||
ID: 5,
|
||||
ID: 10,
|
||||
Name: "Movies",
|
||||
Type: "movie",
|
||||
Enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user