fix(jellycompat): tolerate autoscan sidecar updates

This commit is contained in:
zZebrahz
2026-05-25 19:37:25 -07:00
parent b2fae0aa70
commit fd39824c0e
2 changed files with 425 additions and 15 deletions
+176 -5
View File
@@ -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) {
+249 -10
View File
@@ -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,