From 100a218aed5ba11ce4ff77b5c6d162bfe221955c Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Thu, 16 Apr 2026 16:55:52 +0200 Subject: [PATCH] Add poster image support to Matrix download listings - Fetch and display poster images for tracked items in Matrix - Show monitored/unmonitored icons in listings - Limit displayed items to 12, with count and overflow message - Add tests for image fetching and formatting - Enable Grafana datasources - Fix Sonarr/Radarr URL config bug --- .../services/communication/matrix/default.nix | 4 +- .../observability/grafana/default.nix | 64 ++++---- packages/arrtrix/pkg/arrclient/client.go | 152 ++++++++++++++++++ packages/arrtrix/pkg/arrclient/client_test.go | 80 +++++++++ packages/arrtrix/pkg/arrclient/radarr.go | 22 ++- packages/arrtrix/pkg/arrclient/sonarr.go | 22 ++- packages/arrtrix/pkg/matrixcmd/download.go | 58 +++++-- .../arrtrix/pkg/matrixcmd/download_test.go | 44 +++++ packages/arrtrix/pkg/matrixcmd/processor.go | 45 +++++- 9 files changed, 432 insertions(+), 59 deletions(-) create mode 100644 packages/arrtrix/pkg/arrclient/client_test.go create mode 100644 packages/arrtrix/pkg/matrixcmd/download_test.go diff --git a/modules/nixos/services/communication/matrix/default.nix b/modules/nixos/services/communication/matrix/default.nix index 4661f2a..9a7d53c 100644 --- a/modules/nixos/services/communication/matrix/default.nix +++ b/modules/nixos/services/communication/matrix/default.nix @@ -116,7 +116,7 @@ in { settings = { observability = { - otlp_grpc_endpoint = "http://[::1]:9062"; + otlp_grpc_endpoint = "http://[::1]:9071"; service_name = "arrtrix"; }; @@ -128,7 +128,7 @@ in { quality_profile_id = 5; }; series = { - url = "http://[::1]:${toString config.services.radarr.settings.server.port}"; + url = "http://[::1]:${toString config.services.sonarr.settings.server.port}"; api_key = "$SONARR_APIKEY"; root_folder_path = "/var/media/series"; quality_profile_id = 5; diff --git a/modules/nixos/services/observability/grafana/default.nix b/modules/nixos/services/observability/grafana/default.nix index 363033c..879ecdc 100644 --- a/modules/nixos/services/observability/grafana/default.nix +++ b/modules/nixos/services/observability/grafana/default.nix @@ -102,40 +102,40 @@ in { }; datasources.settings.datasources = [ - # { - # name = "Prometheus"; - # uid = "prometheus"; - # type = "prometheus"; - # url = "http://[::1]:9020"; - # isDefault = true; - # editable = false; - # } + { + name = "Prometheus"; + uid = "prometheus"; + type = "prometheus"; + url = "http://[::1]:9020"; + isDefault = true; + editable = false; + } - # { - # name = "Loki"; - # uid = "loki"; - # type = "loki"; - # url = "http://[::1]:9030"; - # editable = false; - # } + { + name = "Loki"; + uid = "loki"; + type = "loki"; + url = "http://[::1]:9030"; + editable = false; + } - # { - # name = "Tempo"; - # uid = "tempo"; - # type = "tempo"; - # url = "http://localhost:9060"; - # editable = false; - # jsonData = { - # nodeGraph.enabled = true; - # serviceMap.datasourceUid = "prometheus"; - # tracesToLogsV2 = { - # datasourceUid = "loki"; - # filterByTraceID = true; - # spanStartTimeShift = "-1h"; - # spanEndTimeShift = "1h"; - # }; - # }; - # } + { + name = "Tempo"; + uid = "tempo"; + type = "tempo"; + url = "http://localhost:9060"; + editable = false; + jsonData = { + nodeGraph.enabled = true; + serviceMap.datasourceUid = "prometheus"; + tracesToLogsV2 = { + datasourceUid = "loki"; + filterByTraceID = true; + spanStartTimeShift = "-1h"; + spanEndTimeShift = "1h"; + }; + }; + } ]; }; }; diff --git a/packages/arrtrix/pkg/arrclient/client.go b/packages/arrtrix/pkg/arrclient/client.go index 558dc52..fc7fb53 100644 --- a/packages/arrtrix/pkg/arrclient/client.go +++ b/packages/arrtrix/pkg/arrclient/client.go @@ -5,10 +5,13 @@ import ( "context" "encoding/json" "fmt" + "html" "io" + "mime" "net/http" "net/url" "path" + "path/filepath" "strings" "sneeuwvlok/packages/arrtrix/pkg/arr" @@ -21,6 +24,7 @@ type Client interface { Add(context.Context, SearchResult) (*ManagedItem, error) SetMonitored(context.Context, int64, bool) (*ManagedItem, error) Delete(context.Context, int64) error + FetchImage(context.Context, ManagedItem) (*MediaAsset, error) } type SearchResult struct { @@ -37,6 +41,13 @@ type ManagedItem struct { Year int Monitored bool Path string + ImageURL string +} + +type MediaAsset struct { + Data []byte + FileName string + MimeType string } type RadarrConfig struct { @@ -65,6 +76,12 @@ type httpClient struct { httpClient *http.Client } +type mediaImage struct { + CoverType string `json:"coverType"` + URL string `json:"url"` + RemoteURL string `json:"remoteUrl"` +} + func (c *RadarrConfig) ApplyDefaults() { if c.MinimumAvailability == "" { c.MinimumAvailability = "released" @@ -209,3 +226,138 @@ func FormatSearchResult(result SearchResult) string { } return result.Title } + +func FormatManagedItem(item ManagedItem) string { + if item.Year != 0 { + return fmt.Sprintf("%s (%d)", item.Title, item.Year) + } + return item.Title +} + +func EscapeText(text string) string { + return html.EscapeString(text) +} + +func (c *httpClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) { + imageURL := strings.TrimSpace(item.ImageURL) + if imageURL == "" { + return nil, nil + } + + endpoint, err := url.Parse(imageURL) + if err != nil { + return nil, fmt.Errorf("parse image URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(endpoint).String(), nil) + if err != nil { + return nil, err + } + if sameHost(req.URL, c.baseURL) { + req.Header.Set("X-Api-Key", c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("GET %s returned %d: %s", req.URL.String(), resp.StatusCode, strings.TrimSpace(string(data))) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, err + } + + mimeType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if idx := strings.Index(mimeType, ";"); idx >= 0 { + mimeType = strings.TrimSpace(mimeType[:idx]) + } + if mimeType == "" { + mimeType = http.DetectContentType(data) + } + + return &MediaAsset{ + Data: data, + FileName: imageFileName(item, endpoint, mimeType), + MimeType: mimeType, + }, nil +} + +func (c *httpClient) imageURL(images []mediaImage) string { + for _, coverType := range []string{"poster", "cover", "fanart"} { + for _, image := range images { + if !strings.EqualFold(image.CoverType, coverType) { + continue + } + if resolved := c.resolveMediaURL(image); resolved != "" { + return resolved + } + } + } + return "" +} + +func (c *httpClient) resolveMediaURL(image mediaImage) string { + switch { + case strings.TrimSpace(image.URL) != "": + ref, err := url.Parse(strings.TrimSpace(image.URL)) + if err != nil { + return "" + } + return c.baseURL.ResolveReference(ref).String() + case strings.TrimSpace(image.RemoteURL) != "": + return strings.TrimSpace(image.RemoteURL) + default: + return "" + } +} + +func imageFileName(item ManagedItem, endpoint *url.URL, mimeType string) string { + baseName := sanitizeFileName(strings.TrimSpace(item.Title)) + if baseName == "" { + baseName = fmt.Sprintf("arrtrix-%d", item.ID) + } + + ext := strings.TrimSpace(filepath.Ext(endpoint.Path)) + if ext == "" && mimeType != "" { + if extensions, err := mime.ExtensionsByType(mimeType); err == nil && len(extensions) > 0 { + ext = extensions[0] + } + } + if ext == "" { + ext = ".jpg" + } + if item.ID != 0 { + return fmt.Sprintf("%s-%d%s", baseName, item.ID, ext) + } + return baseName + ext +} + +func sanitizeFileName(value string) string { + replacer := strings.NewReplacer( + "<", "", + ">", "", + ":", "", + "\"", "", + "/", "-", + "\\", "-", + "|", "-", + "?", "", + "*", "", + ) + value = replacer.Replace(value) + value = strings.Join(strings.Fields(value), "-") + return strings.Trim(value, ".- ") +} + +func sameHost(left, right *url.URL) bool { + if left == nil || right == nil { + return false + } + return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host) +} diff --git a/packages/arrtrix/pkg/arrclient/client_test.go b/packages/arrtrix/pkg/arrclient/client_test.go new file mode 100644 index 0000000..ecce6c3 --- /dev/null +++ b/packages/arrtrix/pkg/arrclient/client_test.go @@ -0,0 +1,80 @@ +package arrclient + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestImageURLPrefersPosterAndResolvesRelativePath(t *testing.T) { + baseURL, err := url.Parse("https://radarr.example") + if err != nil { + t.Fatalf("failed to parse base URL: %v", err) + } + + client := &httpClient{baseURL: baseURL} + imageURL := client.imageURL([]mediaImage{ + {CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"}, + {CoverType: "poster", URL: "/MediaCover/1/poster.jpg"}, + }) + if imageURL != "https://radarr.example/MediaCover/1/poster.jpg" { + t.Fatalf("unexpected image URL %q", imageURL) + } +} + +func TestImageURLFallsBackToRemoteURL(t *testing.T) { + baseURL, err := url.Parse("https://sonarr.example") + if err != nil { + t.Fatalf("failed to parse base URL: %v", err) + } + + client := &httpClient{baseURL: baseURL} + imageURL := client.imageURL([]mediaImage{ + {CoverType: "poster", RemoteURL: "https://images.example/poster.jpg"}, + }) + if imageURL != "https://images.example/poster.jpg" { + t.Fatalf("unexpected remote image URL %q", imageURL) + } +} + +func TestFetchImageUsesAPIKeyForSameHost(t *testing.T) { + headers := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Get("X-Api-Key") + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("jpeg-bytes")) + })) + defer server.Close() + + client, err := newHTTPClient(server.URL, "secret") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + asset, err := client.FetchImage(context.Background(), ManagedItem{ + ID: 42, + Title: "Dune Part Two", + ImageURL: server.URL + "/MediaCover/42/poster.jpg", + }) + if err != nil { + t.Fatalf("failed to fetch image: %v", err) + } + if asset == nil { + t.Fatal("expected media asset") + } + if got := <-headers; got != "secret" { + t.Fatalf("expected API key header, got %q", got) + } + if got := string(asset.Data); got != "jpeg-bytes" { + t.Fatalf("unexpected media bytes %q", got) + } + if asset.MimeType != "image/jpeg" { + t.Fatalf("unexpected mime type %q", asset.MimeType) + } + if !strings.HasPrefix(asset.FileName, "Dune-Part-Two-42") || !strings.HasSuffix(asset.FileName, ".jpg") { + t.Fatalf("unexpected filename %q", asset.FileName) + } +} diff --git a/packages/arrtrix/pkg/arrclient/radarr.go b/packages/arrtrix/pkg/arrclient/radarr.go index 21ac1fd..e214ce3 100644 --- a/packages/arrtrix/pkg/arrclient/radarr.go +++ b/packages/arrtrix/pkg/arrclient/radarr.go @@ -17,13 +17,14 @@ type RadarrClient struct { } type radarrMovie struct { - ID int64 `json:"id"` - Title string `json:"title"` - Year int `json:"year"` - TMDBID int64 `json:"tmdbId"` - Overview string `json:"overview"` - Monitored bool `json:"monitored"` - Path string `json:"path"` + ID int64 `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + TMDBID int64 `json:"tmdbId"` + Overview string `json:"overview"` + Monitored bool `json:"monitored"` + Path string `json:"path"` + Images []mediaImage `json:"images"` } func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) { @@ -81,6 +82,7 @@ func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, e Year: movie.Year, Monitored: movie.Monitored, Path: movie.Path, + ImageURL: c.http.imageURL(movie.Images), }) } return items, nil @@ -111,6 +113,7 @@ func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedIt Year: response.Year, Monitored: response.Monitored, Path: response.Path, + ImageURL: c.http.imageURL(response.Images), } return &item, nil } @@ -134,6 +137,7 @@ func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored boo Year: response.Year, Monitored: response.Monitored, Path: response.Path, + ImageURL: c.http.imageURL(response.Images), } return &item, nil } @@ -145,6 +149,10 @@ func (c *RadarrClient) Delete(ctx context.Context, id int64) error { }, nil, nil) } +func (c *RadarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) { + return c.http.FetchImage(ctx, item) +} + func PickSingleResult(results []SearchResult, query string) (SearchResult, error) { switch len(results) { case 0: diff --git a/packages/arrtrix/pkg/arrclient/sonarr.go b/packages/arrtrix/pkg/arrclient/sonarr.go index 9b0691b..caa6cec 100644 --- a/packages/arrtrix/pkg/arrclient/sonarr.go +++ b/packages/arrtrix/pkg/arrclient/sonarr.go @@ -16,13 +16,14 @@ type SonarrClient struct { } type sonarrSeries struct { - ID int64 `json:"id"` - Title string `json:"title"` - Year int `json:"year"` - TVDBID int64 `json:"tvdbId"` - Overview string `json:"overview"` - Monitored bool `json:"monitored"` - Path string `json:"path"` + ID int64 `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + TVDBID int64 `json:"tvdbId"` + Overview string `json:"overview"` + Monitored bool `json:"monitored"` + Path string `json:"path"` + Images []mediaImage `json:"images"` } func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) { @@ -80,6 +81,7 @@ func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, e Year: series.Year, Monitored: series.Monitored, Path: series.Path, + ImageURL: c.http.imageURL(series.Images), }) } return items, nil @@ -114,6 +116,7 @@ func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedIt Year: response.Year, Monitored: response.Monitored, Path: response.Path, + ImageURL: c.http.imageURL(response.Images), } return &item, nil } @@ -137,6 +140,7 @@ func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored boo Year: response.Year, Monitored: response.Monitored, Path: response.Path, + ImageURL: c.http.imageURL(response.Images), } return &item, nil } @@ -147,3 +151,7 @@ func (c *SonarrClient) Delete(ctx context.Context, id int64) error { "addImportListExclusion": {"false"}, }, nil, nil) } + +func (c *SonarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) { + return c.http.FetchImage(ctx, item) +} diff --git a/packages/arrtrix/pkg/matrixcmd/download.go b/packages/arrtrix/pkg/matrixcmd/download.go index 6d27a1a..23414b1 100644 --- a/packages/arrtrix/pkg/matrixcmd/download.go +++ b/packages/arrtrix/pkg/matrixcmd/download.go @@ -73,16 +73,22 @@ func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.C return } - var builder strings.Builder - builder.WriteString(fmt.Sprintf("Tracked %s:\n", contentType.Label())) + count := len(items) + if count > 12 { + count = 12 + } + ctx.Reply("Tracked %s (showing %d of %d):", contentType.Label(), count, len(items)) for i, item := range items { - if i == 10 { - builder.WriteString("…\n") + if i == 12 { break } - builder.WriteString(fmt.Sprintf("- `%d` %s β€” monitored=%t\n", item.ID, formatManagedItem(item), item.Monitored)) + if err := replyWithManagedItem(ctx, client, item); err != nil { + ctx.Log.Err(err).Int64("item_id", item.ID).Str("content_type", contentType.Label()).Msg("Failed to send Matrix-native image for download listing") + } + } + if len(items) > 12 { + ctx.Reply("…and %d more.", len(items)-12) } - ctx.Reply(builder.String()) } func handleDownloadSearch(ctx *Context, client arrclient.Client, contentType arr.ContentType) { @@ -200,10 +206,7 @@ func replyWithSearchResults(ctx *Context, contentType arr.ContentType, query str } func formatManagedItem(item arrclient.ManagedItem) string { - if item.Year != 0 { - return fmt.Sprintf("%s (%d)", item.Title, item.Year) - } - return item.Title + return arrclient.FormatManagedItem(item) } func parseEnabled(value string) (bool, error) { @@ -220,3 +223,38 @@ func parseEnabled(value string) (bool, error) { func userIDString(userID id.UserID) string { return userID.String() } + +func replyWithManagedItem(ctx *Context, client arrclient.Client, item arrclient.ManagedItem) error { + details := formatDownloadListCaption(item) + if item.ImageURL != "" { + asset, err := client.FetchImage(ctx.Ctx, item) + if err != nil { + ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to fetch poster for Matrix listing") + } else if asset != nil { + if err := ctx.SendImage(asset, details); err != nil { + ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to upload poster for Matrix listing") + } else { + return nil + } + } else { + ctx.Log.Debug().Int64("item_id", item.ID).Msg("Poster was empty for Matrix listing") + } + } + ctx.Reply(details) + return nil +} + +func formatDownloadListCaption(item arrclient.ManagedItem) string { + return fmt.Sprintf("%s %s", monitoredIcon(item.Monitored), arrclient.FormatManagedItem(item)) +} + +func formatDownloadListFallbackCard(item arrclient.ManagedItem) string { + return formatDownloadListCaption(item) +} + +func monitoredIcon(monitored bool) string { + if monitored { + return "πŸ‘" + } + return "🚫" +} diff --git a/packages/arrtrix/pkg/matrixcmd/download_test.go b/packages/arrtrix/pkg/matrixcmd/download_test.go new file mode 100644 index 0000000..19b93b9 --- /dev/null +++ b/packages/arrtrix/pkg/matrixcmd/download_test.go @@ -0,0 +1,44 @@ +package matrixcmd + +import ( + "testing" + + "sneeuwvlok/packages/arrtrix/pkg/arrclient" +) + +func TestFormatDownloadListFallbackCardUsesMonitoredIcon(t *testing.T) { + item := arrclient.ManagedItem{ + ID: 1, + Title: "Severance", + Year: 2022, + Monitored: true, + } + + fallback := formatDownloadListFallbackCard(item) + if fallback != "πŸ‘ Severance (2022)" { + t.Fatalf("unexpected monitored fallback %q", fallback) + } +} + +func TestFormatDownloadListFallbackCardUsesUnmonitoredIcon(t *testing.T) { + item := arrclient.ManagedItem{ + ID: 7, + Title: "Andor", + Year: 2022, + Monitored: false, + } + + fallback := formatDownloadListFallbackCard(item) + if fallback != "🚫 Andor (2022)" { + t.Fatalf("unexpected unmonitored fallback %q", fallback) + } +} + +func TestMonitoredIcon(t *testing.T) { + if monitoredIcon(true) != "πŸ‘" { + t.Fatalf("expected monitored icon, got %q", monitoredIcon(true)) + } + if monitoredIcon(false) != "🚫" { + t.Fatalf("expected unmonitored icon, got %q", monitoredIcon(false)) + } +} diff --git a/packages/arrtrix/pkg/matrixcmd/processor.go b/packages/arrtrix/pkg/matrixcmd/processor.go index 78915ea..e9d3980 100644 --- a/packages/arrtrix/pkg/matrixcmd/processor.go +++ b/packages/arrtrix/pkg/matrixcmd/processor.go @@ -18,6 +18,7 @@ import ( "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" + "sneeuwvlok/packages/arrtrix/pkg/arrclient" "sneeuwvlok/packages/arrtrix/pkg/observability" ) @@ -221,7 +222,49 @@ func (c *Context) Reply(message string, args ...any) { content := format.RenderMarkdown(message, true, false) content.MsgType = event.MsgNotice - if _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil { + if err := c.sendNotice(&content); err != nil { c.Log.Err(err).Msg("Failed to reply to Matrix room command") } } + +func (c *Context) ReplyFormatted(body, formattedBody string) { + content := &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body, + Format: event.FormatHTML, + FormattedBody: formattedBody, + } + if err := c.sendNotice(content); err != nil { + c.Log.Err(err).Msg("Failed to reply to Matrix room command") + } +} + +func (c *Context) SendImage(asset *arrclient.MediaAsset, body string) error { + if asset == nil || len(asset.Data) == 0 { + return nil + } + + mxcURL, file, err := c.Bot.UploadMedia(c.Ctx, c.OrigRoomID, asset.Data, asset.FileName, asset.MimeType) + if err != nil { + return err + } + + content := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: body, + FileName: asset.FileName, + URL: mxcURL, + File: file, + Info: &event.FileInfo{ + MimeType: asset.MimeType, + Size: len(asset.Data), + }, + } + _, err = c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + return err +} + +func (c *Context) sendNotice(content *event.MessageEventContent) error { + _, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil) + return err +}