This commit is contained in:
Silo Server Migration
2026-05-26 08:35:16 -04:00
14 changed files with 1824 additions and 272 deletions
+3
View File
@@ -1577,6 +1577,9 @@ func main() {
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.APIKeyUserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
cfg.Auth.JWTSecret,
+15 -2
View File
@@ -185,9 +185,22 @@ curl -X POST http://your-server:8090/api/v1/scan \
## Integration with Autoscan
[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and other sources for new downloads, then relays scan requests to media servers. To use Autoscan with Silo, configure a **manual/generic target** using a custom script or webhook that calls the Silo scan API.
[Autoscan](https://github.com/Cloudbox/autoscan) monitors Sonarr, Radarr, and
other sources for new downloads, then relays scan requests to media servers.
Silo supports Autoscan's stock Jellyfin target through the Jellyfin compatibility
server.
### Autoscan Custom Script Target
Use:
- URL: Silo's Jellyfin compatibility URL, usually `http://your-server:8096`
- Token: a Silo admin API key beginning with `sa_`
- Target type: Autoscan `jellyfin`
Autoscan discovers library roots from `GET /Library/VirtualFolders` and sends
changed paths to `POST /Library/Media/Updated`. The paths must be server-side
paths as Silo sees them.
### Alternative: Autoscan Custom Script Target
Create a script (e.g., `silo-scan.sh`) that Autoscan calls with the changed path:
@@ -0,0 +1,166 @@
# Jellyfin Autoscan Scan Compatibility Design
## Goal
Make Silo work with Autoscan's stock Jellyfin target. Autoscan should be able to
point at Silo's Jellyfin compatibility URL, use a Silo admin API key as the
Jellyfin token, discover Silo library roots, and notify Silo about changed media
paths without a custom script.
This design is intentionally scoped to Jellyfin compatibility only. Emby routes
and aliases are out of scope.
## Current State
Silo already has a native admin scan API at `POST /api/v1/scan`. It accepts
either `library_id`, `path`, or both, resolves the target to a full-library,
subtree, or single-file scan, and dispatches through the existing scan queue or
scanner path.
Silo's Jellyfin compatibility server currently supports enough read/playback
routes for Jellyfin clients, including `GET /System/Info` and
`GET /Library/VirtualFolders`, but it does not expose Jellyfin's scan notification
endpoint. `GET /Library/VirtualFolders` also returns empty `Locations`, which
prevents Autoscan from matching incoming paths to Jellyfin libraries.
Autoscan's Jellyfin target uses this flow:
1. `GET /System/Info` with `X-Emby-Token`.
2. `GET /Library/VirtualFolders` with `X-Emby-Token`.
3. `POST /Library/Media/Updated` with `X-Emby-Token` and a body shaped like:
```json
{
"Updates": [
{
"path": "/media/tv/Show/Season 01/Episode.mkv",
"updateType": "Modified"
}
]
}
```
## Compatibility Surface
Add a small Jellyfin scan compatibility adapter under `internal/jellycompat`.
The adapter should own Jellyfin scan/discovery semantics and translate them into
Silo's existing scan behavior.
The first supported route set is:
- `GET /System/Info`: already present, but should accept Silo admin API keys on
the Autoscan path.
- `GET /Library/VirtualFolders`: return enabled Silo libraries with real
configured root paths in `Locations`.
- `POST /Library/Media/Updated`: accept Autoscan update payloads and enqueue
equivalent Silo scans.
Do not add Emby-specific routes such as `/emby/Library/SelectableMediaFolders`
or `/emby/Library/Media/Updated` in this pass.
## Authentication
For the Jellyfin scan/discovery routes needed by Autoscan, allow a Silo admin API
key (`sa_...`) in the token locations Autoscan uses:
- `X-Emby-Token`
- `X-Mediabrowser-Token`
- `Authorization: Bearer`
- `api_key` query parameter
The API key must resolve to an enabled Silo admin user. Non-admin API keys must
receive a non-2xx authorization error. Existing Jellyfin compatibility session
tokens should continue to work for normal Jellyfin client routes; this change
should not broadly weaken playback or browse authorization.
## Library Discovery
`GET /Library/VirtualFolders` should include `Locations` using the exact
server-side paths configured on each enabled Silo library. Autoscan appends a
trailing slash internally and compares incoming paths against these roots, so the
paths must be real filesystem paths as Silo sees them.
Disabled libraries should be omitted from the Autoscan discovery response because
they are not valid scan targets.
## Scan Notification Behavior
`POST /Library/Media/Updated` should parse every `Updates[]` entry with a
non-empty `path`. The first pass ignores `updateType`; Autoscan sends
`Modified`, and Silo's existing path resolver determines the correct scan mode.
Each update path should use the same effective target resolution as
`POST /api/v1/scan`:
- A path equal to a configured library root becomes a full-library scan.
- A directory under a configured root becomes a subtree scan.
- A supported media file under a configured root becomes a file scan.
- Paths outside all libraries, missing paths, permission failures, special files,
disabled libraries, and unsupported file extensions are rejected.
For requests containing multiple updates, resolution should be all-or-fail:
validate every update first, enqueue nothing if any update is invalid, and return
a non-2xx error. This avoids Autoscan seeing success while Silo silently drops
part of the request.
When all updates are valid, enqueue each resolved scan independently and let the
existing scan queue deduplicate or serialize overlapping work. The compatibility
adapter should not implement a separate deduplication policy.
The successful response can be `204 No Content`; Autoscan only requires a 2xx.
## Component Boundaries
Keep the compatibility layer small and explicit:
- Add a Jellyfin scan handler in `internal/jellycompat` for
`Library/Media/Updated` and Autoscan-facing `VirtualFolders`.
- Share scan target resolution with the native scan API by extracting the
resolver/enqueue logic behind a small interface or helper. Avoid duplicating
path classification rules in two packages.
- Reuse the existing API key repository and user lookup logic for admin API key
validation rather than creating a Jellyfin-specific API key store.
- Continue routing normal playback, browse, and user-data Jellyfin endpoints
through the existing compat session authenticator.
## Error Handling
Return non-2xx responses for invalid scan notifications so Autoscan can treat the
target as failed:
- `401 Unauthorized` for missing or invalid tokens.
- `403 Forbidden` for valid non-admin keys.
- `400 Bad Request` for malformed JSON, empty update lists, empty paths, paths
outside libraries, missing paths, unsupported files, and other validation
failures.
- `409 Conflict` for paths that map only to a disabled library.
- `503 Service Unavailable` if the scanner or scan queue is unavailable.
- `500 Internal Server Error` for unexpected repository or enqueue failures.
The response body may use Silo's existing JSON error shape where practical.
## Testing
Add focused backend tests for this compatibility surface:
- Admin API key auth is accepted by Autoscan routes.
- Non-admin or invalid keys are rejected.
- `GET /Library/VirtualFolders` includes enabled library `Locations`.
- `POST /Library/Media/Updated` maps a valid file or directory path into an
enqueued Silo scan.
- Multi-update requests are all-or-fail and do not enqueue partial scans when
one path is invalid.
No frontend tests are needed.
## Documentation
Update `docs/scan-api.md` to explain that Autoscan can use its stock Jellyfin
target:
- URL: Silo's Jellyfin compatibility URL, usually `http://host:8096`.
- Token: a Silo admin API key beginning with `sa_`.
- Paths: server-side paths as seen by Silo.
Keep the custom script/webhook example as an alternative for users who do not
want to expose the Jellyfin compatibility endpoint.
+31 -269
View File
@@ -32,6 +32,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scantrigger"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -570,10 +571,10 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
}
} else {
initialScanID := ulid.Make().String()
h.recordAcceptedScan(initialScanID, &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "library_created",
h.recordAcceptedScan(initialScanID, &scantrigger.Target{
Folder: folder,
Mode: scantrigger.ModeLibrary,
Trigger: "library_created",
})
h.runFolderScanAsync(initialScanID, folder, "library_created")
}
@@ -660,10 +661,10 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
}
} else {
updateScanID := ulid.Make().String()
h.recordAcceptedScan(updateScanID, &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "library_paths_changed",
h.recordAcceptedScan(updateScanID, &scantrigger.Target{
Folder: folder,
Mode: scantrigger.ModeLibrary,
Trigger: "library_paths_changed",
})
h.runFolderScanAsync(updateScanID, folder, "library_paths_changed")
}
@@ -791,29 +792,6 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.
writeJSON(w, http.StatusOK, resp)
}
type scanMode string
const (
scanModeLibrary scanMode = "library"
scanModeSubtree scanMode = "subtree"
scanModeFile scanMode = "file"
)
type resolvedScanTarget struct {
folder *models.MediaFolder
mode scanMode
path string
trigger string
}
type scanRequestError struct {
status int
code string
message string
}
func (e *scanRequestError) Error() string { return e.message }
// HandleScan handles POST /scan. It accepts either a library_id, a path, or both
// and dispatches to full-library, subtree, or single-file scanning.
func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
@@ -823,11 +801,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
return
}
target, err := h.resolveScanTarget(r.Context(), req)
target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{
LibraryID: req.LibraryID,
Path: req.Path,
})
if err != nil {
var reqErr *scanRequestError
var reqErr *scantrigger.RequestError
if errors.As(err, &reqErr) {
writeError(w, reqErr.status, reqErr.code, reqErr.message)
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
return
}
slog.Error("resolving scan target", "error", err)
@@ -836,21 +817,21 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
}
if h.ScanQueue != nil {
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.folder.ID, string(target.mode), target.path, target.trigger); err != nil {
slog.Error("queueing library scan", "library_id", target.folder.ID, "error", err)
if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil {
slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan")
return
}
} else if h.ingester != nil {
scanID := ulid.Make().String()
h.recordAcceptedScan(scanID, target)
switch target.mode {
case scanModeFile:
h.runFileScanAsync(scanID, target.folder, target.path, target.trigger)
case scanModeSubtree:
h.runSubtreeScanAsync(scanID, target.folder, target.path, target.trigger)
switch target.Mode {
case scantrigger.ModeFile:
h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger)
case scantrigger.ModeSubtree:
h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger)
default:
h.runFolderScanAsync(scanID, target.folder, target.trigger)
h.runFolderScanAsync(scanID, target.Folder, target.Trigger)
}
} else {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available")
@@ -859,8 +840,8 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, scanResponse{
Status: "accepted",
Mode: string(target.mode),
LibraryID: target.folder.ID,
Mode: target.Mode,
LibraryID: target.Folder.ID,
})
}
@@ -908,225 +889,6 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request
})
}
func (h *LibraryHandler) resolveScanTarget(ctx context.Context, req scanRequest) (*resolvedScanTarget, error) {
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
return nil, &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Either library_id or path is required",
}
}
var (
folder *models.MediaFolder
err error
)
if req.LibraryID != nil {
folder, err = h.folderRepo.GetByID(ctx, *req.LibraryID)
if err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
return nil, &scanRequestError{
status: http.StatusNotFound,
code: "not_found",
message: "Library not found",
}
}
return nil, fmt.Errorf("fetching library for scan: %w", err)
}
}
if strings.TrimSpace(req.Path) == "" {
if folder != nil && !folder.Enabled {
return nil, &scanRequestError{
status: http.StatusConflict,
code: "conflict",
message: "Library is disabled",
}
}
return &resolvedScanTarget{
folder: folder,
mode: scanModeLibrary,
trigger: "manual",
}, nil
}
cleanPath := filepath.Clean(req.Path)
var matchedRoot string
if folder != nil {
matchedRoot, err = longestMatchingRoot(cleanPath, folder.Paths)
if err != nil {
return nil, err
}
if matchedRoot == "" {
return nil, &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path does not belong to the specified library",
}
}
} else {
folders, err := h.folderRepo.List(ctx)
if err != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", err)
}
folder, matchedRoot, err = matchFolderForPath(cleanPath, folders)
if err != nil {
return nil, err
}
}
if folder != nil && !folder.Enabled {
return nil, &scanRequestError{
status: http.StatusConflict,
code: "conflict",
message: "Library is disabled",
}
}
mode, err := classifyScanPath(cleanPath, matchedRoot)
if err != nil {
return nil, err
}
trigger := "path"
if req.LibraryID != nil {
trigger = "library_id_path"
}
return &resolvedScanTarget{
folder: folder,
mode: mode,
path: cleanPath,
trigger: trigger,
}, nil
}
func longestMatchingRoot(targetPath string, roots []string) (string, error) {
bestRoot := ""
bestLen := -1
for _, root := range roots {
if !pathWithinRoot(targetPath, root) {
continue
}
cleanRoot := filepath.Clean(root)
rootLen := len(cleanRoot)
if rootLen > bestLen {
bestRoot = cleanRoot
bestLen = rootLen
}
}
return bestRoot, nil
}
func matchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
var (
bestFolder *models.MediaFolder
bestRoot string
bestLen = -1
ambiguous bool
)
for _, folder := range folders {
if folder == nil {
continue
}
root, err := longestMatchingRoot(targetPath, folder.Paths)
if err != nil {
return nil, "", err
}
if root == "" {
continue
}
rootLen := len(root)
if rootLen > bestLen {
bestFolder = folder
bestRoot = root
bestLen = rootLen
ambiguous = false
continue
}
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
ambiguous = true
}
}
if ambiguous {
return nil, "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path matches multiple libraries",
}
}
if bestFolder == nil {
return nil, "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "No library matches the given path",
}
}
return bestFolder, bestRoot, nil
}
func classifyScanPath(targetPath, matchedRoot string) (scanMode, error) {
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
return scanModeLibrary, nil
}
info, err := os.Stat(targetPath)
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path does not exist",
}
case errors.Is(err, os.ErrPermission):
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Permission denied for path",
}
default:
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path could not be inspected",
}
}
}
if info.IsDir() {
return scanModeSubtree, nil
}
if !info.Mode().IsRegular() {
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Path must be a file or directory",
}
}
if !scanner.SupportsVideoFile(targetPath) {
return "", &scanRequestError{
status: http.StatusBadRequest,
code: "bad_request",
message: "Unsupported media file extension",
}
}
return scanModeFile, nil
}
func pathWithinRoot(targetPath, rootPath string) bool {
cleanTarget := filepath.Clean(targetPath)
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanTarget)
if err != nil {
return false
}
if rel == "." || rel == "" {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) {
go func() {
h.markScanRunning(scanID)
@@ -1287,16 +1049,16 @@ func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFol
}()
}
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *resolvedScanTarget) {
if h == nil || h.ScanRegistry == nil || target == nil || target.folder == nil {
func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) {
if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil {
return
}
h.ScanRegistry.Upsert(evt.ScanRun{
ID: scanID,
LibraryID: target.folder.ID,
Mode: string(target.mode),
Path: target.path,
Trigger: target.trigger,
LibraryID: target.Folder.ID,
Mode: target.Mode,
Path: target.Path,
Trigger: target.Trigger,
Status: "accepted",
})
}
+122
View File
@@ -0,0 +1,122 @@
package jellycompat
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type adminAPIKeyContextKey string
const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key"
type apiKeyValidator interface {
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
UpdateLastUsed(ctx context.Context, id int64) error
}
type apiKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
type AdminAPIKeyAuthenticator struct {
keys apiKeyValidator
users apiKeyUserLoader
}
type adminAPIKeyAuthResult struct {
ctx context.Context
status int
code string
message string
ok bool
}
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader) *AdminAPIKeyAuthenticator {
if keys == nil || users == nil {
return nil
}
return &AdminAPIKeyAuthenticator{keys: keys, users: users}
}
func AdminAPIKeyFromContext(ctx context.Context) bool {
ok, _ := ctx.Value(adminAPIKeyKey).(bool)
return ok
}
func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result := a.authenticate(r)
if !result.ok {
writeError(w, result.status, result.code, result.message)
return
}
next.ServeHTTP(w, r.WithContext(result.ctx))
})
}
func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := ExtractToken(r)
if ok && strings.HasPrefix(token, "sa_") {
result := keyAuth.authenticate(r)
if !result.ok {
writeError(w, result.status, result.code, result.message)
return
}
next.ServeHTTP(w, r.WithContext(result.ctx))
return
}
sessionAuth.RequireSession(next).ServeHTTP(w, r)
})
}
}
func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult {
unauthorized := adminAPIKeyAuthResult{
ctx: r.Context(),
status: http.StatusUnauthorized,
code: "Unauthorized",
message: "Invalid API key",
}
if a == nil || a.keys == nil || a.users == nil {
return unauthorized
}
token, ok := ExtractToken(r)
if !ok || !strings.HasPrefix(token, "sa_") {
return unauthorized
}
apiKey, err := a.keys.GetByKey(r.Context(), token)
if err != nil || apiKey == nil {
return unauthorized
}
user, err := a.users.GetByID(r.Context(), apiKey.UserID)
if err != nil || user == nil || !user.Enabled {
return unauthorized
}
if user.Role != "admin" {
return adminAPIKeyAuthResult{
ctx: r.Context(),
status: http.StatusForbidden,
code: "Forbidden",
message: "Admin access required",
}
}
go func(id int64) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.keys.UpdateLastUsed(ctx, id); err != nil {
slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err)
}
}(apiKey.ID)
return adminAPIKeyAuthResult{
ctx: context.WithValue(r.Context(), adminAPIKeyKey, true),
status: http.StatusOK,
ok: true,
}
}
+129
View File
@@ -1,10 +1,14 @@
package jellycompat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRequireSession_SkipsRefreshWhenNoAuthService(t *testing.T) {
@@ -114,3 +118,128 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
t.Errorf("expected 200 (no authService = skip refresh), got %d", rec.Code)
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !AdminAPIKeyFromContext(r.Context()) {
t.Fatal("expected admin API key marker in context")
}
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_RejectsNonAdminKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "user", Enabled: true}},
)
req := httptest.NewRequest("POST", "/Library/Media/Updated", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_RejectsNilAPIKey(t *testing.T) {
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{returnNilWithoutError: true},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestRequireAdminAPIKey_LastUsedUpdateHasDeadline(t *testing.T) {
called := make(chan bool, 1)
authn := NewAdminAPIKeyAuthenticator(
&fakeAPIKeyValidator{
key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"},
update: func(ctx context.Context, _ int64) error {
_, ok := ctx.Deadline()
called <- ok
return nil
},
},
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
)
req := httptest.NewRequest("GET", "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", "sa_test")
rec := httptest.NewRecorder()
authn.RequireAdminAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String())
}
select {
case ok := <-called:
if !ok {
t.Fatal("expected last-used update context to have a deadline")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for last-used update")
}
}
type fakeAPIKeyValidator struct {
key *models.APIKey
returnNilWithoutError bool
update func(context.Context, int64) error
}
func (f *fakeAPIKeyValidator) GetByKey(_ context.Context, key string) (*models.APIKey, error) {
if f.returnNilWithoutError {
return nil, nil
}
if f.key != nil && f.key.Key == key {
return f.key, nil
}
return nil, auth.ErrAPIKeyNotFound
}
func (f *fakeAPIKeyValidator) UpdateLastUsed(ctx context.Context, id int64) error {
if f.update != nil {
return f.update(ctx, id)
}
return nil
}
type fakeAPIKeyUserLoader struct {
user *models.User
}
func (f *fakeAPIKeyUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
if f.user != nil && f.user.ID == id {
return f.user, nil
}
return nil, auth.ErrNotFound
}
+316
View File
@@ -0,0 +1,316 @@
package jellycompat
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"path/filepath"
"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
}
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
}
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 = compactAutoscanTargets(targets)
if len(targets) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := scantrigger.EnqueueAll(r.Context(), h.queue, targets); err != nil {
writeScanTriggerError(w, err)
return
}
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) {
writeError(w, reqErr.Status, reqErr.Code, reqErr.Message)
return
}
slog.Error("jellycompat autoscan: scan update failed", "error", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to process scan update")
}
@@ -0,0 +1,442 @@
package jellycompat
import (
"bytes"
"context"
"encoding/json"
"errors"
"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"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
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
batches [][]scantrigger.Target
batchErr error
}
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 (q *fakeAutoscanQueue) EnqueueScans(_ context.Context, targets []scantrigger.Target) error {
copied := append([]scantrigger.Target(nil), targets...)
q.batches = append(q.batches, copied)
if q.batchErr != nil {
return q.batchErr
}
for _, target := range targets {
folderID := 0
if target.Folder != nil {
folderID = target.Folder.ID
}
q.calls = append(q.calls, queuedScan{
libraryID: folderID,
mode: target.Mode,
path: target.Path,
trigger: target.Trigger,
})
}
return 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 len(queue.batches) != 1 {
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
}
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 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},
},
{
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": ambiguousPath, "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)
}
}
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")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
queue := &fakeAutoscanQueue{batchErr: errors.New("database password leaked")}
handler := NewAutoscanHandler(&fakeAutoscanFolders{folders: []*models.MediaFolder{{
ID: 10,
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.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String())
}
if bytes.Contains(rec.Body.Bytes(), []byte("database password leaked")) {
t.Fatalf("response leaked internal error: %s", rec.Body.String())
}
}
+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)
+6
View File
@@ -15,6 +15,7 @@ import (
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/scantrigger"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -41,6 +42,11 @@ type Dependencies struct {
UserDataService UserDataService
AuthService *auth.Service
// Autoscan / admin compatibility support.
APIKeyValidator apiKeyValidator
APIKeyUserLoader apiKeyUserLoader
ScanQueue scantrigger.Queuer
// Catalog repos (for ContentService construction)
BrowseRepo *catalog.BrowseRepository
ItemRepo *catalog.ItemRepository
+62
View File
@@ -121,6 +121,68 @@ func (r *Repository) Create(ctx context.Context, input CreateInput) (*models.Sca
return nil, false, fmt.Errorf("create scan run: %w", err)
}
func (r *Repository) CreateBatch(ctx context.Context, inputs []CreateInput) ([]*models.ScanRun, []bool, error) {
if len(inputs) == 0 {
return []*models.ScanRun{}, []bool{}, nil
}
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, nil, fmt.Errorf("begin scan run batch: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
runs := make([]*models.ScanRun, 0, len(inputs))
created := make([]bool, 0, len(inputs))
for _, input := range inputs {
run, err := scanRunRow(tx.QueryRow(ctx, `
INSERT INTO scan_runs (
id, media_folder_id, mode, path, trigger, status
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
RETURNING `+scanRunColumns,
ulid.Make().String(),
input.LibraryID,
input.Mode,
input.Path,
input.Trigger,
StatusAccepted,
))
if err == nil {
runs = append(runs, run)
created = append(created, true)
continue
}
if !errors.Is(err, ErrScanRunNotFound) {
return nil, nil, fmt.Errorf("create scan run: %w", err)
}
existing, lookupErr := scanRunRow(tx.QueryRow(ctx, `
SELECT `+scanRunColumns+`
FROM scan_runs
WHERE media_folder_id = $1
AND mode = $2
AND path = $3
AND status = ANY($4)
ORDER BY requested_at ASC
LIMIT 1`,
input.LibraryID,
input.Mode,
input.Path,
[]string{StatusAccepted, StatusRunning},
))
if lookupErr != nil {
return nil, nil, lookupErr
}
runs = append(runs, existing)
created = append(created, false)
}
if err := tx.Commit(ctx); err != nil {
return nil, nil, fmt.Errorf("commit scan run batch: %w", err)
}
return runs, created, nil
}
func (r *Repository) GetActiveByScope(ctx context.Context, libraryID int, mode, path string) (*models.ScanRun, error) {
return scanRunRow(r.pool.QueryRow(ctx, `
SELECT `+scanRunColumns+`
+29
View File
@@ -13,6 +13,7 @@ import (
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/libraryingest"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
const (
@@ -131,6 +132,34 @@ func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, tri
return created, nil
}
func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error {
if s == nil || s.repo == nil {
return fmt.Errorf("scan queue is not configured")
}
inputs := make([]CreateInput, 0, len(targets))
for _, target := range targets {
if target.Folder == nil {
return fmt.Errorf("scan queue: target is missing folder")
}
inputs = append(inputs, CreateInput{
LibraryID: target.Folder.ID,
Mode: target.Mode,
Path: target.Path,
Trigger: target.Trigger,
})
}
runs, created, err := s.repo.CreateBatch(ctx, inputs)
if err != nil {
return err
}
for i, run := range runs {
if i < len(created) && created[i] {
s.publish(ctx, "scan.accepted", run)
}
}
return nil
}
func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) {
if s == nil || s.repo == nil {
return 0, nil
+280
View File
@@ -0,0 +1,280 @@
package scantrigger
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
const (
ModeLibrary = "library"
ModeSubtree = "subtree"
ModeFile = "file"
)
type FolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
List(ctx context.Context) ([]*models.MediaFolder, error)
}
type Queuer interface {
EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error)
EnqueueScans(ctx context.Context, targets []Target) error
}
type Request struct {
LibraryID *int
Path string
Trigger string
}
// Target is a fully-resolved scan request. Folder is always non-nil for
// targets returned by Resolver; callers should read the library ID via
// target.Folder.ID rather than tracking it separately.
type Target struct {
Folder *models.MediaFolder
Mode string
Path string
Trigger string
}
type RequestError struct {
Status int
Code string
Message string
}
func (e *RequestError) Error() string {
return e.Message
}
type Resolver struct {
folders FolderRepository
}
func NewResolver(folders FolderRepository) *Resolver {
return &Resolver{folders: folders}
}
func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) {
targets := make([]Target, 0, len(requests))
var pathFolders []*models.MediaFolder
pathFoldersLoaded := false
for _, req := range requests {
usePathFolders := req.LibraryID == nil && strings.TrimSpace(req.Path) != ""
if usePathFolders && !pathFoldersLoaded {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
folders, listErr := r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
pathFolders = folders
pathFoldersLoaded = true
}
target, err := r.resolve(ctx, req, pathFolders, usePathFolders)
if err != nil {
return nil, err
}
targets = append(targets, *target)
}
return targets, nil
}
func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) {
return r.resolve(ctx, req, nil, false)
}
func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"}
}
var folder *models.MediaFolder
var err error
if req.LibraryID != nil {
folder, err = r.folders.GetByID(ctx, *req.LibraryID)
if err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"}
}
return nil, fmt.Errorf("fetching library for scan: %w", err)
}
}
trigger := strings.TrimSpace(req.Trigger)
if trigger == "" {
trigger = "manual"
}
if strings.TrimSpace(req.Path) == "" {
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
}
cleanPath := filepath.Clean(req.Path)
var matchedRoot string
if folder != nil {
matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths)
if err != nil {
return nil, err
}
if matchedRoot == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"}
}
} else {
folders := pathFolders
if !usePathFolders {
var listErr error
folders, listErr = r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
}
folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders)
if err != nil {
return nil, err
}
}
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
mode, err := ClassifyPath(cleanPath, matchedRoot)
if err != nil {
return nil, err
}
if trigger == "manual" {
trigger = "path"
if req.LibraryID != nil {
trigger = "library_id_path"
}
}
targetPath := cleanPath
if mode == ModeLibrary {
targetPath = ""
}
return &Target{Folder: folder, Mode: mode, Path: targetPath, Trigger: trigger}, nil
}
func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error {
if queue == nil {
return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if err := queue.EnqueueScans(ctx, targets); err != nil {
return fmt.Errorf("queueing library scans: %w", err)
}
return nil
}
func LongestMatchingRoot(targetPath string, roots []string) (string, error) {
bestRoot := ""
bestLen := -1
for _, root := range roots {
if !PathWithinRoot(targetPath, root) {
continue
}
cleanRoot := filepath.Clean(root)
rootLen := len(cleanRoot)
if rootLen > bestLen {
bestRoot = cleanRoot
bestLen = rootLen
}
}
return bestRoot, nil
}
func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
var bestFolder *models.MediaFolder
bestRoot := ""
bestLen := -1
ambiguous := false
for _, folder := range folders {
if folder == nil {
continue
}
root, err := LongestMatchingRoot(targetPath, folder.Paths)
if err != nil {
return nil, "", err
}
if root == "" {
continue
}
rootLen := len(root)
if rootLen > bestLen {
bestFolder = folder
bestRoot = root
bestLen = rootLen
ambiguous = false
continue
}
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
ambiguous = true
}
}
if ambiguous {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"}
}
if bestFolder == nil {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"}
}
return bestFolder, bestRoot, nil
}
func ClassifyPath(targetPath, matchedRoot string) (string, error) {
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
return ModeLibrary, nil
}
info, err := os.Stat(targetPath)
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"}
case errors.Is(err, os.ErrPermission):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"}
default:
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"}
}
}
if info.IsDir() {
return ModeSubtree, nil
}
if !info.Mode().IsRegular() {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"}
}
if !scanner.SupportsVideoFile(targetPath) {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"}
}
return ModeFile, nil
}
func PathWithinRoot(targetPath, rootPath string) bool {
cleanTarget := filepath.Clean(targetPath)
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanTarget)
if err != nil {
return false
}
if rel == "." || rel == "" {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
+210
View File
@@ -0,0 +1,210 @@
package scantrigger
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeFolderRepo struct {
folders []*models.MediaFolder
listCalls int
}
func (r *fakeFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
for _, folder := range r.folders {
if folder.ID == id {
return folder, nil
}
}
return nil, catalog.ErrFolderNotFound
}
func (r *fakeFolderRepo) List(context.Context) ([]*models.MediaFolder, error) {
r.listCalls++
return r.folders, nil
}
func TestResolverClassifiesLibraryRoot(t *testing.T) {
root := t.TempDir()
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 7,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 7 || target.Mode != ModeLibrary || target.Path != "" {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverClassifiesSubtree(t *testing.T) {
root := t.TempDir()
subtree := filepath.Join(root, "Show")
if err := os.Mkdir(subtree, 0o755); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 8,
Name: "TV",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: subtree})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 8 || target.Mode != ModeSubtree || target.Path != filepath.Clean(subtree) {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverClassifiesVideoFile(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "Movie (2024).mkv")
if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 9,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
target, err := NewResolver(repo).Resolve(context.Background(), Request{Path: filePath})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if target.Folder == nil || target.Folder.ID != 9 || target.Mode != ModeFile || target.Path != filepath.Clean(filePath) {
t.Fatalf("unexpected target: %#v", target)
}
}
func TestResolverRejectsDisabledLibrary(t *testing.T) {
root := t.TempDir()
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 10,
Name: "Disabled",
Enabled: false,
Paths: []string{root},
}}}
_, err := NewResolver(repo).Resolve(context.Background(), Request{Path: root})
var reqErr *RequestError
if !errors.As(err, &reqErr) {
t.Fatalf("expected RequestError, got %T: %v", err, err)
}
if reqErr.Status != http.StatusConflict || reqErr.Code != "conflict" {
t.Fatalf("unexpected error: %#v", reqErr)
}
}
func TestResolveAllIsAllOrFail(t *testing.T) {
root := t.TempDir()
valid := filepath.Join(root, "Movie.mkv")
if err := os.WriteFile(valid, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 11,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
_, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
{Path: valid},
{Path: filepath.Join(root, "missing.mkv")},
})
var reqErr *RequestError
if !errors.As(err, &reqErr) {
t.Fatalf("expected RequestError, got %T: %v", err, err)
}
if reqErr.Message != "Path does not exist" {
t.Fatalf("unexpected error message: %q", reqErr.Message)
}
}
func TestResolveAllReusesPathOnlyLibraryList(t *testing.T) {
root := t.TempDir()
first := filepath.Join(root, "First.mkv")
second := filepath.Join(root, "Second.mkv")
for _, path := range []string{first, second} {
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
}
repo := &fakeFolderRepo{folders: []*models.MediaFolder{{
ID: 12,
Name: "Movies",
Enabled: true,
Paths: []string{root},
}}}
targets, err := NewResolver(repo).ResolveAll(context.Background(), []Request{
{Path: first},
{Path: second},
})
if err != nil {
t.Fatalf("ResolveAll returned error: %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected two targets, got %d", len(targets))
}
if repo.listCalls != 1 {
t.Fatalf("expected one folder list lookup, got %d", repo.listCalls)
}
}
type fakeQueue struct {
calls []Target
batches [][]Target
batchErr error
}
func (q *fakeQueue) EnqueueScan(_ context.Context, folderID int, mode, path, trigger string) (bool, error) {
q.calls = append(q.calls, Target{Folder: &models.MediaFolder{ID: folderID}, Mode: mode, Path: path, Trigger: trigger})
return true, nil
}
func (q *fakeQueue) EnqueueScans(_ context.Context, targets []Target) error {
copied := append([]Target(nil), targets...)
q.batches = append(q.batches, copied)
if q.batchErr != nil {
return q.batchErr
}
q.calls = append(q.calls, targets...)
return nil
}
func TestEnqueueAllUsesBatchQueue(t *testing.T) {
queue := &fakeQueue{}
folder := &models.MediaFolder{ID: 1}
targets := []Target{
{Folder: folder, Mode: ModeFile, Path: "/media/one.mkv", Trigger: "autoscan"},
{Folder: folder, Mode: ModeFile, Path: "/media/two.mkv", Trigger: "autoscan"},
}
if err := EnqueueAll(context.Background(), queue, targets); err != nil {
t.Fatalf("EnqueueAll returned error: %v", err)
}
if len(queue.batches) != 1 {
t.Fatalf("expected one batch enqueue, got %d", len(queue.batches))
}
if len(queue.calls) != 2 {
t.Fatalf("expected two queued calls from batch, got %d", len(queue.calls))
}
}