feat(jellycompat): add autoscan media update route
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user