Release Memby 0.2.64

This commit is contained in:
ponzischeme89
2026-08-14 13:32:14 +12:00
parent ad0c74f880
commit 05d95f51cd
33 changed files with 1142 additions and 112 deletions
+84
View File
@@ -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
}
+39
View File
@@ -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);
+101 -13
View File
@@ -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
+35 -1
View File
@@ -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 {