Add poster image support to Matrix download listings
Some checks failed
Test action / kaas (push) Failing after 2s

- 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
This commit is contained in:
Chris Kruining 2026-04-16 16:55:52 +02:00
parent e07257e137
commit 100a218aed
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
9 changed files with 432 additions and 59 deletions

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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:

View file

@ -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)
}