Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions contracts/api/v2/fixtures/admin_requests_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@
{
"id": "42",
"request_id": "r-3",
"integration_id": "integration-1",
"integration_kind": "radarr",
"instance_name": "Radarr",
"quality": "1080p",
"is_anime": false,
"external_id": "7",
"external_status": "queued",
"status": "queued",
"route_name": "Movies",
"created_at": "2026-01-02T03:04:05.678Z",
"updated_at": "2026-01-02T03:04:05.678Z"
}
Expand Down Expand Up @@ -51,9 +57,15 @@
{
"id": "42",
"request_id": "r-2",
"integration_id": "integration-1",
"integration_kind": "radarr",
"instance_name": "Radarr",
"quality": "1080p",
"is_anime": false,
"external_id": "7",
"external_status": "queued",
"status": "queued",
"route_name": "Movies",
"created_at": "2026-01-02T03:04:05.678Z",
"updated_at": "2026-01-02T03:04:05.678Z"
}
Expand Down Expand Up @@ -82,9 +94,15 @@
{
"id": "42",
"request_id": "r-1",
"integration_id": "integration-1",
"integration_kind": "radarr",
"instance_name": "Radarr",
"quality": "1080p",
"is_anime": false,
"external_id": "7",
"external_status": "queued",
"status": "queued",
"route_name": "Movies",
"created_at": "2026-01-02T03:04:05.678Z",
"updated_at": "2026-01-02T03:04:05.678Z"
}
Expand Down
1 change: 0 additions & 1 deletion contracts/api/v2/fixtures/cancel_request_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"season_progress": [],
"requested_by_user_id": "1",
"requested_by_profile_id": "p-owner",
"integration_kind": "radarr",
"is_anime": false,
"targets": [
{
Expand Down
1 change: 0 additions & 1 deletion contracts/api/v2/fixtures/create_request_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"season_progress": [],
"requested_by_user_id": "1",
"requested_by_profile_id": "p-owner",
"integration_kind": "radarr",
"is_anime": false,
"targets": [],
"created_at": "2026-01-02T03:04:05.678Z",
Expand Down
2 changes: 1 addition & 1 deletion contracts/api/v2/fixtures/get_system_info_ok.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"server_version": "unavailable",
"api_major": 2,
"contract_digest": "8348e2ab6b2f48273ea3734b2c955923f5a8cb5e218c1be06af7c465b294c575",
"contract_digest": "2d062f1cc2421ddb2af274137f1c243c82eedfc881dce028c2f2f93def30a6ad",
"links": {
"openapi": "/api/v2/openapi.json",
"capabilities": "/api/v2/capabilities",
Expand Down
2 changes: 0 additions & 2 deletions contracts/api/v2/fixtures/list_my_requests_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
"season_progress": [],
"requested_by_user_id": "1",
"requested_by_profile_id": "p-owner",
"integration_kind": "radarr",
"is_anime": false,
"targets": [
{
Expand Down Expand Up @@ -45,7 +44,6 @@
"season_progress": [],
"requested_by_user_id": "1",
"requested_by_profile_id": "p-owner",
"integration_kind": "radarr",
"is_anime": false,
"targets": [
{
Expand Down
13 changes: 11 additions & 2 deletions contracts/api/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -28544,9 +28544,11 @@
"description": "How far the request's downloads are over all its servers (1080p and 4K together), while any reports them: bytes summed, the phase that needs the most attention, the latest estimate, and the oldest report's time"
},
"external_id": {
"description": "Admins only: the integration's own identifier",
"type": "string"
},
"external_status": {
"description": "Admins only: the status as the download server reports it",
"type": "string"
},
"id": {
Expand All @@ -28564,6 +28566,7 @@
"type": "string"
},
"integration_kind": {
"description": "Admins only: the download server's kind",
"examples": [
"radarr"
],
Expand All @@ -28573,6 +28576,7 @@
"type": "boolean"
},
"last_error": {
"description": "Admins only: why the last submission to a download server failed. It can name servers and routing rules",
"type": "string"
},
"library_content_id": {
Expand Down Expand Up @@ -36939,10 +36943,11 @@
"description": "How far this target's downloads are, while its download server reports them"
},
"external_id": {
"description": "The integration's own identifier",
"description": "Admins only: the integration's own identifier",
"type": "string"
},
"external_status": {
"description": "Admins only: the status as the download server reports it",
"type": "string"
},
"id": {
Expand All @@ -36954,12 +36959,15 @@
"type": "string"
},
"instance_name": {
"description": "Admins only: the download server's name",
"type": "string"
},
"integration_id": {
"description": "Admins only: the download server holding this target",
"type": "string"
},
"integration_kind": {
"description": "Admins only: the download server's kind",
"examples": [
"radarr"
],
Expand All @@ -36969,6 +36977,7 @@
"type": "boolean"
},
"last_error": {
"description": "Admins only: why the download server failed this target",
"type": "string"
},
"quality": {
Expand All @@ -36986,7 +36995,7 @@
"type": "string"
},
"route_name": {
"description": "The routing rule that sent this target to its server, as named when it was sent",
"description": "Admins only: the routing rule that sent this target to its server, as named when it was sent",
"type": "string"
},
"status": {
Expand Down
17 changes: 17 additions & 0 deletions docs/architecture/media-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,23 @@ accounts' failed requests are left alone as those users' history. Retrying one
of them after someone else has requested the title answers
`ErrAlreadyRequested`, since only one active request per title may exist.

## What a requester sees

The v2 request operations a profile calls (`createRequest`, `listMyRequests`,
`getRequest` and `cancelRequest`) give a viewer who is not an admin the request
without its download server details: the request's `integration_kind`,
`external_id`, `external_status` and `last_error`, and each target's
`integration_id`, `integration_kind`, `instance_name`, `external_id`,
`external_status`, `route_name` and `last_error`. These name the admin's
download servers and routing rules and carry the servers' raw statuses and
errors, none of which a requester can act on. The request's `last_error` is
written for the admin who fixes the submission: it can name a server or a
routing rule, or pass on a plugin's own error text. A requester still sees the
request's state and `outcome_reason`, and each target's quality, status and
`download`. An admin sees every field, on those operations and on the
`/api/v2/admin/requests` operations. The frozen `/api/v1` request routes still
return them to everyone.

## Admin queue

The admin queue groups requests by what an admin does next, from status and
Expand Down
4 changes: 2 additions & 2 deletions internal/apiv2/admin_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func registerAdminRequests(reg *Registry) {
if err != nil {
return nil, requestProblem(err)
}
return &MediaRequestOutput{Body: mediaRequestOf(r)}, nil
return &MediaRequestOutput{Body: mediaRequestOf(r, v)}, nil
})
}
Register(reg, op(http.MethodGet, "/admin/request-settings", opGetAdminRequestSettings, false), reg.getAdminRequestSettings)
Expand Down Expand Up @@ -306,7 +306,7 @@ func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in
}
items := make([]MediaRequest, 0, len(rows))
for _, r := range rows {
items = append(items, mediaRequestOf(r))
items = append(items, mediaRequestOf(r, v))
}
return &MediaRequestCollectionOutput{Body: MediaRequestCollection{Collection: Paginated(items, next)}}, nil
}
Expand Down
39 changes: 37 additions & 2 deletions internal/apiv2/admin_requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type fakeAdminRequests struct {
filter mediarequests.ListFilter
action, reason, requestID string
probedBaseURL string
// failed gives every returned request the errors a failed submission
// leaves (withSubmissionErrors).
failed bool
}

func fixtureAdminRequests() *fakeAdminRequests {
Expand Down Expand Up @@ -130,7 +133,7 @@ func TestAdminRequestOptionsUnreachableIntegration(t *testing.T) {
func (f *fakeAdminRequests) ListAdmin(_ context.Context, v mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) {
f.viewer = v
f.filter = filter
rows := []*mediarequests.Request{fixtureMediaRequest("r-3", 3), fixtureMediaRequest("r-2", 2), fixtureMediaRequest("r-1", 1)}
rows := []*mediarequests.Request{f.request("r-3", 3), f.request("r-2", 2), f.request("r-1", 1)}
out := []*mediarequests.Request{}
for _, r := range rows {
if filter.Before != nil && r.ID >= filter.Before.ID {
Expand All @@ -147,7 +150,14 @@ func (f *fakeAdminRequests) moderate(v mediarequests.Viewer, action, id, reason
f.viewer = v
f.action, f.requestID, f.reason = action, id, reason
f.writes++
return fixtureMediaRequest(id, 1), nil
return f.request(id, 1), nil
}
func (f *fakeAdminRequests) request(id string, tmdbID int) *mediarequests.Request {
r := fixtureMediaRequest(id, tmdbID)
if f.failed {
withSubmissionErrors(r)
}
return r
}
func (f *fakeAdminRequests) Approve(_ context.Context, v mediarequests.Viewer, id string) (*mediarequests.Request, error) {
return f.moderate(v, "approve", id, "")
Expand Down Expand Up @@ -319,6 +329,31 @@ func TestAdminRequestLimitsModerationAndOptions(t *testing.T) {
t.Fatalf("validation %+v", p)
}
}

// The admin request operations carry every download server detail.
func TestAdminRequestsCarryDownloadServerDetails(t *testing.T) {
f := fixtureAdminRequests()
f.failed = true
h := adminRequestsHandler(f)
rec := do(t, h, http.MethodGet, Prefix+"/admin/requests", "", actingRequestAdmin)
var page struct {
Items []map[string]any `json:"items"`
}
decodeBody(t, rec.Body, &page)
if rec.Code != http.StatusOK || len(page.Items) != 3 {
t.Fatalf("listAdminRequests: %d %s", rec.Code, rec.Body.String())
}
for _, item := range page.Items {
if targets, _ := item["targets"].([]any); len(targets) == 0 {
t.Fatalf("listAdminRequests: no targets in %v", item)
}
requireAdminMembers(t, "listAdminRequests", item, adminRequestMembers, adminTargetMembers)
}
for _, action := range []string{"approve", "decline", "cancel", "retry"} {
got := requireTargets(t, action, do(t, h, http.MethodPost, Prefix+"/admin/requests/r-1/"+action, `{}`, actingRequestAdmin))
requireAdminMembers(t, action, got, adminRequestMembers, adminTargetMembers)
}
}
func TestAdminRequestCursorBoundaries(t *testing.T) {
f := fixtureAdminRequests()
h := adminRequestsHandler(f)
Expand Down
5 changes: 3 additions & 2 deletions internal/apiv2/request_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,12 @@ func registerRequestLifecycle(reg *Registry, requests RequestLifecycleService, p
if requests == nil {
return nil, unavailable("requests")
}
result, err := requests.Cancel(ctx, lifecycleViewer(ctx), string(in.ID), in.Body.Reason)
viewer := lifecycleViewer(ctx)
result, err := requests.Cancel(ctx, viewer, string(in.ID), in.Body.Reason)
if err != nil {
return nil, requestProblem(err)
}
return &MediaRequestOutput{Body: mediaRequestOf(result)}, nil
return &MediaRequestOutput{Body: mediaRequestOf(result, viewer)}, nil
})
scope := func(ctx context.Context) (int, string, error) {
if providers == nil {
Expand Down
Loading
Loading