Merge branch 'main' of https://github.com/Silo-Server/silo-server
This commit is contained in:
@@ -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
@@ -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.
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user