feat(jellycompat): add autoscan media update route

This commit is contained in:
Silo Server Migration
2026-05-25 11:18:02 -04:00
parent 6ceb75e717
commit f73febbd55
3 changed files with 305 additions and 1 deletions
+146
View File
@@ -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))
}
@@ -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)
}
}
+13 -1
View File
@@ -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)