diff --git a/internal/jellycompat/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go new file mode 100644 index 00000000..52dc5cb9 --- /dev/null +++ b/internal/jellycompat/handlers_autoscan.go @@ -0,0 +1,146 @@ +package jellycompat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scantrigger" +) + +const autoscanTrigger = "jellyfin_autoscan" + +type autoscanFolderRepository interface { + GetByID(ctx context.Context, id int) (*models.MediaFolder, error) + List(ctx context.Context) ([]*models.MediaFolder, error) +} + +type autoscanVirtualFolderFallback interface { + HandleVirtualFolders(w http.ResponseWriter, r *http.Request) +} + +type AutoscanHandler struct { + folders autoscanFolderRepository + queue scantrigger.Queuer + codec *ResourceIDCodec + fallback autoscanVirtualFolderFallback +} + +func NewAutoscanHandler( + folders autoscanFolderRepository, + queue scantrigger.Queuer, + codec *ResourceIDCodec, + fallback autoscanVirtualFolderFallback, +) *AutoscanHandler { + if codec == nil { + codec = NewResourceIDCodec() + } + return &AutoscanHandler{folders: folders, queue: queue, codec: codec, fallback: fallback} +} + +func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Request) { + if h == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available") + return + } + if !AdminAPIKeyFromContext(r.Context()) { + if h.fallback != nil { + h.fallback.HandleVirtualFolders(w, r) + return + } + writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") + return + } + if h.folders == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Library discovery not available") + return + } + folders, err := h.folders.List(r.Context()) + if err != nil { + slog.Error("jellycompat autoscan: listing libraries", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries") + return + } + resp := make([]virtualFolderDTO, 0, len(folders)) + for _, folder := range folders { + if folder == nil || !folder.Enabled { + continue + } + resp = append(resp, virtualFolderDTO{ + Name: folder.Name, + Locations: folder.Paths, + CollectionType: libraryCollectionType(folder.Type), + ItemID: h.codec.EncodeIntID(EncodedIDLibrary, int64(folder.ID)), + LibraryOptions: virtualLibraryOptDTO{ + Enabled: true, + EnableRealtimeMonitor: true, + EnableInternetProviders: true, + SeasonZeroDisplayName: "Specials", + TypeOptions: []string{}, + }, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +type mediaUpdatedRequest struct { + Updates []mediaUpdatedEntry `json:"Updates"` +} + +type mediaUpdatedEntry struct { + Path string `json:"path"` + UpdateType string `json:"updateType"` +} + +func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Request) { + if h == nil || h.folders == nil || h.queue == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") + return + } + var req mediaUpdatedRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body") + return + } + if len(req.Updates) == 0 { + writeError(w, http.StatusBadRequest, "BadRequest", "Updates is required") + return + } + scanRequests := make([]scantrigger.Request, 0, 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{ + Path: path, + Trigger: autoscanTrigger, + }) + } + targets, err := scantrigger.NewResolver(h.folders).ResolveAll(r.Context(), scanRequests) + if err != nil { + writeScanTriggerError(w, err) + return + } + if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil { + writeScanTriggerError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeScanTriggerError(w http.ResponseWriter, err error) { + var reqErr *scantrigger.RequestError + if errors.As(err, &reqErr) { + writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) + return + } + slog.Error("jellycompat autoscan: scan update failed", "error", err) + writeError(w, http.StatusInternalServerError, "InternalServerError", fmt.Sprintf("Failed to process scan update: %v", err)) +} diff --git a/internal/jellycompat/handlers_autoscan_test.go b/internal/jellycompat/handlers_autoscan_test.go new file mode 100644 index 00000000..6dd98112 --- /dev/null +++ b/internal/jellycompat/handlers_autoscan_test.go @@ -0,0 +1,146 @@ +package jellycompat + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeAutoscanFolders struct { + folders []*models.MediaFolder +} + +func (f *fakeAutoscanFolders) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { + for _, folder := range f.folders { + if folder.ID == id { + return folder, nil + } + } + return nil, catalog.ErrFolderNotFound +} + +func (f *fakeAutoscanFolders) List(context.Context) ([]*models.MediaFolder, error) { + return f.folders, nil +} + +type fakeAutoscanQueue struct { + calls []queuedScan +} + +type queuedScan struct { + libraryID int + mode string + path string + trigger string +} + +func (q *fakeAutoscanQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) { + q.calls = append(q.calls, queuedScan{libraryID: folderID, mode: mode, path: path, trigger: trigger}) + return true, nil +} + +func TestAutoscanVirtualFoldersIncludesEnabledLocationsForAdminKey(t *testing.T) { + enabledRoot := t.TempDir() + disabledRoot := t.TempDir() + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{ + {ID: 1, Name: "Movies", Type: "movie", Enabled: true, Paths: []string{enabledRoot}}, + {ID: 2, Name: "Disabled", Type: "movie", Enabled: false, Paths: []string{disabledRoot}}, + }}, nil, NewResourceIDCodec(), nil) + + req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil) + req = req.WithContext(context.WithValue(req.Context(), adminAPIKeyKey, true)) + rec := httptest.NewRecorder() + + handler.HandleVirtualFolders(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var got []virtualFolderDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one enabled library, got %d", len(got)) + } + if got[0].Name != "Movies" || len(got[0].Locations) != 1 || got[0].Locations[0] != enabledRoot { + t.Fatalf("unexpected folder response: %#v", got[0]) + } +} + +func TestAutoscanMediaUpdatedEnqueuesResolvedPath(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + queue := &fakeAutoscanQueue{} + handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{ + ID: 3, + Name: "Movies", + Type: "movie", + Enabled: true, + Paths: []string{root}, + }}}, queue, NewResourceIDCodec(), nil) + + body := []byte(`{"Updates":[{"path":` + strconv.Quote(filePath) + `,"updateType":"Modified"}]}`) + req := httptest.NewRequest(http.MethodPost, "/Library/Media/Updated", bytes.NewReader(body)) + 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 != 3 || 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 TestAutoscanMediaUpdatedAllOrFail(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "Movie.mkv") + if err := os.WriteFile(filePath, []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) + + payload := map[string]any{"Updates": []map[string]string{ + {"path": filePath, "updateType": "Modified"}, + {"path": filepath.Join(root, "missing.mkv"), "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.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } + if len(queue.calls) != 0 { + t.Fatalf("expected no partial enqueue, got %#v", queue.calls) + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index 0cb16a92..f7c2a03d 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -73,6 +73,16 @@ func NewRouter(deps Dependencies) chi.Router { } itemsHandler := NewItemsHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.ImageCache, nextUpRepo, deps.BrowseRepo, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.EpisodeRepo, deps.AccessFilterFn, subtitleRepo) itemsHandler.recommender = deps.Recommender + autoscanHandler := NewAutoscanHandler(deps.FolderRepo, deps.ScanQueue, deps.IDCodec, itemsHandler) + adminAPIKeyAuth := NewAdminAPIKeyAuthenticator(deps.APIKeyValidator, deps.APIKeyUserLoader) + autoscanVirtualFoldersRegistered := false + if deps.Authenticator != nil && adminAPIKeyAuth != nil && autoscanHandler != nil { + r.With(RequireSessionOrAdminAPIKey(deps.Authenticator, adminAPIKeyAuth)). + Get("/Library/VirtualFolders", autoscanHandler.HandleVirtualFolders) + r.With(adminAPIKeyAuth.RequireAdminAPIKey). + Post("/Library/Media/Updated", autoscanHandler.HandleMediaUpdated) + autoscanVirtualFoldersRegistered = true + } userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config) playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider) if deps.DB != nil { @@ -120,7 +130,9 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/Users/{id}", authHandler.HandleUserByID) r.Get("/UserViews", itemsHandler.HandleViews) r.Get("/UserViews/GroupingOptions", itemsHandler.HandleGroupingOptionsStub) - r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + if !autoscanVirtualFoldersRegistered { + r.Get("/Library/VirtualFolders", itemsHandler.HandleVirtualFolders) + } r.Get("/Users/{userId}/Views", itemsHandler.HandleViews) r.Get("/Items", itemsHandler.HandleItems) r.Get("/Users/{id}/Items", itemsHandler.HandleItems)