0.2.58 - Requests module

This commit is contained in:
ponzischeme89
2026-08-12 14:13:19 +12:00
parent 64f19aeef2
commit 613f203cf9
27 changed files with 3026 additions and 23 deletions
+46
View File
@@ -180,6 +180,52 @@ func (s *Store) LibraryContainsProviderIDs(
return found, rows.Err()
}
// LibraryProviderItemIDs is LibraryContainsProviderIDs with the answer the request page
// needs: not only whether Emby has the title, but which item it is.
//
// That id is what lets a request that has finally downloaded stop being a status card and
// become something a viewer can press. Ordering by item_id keeps the answer stable when a
// household holds the same title twice — a duplicate import, or a remake sharing an id in
// bad metadata — so a card does not point at a different copy between two reads.
func (s *Store) LibraryProviderItemIDs(
ctx context.Context, provider string, ids []int,
) (map[int]string, error) {
found := map[int]string{}
if len(ids) == 0 {
return found, nil
}
values := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
values = append(values, fmt.Sprint(id))
}
}
if len(values) == 0 {
return found, nil
}
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON ((payload->'ProviderIds'->>$1)::int)
(payload->'ProviderIds'->>$1)::int, id
FROM library_items
WHERE payload->'ProviderIds'->>$1 = ANY($2::text[])
ORDER BY (payload->'ProviderIds'->>$1)::int, id`, provider, values)
if err != nil {
return nil, fmt.Errorf("store: library provider item ids: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id int
itemID string
)
if err := rows.Scan(&id, &itemID); err != nil {
return nil, err
}
found[id] = itemID
}
return found, rows.Err()
}
// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the
// recommendation engine. User state is applied by the caller, which is the only place
// that knows it.
+93
View File
@@ -0,0 +1,93 @@
package store
import (
"context"
"fmt"
"strings"
"time"
)
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
// comment on media_requests for why the state is derived per read rather than stored.
type MediaRequest struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
RequestedAt time.Time `json:"requestedAt"`
}
// MediaRequestLimit caps what one viewer's page will read back. A household that has been
// asking for things for two years should not turn the page into an unbounded query, and
// nobody scrolls past the most recent hundred by remote.
const MediaRequestLimit = 100
// SaveMediaRequest records an ask, or refreshes one already held.
//
// The repeat is deliberately an update rather than a no-op: asking again is how somebody
// says they still want it, and the page is ordered by when they asked.
func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRequest) error {
userID = strings.TrimSpace(userID)
if userID == "" || req.ForeignID <= 0 {
return fmt.Errorf("store: media request needs a user and a foreign id")
}
_, err := s.pool.Exec(ctx, `
INSERT INTO media_requests
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
SET title = EXCLUDED.title,
year = EXCLUDED.year,
poster_url = EXCLUDED.poster_url,
requested_at = now()`,
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
if err != nil {
return fmt.Errorf("store: save media request: %w", err)
}
return nil
}
// MediaRequests returns one viewer's asks, most recent first.
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, requested_at
FROM media_requests
WHERE emby_user_id = $1
ORDER BY requested_at DESC
LIMIT $2`, strings.TrimSpace(userID), MediaRequestLimit)
if err != nil {
return nil, fmt.Errorf("store: read media requests: %w", err)
}
defer rows.Close()
requests := []MediaRequest{}
for rows.Next() {
var req MediaRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
); err != nil {
return nil, fmt.Errorf("store: scan media request: %w", err)
}
requests = append(requests, req)
}
return requests, rows.Err()
}
// DeleteMediaRequest removes a viewer's ask from their own page.
//
// It deliberately does not touch Radarr or Sonarr. The title may well have been downloaded
// by now, and other people may have asked for it too — this only says the viewer no longer
// wants it listed among theirs.
func (s *Store) DeleteMediaRequest(
ctx context.Context, userID, mediaType string, foreignID int,
) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM media_requests
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
strings.TrimSpace(userID), mediaType, foreignID)
if err != nil {
return fmt.Errorf("store: delete media request: %w", err)
}
return nil
}
+31
View File
@@ -508,3 +508,34 @@ CREATE TABLE IF NOT EXISTS downloaded_subtitles (
);
CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id);
-- What a viewer has asked the household to get hold of.
--
-- Radarr and Sonarr are the things that actually fetch a title, and neither keeps any idea
-- of *who* wanted it: an added movie is an added movie. So this table is the only record of
-- authorship, and it is what makes "My requests" a per-person page rather than a list of
-- everything the household has ever added.
--
-- It deliberately stores no status. A request's state — waiting for a release, searching,
-- downloaded, in the library — is Radarr's and Sonarr's to answer and changes without
-- anybody touching Memby, so a status column here would be a second copy that is wrong
-- within the hour. What is stored is the identity (which title, from which catalogue) plus
-- enough metadata to draw the card before the *arr lookup returns; the state is derived per
-- request by requestStatusFor.
--
-- The primary key is (viewer, catalogue, id) rather than a serial, so asking twice for the
-- same film is the same request rather than two rows a viewer has to tell apart. The repeat
-- refreshes requested_at, because the second ask is the one they remember making.
CREATE TABLE IF NOT EXISTS media_requests (
emby_user_id TEXT NOT NULL,
media_type TEXT NOT NULL,
foreign_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
year INTEGER NOT NULL DEFAULT 0,
poster_url TEXT NOT NULL DEFAULT '',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, media_type, foreign_id)
);
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);