2026-08-12 14:13:19 +12:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 09:40:03 +12:00
|
|
|
// RequestUsage is the operator-facing use of the request feature. A recorded request is
|
|
|
|
|
// one that Memby has already handed to Radarr or Sonarr; their later download state is
|
|
|
|
|
// deliberately not duplicated here.
|
|
|
|
|
type RequestUsage struct {
|
|
|
|
|
UserID string `json:"userId"`
|
|
|
|
|
Requests int64 `json:"requests"`
|
|
|
|
|
LastRequest time.Time `json:"lastRequest,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Store) RequestUsage(ctx context.Context) ([]RequestUsage, error) {
|
|
|
|
|
rows, err := s.pool.Query(ctx, `SELECT emby_user_id, count(*), max(requested_at)
|
|
|
|
|
FROM media_requests GROUP BY emby_user_id`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("store: request usage: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
out := []RequestUsage{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var value RequestUsage
|
|
|
|
|
if err := rows.Scan(&value.UserID, &value.Requests, &value.LastRequest); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
out = append(out, value)
|
|
|
|
|
}
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 14:13:19 +12:00
|
|
|
// 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
|
|
|
|
|
}
|