Change observability service ports and add Arrtrix content management
- Update ports for Alloy, Grafana, Loki, Prometheus, Promtail, Tempo, and Uptime Kuma to new ranges - Add Arrtrix content management commands and subscriptions - Implement Radarr and Sonarr client logic for movie and series management - Add matrix commands for download and subscription management - Add subscription repository with database schema and logic - Update Arrtrix config and example config for content section - Update help text and command processor to include new commands - Update vendor hash for Arrtrix package
This commit is contained in:
parent
9b93f017b6
commit
e26e25b566
24 changed files with 1340 additions and 82 deletions
222
packages/arrtrix/pkg/matrixcmd/download.go
Normal file
222
packages/arrtrix/pkg/matrixcmd/download.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
package matrixcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
|
||||
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
|
||||
)
|
||||
|
||||
type commandServiceProvider interface {
|
||||
ContentClient(arr.ContentType) (arrclient.Client, bool)
|
||||
Subscriptions() *subscriptions.Repository
|
||||
}
|
||||
|
||||
func NewDownloadHandler() Handler {
|
||||
return NewHandler(Meta{
|
||||
Name: "download",
|
||||
Description: "Manage monitored movies and series in Arr.",
|
||||
Usage: "<list|search|add|monitor|remove> <movies|series> [...]",
|
||||
}, func(ctx *Context) {
|
||||
if len(ctx.Args) < 2 {
|
||||
ctx.Reply("Usage: `download <list|search|add|monitor|remove> <movies|series> [...]`")
|
||||
return
|
||||
}
|
||||
|
||||
contentType, err := arr.ParseContentType(ctx.Args[1])
|
||||
if err != nil {
|
||||
ctx.Reply(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := contentClient(ctx, contentType)
|
||||
if !ok {
|
||||
ctx.Reply("No %s client is configured yet.", contentType.Label())
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(ctx.Args[0]) {
|
||||
case "list":
|
||||
handleDownloadList(ctx, client, contentType)
|
||||
case "search":
|
||||
handleDownloadSearch(ctx, client, contentType)
|
||||
case "add":
|
||||
handleDownloadAdd(ctx, client, contentType)
|
||||
case "monitor":
|
||||
handleDownloadMonitor(ctx, client, contentType)
|
||||
case "remove":
|
||||
handleDownloadRemove(ctx, client, contentType)
|
||||
default:
|
||||
ctx.Reply("Unknown download subcommand `%s`.", ctx.Args[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||
items, err := client.List(ctx.Ctx, query)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to list %s: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if query == "" {
|
||||
ctx.Reply("No monitored %s are currently tracked.", contentType.Label())
|
||||
} else {
|
||||
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("Tracked %s:\n", contentType.Label()))
|
||||
for i, item := range items {
|
||||
if i == 10 {
|
||||
builder.WriteString("…\n")
|
||||
break
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("- `%d` %s — monitored=%t\n", item.ID, formatManagedItem(item), item.Monitored))
|
||||
}
|
||||
ctx.Reply(builder.String())
|
||||
}
|
||||
|
||||
func handleDownloadSearch(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||
if query == "" {
|
||||
ctx.Reply("Usage: `download search %s <query>`", contentType.Label())
|
||||
return
|
||||
}
|
||||
results, err := client.Search(ctx.Ctx, query)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
replyWithSearchResults(ctx, contentType, query, results)
|
||||
}
|
||||
|
||||
func handleDownloadAdd(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
|
||||
if query == "" {
|
||||
ctx.Reply("Usage: `download add %s <query>`", contentType.Label())
|
||||
return
|
||||
}
|
||||
results, err := client.Search(ctx.Ctx, query)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
result, err := arrclient.PickSingleResult(results, query)
|
||||
if err != nil {
|
||||
replyWithSearchResults(ctx, contentType, query, results)
|
||||
return
|
||||
}
|
||||
item, err := client.Add(ctx.Ctx, result)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to add %s: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
ctx.Reply("Added %s to %s with id `%d`.", formatManagedItem(*item), contentType.Label(), item.ID)
|
||||
}
|
||||
|
||||
func handleDownloadMonitor(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||
if len(ctx.Args) < 4 {
|
||||
ctx.Reply("Usage: `download monitor %s <id> <on|off>`", contentType.Label())
|
||||
return
|
||||
}
|
||||
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
|
||||
if err != nil {
|
||||
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
|
||||
return
|
||||
}
|
||||
|
||||
state, err := parseEnabled(ctx.Args[3])
|
||||
if err != nil {
|
||||
ctx.Reply(err.Error())
|
||||
return
|
||||
}
|
||||
item, err := client.SetMonitored(ctx.Ctx, itemID, state)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to update %s monitoring: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
ctx.Reply("%s is now monitored=%t.", formatManagedItem(*item), item.Monitored)
|
||||
}
|
||||
|
||||
func handleDownloadRemove(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
|
||||
if len(ctx.Args) < 3 {
|
||||
ctx.Reply("Usage: `download remove %s <id>`", contentType.Label())
|
||||
return
|
||||
}
|
||||
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
|
||||
if err != nil {
|
||||
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
|
||||
return
|
||||
}
|
||||
if err = client.Delete(ctx.Ctx, itemID); err != nil {
|
||||
ctx.Reply("Failed to remove %s: %v", contentType.Label(), err)
|
||||
return
|
||||
}
|
||||
ctx.Reply("Removed `%d` from %s.", itemID, contentType.Label())
|
||||
}
|
||||
|
||||
func contentClient(ctx *Context, contentType arr.ContentType) (arrclient.Client, bool) {
|
||||
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return provider.ContentClient(contentType)
|
||||
}
|
||||
|
||||
func contentSubscriptions(ctx *Context) *subscriptions.Repository {
|
||||
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return provider.Subscriptions()
|
||||
}
|
||||
|
||||
func replyWithSearchResults(ctx *Context, contentType arr.ContentType, query string, results []arrclient.SearchResult) {
|
||||
if len(results) == 0 {
|
||||
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
|
||||
return
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("Search results for `%s` in %s:\n", query, contentType.Label()))
|
||||
for i, result := range results {
|
||||
if i == 8 {
|
||||
builder.WriteString("…\n")
|
||||
break
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("- `%d` %s\n", result.LookupID, arrclient.FormatSearchResult(result)))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("\nRefine the query and rerun `download add %s <query>` until only one match remains.", contentType.Label()))
|
||||
ctx.Reply(builder.String())
|
||||
}
|
||||
|
||||
func formatManagedItem(item arrclient.ManagedItem) string {
|
||||
if item.Year != 0 {
|
||||
return fmt.Sprintf("%s (%d)", item.Title, item.Year)
|
||||
}
|
||||
return item.Title
|
||||
}
|
||||
|
||||
func parseEnabled(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "on", "true", "yes", "enabled":
|
||||
return true, nil
|
||||
case "off", "false", "no", "disabled":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("expected `on` or `off`, got `%s`", value)
|
||||
}
|
||||
}
|
||||
|
||||
func userIDString(userID id.UserID) string {
|
||||
return userID.String()
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ func TestFormatHelpManagementRoom(t *testing.T) {
|
|||
|
||||
for _, fragment := range []string{
|
||||
"prefixing commands with `!arr` is not required",
|
||||
"**download** <list|search|add|monitor|remove> <movies|series> [...] - Manage monitored movies and series in Arr.",
|
||||
"**help** - Show this help message.",
|
||||
"**subscriptions** <list|enable|disable> [movies|series] [event-type|all] - Manage notification subscriptions by content type and event type.",
|
||||
"Extra help text.",
|
||||
} {
|
||||
if !strings.Contains(out, fragment) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomText
|
|||
alias: make(map[string]string),
|
||||
}
|
||||
proc.Add(NewHelpHandler(proc))
|
||||
proc.Add(NewDownloadHandler())
|
||||
proc.Add(NewSubscriptionsHandler())
|
||||
return proc
|
||||
}
|
||||
|
||||
|
|
|
|||
107
packages/arrtrix/pkg/matrixcmd/subscriptions.go
Normal file
107
packages/arrtrix/pkg/matrixcmd/subscriptions.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package matrixcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"sneeuwvlok/packages/arrtrix/pkg/arr"
|
||||
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
|
||||
)
|
||||
|
||||
func NewSubscriptionsHandler() Handler {
|
||||
return NewHandler(Meta{
|
||||
Name: "subscriptions",
|
||||
Aliases: []string{"subscription", "notify"},
|
||||
Description: "Manage notification subscriptions by content type and event type.",
|
||||
Usage: "<list|enable|disable> [movies|series] [event-type|all]",
|
||||
}, func(ctx *Context) {
|
||||
repo := contentSubscriptions(ctx)
|
||||
if repo == nil {
|
||||
ctx.Reply("Subscription storage is not available.")
|
||||
return
|
||||
}
|
||||
if len(ctx.Args) == 0 || strings.EqualFold(ctx.Args[0], "list") {
|
||||
handleSubscriptionList(ctx, repo)
|
||||
return
|
||||
}
|
||||
if len(ctx.Args) < 3 {
|
||||
ctx.Reply("Usage: `subscriptions <enable|disable> <movies|series> <event-type|all>`")
|
||||
return
|
||||
}
|
||||
|
||||
contentType, err := arr.ParseContentType(ctx.Args[1])
|
||||
if err != nil {
|
||||
ctx.Reply(err.Error())
|
||||
return
|
||||
}
|
||||
eventType, err := arr.ParseEventType(contentType, ctx.Args[2])
|
||||
if err != nil {
|
||||
ctx.Reply(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(ctx.Args[0]) {
|
||||
case "enable":
|
||||
handleSubscriptionSet(ctx, repo, contentType, eventType, true)
|
||||
case "disable":
|
||||
handleSubscriptionSet(ctx, repo, contentType, eventType, false)
|
||||
default:
|
||||
ctx.Reply("Unknown subscriptions subcommand `%s`.", ctx.Args[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleSubscriptionList(ctx *Context, repo subscriptionRepo) {
|
||||
preferences, err := repo.List(ctx.Ctx, ctx.User.MXID)
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to load subscriptions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Current notification subscriptions:\n")
|
||||
for _, contentType := range arr.SupportedContentTypes() {
|
||||
builder.WriteString(fmt.Sprintf("\n**%s**\n", strings.Title(contentType.Label())))
|
||||
for _, eventType := range arr.SupportedEventTypes(contentType) {
|
||||
enabled := findPreference(preferences, contentType, eventType)
|
||||
builder.WriteString(fmt.Sprintf("- `%s`: %t\n", eventType, enabled))
|
||||
}
|
||||
}
|
||||
ctx.Reply(builder.String())
|
||||
}
|
||||
|
||||
func handleSubscriptionSet(ctx *Context, repo subscriptionRepo, contentType arr.ContentType, eventType string, enabled bool) {
|
||||
var err error
|
||||
if eventType == "all" {
|
||||
err = repo.SetAll(ctx.Ctx, ctx.User.MXID, contentType, enabled)
|
||||
} else {
|
||||
err = repo.Set(ctx.Ctx, ctx.User.MXID, contentType, eventType, enabled)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.Reply("Failed to update subscriptions: %v", err)
|
||||
return
|
||||
}
|
||||
if eventType == "all" {
|
||||
ctx.Reply("Set all `%s` notifications for %s to %t.", contentType.Label(), userIDString(ctx.User.MXID), enabled)
|
||||
return
|
||||
}
|
||||
ctx.Reply("Set `%s/%s` notifications to %t.", contentType.Label(), eventType, enabled)
|
||||
}
|
||||
|
||||
type subscriptionRepo interface {
|
||||
List(ctx context.Context, userID id.UserID) ([]subscriptions.Preference, error)
|
||||
Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error
|
||||
SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error
|
||||
}
|
||||
|
||||
func findPreference(preferences []subscriptions.Preference, contentType arr.ContentType, eventType string) bool {
|
||||
for _, preference := range preferences {
|
||||
if preference.ContentType == contentType && preference.EventType == eventType {
|
||||
return preference.Enabled
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue