0.2.58 - Requests module
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user