Release Memby 0.2.64
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaReport struct {
|
||||
ID int64 `json:"id"`
|
||||
EmbyItemID string `json:"embyItemId"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Title string `json:"title"`
|
||||
SeriesTitle string `json:"seriesTitle,omitempty"`
|
||||
SeasonNumber int `json:"seasonNumber,omitempty"`
|
||||
EpisodeNumber int `json:"episodeNumber,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
ReportedByUserID string `json:"reportedByUserId"`
|
||||
ReportedByUsername string `json:"reportedByUsername"`
|
||||
ReportedByDevice string `json:"reportedByDevice,omitempty"`
|
||||
ReplacementRequested bool `json:"replacementRequested"`
|
||||
ReplacementStatus string `json:"replacementStatus,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ArrItemID int `json:"arrItemId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Actions []MediaReportAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type MediaReportAction struct {
|
||||
ID int64 `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateMediaReport(ctx context.Context, report MediaReport) (MediaReport, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO media_reports
|
||||
(emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
|
||||
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id, status, created_at, updated_at`, report.EmbyItemID, report.MediaType, report.Title,
|
||||
report.SeriesTitle, report.SeasonNumber, report.EpisodeNumber, report.Reason, report.Comment,
|
||||
report.ReportedByUserID, report.ReportedByUsername, report.ReportedByDevice, report.ReplacementRequested,
|
||||
).Scan(&report.ID, &report.Status, &report.CreatedAt, &report.UpdatedAt)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("store: create media report: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1, 'reported', $2)`, report.ID, report.ReportedByUsername)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("store: record media report action: %w", err)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (s *Store) MediaReports(ctx context.Context) ([]MediaReport, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
|
||||
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested, replacement_status, status, arr_item_id, created_at, updated_at
|
||||
FROM media_reports ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list media reports: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
reports := []MediaReport{}
|
||||
for rows.Next() {
|
||||
var r MediaReport
|
||||
if err := rows.Scan(&r.ID, &r.EmbyItemID, &r.MediaType, &r.Title, &r.SeriesTitle, &r.SeasonNumber, &r.EpisodeNumber, &r.Reason, &r.Comment, &r.ReportedByUserID, &r.ReportedByUsername, &r.ReportedByDevice, &r.ReplacementRequested, &r.ReplacementStatus, &r.Status, &r.ArrItemID, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media report: %w", err)
|
||||
}
|
||||
reports = append(reports, r)
|
||||
}
|
||||
return reports, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetMediaReportStatus(ctx context.Context, id int64, status, actor string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE media_reports SET status=$2, updated_at=now() WHERE id=$1`, id, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: update media report: %w", err)
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1,$2,$3)`, id, status, actor)
|
||||
return err
|
||||
}
|
||||
@@ -657,3 +657,42 @@ CREATE TABLE IF NOT EXISTS integration_deliveries (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
|
||||
ON integration_deliveries (integration_id, attempted_at DESC);
|
||||
|
||||
-- A viewer's report about one concrete Emby movie or episode. A replacement is a
|
||||
-- separate state on the report so an ordinary playback complaint can never start a
|
||||
-- download. The uniqueness constraint is the first duplicate guard: one open workflow
|
||||
-- owns an item until an operator resolves or dismisses it.
|
||||
CREATE TABLE IF NOT EXISTS media_reports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
emby_item_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL, -- movie | episode
|
||||
title TEXT NOT NULL,
|
||||
series_title TEXT NOT NULL DEFAULT '',
|
||||
season_number INT NOT NULL DEFAULT 0,
|
||||
episode_number INT NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
reported_by_user_id TEXT NOT NULL,
|
||||
reported_by_username TEXT NOT NULL,
|
||||
reported_by_device TEXT NOT NULL DEFAULT '',
|
||||
replacement_requested BOOLEAN NOT NULL DEFAULT false,
|
||||
replacement_status TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
arr_item_id INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS media_reports_open_item_idx
|
||||
ON media_reports (emby_item_id) WHERE status IN ('new', 'acknowledged', 'replacement_requested', 'downloading');
|
||||
CREATE INDEX IF NOT EXISTS media_reports_created_idx ON media_reports (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_report_actions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
report_id BIGINT NOT NULL REFERENCES media_reports(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
|
||||
ON media_report_actions (report_id, created_at ASC);
|
||||
|
||||
@@ -85,24 +85,78 @@ const MDBListSettingsKey = "mdblist_settings"
|
||||
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
|
||||
// remain library data, so a metadata correction appears without rewriting operator policy.
|
||||
type HeroPolicy struct {
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Schedules []HeroSchedule `json:"schedules"`
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
Placements map[string]HeroPlacementPolicy `json:"placements,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Schedules []HeroSchedule `json:"schedules"`
|
||||
}
|
||||
|
||||
// HeroPlacementPolicy is one independently resolved spotlight. Keeping the placement as
|
||||
// a map key means another section can be added without changing the stored document.
|
||||
type HeroPlacementPolicy struct {
|
||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||
PrimeSubtitle string `json:"primeSubtitle"`
|
||||
}
|
||||
|
||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||
type HeroSchedule struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"itemId"`
|
||||
StartAt time.Time `json:"startAt"`
|
||||
EndAt time.Time `json:"endAt"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Placements []string `json:"placements,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
HeroPlacementHome = "home"
|
||||
HeroPlacementMovies = "movies"
|
||||
HeroPlacementTVShows = "tv_shows"
|
||||
)
|
||||
|
||||
func ValidHeroPlacement(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normaliseHeroPlacementPolicy(policy HeroPlacementPolicy) HeroPlacementPolicy {
|
||||
seen := map[string]bool{}
|
||||
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
|
||||
for _, id := range policy.PinnedItemIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] || len(ids) == 4 {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
policy.PinnedItemIDs = ids
|
||||
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
|
||||
if runes := []rune(policy.PrimeSubtitle); len(runes) > 160 {
|
||||
policy.PrimeSubtitle = string(runes[:160])
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (policy HeroPolicy) Placement(name string) HeroPlacementPolicy {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if placement, ok := policy.Placements[name]; ok {
|
||||
return placement
|
||||
}
|
||||
if name == HeroPlacementHome {
|
||||
return HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
|
||||
}
|
||||
return HeroPlacementPolicy{PinnedItemIDs: []string{}}
|
||||
}
|
||||
|
||||
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
@@ -126,6 +180,27 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
if len(runes) > 160 {
|
||||
policy.PrimeSubtitle = string(runes[:160])
|
||||
}
|
||||
if policy.Placements == nil {
|
||||
policy.Placements = map[string]HeroPlacementPolicy{}
|
||||
}
|
||||
if _, exists := policy.Placements[HeroPlacementHome]; !exists {
|
||||
policy.Placements[HeroPlacementHome] = HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
|
||||
}
|
||||
cleanPlacements := make(map[string]HeroPlacementPolicy, len(policy.Placements))
|
||||
for name, placement := range policy.Placements {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if ValidHeroPlacement(name) {
|
||||
cleanPlacements[name] = normaliseHeroPlacementPolicy(placement)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows} {
|
||||
if _, exists := cleanPlacements[name]; !exists {
|
||||
cleanPlacements[name] = HeroPlacementPolicy{PinnedItemIDs: []string{}}
|
||||
}
|
||||
}
|
||||
policy.Placements = cleanPlacements
|
||||
home := policy.Placement(HeroPlacementHome)
|
||||
policy.PinnedItemIDs, policy.PrimeSubtitle = home.PinnedItemIDs, home.PrimeSubtitle
|
||||
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
|
||||
seenSchedules := map[string]bool{}
|
||||
for _, schedule := range policy.Schedules {
|
||||
@@ -149,6 +224,19 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||
}
|
||||
}
|
||||
schedule.Weekdays = weekdays
|
||||
placements := make([]string, 0, len(schedule.Placements))
|
||||
seenPlacements := map[string]bool{}
|
||||
for _, placement := range schedule.Placements {
|
||||
placement = strings.ToLower(strings.TrimSpace(placement))
|
||||
if ValidHeroPlacement(placement) && !seenPlacements[placement] {
|
||||
seenPlacements[placement] = true
|
||||
placements = append(placements, placement)
|
||||
}
|
||||
}
|
||||
if len(placements) == 0 {
|
||||
placements = []string{HeroPlacementHome}
|
||||
}
|
||||
schedule.Placements = placements
|
||||
cleanSchedules = append(cleanSchedules, schedule)
|
||||
}
|
||||
policy.Schedules = cleanSchedules
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
|
||||
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
||||
@@ -50,6 +53,37 @@ func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroPolicyMigratesHomeAndKeepsPlacementsIndependent(t *testing.T) {
|
||||
got := normalizeHeroPolicy(HeroPolicy{
|
||||
PinnedItemIDs: []string{"home-1"},
|
||||
Placements: map[string]HeroPlacementPolicy{
|
||||
HeroPlacementMovies: {PinnedItemIDs: []string{"film-1"}},
|
||||
HeroPlacementTVShows: {PinnedItemIDs: []string{"series-1"}},
|
||||
},
|
||||
})
|
||||
if got.Placement(HeroPlacementHome).PinnedItemIDs[0] != "home-1" {
|
||||
t.Fatalf("legacy Home policy was not migrated: %+v", got.Placements)
|
||||
}
|
||||
if got.Placement(HeroPlacementMovies).PinnedItemIDs[0] != "film-1" ||
|
||||
got.Placement(HeroPlacementTVShows).PinnedItemIDs[0] != "series-1" {
|
||||
t.Fatalf("placement policies crossed: %+v", got.Placements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
|
||||
now := time.Now()
|
||||
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
|
||||
{ID: "old", ItemID: "one", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true},
|
||||
{ID: "new", ItemID: "two", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true, Placements: []string{" MOVIES ", "movies", "bad"}},
|
||||
}})
|
||||
if len(got.Schedules[0].Placements) != 1 || got.Schedules[0].Placements[0] != HeroPlacementHome {
|
||||
t.Fatalf("old schedule placements = %v", got.Schedules[0].Placements)
|
||||
}
|
||||
if len(got.Schedules[1].Placements) != 1 || got.Schedules[1].Placements[0] != HeroPlacementMovies {
|
||||
t.Fatalf("new schedule placements = %v", got.Schedules[1].Placements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||
defaults := DefaultMDBListSettings()
|
||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user