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
27 changes: 18 additions & 9 deletions docs/architecture/media-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ marked 4K (the plugin's `is_4k` switch, or its older `is_default_4k`), HD
versions only to one that is not. Saving a route that breaks this is refused,
and so, under Advanced, is changing a server's 4K switch while a route sends it
the other version. A server of another plugin (Seerr) takes either version.
Changing a server's type or media types while a route sends it a media type it
would no longer take is refused too. A route save and a server save each check
the other again with the server row locked, so two admins saving at once
cannot leave a route pointing at a server that no longer fits.

The migration that introduced routes carried the Sonarr/Radarr plugin's routing
over unchanged: each media type's first usable default and default-4K servers
Expand Down Expand Up @@ -305,15 +309,20 @@ row until the follow commits, so a follow cannot land just after the request
was declined, cancelled or completed, and miss that transition's follow
cleanup.

A follow belongs to the title and the profile (`media_request_follows`, keyed
by account and profile id, since profile ids repeat across accounts), not to
one request, so it survives the request failing and being retried or requested
again. Declining or cancelling the request clears the title's follows: the title
is no longer on its way, and the follower can request it themselves. The
requesting profile never needs a follow: the fulfilled notification always
reaches it. When a request's fulfilled notification goes out, it is also sent
to every follower of the title, marked `follower` so its wording does not say
"your request", and those follows are then cleared. A dispatch failure leaves
A follow belongs to a profile and the request that was open when it was made
(`media_request_follows`, keyed by account, profile id and request, since
profile ids repeat across accounts). A series can have completed requests still
waiting for the library beside a newer open request for other seasons; each
request's notification goes to its own follows, and a profile can follow each
of them. Unfollowing a title removes the profile's follows on all of them. A follow
survives its request failing: the title's next request takes over the follows
of a failed request, or one its requester replaced. Declining or cancelling a
request clears its follows: the title is no longer on its way, and the follower
can request it themselves. The requesting profile never needs a follow: the
fulfilled notification always reaches it. When a request's fulfilled
notification goes out, it is also sent to the request's followers, marked
`follower` so its wording does not say "your request", and those follows are
then cleared. A dispatch failure leaves
the follows for the retry, and the server-channel announcement waits until an
attempt has reached every recipient, so a retry does not repeat it.

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ require (
)

require (
github.com/Silo-Server/silo-plugin-sdk v0.18.1-0.20260928194839-1abd582d0304
github.com/Silo-Server/silo-plugin-sdk v0.19.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.18.1-0.20260928194839-1abd582d0304 h1:D8lB4rqvGxOQTw9DeJiE7Dwr6qTVk2xqjI5uuyJM+i0=
github.com/Silo-Server/silo-plugin-sdk v0.18.1-0.20260928194839-1abd582d0304/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM=
github.com/Silo-Server/silo-plugin-sdk v0.19.0 h1:LWYI9x6OxBr8d+IxsHjJpcABpjvx/lA4UsDD13ynlAg=
github.com/Silo-Server/silo-plugin-sdk v0.19.0/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM=
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
Expand Down
6 changes: 2 additions & 4 deletions internal/requests/editor_concurrency.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,8 @@ func (r *Repository) UpdateIntegrationConditional(ctx context.Context, in Integr
if err = lockRevision(ctx, tx, `SELECT revision FROM request_integrations WHERE id=$1 FOR UPDATE`, []any{in.ID}, expected, false); err != nil {
return nil, err
}
if !standard {
if err = ensureTierKeptUnderAdvanced(ctx, tx, in); err != nil {
return nil, err
}
if err = ensureRoutesStillFit(ctx, tx, in, !standard); err != nil {
return nil, err
}
out, err := r.updateIntegration(ctx, tx, in)
if err == nil && standard {
Expand Down
127 changes: 81 additions & 46 deletions internal/requests/follows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@ package requests
import (
"context"
"fmt"
"maps"
"slices"
"strings"
)

// Following a title: a profile that finds a title someone else has already
// requested can ask to be notified when it becomes available, instead of
// requesting it again. A follow belongs to the title, not to one request, so it
// survives the request failing and being retried or requested again. It is
// cleared once the fulfilled notification has gone out, and when the request
// is declined or withdrawn: the title is then no longer on its way, and the
// follower can request it themselves. The requester is always notified and
// never needs a follow.
// requesting it again. A follow belongs to the request that was open when it
// was made, since a series can have completed requests still waiting for the
// library beside a newer open request for other seasons; each request's
// notification goes to its own follows, and a profile can follow each of them.
// A follow survives its request failing: the title's next request takes over
// the follows of a failed or replaced one.
// It is cleared once the fulfilled notification has gone out, and when its
// request is declined or withdrawn: the title is then no longer on its way,
// and the follower can request it themselves. The requester is always
// notified and never needs a follow.

// Follower is a profile waiting to hear that a requested title is available.
type Follower struct {
Expand Down Expand Up @@ -97,7 +103,7 @@ func (s *Service) Unfollow(ctx context.Context, viewer Viewer, mediaType MediaTy
// is waiting on, either as the requesting profile or as a follower.
func (s *Service) followedTitles(ctx context.Context, viewer Viewer, mediaType MediaType, active map[int]*Request) (map[int]bool, error) {
out := map[int]bool{}
var others []int
others := map[string]int{}
for tmdbID, req := range active {
if req == nil {
continue
Expand All @@ -106,18 +112,18 @@ func (s *Service) followedTitles(ctx context.Context, viewer Viewer, mediaType M
out[tmdbID] = true
continue
}
others = append(others, tmdbID)
others[req.ID] = tmdbID
}
if len(others) == 0 || strings.TrimSpace(viewer.ProfileID) == "" {
return out, nil
}
followed, err := s.store.FollowedTitles(ctx, mediaType, others, viewer)
followed, err := s.store.FollowedRequests(ctx, slices.Collect(maps.Keys(others)), viewer)
if err != nil {
return nil, err
}
for tmdbID, ok := range followed {
for id, ok := range followed {
if ok {
out[tmdbID] = true
out[others[id]] = true
}
}
return out, nil
Expand All @@ -127,7 +133,8 @@ func (s *Service) followedTitles(ctx context.Context, viewer Viewer, mediaType M
// the same statement, so a follow cannot land just after the request
// completed and never be told. It answers ErrNotRequested when there is none.
//
// FOR SHARE holds the open request until the follow commits. Every transition
// The follow records the open request it read. FOR SHARE holds that request
// until the follow commits. Every transition
// that closes a request (decline, cancel, completion) updates its row, and
// that row lock conflicts with FOR SHARE, so the close cannot commit, and its
// follow cleanup cannot run, between the read and the insert. A follow that
Expand All @@ -137,15 +144,15 @@ func (r *Repository) FollowTitle(ctx context.Context, mediaType MediaType, tmdbI
var open bool
if err := r.pool.QueryRow(ctx, `
WITH open_request AS (
SELECT 1 FROM media_requests
SELECT id FROM media_requests
WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2
AND outcome = 'active' AND status <> 'completed'
LIMIT 1
FOR SHARE
), inserted AS (
INSERT INTO media_request_follows (media_type, tmdb_id, user_id, profile_id)
SELECT $1, $2, $3, $4 FROM open_request
ON CONFLICT (media_type, tmdb_id, user_id, profile_id) DO NOTHING
INSERT INTO media_request_follows (media_type, tmdb_id, user_id, profile_id, request_id)
SELECT $1, $2, $3, $4, id FROM open_request
ON CONFLICT (user_id, profile_id, request_id) DO NOTHING
)
SELECT EXISTS (SELECT 1 FROM open_request)
`, mediaType, tmdbID, viewer.UserID, viewer.ProfileID).Scan(&open); err != nil {
Expand All @@ -157,23 +164,46 @@ func (r *Repository) FollowTitle(ctx context.Context, mediaType MediaType, tmdbI
return nil
}

// forgetTitleFollows removes the follows on the title of a request that was
// just declined or withdrawn, unless the title has another open request whose
// followers are still waiting for it.
// forgetTitleFollows removes the follows of a request that was just declined
// or withdrawn.
func forgetTitleFollows(ctx context.Context, exec requestExecutor, closed *Request) error {
if _, err := exec.Exec(ctx, `
DELETE FROM media_request_follows
WHERE media_type = $1 AND tmdb_id = $2
AND NOT EXISTS (
SELECT 1 FROM media_requests
WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2
AND outcome = 'active' AND status <> 'completed' AND id <> $3)
`, closed.MediaType, closed.TMDBID, closed.ID); err != nil {
if _, err := exec.Exec(ctx, `DELETE FROM media_request_follows WHERE request_id = $1`, closed.ID); err != nil {
return fmt.Errorf("forget title follows: %w", err)
}
return nil
}

// adoptTitleFollows gives a new request the follows of the title's failed
// requests, so a follow survives its request failing. The caller creates the
// request in the same transaction, before deleting any failed request it
// replaces.
//
// The follows move in place: an UnfollowTitle that waited on a moved row
// re-checks the moved row, still on the title and the profile, and deletes it,
// where a delete and re-insert would leave it a row it cannot see.
func adoptTitleFollows(ctx context.Context, exec requestExecutor, req *Request) error {
const failed = `SELECT id FROM media_requests
WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2 AND outcome = 'failed'`
// A profile that followed two failed requests keeps its earliest follow.
if _, err := exec.Exec(ctx, `
DELETE FROM media_request_follows f
USING media_request_follows keep
WHERE f.request_id IN (`+failed+`) AND keep.request_id IN (`+failed+`)
AND keep.user_id = f.user_id AND keep.profile_id = f.profile_id
AND (keep.created_at, keep.request_id) < (f.created_at, f.request_id)
`, req.MediaType, req.TMDBID); err != nil {
return fmt.Errorf("adopt title follows: %w", err)
}
if _, err := exec.Exec(ctx, `
UPDATE media_request_follows SET request_id = $3
WHERE request_id IN (`+failed+`)
`, req.MediaType, req.TMDBID, req.ID); err != nil {
return fmt.Errorf("adopt title follows: %w", err)
}
return nil
}

// UnfollowTitle removes the profile's follows on every request of the title.
func (r *Repository) UnfollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error {
if _, err := r.pool.Exec(ctx, `
DELETE FROM media_request_follows
Expand All @@ -184,37 +214,39 @@ func (r *Repository) UnfollowTitle(ctx context.Context, mediaType MediaType, tmd
return nil
}

func (r *Repository) FollowedTitles(ctx context.Context, mediaType MediaType, tmdbIDs []int, viewer Viewer) (map[int]bool, error) {
out := map[int]bool{}
if len(tmdbIDs) == 0 {
// FollowedRequests reports which of the requests the profile follows.
func (r *Repository) FollowedRequests(ctx context.Context, requestIDs []string, viewer Viewer) (map[string]bool, error) {
out := map[string]bool{}
if len(requestIDs) == 0 {
return out, nil
}
rows, err := r.pool.Query(ctx, `
SELECT tmdb_id FROM media_request_follows
WHERE media_type = $1 AND tmdb_id = ANY($2) AND user_id = $3 AND profile_id = $4
`, mediaType, tmdbIDs, viewer.UserID, viewer.ProfileID)
SELECT request_id FROM media_request_follows
WHERE request_id = ANY($1) AND user_id = $2 AND profile_id = $3
`, requestIDs, viewer.UserID, viewer.ProfileID)
if err != nil {
return nil, fmt.Errorf("list followed titles: %w", err)
return nil, fmt.Errorf("list followed requests: %w", err)
}
defer rows.Close()
for rows.Next() {
var tmdbID int
if err := rows.Scan(&tmdbID); err != nil {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[tmdbID] = true
out[id] = true
}
return out, rows.Err()
}

func (r *Repository) ListTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int) ([]Follower, error) {
// ListRequestFollowers lists the follows a request's notification goes to.
func (r *Repository) ListRequestFollowers(ctx context.Context, req Request) ([]Follower, error) {
rows, err := r.pool.Query(ctx, `
SELECT user_id, profile_id FROM media_request_follows
WHERE media_type = $1 AND tmdb_id = $2
WHERE request_id = $1
ORDER BY created_at, user_id, profile_id
`, mediaType, tmdbID)
`, req.ID)
if err != nil {
return nil, fmt.Errorf("list title followers: %w", err)
return nil, fmt.Errorf("list request followers: %w", err)
}
defer rows.Close()
var out []Follower
Expand All @@ -228,7 +260,10 @@ func (r *Repository) ListTitleFollowers(ctx context.Context, mediaType MediaType
return out, rows.Err()
}

func (r *Repository) ClearTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int, followers []Follower) error {
// ClearRequestFollowers removes the listed follows once the request's
// notification has gone out. A profile that unfollowed and followed again
// since, for a newer request, keeps its new follow.
func (r *Repository) ClearRequestFollowers(ctx context.Context, req Request, followers []Follower) error {
if len(followers) == 0 {
return nil
}
Expand All @@ -240,10 +275,10 @@ func (r *Repository) ClearTitleFollowers(ctx context.Context, mediaType MediaTyp
}
if _, err := r.pool.Exec(ctx, `
DELETE FROM media_request_follows
WHERE media_type = $1 AND tmdb_id = $2
AND (user_id, profile_id) IN (SELECT * FROM unnest($3::int[], $4::text[]))
`, mediaType, tmdbID, userIDs, profileIDs); err != nil {
return fmt.Errorf("clear title followers: %w", err)
WHERE request_id = $1
AND (user_id, profile_id) IN (SELECT * FROM unnest($2::int[], $3::text[]))
`, req.ID, userIDs, profileIDs); err != nil {
return fmt.Errorf("clear request followers: %w", err)
}
return nil
}
Loading
Loading