From 8ffa92276e927ea9bf2175f34e5d93ec1a100769 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 4 May 2026 18:40:31 +0200 Subject: [PATCH] Add Jacket indexer with capabilities implemented --- api/Indexer/Capabilities.bru | 24 ++++ api/Indexer/folder.bru | 8 ++ api/Jackett/Capabilities.bru | 20 ++++ api/Jackett/Search.bru | 23 ++++ api/Jackett/folder.bru | 8 ++ api/bruno.json | 4 + cmd/music-agregator/main.go | 7 +- config.example.yaml | 10 +- internal/config/config.go | 34 +++++- internal/indexer/indexer.go | 104 +++++++++++++++++- internal/indexer/jackett.go | 64 +++++++++++ internal/indexer/server.go | 24 +++- .../music_agregator/indexer/v1/indexer.proto | 59 +++++++++- 13 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 api/Indexer/Capabilities.bru create mode 100644 api/Indexer/folder.bru create mode 100644 api/Jackett/Capabilities.bru create mode 100644 api/Jackett/Search.bru create mode 100644 api/Jackett/folder.bru create mode 100644 internal/indexer/jackett.go diff --git a/api/Indexer/Capabilities.bru b/api/Indexer/Capabilities.bru new file mode 100644 index 0000000..89fb2fa --- /dev/null +++ b/api/Indexer/Capabilities.bru @@ -0,0 +1,24 @@ +meta { + name: Capabilities + type: grpc + seq: 1 +} + +grpc { + url: localhost:3000 + method: /music_agregator.indexer.v1.IndexerService/Capabilities + body: grpc + protoPath: ../proto/music_agregator/indexer/v1/indexer.proto + auth: inherit + methodType: unary +} + +body:grpc { + name: message 1 + content: ''' + { + "indexer": "rutracker" + + } + ''' +} diff --git a/api/Indexer/folder.bru b/api/Indexer/folder.bru new file mode 100644 index 0000000..059f94d --- /dev/null +++ b/api/Indexer/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Indexer + seq: 7 +} + +auth { + mode: inherit +} diff --git a/api/Jackett/Capabilities.bru b/api/Jackett/Capabilities.bru new file mode 100644 index 0000000..9f62643 --- /dev/null +++ b/api/Jackett/Capabilities.bru @@ -0,0 +1,20 @@ +meta { + name: Capabilities + type: http + seq: 2 +} + +get { + url: http://localhost:9117/api/v2.0/indexers/rutracker/results/torznab/api?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&t=caps + body: none + auth: inherit +} + +params:query { + apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi + t: caps +} + +settings { + encodeUrl: true +} diff --git a/api/Jackett/Search.bru b/api/Jackett/Search.bru new file mode 100644 index 0000000..7f0d565 --- /dev/null +++ b/api/Jackett/Search.bru @@ -0,0 +1,23 @@ +meta { + name: Search + type: http + seq: 1 +} + +get { + url: http://localhost:9117/api/v2.0/indexers/all/results/torznab?apikey=3jfvdvt1etzz36drkw5id5sb95sc47fi&limit=1&artist=Metallica&t=music + body: none + auth: inherit +} + +params:query { + apikey: 3jfvdvt1etzz36drkw5id5sb95sc47fi + limit: 1 + artist: Metallica + t: music +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/api/Jackett/folder.bru b/api/Jackett/folder.bru new file mode 100644 index 0000000..8f1a658 --- /dev/null +++ b/api/Jackett/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Jackett + seq: 5 +} + +auth { + mode: inherit +} diff --git a/api/bruno.json b/api/bruno.json index 29714da..72756d2 100644 --- a/api/bruno.json +++ b/api/bruno.json @@ -13,6 +13,10 @@ { "path": "../proto/music_agregator/hello/v1/service.proto", "type": "file" + }, + { + "path": "../proto/music_agregator/indexer/v1/indexer.proto", + "type": "file" } ] }, diff --git a/cmd/music-agregator/main.go b/cmd/music-agregator/main.go index fb8b89b..74b9c93 100644 --- a/cmd/music-agregator/main.go +++ b/cmd/music-agregator/main.go @@ -54,9 +54,14 @@ func serveGrpc(config config.Config) { var opts []grpc.ServerOption server := grpc.NewServer(opts...) + indexerServer, err := indexer.NewIndexerServer(config) + if err != nil { + log.Fatal().Err(err).Msg("Failed to create IndexerServer") + } + services := []internal.Registrable{ hello.NewHelloServer(), - indexer.NewIndexerServer(), + indexerServer, } for _, service := range services { diff --git a/config.example.yaml b/config.example.yaml index f542e62..37fba6f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -7,11 +7,11 @@ database: metadata: endpoint: "http://localhost:50051" -indexers: - - name: "Jackett" - indexer_type: jackett # jackett, prowlarr, or torznab - url: "http://localhost:9117" - api_key: "your-jackett-api-key" +indexer: + name: "Jackett" + indexer_type: jackett # jackett, prowlarr, or torznab + url: "http://localhost:9117" + api_key: "your-jackett-api-key" # Torrent client - choose one of: qbittorrent, stub, none torrent: diff --git a/internal/config/config.go b/internal/config/config.go index d11e2c3..69e61c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,42 @@ package config +import ( + "fmt" +) + +const ( + IndexerTypeJackett IndexerType = "jackett" +) + +type IndexerType string + type Config struct { App struct { - Port string `yaml:"port"` Host string `yaml:"host"` + Port string `yaml:"port"` } `yaml:"app"` + + Indexer struct { + Url string `yaml:"url"` + Port string `yaml:"port"` + Type IndexerType `yaml:"type"` + ApiKey string `yaml:"api_key"` + } `yaml:"indexer"` +} + +func (t *IndexerType) UnmarshalYAML(unmarshal func(any) error) error { + var value string + if err := unmarshal(&value); err != nil { + return err + } + + switch IndexerType(value) { + case IndexerTypeJackett: + *t = IndexerType(value) + return nil + default: + return fmt.Errorf("unknown indexer type: %s", value) + } } func NewConfig() *Config { diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 43e3732..9fcc962 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -1,6 +1,108 @@ package indexer -import () +import ( + "encoding/xml" + "strings" + + pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1" +) type Indexer interface { + Search() + Capabilities(indexerName string) (IndexerCapabilities, error) +} + +type IndexerCapabilities struct { + XMLName xml.Name `xml:"caps"` + Server Server `xml:"server"` + Limits Limits `xml:"limits"` + Searching Searching `xml:"searching"` + Categories []Category `xml:"categories>category"` +} + +type Server struct { + Title string `xml:"title,attr"` +} + +type Limits struct { + Default int `xml:"default,attr"` + Max int `xml:"max,attr"` +} + +type Searching struct { + Search SearchCapability `xml:"search"` + TvSearch SearchCapability `xml:"tv-search"` + MovieSearch SearchCapability `xml:"movie-search"` + MusicSearch SearchCapability `xml:"music-search"` + AudioSearch SearchCapability `xml:"audio-search"` + BookSearch SearchCapability `xml:"book-search"` +} + +type SearchCapability struct { + Available string `xml:"available,attr"` + SupportedParams string `xml:"supportedParams,attr"` + SearchEngine string `xml:"searchEngine,attr"` +} + +type Category struct { + ID int `xml:"id,attr"` + Name string `xml:"name,attr"` + Subcats []Subcat `xml:"subcat"` +} + +type Subcat struct { + ID int `xml:"id,attr"` + Name string `xml:"name,attr"` +} + +func (c *IndexerCapabilities) ToProto() *pb.CapabilitiesResponse { + return &pb.CapabilitiesResponse{ + Server: &pb.Server{ + Title: c.Server.Title, + }, + Limits: &pb.Limits{ + Default: int32(c.Limits.Default), + Max: int32(c.Limits.Max), + }, + Searching: &pb.Searching{ + Search: c.Searching.Search.toProto(), + TvSearch: c.Searching.TvSearch.toProto(), + MovieSearch: c.Searching.MovieSearch.toProto(), + MusicSearch: c.Searching.MusicSearch.toProto(), + AudioSearch: c.Searching.AudioSearch.toProto(), + BookSearch: c.Searching.BookSearch.toProto(), + }, + Categories: c.categoriesToProto(), + } +} + +func (s *SearchCapability) toProto() *pb.SearchCapability { + var params []string + if s.SupportedParams != "" { + params = strings.Split(s.SupportedParams, ",") + } + return &pb.SearchCapability{ + Available: s.Available == "yes", + SupportedParams: params, + SearchEngine: s.SearchEngine, + } +} + +func (c *IndexerCapabilities) categoriesToProto() []*pb.Category { + categories := make([]*pb.Category, len(c.Categories)) + for i, cat := range c.Categories { + subcats := make([]*pb.Subcat, len(cat.Subcats)) + for j, sub := range cat.Subcats { + subcats[j] = &pb.Subcat{ + Id: int32(sub.ID), + Name: sub.Name, + } + } + categories[i] = &pb.Category{ + Id: int32(cat.ID), + Name: cat.Name, + Subcats: subcats, + } + } + return categories } diff --git a/internal/indexer/jackett.go b/internal/indexer/jackett.go new file mode 100644 index 0000000..edfc06c --- /dev/null +++ b/internal/indexer/jackett.go @@ -0,0 +1,64 @@ +package indexer + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "time" + + "github.com/rs/zerolog/log" + "homelab.lan/music-agregator/internal/config" +) + +type JacketIndexer struct { + cfg config.Config + client *http.Client +} + +func NewIndexer(cfg config.Config) Indexer { + return &JacketIndexer{ + cfg: cfg, + client: &http.Client{ + Timeout: time.Second * 10, + }, + } +} + +func (indexer *JacketIndexer) Search() { + log.Warn().Msg("Unimplemented method search on the Jacket Indexer") +} + +func (indexer *JacketIndexer) Capabilities(indexerName string) (IndexerCapabilities, error) { + url := indexer.cfg.Indexer.Url + uri := fmt.Sprintf("%v/api/v2.0/indexers/%v/results/torznab/api?apikey=%v&t=caps", url, indexerName, indexer.cfg.Indexer.ApiKey) + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + log.Error().Err(err).Msg("Error creating request") + return IndexerCapabilities{}, err + } + + resp, err := indexer.client.Do(req) + if err != nil { + log.Error().Err(err).Msg("Error making capabilities request") + return IndexerCapabilities{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Error().Err(err).Msg("Error reading response body") + return IndexerCapabilities{}, err + } + + var capabilities IndexerCapabilities + if err := xml.Unmarshal(body, &capabilities); err != nil { + log.Error().Err(err).Msg("Error parsing capabilities XML") + return IndexerCapabilities{}, err + } + + log.Debug().Str("server", capabilities.Server.Title).Msg("Parsed capabilities") + + return capabilities, nil +} diff --git a/internal/indexer/server.go b/internal/indexer/server.go index 575377b..3c4bae7 100644 --- a/internal/indexer/server.go +++ b/internal/indexer/server.go @@ -2,23 +2,43 @@ package indexer import ( "context" + "fmt" + "github.com/rs/zerolog/log" "google.golang.org/grpc" pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1" + "homelab.lan/music-agregator/internal/config" ) type IndexerServer struct { + indexer Indexer + pb.UnimplementedIndexerServiceServer } -func NewIndexerServer() *IndexerServer { - return &IndexerServer{} +func NewIndexerServer(cfg config.Config) (*IndexerServer, error) { + switch cfg.Indexer.Type { + case config.IndexerTypeJackett: + indexer := NewIndexer(cfg) + return &IndexerServer{indexer: indexer}, nil + default: + return nil, fmt.Errorf("Unable to create the indexer for type: %v", cfg.Indexer.Type) + } } func (server *IndexerServer) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) { return &pb.SearchResponse{}, nil } +func (server *IndexerServer) Capabilities(ctx context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) { + capabilities, err := server.indexer.Capabilities(req.GetIndexer()) + if err != nil { + log.Error().Err(err).Msg("Failed to get capabilities from indexer") + return nil, err + } + return capabilities.ToProto(), nil +} + func (s *IndexerServer) Register(server *grpc.Server) { pb.RegisterIndexerServiceServer(server, s) } diff --git a/proto/music_agregator/indexer/v1/indexer.proto b/proto/music_agregator/indexer/v1/indexer.proto index 4a28693..61a4b16 100644 --- a/proto/music_agregator/indexer/v1/indexer.proto +++ b/proto/music_agregator/indexer/v1/indexer.proto @@ -3,9 +3,60 @@ package music_agregator.indexer.v1; option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/indexer"; service IndexerService { - rpc Search(SearchRequest) returns (SearchResponse){ - } + rpc Search(SearchRequest) returns (SearchResponse) {} + rpc Capabilities(CapabilitiesRequest) returns (CapabilitiesResponse) {} } -message SearchRequest {} -message SearchResponse {} +message SearchRequest { + string indexer = 1; + string query = 2; + int32 limit = 3; +} +message SearchResponse { +} + +message CapabilitiesRequest { + string indexer = 1; +} + +message CapabilitiesResponse { + Server server = 1; + Limits limits = 2; + Searching searching = 3; + repeated Category categories = 4; +} + +message Server { + string title = 1; +} + +message Limits { + int32 default = 1; + int32 max = 2; +} + +message Searching { + SearchCapability search = 1; + SearchCapability tv_search = 2; + SearchCapability movie_search = 3; + SearchCapability music_search = 4; + SearchCapability audio_search = 5; + SearchCapability book_search = 6; +} + +message SearchCapability { + bool available = 1; + repeated string supported_params = 2; + string search_engine = 3; +} + +message Category { + int32 id = 1; + string name = 2; + repeated Subcat subcats = 3; +} + +message Subcat { + int32 id = 1; + string name = 2; +}