Add poster image support to Matrix download listings
Some checks failed
Test action / kaas (push) Failing after 2s
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:
parent
e07257e137
commit
100a218aed
9 changed files with 432 additions and 59 deletions
|
|
@ -116,7 +116,7 @@ in {
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
observability = {
|
observability = {
|
||||||
otlp_grpc_endpoint = "http://[::1]:9062";
|
otlp_grpc_endpoint = "http://[::1]:9071";
|
||||||
service_name = "arrtrix";
|
service_name = "arrtrix";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,7 +128,7 @@ in {
|
||||||
quality_profile_id = 5;
|
quality_profile_id = 5;
|
||||||
};
|
};
|
||||||
series = {
|
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";
|
api_key = "$SONARR_APIKEY";
|
||||||
root_folder_path = "/var/media/series";
|
root_folder_path = "/var/media/series";
|
||||||
quality_profile_id = 5;
|
quality_profile_id = 5;
|
||||||
|
|
|
||||||
|
|
@ -102,40 +102,40 @@ in {
|
||||||
};
|
};
|
||||||
|
|
||||||
datasources.settings.datasources = [
|
datasources.settings.datasources = [
|
||||||
# {
|
{
|
||||||
# name = "Prometheus";
|
name = "Prometheus";
|
||||||
# uid = "prometheus";
|
uid = "prometheus";
|
||||||
# type = "prometheus";
|
type = "prometheus";
|
||||||
# url = "http://[::1]:9020";
|
url = "http://[::1]:9020";
|
||||||
# isDefault = true;
|
isDefault = true;
|
||||||
# editable = false;
|
editable = false;
|
||||||
# }
|
}
|
||||||
|
|
||||||
# {
|
{
|
||||||
# name = "Loki";
|
name = "Loki";
|
||||||
# uid = "loki";
|
uid = "loki";
|
||||||
# type = "loki";
|
type = "loki";
|
||||||
# url = "http://[::1]:9030";
|
url = "http://[::1]:9030";
|
||||||
# editable = false;
|
editable = false;
|
||||||
# }
|
}
|
||||||
|
|
||||||
# {
|
{
|
||||||
# name = "Tempo";
|
name = "Tempo";
|
||||||
# uid = "tempo";
|
uid = "tempo";
|
||||||
# type = "tempo";
|
type = "tempo";
|
||||||
# url = "http://localhost:9060";
|
url = "http://localhost:9060";
|
||||||
# editable = false;
|
editable = false;
|
||||||
# jsonData = {
|
jsonData = {
|
||||||
# nodeGraph.enabled = true;
|
nodeGraph.enabled = true;
|
||||||
# serviceMap.datasourceUid = "prometheus";
|
serviceMap.datasourceUid = "prometheus";
|
||||||
# tracesToLogsV2 = {
|
tracesToLogsV2 = {
|
||||||
# datasourceUid = "loki";
|
datasourceUid = "loki";
|
||||||
# filterByTraceID = true;
|
filterByTraceID = true;
|
||||||
# spanStartTimeShift = "-1h";
|
spanStartTimeShift = "-1h";
|
||||||
# spanEndTimeShift = "1h";
|
spanEndTimeShift = "1h";
|
||||||
# };
|
};
|
||||||
# };
|
};
|
||||||
# }
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||||
|
|
@ -21,6 +24,7 @@ type Client interface {
|
||||||
Add(context.Context, SearchResult) (*ManagedItem, error)
|
Add(context.Context, SearchResult) (*ManagedItem, error)
|
||||||
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
|
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
|
||||||
Delete(context.Context, int64) error
|
Delete(context.Context, int64) error
|
||||||
|
FetchImage(context.Context, ManagedItem) (*MediaAsset, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
|
|
@ -37,6 +41,13 @@ type ManagedItem struct {
|
||||||
Year int
|
Year int
|
||||||
Monitored bool
|
Monitored bool
|
||||||
Path string
|
Path string
|
||||||
|
ImageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAsset struct {
|
||||||
|
Data []byte
|
||||||
|
FileName string
|
||||||
|
MimeType string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RadarrConfig struct {
|
type RadarrConfig struct {
|
||||||
|
|
@ -65,6 +76,12 @@ type httpClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mediaImage struct {
|
||||||
|
CoverType string `json:"coverType"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
RemoteURL string `json:"remoteUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RadarrConfig) ApplyDefaults() {
|
func (c *RadarrConfig) ApplyDefaults() {
|
||||||
if c.MinimumAvailability == "" {
|
if c.MinimumAvailability == "" {
|
||||||
c.MinimumAvailability = "released"
|
c.MinimumAvailability = "released"
|
||||||
|
|
@ -209,3 +226,138 @@ func FormatSearchResult(result SearchResult) string {
|
||||||
}
|
}
|
||||||
return result.Title
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
80
packages/arrtrix/pkg/arrclient/client_test.go
Normal file
80
packages/arrtrix/pkg/arrclient/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ type radarrMovie struct {
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
Monitored bool `json:"monitored"`
|
Monitored bool `json:"monitored"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Images []mediaImage `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
|
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
|
||||||
|
|
@ -81,6 +82,7 @@ func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, e
|
||||||
Year: movie.Year,
|
Year: movie.Year,
|
||||||
Monitored: movie.Monitored,
|
Monitored: movie.Monitored,
|
||||||
Path: movie.Path,
|
Path: movie.Path,
|
||||||
|
ImageURL: c.http.imageURL(movie.Images),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|
@ -111,6 +113,7 @@ func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedIt
|
||||||
Year: response.Year,
|
Year: response.Year,
|
||||||
Monitored: response.Monitored,
|
Monitored: response.Monitored,
|
||||||
Path: response.Path,
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
}
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +137,7 @@ func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored boo
|
||||||
Year: response.Year,
|
Year: response.Year,
|
||||||
Monitored: response.Monitored,
|
Monitored: response.Monitored,
|
||||||
Path: response.Path,
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
}
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +149,10 @@ func (c *RadarrClient) Delete(ctx context.Context, id int64) error {
|
||||||
}, nil, nil)
|
}, 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) {
|
func PickSingleResult(results []SearchResult, query string) (SearchResult, error) {
|
||||||
switch len(results) {
|
switch len(results) {
|
||||||
case 0:
|
case 0:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ type sonarrSeries struct {
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
Monitored bool `json:"monitored"`
|
Monitored bool `json:"monitored"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Images []mediaImage `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
|
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
|
||||||
|
|
@ -80,6 +81,7 @@ func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, e
|
||||||
Year: series.Year,
|
Year: series.Year,
|
||||||
Monitored: series.Monitored,
|
Monitored: series.Monitored,
|
||||||
Path: series.Path,
|
Path: series.Path,
|
||||||
|
ImageURL: c.http.imageURL(series.Images),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|
@ -114,6 +116,7 @@ func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedIt
|
||||||
Year: response.Year,
|
Year: response.Year,
|
||||||
Monitored: response.Monitored,
|
Monitored: response.Monitored,
|
||||||
Path: response.Path,
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
}
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +140,7 @@ func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored boo
|
||||||
Year: response.Year,
|
Year: response.Year,
|
||||||
Monitored: response.Monitored,
|
Monitored: response.Monitored,
|
||||||
Path: response.Path,
|
Path: response.Path,
|
||||||
|
ImageURL: c.http.imageURL(response.Images),
|
||||||
}
|
}
|
||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
@ -147,3 +151,7 @@ func (c *SonarrClient) Delete(ctx context.Context, id int64) error {
|
||||||
"addImportListExclusion": {"false"},
|
"addImportListExclusion": {"false"},
|
||||||
}, nil, nil)
|
}, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *SonarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
|
||||||
|
return c.http.FetchImage(ctx, item)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,16 +73,22 @@ func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.C
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder strings.Builder
|
count := len(items)
|
||||||
builder.WriteString(fmt.Sprintf("Tracked %s:\n", contentType.Label()))
|
if count > 12 {
|
||||||
|
count = 12
|
||||||
|
}
|
||||||
|
ctx.Reply("Tracked %s (showing %d of %d):", contentType.Label(), count, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if i == 10 {
|
if i == 12 {
|
||||||
builder.WriteString("…\n")
|
|
||||||
break
|
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) {
|
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 {
|
func formatManagedItem(item arrclient.ManagedItem) string {
|
||||||
if item.Year != 0 {
|
return arrclient.FormatManagedItem(item)
|
||||||
return fmt.Sprintf("%s (%d)", item.Title, item.Year)
|
|
||||||
}
|
|
||||||
return item.Title
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseEnabled(value string) (bool, error) {
|
func parseEnabled(value string) (bool, error) {
|
||||||
|
|
@ -220,3 +223,38 @@ func parseEnabled(value string) (bool, error) {
|
||||||
func userIDString(userID id.UserID) string {
|
func userIDString(userID id.UserID) string {
|
||||||
return 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 "🚫"
|
||||||
|
}
|
||||||
|
|
|
||||||
44
packages/arrtrix/pkg/matrixcmd/download_test.go
Normal file
44
packages/arrtrix/pkg/matrixcmd/download_test.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"maunium.net/go/mautrix/format"
|
"maunium.net/go/mautrix/format"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
|
||||||
"sneeuwvlok/packages/arrtrix/pkg/observability"
|
"sneeuwvlok/packages/arrtrix/pkg/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -221,7 +222,49 @@ func (c *Context) Reply(message string, args ...any) {
|
||||||
|
|
||||||
content := format.RenderMarkdown(message, true, false)
|
content := format.RenderMarkdown(message, true, false)
|
||||||
content.MsgType = event.MsgNotice
|
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")
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue