0.2.76 - Icon Packs
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// fakeStore is the queue and the catalogue as maps. Everything the worker does is visible
|
||||
// in it, which is the point of the store being an interface here.
|
||||
type fakeStore struct {
|
||||
jobs map[string]*store.IngestJob
|
||||
order []string
|
||||
items map[string]store.LibraryItem
|
||||
deleted []string
|
||||
series []store.SeriesRef
|
||||
episodes []store.CreditsEpisodeRow
|
||||
named []store.NamedItem
|
||||
failNext error
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{jobs: map[string]*store.IngestJob{}, items: map[string]store.LibraryItem{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) EnqueueIngest(_ context.Context, job store.IngestJob) (bool, error) {
|
||||
existing, found := f.jobs[job.Key]
|
||||
if found {
|
||||
// The real table's ON CONFLICT: one row, and a re-delivery never pulls the settle
|
||||
// delay forward.
|
||||
if job.DueAt.After(existing.DueAt) {
|
||||
existing.DueAt = job.DueAt
|
||||
}
|
||||
existing.State = store.IngestPending
|
||||
return false, nil
|
||||
}
|
||||
stored := job
|
||||
stored.State = store.IngestPending
|
||||
f.jobs[job.Key] = &stored
|
||||
f.order = append(f.order, job.Key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClaimIngest(_ context.Context, now time.Time, limit int) ([]store.IngestJob, error) {
|
||||
out := []store.IngestJob{}
|
||||
for _, key := range f.order {
|
||||
job := f.jobs[key]
|
||||
if job.State != store.IngestPending || job.DueAt.After(now) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *job)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FinishIngest(
|
||||
_ context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time,
|
||||
) error {
|
||||
job, found := f.jobs[key]
|
||||
if !found {
|
||||
return errors.New("no such job")
|
||||
}
|
||||
job.State, job.Outcome, job.ItemID = state, outcome, itemID
|
||||
job.LastError, job.DueAt = errorText, retryAt
|
||||
job.Attempts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertLibraryItems(
|
||||
_ context.Context, items []store.LibraryItem, _ time.Time,
|
||||
) (int64, error) {
|
||||
if f.failNext != nil {
|
||||
err := f.failNext
|
||||
f.failNext = nil
|
||||
return 0, err
|
||||
}
|
||||
for _, item := range items {
|
||||
f.items[item.ID] = item
|
||||
}
|
||||
return int64(len(items)), nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteLibraryItem(_ context.Context, itemID string) (int64, error) {
|
||||
f.deleted = append(f.deleted, itemID)
|
||||
delete(f.items, itemID)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SeriesRefs(context.Context) ([]store.SeriesRef, error) { return f.series, nil }
|
||||
|
||||
func (f *fakeStore) CreditsSeriesEpisodes(
|
||||
context.Context, []string,
|
||||
) ([]store.CreditsEpisodeRow, error) {
|
||||
return f.episodes, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) LibraryItemsByName(
|
||||
_ context.Context, _, _ string,
|
||||
) ([]store.NamedItem, error) {
|
||||
return f.named, nil
|
||||
}
|
||||
|
||||
// fakeEmby answers the two lookups and counts the nudges.
|
||||
type fakeEmby struct {
|
||||
items []json.RawMessage
|
||||
episodes []json.RawMessage
|
||||
refreshed []string
|
||||
itemQueries []url.Values
|
||||
seasons []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmby) Items(
|
||||
_ context.Context, _ emby.Credentials, params url.Values,
|
||||
) (*emby.ItemsResult, error) {
|
||||
f.itemQueries = append(f.itemQueries, params)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &emby.ItemsResult{Items: f.items}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmby) Episodes(
|
||||
_ context.Context, _ emby.Credentials, _ string, params url.Values,
|
||||
) (*emby.ItemsResult, error) {
|
||||
f.seasons = append(f.seasons, params.Get("Season"))
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &emby.ItemsResult{Items: f.episodes}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmby) RefreshItem(_ context.Context, _ emby.Credentials, itemID string) error {
|
||||
f.refreshed = append(f.refreshed, itemID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testIngester(st *fakeStore, source *fakeEmby) *Ingester {
|
||||
return &Ingester{
|
||||
Store: st,
|
||||
Emby: source,
|
||||
Credentials: func(context.Context) (emby.Credentials, error) {
|
||||
return emby.Credentials{UserID: "u", Token: "t"}, nil
|
||||
},
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Settle: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func episodePayload(id string, season, episode int) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
|
||||
"ParentIndexNumber": season, "IndexNumber": episode,
|
||||
})
|
||||
return raw
|
||||
}
|
||||
|
||||
func enqueueOne(t *testing.T, ingester *Ingester, request IngestRequest) {
|
||||
t.Helper()
|
||||
if _, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func episodeRequest() IngestRequest {
|
||||
return IngestRequest{
|
||||
Key: "sonarr:episodefile:8123:551", Action: ActionRefresh, Kind: KindEpisode,
|
||||
Reason: ReasonImport, Series: "Blue Bloods", SeriesYear: 2010, Season: 6, Episode: 7,
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary path: the series is already in the catalogue, so Emby is asked for one
|
||||
// season and the episode is written.
|
||||
func TestImportWritesTheEpisodeFromOneSeasonLookup(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
source := &fakeEmby{episodes: []json.RawMessage{
|
||||
episodePayload("emby-ep-6", 6, 6),
|
||||
episodePayload("emby-ep-7", 6, 7),
|
||||
}}
|
||||
ingester := testIngester(st, source)
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
|
||||
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
||||
|
||||
if _, written := st.items["emby-ep-7"]; !written {
|
||||
t.Fatalf("the episode was not written: %v", st.items)
|
||||
}
|
||||
if _, extra := st.items["emby-ep-6"]; extra {
|
||||
t.Fatal("an episode nobody asked about was written")
|
||||
}
|
||||
if len(source.seasons) != 1 || source.seasons[0] != "6" {
|
||||
t.Fatalf("expected one season-scoped lookup, got %v", source.seasons)
|
||||
}
|
||||
// A series already in the catalogue costs no search at all.
|
||||
if len(source.itemQueries) != 0 {
|
||||
t.Fatalf("the catalogue was not used for the series: %v", source.itemQueries)
|
||||
}
|
||||
job := st.jobs["sonarr:episodefile:8123:551"]
|
||||
if job.State != store.IngestDone || job.Outcome != "imported" {
|
||||
t.Fatalf("unexpected outcome: %+v", job)
|
||||
}
|
||||
}
|
||||
|
||||
// The field set must be the scheduled import's own, or an event-imported title arrives
|
||||
// without the cast, streams and provider ids everything downstream reads.
|
||||
func TestLookupsAskForTheFullSyncFields(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
source := &fakeEmby{}
|
||||
ingester := testIngester(st, source)
|
||||
enqueueOne(t, ingester, IngestRequest{
|
||||
Key: "radarr:moviefile:441:import", Action: ActionRefresh, Kind: KindMovie,
|
||||
Reason: ReasonImport, Title: "Arrival", Year: 2016,
|
||||
})
|
||||
ingester.work(context.Background(), *st.jobs["radarr:moviefile:441:import"])
|
||||
|
||||
if len(source.itemQueries) != 1 {
|
||||
t.Fatalf("expected one lookup, got %d", len(source.itemQueries))
|
||||
}
|
||||
query := source.itemQueries[0]
|
||||
if query.Get("Fields") != syncFields {
|
||||
t.Fatalf("a thinner field set was requested: %q", query.Get("Fields"))
|
||||
}
|
||||
if query.Get("EnableUserData") != "false" {
|
||||
t.Fatal("the shared catalogue must never carry one viewer's user data")
|
||||
}
|
||||
}
|
||||
|
||||
// Emby not having scanned the file yet is the expected first answer, not a fault: the row
|
||||
// waits, one nudge is sent, and the backoff widens.
|
||||
func TestNotFoundDefersWithABackoffRatherThanFailing(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
source := &fakeEmby{}
|
||||
ingester := testIngester(st, source)
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
key := "sonarr:episodefile:8123:551"
|
||||
|
||||
before := time.Now().UTC()
|
||||
ingester.work(context.Background(), *st.jobs[key])
|
||||
|
||||
job := st.jobs[key]
|
||||
if job.State != store.IngestPending || job.Outcome != "not_found" {
|
||||
t.Fatalf("expected a deferral, got %+v", job)
|
||||
}
|
||||
if !job.DueAt.After(before) {
|
||||
t.Fatal("the next attempt was not scheduled into the future")
|
||||
}
|
||||
if len(source.refreshed) != 1 || source.refreshed[0] != "emby-series" {
|
||||
t.Fatalf("expected one rescan nudge at the series, got %v", source.refreshed)
|
||||
}
|
||||
if len(st.items) != 0 {
|
||||
t.Fatal("nothing should have been written")
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts are given up on eventually, because past the last step of the backoff the cause
|
||||
// is not timing and a row retrying for ever is one nobody looks at.
|
||||
func TestRepeatedFailureIsEventuallyGivenUpOn(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
source := &fakeEmby{err: errors.New("emby is not answering")}
|
||||
ingester := testIngester(st, source)
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
key := "sonarr:episodefile:8123:551"
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
ingester.work(context.Background(), *st.jobs[key])
|
||||
}
|
||||
if st.jobs[key].State != store.IngestFailed {
|
||||
t.Fatalf("expected the row to be given up on, got %+v", st.jobs[key])
|
||||
}
|
||||
if st.jobs[key].LastError == "" {
|
||||
t.Fatal("a failed row must record why")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayWidensAndSettles(t *testing.T) {
|
||||
previous := time.Duration(0)
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
delay := IngestRetryDelay(attempt)
|
||||
if delay < previous {
|
||||
t.Fatalf("the backoff narrowed at attempt %d: %s after %s", attempt, delay, previous)
|
||||
}
|
||||
previous = delay
|
||||
}
|
||||
if IngestRetryDelay(1) != time.Minute {
|
||||
t.Fatalf("the first retry should be quick, got %s", IngestRetryDelay(1))
|
||||
}
|
||||
}
|
||||
|
||||
// A delete resolves against the catalogue, not against Emby: the file is gone, and Emby is
|
||||
// the least likely thing to still be able to name it.
|
||||
func TestDeleteRemovesTheEpisodeFromTheCatalogue(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
st.episodes = []store.CreditsEpisodeRow{
|
||||
{ItemID: "emby-ep-6", SeriesID: "emby-series", Season: 6, Episode: 6},
|
||||
{ItemID: "emby-ep-7", SeriesID: "emby-series", Season: 6, Episode: 7},
|
||||
}
|
||||
source := &fakeEmby{}
|
||||
ingester := testIngester(st, source)
|
||||
request := episodeRequest()
|
||||
request.Action, request.Reason, request.Key = ActionRemove, ReasonDelete, "sonarr:episodefile:8123:delete"
|
||||
enqueueOne(t, ingester, request)
|
||||
|
||||
ingester.work(context.Background(), *st.jobs[request.Key])
|
||||
|
||||
if len(st.deleted) != 1 || st.deleted[0] != "emby-ep-7" {
|
||||
t.Fatalf("unexpected deletions: %v", st.deleted)
|
||||
}
|
||||
if len(source.itemQueries) != 0 || len(source.seasons) != 0 {
|
||||
t.Fatal("a delete must not need to ask Emby anything")
|
||||
}
|
||||
}
|
||||
|
||||
// A delete of something the catalogue never held is settled rather than retried: there is
|
||||
// nothing to remove and no later attempt could change that.
|
||||
func TestDeleteOfSomethingAbsentSettlesQuietly(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
ingester := testIngester(st, &fakeEmby{})
|
||||
request := IngestRequest{
|
||||
Key: "radarr:moviefile:9:delete", Action: ActionRemove, Kind: KindMovie,
|
||||
Reason: ReasonDelete, Title: "Never Imported", Year: 1999,
|
||||
}
|
||||
enqueueOne(t, ingester, request)
|
||||
ingester.work(context.Background(), *st.jobs[request.Key])
|
||||
|
||||
job := st.jobs[request.Key]
|
||||
if job.State != store.IngestDone || job.Outcome != "absent" {
|
||||
t.Fatalf("expected a quiet settle, got %+v", job)
|
||||
}
|
||||
if len(st.deleted) != 0 {
|
||||
t.Fatalf("something was deleted: %v", st.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// A brand-new show is the case the local index cannot answer, and it is exactly the case
|
||||
// this feature exists for. Emby is asked, and the series row is written beside its episode
|
||||
// so the episode is not a child of a show the catalogue has never heard of.
|
||||
func TestANewSeriesIsResolvedThroughEmbyAndWrittenToo(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
seriesRaw, _ := json.Marshal(map[string]any{
|
||||
"Id": "emby-series", "Name": "Blue Bloods", "Type": "Series", "ProductionYear": 2010,
|
||||
})
|
||||
source := &fakeEmby{
|
||||
items: []json.RawMessage{seriesRaw},
|
||||
episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)},
|
||||
}
|
||||
ingester := testIngester(st, source)
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
||||
|
||||
if _, written := st.items["emby-series"]; !written {
|
||||
t.Fatalf("the new series row was not written: %v", st.items)
|
||||
}
|
||||
if _, written := st.items["emby-ep-7"]; !written {
|
||||
t.Fatal("the episode was not written")
|
||||
}
|
||||
}
|
||||
|
||||
// The year separates a remake from its original where both systems know it, and the title
|
||||
// alone is the fallback because they disagree about years more often than about names.
|
||||
func TestMovieMatchingPrefersTheYearAndFallsBackToTheTitle(t *testing.T) {
|
||||
original, _ := json.Marshal(map[string]any{
|
||||
"Id": "old", "Name": "The Thing", "Type": "Movie", "ProductionYear": 1982,
|
||||
})
|
||||
remake, _ := json.Marshal(map[string]any{
|
||||
"Id": "new", "Name": "The Thing", "Type": "Movie", "ProductionYear": 2011,
|
||||
})
|
||||
items := []json.RawMessage{original, remake}
|
||||
|
||||
if _, id := pickByTitle(items, "The Thing", 2011); id != "new" {
|
||||
t.Fatalf("the year did not decide: %q", id)
|
||||
}
|
||||
if _, id := pickByTitle(items, "The Thing", 0); id != "old" {
|
||||
t.Fatalf("expected the first title match as the fallback, got %q", id)
|
||||
}
|
||||
if _, id := pickByTitle(items, "Something Else", 0); id != "" {
|
||||
t.Fatalf("an unrelated title matched: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// A repeated webhook is one row, and it never pulls the settle delay forward — the whole
|
||||
// point of the delay is that the file has finished being written.
|
||||
func TestRepeatedEnqueueIsOneRowAndKeepsTheSettleDelay(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
ingester := testIngester(st, &fakeEmby{})
|
||||
request := episodeRequest()
|
||||
|
||||
fresh, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
|
||||
if err != nil || fresh != 1 {
|
||||
t.Fatalf("first delivery: fresh=%d err=%v", fresh, err)
|
||||
}
|
||||
first := st.jobs[request.Key].DueAt
|
||||
|
||||
fresh, err = ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
|
||||
if err != nil || fresh != 0 {
|
||||
t.Fatalf("a repeat was treated as news: fresh=%d err=%v", fresh, err)
|
||||
}
|
||||
if len(st.jobs) != 1 {
|
||||
t.Fatalf("a repeat produced %d rows", len(st.jobs))
|
||||
}
|
||||
if st.jobs[request.Key].DueAt.Before(first) {
|
||||
t.Fatal("a repeat pulled the settle delay forward")
|
||||
}
|
||||
}
|
||||
|
||||
// Quiet time stands the worker down without touching the queue, which is the arrangement
|
||||
// that lets the hook accept an event at any hour.
|
||||
func TestQuietTimeStopsTheWorkerAndNotTheQueue(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
source := &fakeEmby{episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)}}
|
||||
ingester := testIngester(st, source)
|
||||
ingester.Paused = func() bool { return true }
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { ingester.Run(ctx); close(done) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
if len(st.items) != 0 {
|
||||
t.Fatal("work was done during quiet time")
|
||||
}
|
||||
if st.jobs["sonarr:episodefile:8123:551"].State != store.IngestPending {
|
||||
t.Fatal("the queued work was lost rather than deferred")
|
||||
}
|
||||
}
|
||||
|
||||
// A finished scan is the moment there is something truthful to announce, which is why the
|
||||
// hook fires from here rather than from the webhook. It must carry Emby's own names: the
|
||||
// *arr and Emby disagree about punctuation often enough that a banner built from the
|
||||
// webhook would name the same thing differently from the card underneath it.
|
||||
func TestAFinishedImportIsAnnouncedWithEmbysOwnNames(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
source := &fakeEmby{episodes: []json.RawMessage{episodeWithArtwork("emby-ep-7", 6, 7)}}
|
||||
ingester := testIngester(st, source)
|
||||
|
||||
var announced []IngestResult
|
||||
ingester.Announce = func(_ context.Context, result IngestResult) {
|
||||
announced = append(announced, result)
|
||||
}
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
||||
|
||||
if len(announced) != 1 {
|
||||
t.Fatalf("expected one announcement, got %d", len(announced))
|
||||
}
|
||||
result := announced[0]
|
||||
if result.ItemID != "emby-ep-7" || result.Name != "The Job" {
|
||||
t.Errorf("announced %q/%q, want Emby's id and episode title", result.ItemID, result.Name)
|
||||
}
|
||||
if result.SeriesName != "Blue Bloods" {
|
||||
t.Errorf("series = %q, want Emby's series name", result.SeriesName)
|
||||
}
|
||||
if result.Season != 6 || result.Episode != 7 {
|
||||
t.Errorf("position = S%02dE%02d, want S06E07", result.Season, result.Episode)
|
||||
}
|
||||
if result.ImageTag != "poster-tag" {
|
||||
t.Errorf("image tag = %q, want the poster from the stored payload", result.ImageTag)
|
||||
}
|
||||
if result.Reason != ReasonImport || result.Kind != KindEpisode {
|
||||
t.Errorf("result = %+v, want the import reason and kind carried through", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is announced for work that did not land. The banner claims the title is in the
|
||||
// catalogue, so a lookup that found nothing must stay silent and simply be retried.
|
||||
func TestNothingIsAnnouncedWhenEmbyHasNotScannedYet(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
|
||||
ingester := testIngester(st, &fakeEmby{})
|
||||
|
||||
announcements := 0
|
||||
ingester.Announce = func(context.Context, IngestResult) { announcements++ }
|
||||
enqueueOne(t, ingester, episodeRequest())
|
||||
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
|
||||
|
||||
if announcements != 0 {
|
||||
t.Fatalf("announced %d times for an episode Emby has not scanned", announcements)
|
||||
}
|
||||
}
|
||||
|
||||
func episodeWithArtwork(id string, season, episode int) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
|
||||
"SeriesName": "Blue Bloods",
|
||||
"ParentIndexNumber": season, "IndexNumber": episode,
|
||||
"ImageTags": map[string]string{"Primary": "poster-tag"},
|
||||
})
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user