From 542fecc33dbabc5f636b1400f3811d5a4d5de657 Mon Sep 17 00:00:00 2001 From: mgdigital Date: Sun, 23 Mar 2025 10:58:45 +0000 Subject: [PATCH] feat(torznab): refactor and add profiles (#408) --- .mockery.yml | 3 + internal/torznab/adapter/adapter.go | 53 ++- internal/torznab/adapter/caps.go | 61 ---- internal/torznab/adapter/search.go | 324 ------------------ internal/torznab/adapter/search_options.go | 164 +++++++++ internal/torznab/adapter/search_result.go | 168 +++++++++ internal/torznab/attributes.go | 2 +- internal/torznab/caps.go | 4 +- internal/torznab/config.go | 29 ++ internal/torznab/errors.go | 4 +- internal/torznab/functions.go | 2 +- .../torznab/gencategories/gencategories.go | 205 +++++------ internal/torznab/httpserver/handler.go | 165 +++++++++ internal/torznab/httpserver/handler_test.go | 204 +++++++++++ internal/torznab/httpserver/httpserver.go | 134 +------- internal/torznab/interface.go | 1 - internal/torznab/mocks/Client.go | 94 +++++ internal/torznab/parameters.go | 4 +- internal/torznab/profile.go | 89 +++++ internal/torznab/request.go | 5 +- internal/torznab/result.go | 32 +- internal/torznab/result_test.go | 25 -- internal/torznab/torznabfx/module.go | 24 +- internal/torznab/xmlutil.go | 6 +- 24 files changed, 1105 insertions(+), 697 deletions(-) delete mode 100644 internal/torznab/adapter/caps.go delete mode 100644 internal/torznab/adapter/search.go create mode 100644 internal/torznab/adapter/search_options.go create mode 100644 internal/torznab/adapter/search_result.go create mode 100644 internal/torznab/config.go create mode 100644 internal/torznab/httpserver/handler.go create mode 100644 internal/torznab/httpserver/handler_test.go create mode 100644 internal/torznab/mocks/Client.go create mode 100644 internal/torznab/profile.go delete mode 100644 internal/torznab/result_test.go diff --git a/.mockery.yml b/.mockery.yml index 9b86ac6..a622eeb 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -16,3 +16,6 @@ packages: github.com/bitmagnet-io/bitmagnet/internal/tmdb: interfaces: Client: + github.com/bitmagnet-io/bitmagnet/internal/torznab: + interfaces: + Client: diff --git a/internal/torznab/adapter/adapter.go b/internal/torznab/adapter/adapter.go index 0666bdc..67eec18 100644 --- a/internal/torznab/adapter/adapter.go +++ b/internal/torznab/adapter/adapter.go @@ -1,42 +1,33 @@ package adapter import ( - "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/lazy" + "context" + + "github.com/bitmagnet-io/bitmagnet/internal/database/query" "github.com/bitmagnet-io/bitmagnet/internal/database/search" "github.com/bitmagnet-io/bitmagnet/internal/torznab" - "go.uber.org/fx" ) -type Params struct { - fx.In - Search lazy.Lazy[search.Search] -} - -type Result struct { - fx.Out - Client lazy.Lazy[torznab.Client] -} - -func New(p Params) Result { - return Result{ - Client: lazy.New[torznab.Client](func() (torznab.Client, error) { - s, err := p.Search.Get() - if err != nil { - return nil, err - } - return adapter{ - title: "bitmagnet", - maxLimit: 100, - defaultLimit: 100, - search: s, - }, nil - }), +func New(search search.Search) Adapter { + return Adapter{ + search: search, } } -type adapter struct { - title string - maxLimit uint - defaultLimit uint - search search.Search +type Adapter struct { + search search.Search +} + +func (a Adapter) Search(ctx context.Context, req torznab.SearchRequest) (torznab.SearchResult, error) { + options := []query.Option{search.TorrentContentDefaultOption(), query.WithTotalCount(false)} + if reqOptions, reqErr := searchRequestToQueryOptions(req); reqErr != nil { + return torznab.SearchResult{}, reqErr + } else { + options = append(options, reqOptions...) + } + searchResult, searchErr := a.search.TorrentContent(ctx, options...) + if searchErr != nil { + return torznab.SearchResult{}, searchErr + } + return torrentContentResultToTorznabResult(req, searchResult), nil } diff --git a/internal/torznab/adapter/caps.go b/internal/torznab/adapter/caps.go deleted file mode 100644 index 4629f5b..0000000 --- a/internal/torznab/adapter/caps.go +++ /dev/null @@ -1,61 +0,0 @@ -package adapter - -import ( - "context" - "github.com/bitmagnet-io/bitmagnet/internal/torznab" - "strings" -) - -func (a adapter) Caps(context.Context) (torznab.Caps, error) { - return torznab.Caps{ - Server: torznab.CapsServer{ - Title: a.title, - }, - Limits: torznab.CapsLimits{ - Max: a.maxLimit, - Default: a.defaultLimit, - }, - Searching: torznab.CapsSearching{ - Search: torznab.CapsSearch{ - Available: "yes", - SupportedParams: strings.Join([]string{ - torznab.ParamQuery, - torznab.ParamImdbId, - torznab.ParamTmdbId, - }, ","), - }, - TvSearch: torznab.CapsSearch{ - Available: "yes", - SupportedParams: strings.Join([]string{ - torznab.ParamQuery, - torznab.ParamImdbId, - torznab.ParamTmdbId, - torznab.ParamSeason, - torznab.ParamEpisode, - }, ","), - }, - MovieSearch: torznab.CapsSearch{ - Available: "yes", - SupportedParams: strings.Join([]string{ - torznab.ParamQuery, - torznab.ParamImdbId, - torznab.ParamTmdbId, - }, ","), - }, - MusicSearch: torznab.CapsSearch{ - Available: "yes", - SupportedParams: torznab.ParamQuery, - }, - AudioSearch: torznab.CapsSearch{ - Available: "no", - }, - BookSearch: torznab.CapsSearch{ - Available: "yes", - SupportedParams: torznab.ParamQuery, - }, - }, - Categories: torznab.CapsCategories{ - Categories: torznab.TopLevelCategories, - }, - }, nil -} diff --git a/internal/torznab/adapter/search.go b/internal/torznab/adapter/search.go deleted file mode 100644 index e8fefa8..0000000 --- a/internal/torznab/adapter/search.go +++ /dev/null @@ -1,324 +0,0 @@ -package adapter - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/bitmagnet-io/bitmagnet/internal/database/query" - "github.com/bitmagnet-io/bitmagnet/internal/database/search" - "github.com/bitmagnet-io/bitmagnet/internal/model" - "github.com/bitmagnet-io/bitmagnet/internal/torznab" -) - -func (a adapter) Search(ctx context.Context, req torznab.SearchRequest) (torznab.SearchResult, error) { - options := []query.Option{search.TorrentContentDefaultOption(), query.WithTotalCount(false)} - if reqOptions, reqErr := a.searchRequestOptions(req); reqErr != nil { - return torznab.SearchResult{}, reqErr - } else { - options = append(options, reqOptions...) - } - searchResult, searchErr := a.search.TorrentContent(ctx, options...) - if searchErr != nil { - return torznab.SearchResult{}, searchErr - } - return a.transformSearchResult(req, searchResult), nil -} - -func (a adapter) searchRequestOptions(r torznab.SearchRequest) ([]query.Option, error) { - var options []query.Option - switch r.Type { - case torznab.FunctionSearch: - break - case torznab.FunctionMovie: - options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeMovie))) - case torznab.FunctionTv: - options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeTvShow))) - if r.Season.Valid { - episodes := make(model.Episodes) - if r.Episode.Valid { - episodes = episodes.AddEpisode(r.Season.Int, r.Episode.Int) - } else { - episodes = episodes.AddSeason(r.Season.Int) - } - options = append(options, query.Where(search.TorrentContentEpisodesCriteria(episodes))) - } - case torznab.FunctionMusic: - options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeMusic))) - case torznab.FunctionBook: - options = append(options, query.Where(search.TorrentContentTypeCriteria( - model.ContentTypeEbook, - model.ContentTypeComic, - model.ContentTypeAudiobook, - ))) - default: - return nil, torznab.Error{ - Code: 202, - Description: fmt.Sprintf("no such function (%s)", r.Type), - } - } - if r.Query != "" { - options = append(options, query.QueryString(r.Query), query.OrderByQueryStringRank()) - } - var catsCriteria []query.Criteria - for _, cat := range r.Cats { - var catCriteria []query.Criteria - if torznab.CategoryMovies.Has(cat) { - if r.Type != torznab.FunctionMovie || torznab.CategoryMovies.ID == cat { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeMovie)) - } - if torznab.CategoryMoviesSD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV480p)) - } else if torznab.CategoryMoviesHD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria( - model.VideoResolutionV720p, - model.VideoResolutionV1080p, - model.VideoResolutionV1440p, - model.VideoResolutionV2160p, - )) - } else if torznab.CategoryMoviesUHD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV2160p)) - } else if torznab.CategoryMovies3D.ID == cat { - catCriteria = append(catCriteria, search.Video3DCriteria( - model.Video3DV3D, - model.Video3DV3DSBS, - model.Video3DV3DOU, - )) - } - } else if torznab.CategoryTV.Has(cat) { - if r.Type != torznab.FunctionTv || torznab.CategoryTV.ID == cat { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeTvShow)) - } - if torznab.CategoryTVSD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV480p)) - } else if torznab.CategoryTVHD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria( - model.VideoResolutionV720p, - model.VideoResolutionV1080p, - model.VideoResolutionV1440p, - model.VideoResolutionV2160p, - )) - } else if torznab.CategoryTVUHD.ID == cat { - catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV2160p)) - } - } else if torznab.CategoryXXX.Has(cat) { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeXxx)) - } else if torznab.CategoryPC.Has(cat) { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeSoftware, model.ContentTypeGame)) - } else if torznab.CategoryAudioAudiobook.Has(cat) { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeAudiobook)) - } else if torznab.CategoryAudio.Has(cat) { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeMusic)) - } else if torznab.CategoryBooksComics.Has(cat) { - catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeComic)) - } else if torznab.CategoryBooks.Has(cat) { - options = append(options, query.Where(search.TorrentContentTypeCriteria( - model.ContentTypeEbook, - model.ContentTypeComic, - model.ContentTypeAudiobook, - ))) - } - if len(catCriteria) > 0 { - catsCriteria = append(catsCriteria, query.And(catCriteria...)) - } - } - if len(catsCriteria) > 0 { - options = append(options, query.Where(query.Or(catsCriteria...))) - } - if r.ImdbId.Valid { - imdbId := r.ImdbId.String - if !strings.HasPrefix(imdbId, "tt") { - imdbId = "tt" + imdbId - } - var ct model.ContentType - if r.Type != torznab.FunctionTv { - ct = model.ContentTypeMovie - } else if r.Type != torznab.FunctionMovie { - ct = model.ContentTypeTvShow - } - options = append(options, query.Where(search.ContentAlternativeIdentifierCriteria(model.ContentRef{ - Type: ct, - Source: "imdb", - ID: imdbId, - }))) - } - if r.TmdbId.Valid { - tmdbId := r.TmdbId.String - var ct model.ContentType - if r.Type != torznab.FunctionTv { - ct = model.ContentTypeMovie - } else if r.Type != torznab.FunctionMovie { - ct = model.ContentTypeTvShow - } - options = append(options, query.Where(search.ContentCanonicalIdentifierCriteria(model.ContentRef{ - Type: ct, - Source: "tmdb", - ID: tmdbId, - }))) - } - limit := a.defaultLimit - if r.Limit.Valid { - limit = r.Limit.Uint - if limit > a.maxLimit { - limit = a.maxLimit - } - } - options = append(options, query.Limit(limit)) - if r.Offset.Valid { - options = append(options, query.Offset(r.Offset.Uint)) - } - return options, nil -} - -func (a adapter) transformSearchResult(req torznab.SearchRequest, res search.TorrentContentResult) torznab.SearchResult { - entries := make([]torznab.SearchResultItem, 0, len(res.Items)) - for _, item := range res.Items { - category := "Unknown" - if item.ContentType.Valid { - category = item.ContentType.ContentType.Label() - } - categoryId := torznab.CategoryOther.ID - if item.ContentType.Valid { - switch item.ContentType.ContentType { - case model.ContentTypeMovie: - categoryId = torznab.CategoryMovies.ID - case model.ContentTypeTvShow: - categoryId = torznab.CategoryTV.ID - case model.ContentTypeMusic: - categoryId = torznab.CategoryAudio.ID - case model.ContentTypeEbook: - categoryId = torznab.CategoryBooks.ID - case model.ContentTypeComic: - categoryId = torznab.CategoryBooksComics.ID - case model.ContentTypeAudiobook: - categoryId = torznab.CategoryAudioAudiobook.ID - case model.ContentTypeSoftware: - categoryId = torznab.CategoryPC.ID - case model.ContentTypeGame: - categoryId = torznab.CategoryPCGames.ID - } - } - attrs := []torznab.SearchResultItemTorznabAttr{ - { - AttrName: torznab.AttrInfoHash, - AttrValue: item.Torrent.InfoHash.String(), - }, - { - AttrName: torznab.AttrMagnetUrl, - AttrValue: item.Torrent.MagnetUri(), - }, - { - AttrName: torznab.AttrCategory, - AttrValue: strconv.Itoa(categoryId), - }, - { - AttrName: torznab.AttrSize, - AttrValue: strconv.FormatUint(uint64(item.Torrent.Size), 10), - }, - { - AttrName: torznab.AttrPublishDate, - AttrValue: item.PublishedAt.Format(torznab.RssDateDefaultFormat), - }, - } - seeders := item.Torrent.Seeders() - leechers := item.Torrent.Leechers() - if seeders.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrSeeders, - AttrValue: strconv.Itoa(int(seeders.Uint)), - }) - } - if leechers.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrLeechers, - AttrValue: strconv.Itoa(int(leechers.Uint)), - }) - } - if leechers.Valid && seeders.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrPeers, - AttrValue: strconv.Itoa(int(leechers.Uint) + int(seeders.Uint)), - }) - } - if len(item.Torrent.Files) > 0 { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrFiles, - AttrValue: strconv.Itoa(len(item.Torrent.Files)), - }) - } - if !item.Content.ReleaseYear.IsNil() { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrYear, - AttrValue: strconv.Itoa(int(item.Content.ReleaseYear)), - }) - } - if len(item.Episodes) > 0 { - // should we be adding all seasons and episodes here? - seasons := item.Episodes.SeasonEntries() - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrSeason, - AttrValue: strconv.Itoa(seasons[0].Season), - }) - if len(seasons[0].Episodes) > 0 { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrEpisode, - AttrValue: strconv.Itoa(seasons[0].Episodes[0]), - }) - } - } - if item.VideoCodec.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrVideo, - AttrValue: item.VideoCodec.VideoCodec.Label(), - }) - } - if item.VideoResolution.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrResolution, - AttrValue: item.VideoResolution.VideoResolution.Label(), - }) - } - if item.ReleaseGroup.Valid { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrTeam, - AttrValue: item.ReleaseGroup.String, - }) - } - if tmdbid, ok := item.Content.Identifier("tmdb"); ok { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrTmdb, - AttrValue: tmdbid, - }) - } - if imdbId, ok := item.Content.Identifier("imdb"); ok { - attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ - AttrName: torznab.AttrImdb, - AttrValue: imdbId[2:], - }) - } - entries = append(entries, torznab.SearchResultItem{ - Title: item.Torrent.Name, - Size: item.Torrent.Size, - Category: category, - GUID: item.InfoHash.String(), - PubDate: torznab.RssDate(item.PublishedAt), - Enclosure: torznab.SearchResultItemEnclosure{ - URL: item.Torrent.MagnetUri(), - Type: "application/x-bittorrent;x-scheme-handler/magnet", - Length: strconv.FormatUint(uint64(item.Torrent.Size), 10), - }, - TorznabAttrs: attrs, - }) - } - return torznab.SearchResult{ - Channel: torznab.SearchResultChannel{ - Title: a.title, - Response: torznab.SearchResultResponse{ - Offset: req.Offset.Uint, - //Total: res.TotalCount, - }, - Items: entries, - }, - } -} diff --git a/internal/torznab/adapter/search_options.go b/internal/torznab/adapter/search_options.go new file mode 100644 index 0000000..58090d8 --- /dev/null +++ b/internal/torznab/adapter/search_options.go @@ -0,0 +1,164 @@ +package adapter + +import ( + "fmt" + "strings" + + "github.com/bitmagnet-io/bitmagnet/internal/database/query" + "github.com/bitmagnet-io/bitmagnet/internal/database/search" + "github.com/bitmagnet-io/bitmagnet/internal/model" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" +) + +func searchRequestToQueryOptions(r torznab.SearchRequest) ([]query.Option, error) { + var options []query.Option + switch r.Type { + case torznab.FunctionSearch: + break + case torznab.FunctionMovie: + options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeMovie))) + case torznab.FunctionTV: + options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeTvShow))) + if r.Season.Valid { + episodes := make(model.Episodes) + if r.Episode.Valid { + episodes = episodes.AddEpisode(r.Season.Int, r.Episode.Int) + } else { + episodes = episodes.AddSeason(r.Season.Int) + } + options = append(options, query.Where(search.TorrentContentEpisodesCriteria(episodes))) + } + case torznab.FunctionMusic: + options = append(options, query.Where(search.TorrentContentTypeCriteria(model.ContentTypeMusic))) + case torznab.FunctionBook: + options = append(options, query.Where(search.TorrentContentTypeCriteria( + model.ContentTypeEbook, + model.ContentTypeComic, + model.ContentTypeAudiobook, + ))) + default: + return nil, torznab.Error{ + Code: 202, + Description: fmt.Sprintf("no such function (%s)", r.Type), + } + } + if r.Query != "" { + order := search.TorrentContentOrderByRelevance + if r.Profile.DisableOrderByRelevance { + order = search.TorrentContentOrderByPublishedAt + } + options = append(options, query.QueryString(r.Query), query.OrderBy(order.Clauses(search.OrderDirectionDescending)...)) + } + var catsCriteria []query.Criteria + for _, cat := range r.Cats { + var catCriteria []query.Criteria + if torznab.CategoryMovies.Has(cat) { + if r.Type != torznab.FunctionMovie || torznab.CategoryMovies.ID == cat { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeMovie)) + } + if torznab.CategoryMoviesSD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV480p)) + } else if torznab.CategoryMoviesHD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria( + model.VideoResolutionV720p, + model.VideoResolutionV1080p, + model.VideoResolutionV1440p, + model.VideoResolutionV2160p, + )) + } else if torznab.CategoryMoviesUHD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV2160p)) + } else if torznab.CategoryMovies3D.ID == cat { + catCriteria = append(catCriteria, search.Video3DCriteria( + model.Video3DV3D, + model.Video3DV3DSBS, + model.Video3DV3DOU, + )) + } + } else if torznab.CategoryTV.Has(cat) { + if r.Type != torznab.FunctionTV || torznab.CategoryTV.ID == cat { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeTvShow)) + } + if torznab.CategoryTVSD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV480p)) + } else if torznab.CategoryTVHD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria( + model.VideoResolutionV720p, + model.VideoResolutionV1080p, + model.VideoResolutionV1440p, + model.VideoResolutionV2160p, + )) + } else if torznab.CategoryTVUHD.ID == cat { + catCriteria = append(catCriteria, search.VideoResolutionCriteria(model.VideoResolutionV2160p)) + } + } else if torznab.CategoryXXX.Has(cat) { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeXxx)) + } else if torznab.CategoryPC.Has(cat) { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeSoftware, model.ContentTypeGame)) + } else if torznab.CategoryAudioAudiobook.Has(cat) { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeAudiobook)) + } else if torznab.CategoryAudio.Has(cat) { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeMusic)) + } else if torznab.CategoryBooksComics.Has(cat) { + catCriteria = append(catCriteria, search.TorrentContentTypeCriteria(model.ContentTypeComic)) + } else if torznab.CategoryBooks.Has(cat) { + options = append(options, query.Where(search.TorrentContentTypeCriteria( + model.ContentTypeEbook, + model.ContentTypeComic, + model.ContentTypeAudiobook, + ))) + } + if len(catCriteria) > 0 { + catsCriteria = append(catsCriteria, query.And(catCriteria...)) + } + } + if len(catsCriteria) > 0 { + options = append(options, query.Where(query.Or(catsCriteria...))) + } + if r.IMDBID.Valid { + imdbId := r.IMDBID.String + if !strings.HasPrefix(imdbId, "tt") { + imdbId = "tt" + imdbId + } + var ct model.ContentType + if r.Type != torznab.FunctionTV { + ct = model.ContentTypeMovie + } else if r.Type != torznab.FunctionMovie { + ct = model.ContentTypeTvShow + } + options = append(options, query.Where(search.ContentAlternativeIdentifierCriteria(model.ContentRef{ + Type: ct, + Source: "imdb", + ID: imdbId, + }))) + } + if r.TMDBID.Valid { + tmdbId := r.TMDBID.String + var ct model.ContentType + if r.Type != torznab.FunctionTV { + ct = model.ContentTypeMovie + } else if r.Type != torznab.FunctionMovie { + ct = model.ContentTypeTvShow + } + options = append(options, query.Where(search.ContentCanonicalIdentifierCriteria(model.ContentRef{ + Type: ct, + Source: "tmdb", + ID: tmdbId, + }))) + } + limit := r.Profile.DefaultLimit + if r.Limit.Valid { + limit = r.Limit.Uint + if limit > r.Profile.MaxLimit { + limit = r.Profile.MaxLimit + } + } + options = append(options, query.Limit(limit)) + if r.Offset.Valid { + options = append(options, query.Offset(r.Offset.Uint)) + } + + if len(r.Profile.Tags) > 0 { + options = append(options, query.Where(search.TorrentTagCriteria(r.Profile.Tags...))) + } + return options, nil +} diff --git a/internal/torznab/adapter/search_result.go b/internal/torznab/adapter/search_result.go new file mode 100644 index 0000000..46342d1 --- /dev/null +++ b/internal/torznab/adapter/search_result.go @@ -0,0 +1,168 @@ +package adapter + +import ( + "strconv" + + "github.com/bitmagnet-io/bitmagnet/internal/database/search" + "github.com/bitmagnet-io/bitmagnet/internal/model" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" +) + +func torrentContentResultToTorznabResult( + req torznab.SearchRequest, + res search.TorrentContentResult, +) torznab.SearchResult { + entries := make([]torznab.SearchResultItem, 0, len(res.Items)) + for _, item := range res.Items { + entries = append(entries, torrentContentResultItemToTorznabResultItem(item)) + } + return torznab.SearchResult{ + Channel: torznab.SearchResultChannel{ + Title: req.Profile.Title, + Response: torznab.SearchResultResponse{ + Offset: req.Offset.Uint, + //Total: res.TotalCount, + }, + Items: entries, + }, + } +} + +func torrentContentResultItemToTorznabResultItem(item search.TorrentContentResultItem) torznab.SearchResultItem { + category := "Unknown" + if item.ContentType.Valid { + category = item.ContentType.ContentType.Label() + } + categoryId := torznab.CategoryOther.ID + if item.ContentType.Valid { + switch item.ContentType.ContentType { + case model.ContentTypeMovie: + categoryId = torznab.CategoryMovies.ID + case model.ContentTypeTvShow: + categoryId = torznab.CategoryTV.ID + case model.ContentTypeMusic: + categoryId = torznab.CategoryAudio.ID + case model.ContentTypeEbook: + categoryId = torznab.CategoryBooks.ID + case model.ContentTypeComic: + categoryId = torznab.CategoryBooksComics.ID + case model.ContentTypeAudiobook: + categoryId = torznab.CategoryAudioAudiobook.ID + case model.ContentTypeSoftware: + categoryId = torznab.CategoryPC.ID + case model.ContentTypeGame: + categoryId = torznab.CategoryPCGames.ID + } + } + attrs := []torznab.SearchResultItemTorznabAttr{ + { + AttrName: torznab.AttrInfoHash, + AttrValue: item.Torrent.InfoHash.String(), + }, + { + AttrName: torznab.AttrMagnetURL, + AttrValue: item.Torrent.MagnetUri(), + }, + { + AttrName: torznab.AttrCategory, + AttrValue: strconv.Itoa(categoryId), + }, + { + AttrName: torznab.AttrSize, + AttrValue: strconv.FormatUint(uint64(item.Torrent.Size), 10), + }, + { + AttrName: torznab.AttrPublishDate, + AttrValue: item.PublishedAt.Format(torznab.RssDateDefaultFormat), + }, + } + seeders := item.Torrent.Seeders() + leechers := item.Torrent.Leechers() + if seeders.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrSeeders, + AttrValue: strconv.Itoa(int(seeders.Uint)), + }) + } + if leechers.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrLeechers, + AttrValue: strconv.Itoa(int(leechers.Uint)), + }) + } + if leechers.Valid && seeders.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrPeers, + AttrValue: strconv.Itoa(int(leechers.Uint) + int(seeders.Uint)), + }) + } + if len(item.Torrent.Files) > 0 { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrFiles, + AttrValue: strconv.Itoa(len(item.Torrent.Files)), + }) + } + if !item.Content.ReleaseYear.IsNil() { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrYear, + AttrValue: strconv.Itoa(int(item.Content.ReleaseYear)), + }) + } + if len(item.Episodes) > 0 { + // should we be adding all seasons and episodes here? + seasons := item.Episodes.SeasonEntries() + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrSeason, + AttrValue: strconv.Itoa(seasons[0].Season), + }) + if len(seasons[0].Episodes) > 0 { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrEpisode, + AttrValue: strconv.Itoa(seasons[0].Episodes[0]), + }) + } + } + if item.VideoCodec.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrVideo, + AttrValue: item.VideoCodec.VideoCodec.Label(), + }) + } + if item.VideoResolution.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrResolution, + AttrValue: item.VideoResolution.VideoResolution.Label(), + }) + } + if item.ReleaseGroup.Valid { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrTeam, + AttrValue: item.ReleaseGroup.String, + }) + } + if tmdbid, ok := item.Content.Identifier("tmdb"); ok { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrTmdb, + AttrValue: tmdbid, + }) + } + if imdbId, ok := item.Content.Identifier("imdb"); ok { + attrs = append(attrs, torznab.SearchResultItemTorznabAttr{ + AttrName: torznab.AttrImdb, + AttrValue: imdbId[2:], + }) + } + return torznab.SearchResultItem{ + Title: item.Torrent.Name, + Size: item.Torrent.Size, + Category: category, + GUID: item.InfoHash.String(), + PubDate: torznab.RSSDate(item.PublishedAt), + Enclosure: torznab.SearchResultItemEnclosure{ + URL: item.Torrent.MagnetUri(), + Type: "application/x-bittorrent;x-scheme-handler/magnet", + Length: strconv.FormatUint(uint64(item.Torrent.Size), 10), + }, + TorznabAttrs: attrs, + } +} diff --git a/internal/torznab/attributes.go b/internal/torznab/attributes.go index 8effdf9..5e491f8 100644 --- a/internal/torznab/attributes.go +++ b/internal/torznab/attributes.go @@ -2,7 +2,7 @@ package torznab const ( AttrInfoHash = "infohash" - AttrMagnetUrl = "magneturl" + AttrMagnetURL = "magneturl" // AttrCategory is the Category ID AttrCategory = "category" AttrSize = "size" diff --git a/internal/torznab/caps.go b/internal/torznab/caps.go index 9b68037..7346df5 100644 --- a/internal/torznab/caps.go +++ b/internal/torznab/caps.go @@ -11,8 +11,8 @@ type Caps struct { Tags string `xml:"tags"` } -func (c Caps) Xml() ([]byte, error) { - return objToXml(c) +func (c Caps) XML() ([]byte, error) { + return objToXML(c) } type CapsServer struct { diff --git a/internal/torznab/config.go b/internal/torznab/config.go new file mode 100644 index 0000000..1a63739 --- /dev/null +++ b/internal/torznab/config.go @@ -0,0 +1,29 @@ +package torznab + +import "strings" + +type Config struct { + Profiles []Profile +} + +func (c Config) MergeDefaults() Config { + var profiles []Profile + for _, p := range c.Profiles { + profiles = append(profiles, p.MergeDefaults()) + } + c.Profiles = profiles + return c +} + +func NewDefaultConfig() Config { + return Config{} +} + +func (c Config) GetProfile(name string) (Profile, bool) { + for _, p := range c.Profiles { + if strings.EqualFold(p.ID, name) { + return p, true + } + } + return Profile{}, false +} diff --git a/internal/torznab/errors.go b/internal/torznab/errors.go index ff4dc3b..cb6d047 100644 --- a/internal/torznab/errors.go +++ b/internal/torznab/errors.go @@ -34,6 +34,6 @@ func (e Error) Error() string { return e.Description } -func (e Error) Xml() ([]byte, error) { - return objToXml(e) +func (e Error) XML() ([]byte, error) { + return objToXML(e) } diff --git a/internal/torznab/functions.go b/internal/torznab/functions.go index 365d0f3..303778b 100644 --- a/internal/torznab/functions.go +++ b/internal/torznab/functions.go @@ -4,7 +4,7 @@ const ( FunctionCaps = "caps" FunctionSearch = "search" FunctionMovie = "movie" - FunctionTv = "tvsearch" + FunctionTV = "tvsearch" FunctionMusic = "music" FunctionBook = "book" ) diff --git a/internal/torznab/gencategories/gencategories.go b/internal/torznab/gencategories/gencategories.go index aba2596..8b3a680 100644 --- a/internal/torznab/gencategories/gencategories.go +++ b/internal/torznab/gencategories/gencategories.go @@ -1,14 +1,15 @@ package main import ( - _ "embed" - "github.com/bitmagnet-io/bitmagnet/internal/maps" - "github.com/bitmagnet-io/bitmagnet/internal/torznab" - "os" - "path" - "runtime" - "strconv" - "strings" + _ "embed" + "os" + "path" + "runtime" + "strconv" + "strings" + + "github.com/bitmagnet-io/bitmagnet/internal/maps" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" ) // Taken from https://torznab.github.io/spec-1.3-draft/external/newznab/api.html#predefined-categories @@ -17,104 +18,104 @@ import ( var categoriesCsvString string func main() { - categoriesMap, categoriesMapErr := readCategoriesMap() - checkErr(categoriesMapErr) - var varNames []struct { - name string - id int - } - maxVarNameLength := 0 - var topLevelNames []string - out := "// Code generated by gencategories. DO NOT EDIT.\n\n" - out += "package torznab\n\n" - out += "var categoriesMap = map[int]Category{\n" - for _, category := range categoriesMap.Values() { - out += " " + strconv.Itoa(category.ID) + ": {\n" - out += " ID: " + strconv.Itoa(category.ID) + ",\n" - out += " Name: \"" + category.Name + "\",\n" - out += " Subcat: []Subcategory{\n" - for _, subcategory := range category.Subcat { - out += " {\n" - out += " ID: " + strconv.Itoa(subcategory.ID) + ",\n" - out += " Name: \"" + subcategory.Name + "\",\n" - out += " },\n" - } - out += " },\n" - out += " },\n" - varName := "Category" + strings.Replace(category.Name, "/", "", -1) - varNames = append(varNames, struct { - name string - id int - }{name: varName, id: category.ID}) - if len(varName) > maxVarNameLength { - maxVarNameLength = len(varName) - } - if category.ID%1000 == 0 { - topLevelNames = append(topLevelNames, varName) - } - } - out += "}\n\n" - out += "var (\n" - for _, varName := range varNames { - out += " " + varName.name - for i := 0; i < maxVarNameLength-len(varName.name); i++ { - out += " " - } - out += " = categoriesMap[" + strconv.Itoa(varName.id) + "]\n" - } - out += ")\n\n" - out += "var TopLevelCategories = []Category{\n" - for _, topLevelName := range topLevelNames { - out += " " + topLevelName + ",\n" - } - out += "}\n" - _, filename, _, _ := runtime.Caller(0) - outFile := path.Dir(path.Dir(filename)) + "/categories.gen.go" - f, fErr := os.Create(outFile) - checkErr(fErr) - _, wErr := f.WriteString(out) - checkErr(wErr) + categoriesMap, categoriesMapErr := readCategoriesMap() + checkErr(categoriesMapErr) + var varNames []struct { + name string + id int + } + maxVarNameLength := 0 + var topLevelNames []string + out := "// Code generated by gencategories. DO NOT EDIT.\n\n" + out += "package torznab\n\n" + out += "var categoriesMap = map[int]Category{\n" + for _, category := range categoriesMap.Values() { + out += " " + strconv.Itoa(category.ID) + ": {\n" + out += " ID: " + strconv.Itoa(category.ID) + ",\n" + out += " Name: \"" + category.Name + "\",\n" + out += " Subcat: []Subcategory{\n" + for _, subcategory := range category.Subcat { + out += " {\n" + out += " ID: " + strconv.Itoa(subcategory.ID) + ",\n" + out += " Name: \"" + subcategory.Name + "\",\n" + out += " },\n" + } + out += " },\n" + out += " },\n" + varName := "Category" + strings.Replace(category.Name, "/", "", -1) + varNames = append(varNames, struct { + name string + id int + }{name: varName, id: category.ID}) + if len(varName) > maxVarNameLength { + maxVarNameLength = len(varName) + } + if category.ID%1000 == 0 { + topLevelNames = append(topLevelNames, varName) + } + } + out += "}\n\n" + out += "var (\n" + for _, varName := range varNames { + out += " " + varName.name + for i := 0; i < maxVarNameLength-len(varName.name); i++ { + out += " " + } + out += " = categoriesMap[" + strconv.Itoa(varName.id) + "]\n" + } + out += ")\n\n" + out += "var TopLevelCategories = []Category{\n" + for _, topLevelName := range topLevelNames { + out += " " + topLevelName + ",\n" + } + out += "}\n" + _, filename, _, _ := runtime.Caller(0) + outFile := path.Dir(path.Dir(filename)) + "/categories.gen.go" + f, fErr := os.Create(outFile) + checkErr(fErr) + _, wErr := f.WriteString(out) + checkErr(wErr) } func checkErr(err error) { - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } } func readCategoriesMap() (maps.InsertMap[int, torznab.Category], error) { - categoriesMap := maps.NewInsertMap[int, torznab.Category]() - csvLines := strings.Split(categoriesCsvString, "\n")[1:] - var currentCategoryID int - for _, line := range csvLines { - if len(line) == 0 { - continue - } - parts := strings.Split(line, ",") - enabled := parts[2][0:1] - if enabled != "1" { - continue - } - id, idErr := strconv.Atoi(parts[0]) - if idErr != nil { - return categoriesMap, idErr - } - name := parts[1] - categoriesMap.Set(id, torznab.Category{ - ID: id, - Name: name, - Subcat: make([]torznab.Subcategory, 0), - }) - if parts[0][1:] == "000" { - currentCategoryID = id - } else { - currentCategory, _ := categoriesMap.Get(currentCategoryID) - currentCategory.Subcat = append(currentCategory.Subcat, torznab.Subcategory{ - ID: id, - Name: name, - }) - categoriesMap.Set(currentCategoryID, currentCategory) - } - } - return categoriesMap, nil + categoriesMap := maps.NewInsertMap[int, torznab.Category]() + csvLines := strings.Split(categoriesCsvString, "\n")[1:] + var currentCategoryID int + for _, line := range csvLines { + if len(line) == 0 { + continue + } + parts := strings.Split(line, ",") + enabled := parts[2][0:1] + if enabled != "1" { + continue + } + id, idErr := strconv.Atoi(parts[0]) + if idErr != nil { + return categoriesMap, idErr + } + name := parts[1] + categoriesMap.Set(id, torznab.Category{ + ID: id, + Name: name, + Subcat: make([]torznab.Subcategory, 0), + }) + if parts[0][1:] == "000" { + currentCategoryID = id + } else { + currentCategory, _ := categoriesMap.Get(currentCategoryID) + currentCategory.Subcat = append(currentCategory.Subcat, torznab.Subcategory{ + ID: id, + Name: name, + }) + categoriesMap.Set(currentCategoryID, currentCategory) + } + } + return categoriesMap, nil } diff --git a/internal/torznab/httpserver/handler.go b/internal/torznab/httpserver/handler.go new file mode 100644 index 0000000..c80bf87 --- /dev/null +++ b/internal/torznab/httpserver/handler.go @@ -0,0 +1,165 @@ +package httpserver + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/bitmagnet-io/bitmagnet/internal/model" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" + "github.com/gin-gonic/gin" +) + +type handler struct { + config torznab.Config + client torznab.Client +} + +func (h handler) handleRequest(ctx *gin.Context) { + profile, err := h.getProfile(ctx) + if err != nil { + h.writeError(ctx, err) + return + } + + tp := ctx.Query(torznab.ParamType) + + switch tp { + case "": + h.writeError(ctx, torznab.Error{ + Code: 200, + Description: fmt.Sprintf("missing parameter (%s)", torznab.ParamType), + }) + + case torznab.FunctionCaps: + h.writeXML(ctx, profile.Caps()) + + default: + h.handleSearch(ctx, profile, tp) + } +} + +func (h handler) handleSearch(ctx *gin.Context, profile torznab.Profile, tp string) { + var cats []int + for _, csvCat := range ctx.QueryArray(torznab.ParamCat) { + for _, cat := range strings.Split(csvCat, ",") { + if intCat, err := strconv.Atoi(cat); err == nil { + cats = append(cats, intCat) + } + } + } + imdbID := model.NullString{} + if qImdbId := ctx.Query(torznab.ParamIMDBID); qImdbId != "" { + imdbID.Valid = true + imdbID.String = qImdbId + } + tmdbID := model.NullString{} + if qTmdbId := ctx.Query(torznab.ParamTMDBID); qTmdbId != "" { + tmdbID.Valid = true + tmdbID.String = qTmdbId + } + season := model.NullInt{} + episode := model.NullInt{} + if qSeason := ctx.Query(torznab.ParamSeason); qSeason != "" { + if intSeason, err := strconv.Atoi(qSeason); err == nil { + season.Valid = true + season.Int = intSeason + if qEpisode := ctx.Query(torznab.ParamEpisode); qEpisode != "" { + if intEpisode, err := strconv.Atoi(qEpisode); err == nil { + episode.Valid = true + episode.Int = intEpisode + } + } + } + } + limit := model.NullUint{} + if intLimit, limitErr := strconv.Atoi(ctx.Query(torznab.ParamLimit)); limitErr == nil && intLimit > 0 { + limit.Valid = true + limit.Uint = uint(intLimit) + } + offset := model.NullUint{} + if intOffset, offsetErr := strconv.Atoi(ctx.Query(torznab.ParamOffset)); offsetErr == nil { + offset.Valid = true + offset.Uint = uint(intOffset) + } + result, searchErr := h.client.Search(ctx, torznab.SearchRequest{ + Profile: profile, + Query: ctx.Query(torznab.ParamQuery), + Type: tp, + Cats: cats, + IMDBID: imdbID, + TMDBID: tmdbID, + Season: season, + Episode: episode, + Limit: limit, + Offset: offset, + }) + if searchErr != nil { + h.writeError(ctx, fmt.Errorf("failed to search: %w", searchErr)) + return + } + h.writeXML(ctx, result) +} + +func (h handler) writeXML(ctx *gin.Context, obj torznab.XMLer) { + body, err := obj.XML() + if err != nil { + h.writeHTTPError(ctx, fmt.Errorf("failed to encode xml: %w", err)) + return + } + ctx.Status(http.StatusOK) + ctx.Header("Content-Type", "application/xml; charset=utf-8") + _, _ = ctx.Writer.Write(body) +} + +func (h handler) writeError(ctx *gin.Context, err error) { + var torznabErr torznab.Error + if ok := errors.As(err, &torznabErr); ok { + h.writeXML(ctx, torznabErr) + } else { + h.writeHTTPError(ctx, err) + } +} + +func (h handler) writeHTTPError(ctx *gin.Context, err error) { + code := http.StatusInternalServerError + var httpErr httpError + if ok := errors.As(err, &httpErr); ok { + code = httpErr.httpErrorCode() + } + _ = ctx.AbortWithError(code, err) + _, _ = ctx.Writer.WriteString(err.Error() + "\n") +} + +type httpError interface { + error + httpErrorCode() int +} + +type errProfileNotFound struct { + name string +} + +func (e errProfileNotFound) Error() string { + return fmt.Sprintf("profile not found: %s", e.name) +} + +func (e errProfileNotFound) httpErrorCode() int { + return http.StatusNotFound +} + +func (h handler) getProfile(c *gin.Context) (torznab.Profile, error) { + profilePathPart := strings.ToLower(strings.Split(strings.Trim(c.Param("any"), "/"), "/")[0]) + switch profilePathPart { + case "", "api", torznab.ProfileDefault.ID: + return torznab.ProfileDefault, nil + default: + profile, ok := h.config.GetProfile(profilePathPart) + if !ok { + return profile, errProfileNotFound{name: profilePathPart} + } + return profile, nil + } +} diff --git a/internal/torznab/httpserver/handler_test.go b/internal/torznab/httpserver/handler_test.go new file mode 100644 index 0000000..3fc8786 --- /dev/null +++ b/internal/torznab/httpserver/handler_test.go @@ -0,0 +1,204 @@ +package httpserver_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/lazy" + "github.com/bitmagnet-io/bitmagnet/internal/model" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" + "github.com/bitmagnet-io/bitmagnet/internal/torznab/httpserver" + torznab_mocks "github.com/bitmagnet-io/bitmagnet/internal/torznab/mocks" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +var testCfg = torznab.Config{ + Profiles: []torznab.Profile{ + { + ID: "test", + Title: "Test", + DefaultLimit: 1000, + MaxLimit: 2000, + Tags: []string{"test"}, + }, + }, +}.MergeDefaults() + +type testHarness struct { + t *testing.T + clientMock *torznab_mocks.Client + responseRecorder *httptest.ResponseRecorder + engine *gin.Engine +} + +func newTestHarness(t *testing.T) *testHarness { + t.Helper() + + clientMock := torznab_mocks.NewClient(t) + lazyClient := lazy.New[torznab.Client](func() (torznab.Client, error) { + return clientMock, nil + }) + + engine := gin.New() + err := httpserver.New(lazyClient, testCfg).Apply(engine) + + require.NoError(t, err) + + return &testHarness{ + t: t, + clientMock: clientMock, + responseRecorder: httptest.NewRecorder(), + engine: engine, + } +} + +func TestCaps(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + url string + profile torznab.Profile + }{ + { + url: "/torznab/?t=caps", + profile: torznab.ProfileDefault, + }, + { + url: "/torznab/api?t=caps", + profile: torznab.ProfileDefault, + }, + { + url: "/torznab/test?t=caps", + profile: testCfg.Profiles[0], + }, + { + url: "/torznab/test/api?t=caps", + profile: testCfg.Profiles[0], + }, + { + url: "/torznab/test/api/?t=caps", + profile: testCfg.Profiles[0], + }, + } { + t.Run(testCase.url, func(t *testing.T) { + t.Parallel() + + h := newTestHarness(t) + + req, err := http.NewRequest(http.MethodGet, testCase.url, nil) + require.NoError(t, err) + + h.engine.ServeHTTP(h.responseRecorder, req) + + assert.Equal(t, http.StatusOK, h.responseRecorder.Code) + assert.Equal(t, "application/xml; charset=utf-8", h.responseRecorder.Header().Get("Content-Type")) + + expectedXML, err := testCase.profile.Caps().XML() + require.NoError(t, err) + assert.Equal(t, string(expectedXML), h.responseRecorder.Body.String()) + }) + } +} + +func TestSearch(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + url string + expectedRequest torznab.SearchRequest + }{ + { + url: fmt.Sprintf("/torznab/?%s", url.Values{ + torznab.ParamType: []string{torznab.FunctionSearch}, + }.Encode()), + expectedRequest: torznab.SearchRequest{ + Profile: torznab.ProfileDefault, + Type: torznab.FunctionSearch, + }, + }, + { + url: fmt.Sprintf("/torznab/?%s", url.Values{ + torznab.ParamType: []string{torznab.FunctionMovie}, + torznab.ParamCat: []string{ + strings.Join([]string{"2000", "2030"}, ","), + }, + torznab.ParamLimit: []string{"10"}, + torznab.ParamOffset: []string{"100"}, + }.Encode()), + expectedRequest: torznab.SearchRequest{ + Profile: torznab.ProfileDefault, + Type: torznab.FunctionMovie, + Cats: []int{2000, 2030}, + Limit: model.NewNullUint(10), + Offset: model.NewNullUint(100), + }, + }, + { + url: fmt.Sprintf("/torznab/%s?%s", testCfg.Profiles[0].ID, url.Values{ + torznab.ParamType: []string{torznab.FunctionSearch}, + }.Encode()), + expectedRequest: torznab.SearchRequest{ + Profile: testCfg.Profiles[0], + Type: torznab.FunctionSearch, + }, + }, + { + url: fmt.Sprintf("/torznab/%s?%s", torznab.ProfileDefault.ID, url.Values{ + torznab.ParamType: []string{torznab.FunctionTV}, + torznab.ParamIMDBID: []string{"123"}, + torznab.ParamSeason: []string{"1"}, + }.Encode()), + expectedRequest: torznab.SearchRequest{ + Profile: torznab.ProfileDefault, + Type: torznab.FunctionTV, + IMDBID: model.NewNullString("123"), + Season: model.NewNullInt(1), + }, + }, + { + url: fmt.Sprintf("/torznab/%s?%s", torznab.ProfileDefault.ID, url.Values{ + torznab.ParamType: []string{torznab.FunctionTV}, + torznab.ParamTMDBID: []string{"123"}, + torznab.ParamSeason: []string{"2"}, + torznab.ParamEpisode: []string{"3"}, + }.Encode()), + expectedRequest: torznab.SearchRequest{ + Profile: torznab.ProfileDefault, + Type: torznab.FunctionTV, + TMDBID: model.NewNullString("123"), + Season: model.NewNullInt(2), + Episode: model.NewNullInt(3), + }, + }, + } { + t.Run(testCase.url, func(t *testing.T) { + t.Parallel() + + h := newTestHarness(t) + + result := torznab.SearchResult{} + h.clientMock.EXPECT().Search( + mock.IsType(&gin.Context{}), + testCase.expectedRequest, + ).Return(result, nil).Times(1) + + req, err := http.NewRequest(http.MethodGet, testCase.url, nil) + require.NoError(t, err) + + h.engine.ServeHTTP(h.responseRecorder, req) + + assert.Equal(t, http.StatusOK, h.responseRecorder.Code) + + resultXML, err := result.XML() + require.NoError(t, err) + assert.Equal(t, string(resultXML), h.responseRecorder.Body.String()) + }) + } +} diff --git a/internal/torznab/httpserver/httpserver.go b/internal/torznab/httpserver/httpserver.go index 3bb968d..c77bfb9 100644 --- a/internal/torznab/httpserver/httpserver.go +++ b/internal/torznab/httpserver/httpserver.go @@ -1,38 +1,22 @@ package httpserver import ( - "errors" - "fmt" "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/httpserver" "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/lazy" - "github.com/bitmagnet-io/bitmagnet/internal/model" "github.com/bitmagnet-io/bitmagnet/internal/torznab" "github.com/gin-gonic/gin" - "go.uber.org/fx" - "strconv" - "strings" ) -type Params struct { - fx.In - Client lazy.Lazy[torznab.Client] -} - -type Result struct { - fx.Out - Option httpserver.Option `group:"http_server_options"` -} - -func New(p Params) Result { - return Result{ - Option: builder{ - client: p.Client, - }, +func New(lazyClient lazy.Lazy[torznab.Client], config torznab.Config) httpserver.Option { + return builder{ + lazyClient: lazyClient, + config: config, } } type builder struct { - client lazy.Lazy[torznab.Client] + lazyClient lazy.Lazy[torznab.Client] + config torznab.Config } func (builder) Key() string { @@ -40,108 +24,14 @@ func (builder) Key() string { } func (b builder) Apply(e *gin.Engine) error { - client, err := b.client.Get() + client, err := b.lazyClient.Get() if err != nil { return err } - e.GET("/torznab/*any", func(c *gin.Context) { - writeInternalError := func(err error) { - _ = c.AbortWithError(500, err) - _, _ = c.Writer.WriteString(err.Error() + "\n") - } - writeXml := func(obj torznab.Xmler) { - body, err := obj.Xml() - if err != nil { - writeInternalError(fmt.Errorf("failed to encode xml: %w", err)) - return - } - c.Status(200) - c.Header("Content-Type", "application/xml; charset=utf-8") - _, _ = c.Writer.Write(body) - } - writeErr := func(err error) { - torznabErr := &torznab.Error{} - if ok := errors.As(err, torznabErr); ok { - writeXml(torznabErr) - } else { - writeInternalError(err) - } - } - tp := c.Query(torznab.ParamType) - if tp == "" { - writeErr(torznab.Error{ - Code: 200, - Description: fmt.Sprintf("missing parameter (%s)", torznab.ParamType), - }) - return - } - if tp == torznab.FunctionCaps { - caps, capsErr := client.Caps(c) - if capsErr != nil { - writeErr(fmt.Errorf("failed to execute caps: %w", capsErr)) - return - } - writeXml(caps) - return - } - var cats []int - for _, csvCat := range c.QueryArray(torznab.ParamCat) { - for _, cat := range strings.Split(csvCat, ",") { - if intCat, err := strconv.Atoi(cat); err == nil { - cats = append(cats, intCat) - } - } - } - imdbId := model.NullString{} - if qImdbId := c.Query(torznab.ParamImdbId); qImdbId != "" { - imdbId.Valid = true - imdbId.String = qImdbId - } - tmdbId := model.NullString{} - if qTmdbId := c.Query(torznab.ParamTmdbId); qTmdbId != "" { - tmdbId.Valid = true - tmdbId.String = qTmdbId - } - season := model.NullInt{} - episode := model.NullInt{} - if qSeason := c.Query(torznab.ParamSeason); qSeason != "" { - if intSeason, err := strconv.Atoi(qSeason); err == nil { - season.Valid = true - season.Int = intSeason - if qEpisode := c.Query(torznab.ParamEpisode); qEpisode != "" { - if intEpisode, err := strconv.Atoi(qEpisode); err == nil { - episode.Valid = true - episode.Int = intEpisode - } - } - } - } - limit := model.NullUint{} - if intLimit, limitErr := strconv.Atoi(c.Query(torznab.ParamLimit)); limitErr == nil && intLimit > 0 { - limit.Valid = true - limit.Uint = uint(intLimit) - } - offset := model.NullUint{} - if intOffset, offsetErr := strconv.Atoi(c.Query(torznab.ParamOffset)); offsetErr == nil { - offset.Valid = true - offset.Uint = uint(intOffset) - } - result, searchErr := client.Search(c, torznab.SearchRequest{ - Query: c.Query(torznab.ParamQuery), - Type: tp, - Cats: cats, - ImdbId: imdbId, - TmdbId: tmdbId, - Season: season, - Episode: episode, - Limit: limit, - Offset: offset, - }) - if searchErr != nil { - writeErr(fmt.Errorf("failed to search: %w", searchErr)) - return - } - writeXml(result) - }) + h := handler{ + config: b.config, + client: client, + } + e.GET("/torznab/*any", h.handleRequest) return nil } diff --git a/internal/torznab/interface.go b/internal/torznab/interface.go index 7c745f9..3fbe12b 100644 --- a/internal/torznab/interface.go +++ b/internal/torznab/interface.go @@ -3,6 +3,5 @@ package torznab import "context" type Client interface { - Caps(context.Context) (Caps, error) Search(context.Context, SearchRequest) (SearchResult, error) } diff --git a/internal/torznab/mocks/Client.go b/internal/torznab/mocks/Client.go new file mode 100644 index 0000000..2a1e8b8 --- /dev/null +++ b/internal/torznab/mocks/Client.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.52.1. DO NOT EDIT. + +package torznab_mocks + +import ( + context "context" + + torznab "github.com/bitmagnet-io/bitmagnet/internal/torznab" + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// Search provides a mock function with given fields: _a0, _a1 +func (_m *Client) Search(_a0 context.Context, _a1 torznab.SearchRequest) (torznab.SearchResult, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Search") + } + + var r0 torznab.SearchResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, torznab.SearchRequest) (torznab.SearchResult, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, torznab.SearchRequest) torznab.SearchResult); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(torznab.SearchResult) + } + + if rf, ok := ret.Get(1).(func(context.Context, torznab.SearchRequest) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search' +type Client_Search_Call struct { + *mock.Call +} + +// Search is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 torznab.SearchRequest +func (_e *Client_Expecter) Search(_a0 interface{}, _a1 interface{}) *Client_Search_Call { + return &Client_Search_Call{Call: _e.mock.On("Search", _a0, _a1)} +} + +func (_c *Client_Search_Call) Run(run func(_a0 context.Context, _a1 torznab.SearchRequest)) *Client_Search_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(torznab.SearchRequest)) + }) + return _c +} + +func (_c *Client_Search_Call) Return(_a0 torznab.SearchResult, _a1 error) *Client_Search_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_Search_Call) RunAndReturn(run func(context.Context, torznab.SearchRequest) (torznab.SearchResult, error)) *Client_Search_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/torznab/parameters.go b/internal/torznab/parameters.go index abcf896..d2b09ba 100644 --- a/internal/torznab/parameters.go +++ b/internal/torznab/parameters.go @@ -4,8 +4,8 @@ const ( ParamType = "t" ParamQuery = "q" ParamCat = "cat" - ParamImdbId = "imdbid" - ParamTmdbId = "tmdbid" + ParamIMDBID = "imdbid" + ParamTMDBID = "tmdbid" ParamSeason = "season" ParamEpisode = "ep" ParamLimit = "limit" diff --git a/internal/torznab/profile.go b/internal/torznab/profile.go new file mode 100644 index 0000000..6bab599 --- /dev/null +++ b/internal/torznab/profile.go @@ -0,0 +1,89 @@ +package torznab + +import "strings" + +type Profile struct { + ID string `validate:"required"` + Title string + DisableOrderByRelevance bool + DefaultLimit uint + MaxLimit uint + Tags []string +} + +var ProfileDefault = Profile{ + ID: "default", + Title: "bitmagnet", + DefaultLimit: 100, + MaxLimit: 100, +} + +func (p Profile) MergeDefaults() Profile { + if p.Title == "" { + p.Title = ProfileDefault.Title + } + if p.DefaultLimit == 0 { + p.DefaultLimit = ProfileDefault.DefaultLimit + } + if p.MaxLimit == 0 { + p.MaxLimit = ProfileDefault.MaxLimit + } + if p.DefaultLimit > p.MaxLimit { + p.DefaultLimit = p.MaxLimit + } + return p +} + +func (p Profile) Caps() Caps { + return Caps{ + Server: CapsServer{ + Title: p.Title, + }, + Limits: CapsLimits{ + Max: p.MaxLimit, + Default: p.DefaultLimit, + }, + Searching: CapsSearching{ + Search: CapsSearch{ + Available: "yes", + SupportedParams: strings.Join([]string{ + ParamQuery, + ParamIMDBID, + ParamTMDBID, + }, ","), + }, + TvSearch: CapsSearch{ + Available: "yes", + SupportedParams: strings.Join([]string{ + ParamQuery, + ParamIMDBID, + ParamTMDBID, + ParamSeason, + ParamEpisode, + }, ","), + }, + MovieSearch: CapsSearch{ + Available: "yes", + SupportedParams: strings.Join([]string{ + ParamQuery, + ParamIMDBID, + ParamTMDBID, + }, ","), + }, + MusicSearch: CapsSearch{ + Available: "yes", + SupportedParams: ParamQuery, + }, + AudioSearch: CapsSearch{ + Available: "no", + }, + BookSearch: CapsSearch{ + Available: "yes", + SupportedParams: ParamQuery, + }, + }, + Categories: CapsCategories{ + Categories: TopLevelCategories, + }, + } +} diff --git a/internal/torznab/request.go b/internal/torznab/request.go index f6f89dc..6f8f9a5 100644 --- a/internal/torznab/request.go +++ b/internal/torznab/request.go @@ -5,11 +5,12 @@ import ( ) type SearchRequest struct { + Profile Profile Query string Type string Cats []int - ImdbId model.NullString - TmdbId model.NullString + IMDBID model.NullString + TMDBID model.NullString Season model.NullInt Episode model.NullInt Attrs []string diff --git a/internal/torznab/result.go b/internal/torznab/result.go index 89eff8c..61f78f6 100644 --- a/internal/torznab/result.go +++ b/internal/torznab/result.go @@ -9,19 +9,19 @@ import ( type SearchResult struct { XMLName xml.Name `xml:"rss"` - RssVersion rssVersion `xml:"version,attr"` - AtomNs customNs `xml:"xmlns:atom,attr"` - TorznabNs customNs `xml:"xmlns:torznab,attr"` + RSSVersion rssVersion `xml:"version,attr"` + AtomNS customNS `xml:"xmlns:atom,attr"` + TorznabNS customNS `xml:"xmlns:torznab,attr"` Channel SearchResultChannel `xml:"channel"` } -func (r SearchResult) Xml() ([]byte, error) { - return objToXml(r) +func (r SearchResult) XML() ([]byte, error) { + return objToXML(r) } -type customNs struct{} +type customNS struct{} -func (r customNs) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { +func (r customNS) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { switch name.Local { case "xmlns:atom": return xml.Attr{ @@ -47,11 +47,11 @@ func (r rssVersion) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { }, nil } -type RssDate time.Time +type RSSDate time.Time const RssDateDefaultFormat = "Mon, 02 Jan 2006 15:04:05 -0700" -func (r RssDate) String() string { +func (r RSSDate) String() string { return time.Time(r).Format(RssDateDefaultFormat) } @@ -67,7 +67,7 @@ var rssDateFormats = []string{ //"02 Jan 2006 15:04:05 -0700", } -func (r *RssDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { +func (r *RSSDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var v string if err := d.DecodeElement(&v, &start); err != nil { return err @@ -75,14 +75,14 @@ func (r *RssDate) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { for _, format := range rssDateFormats { parsed, err := time.Parse(format, v) if err == nil { - *r = RssDate(parsed) + *r = RSSDate(parsed) return nil } } - return fmt.Errorf("cannot parse %q as RssDate", v) + return fmt.Errorf("cannot parse %q as RSSDate", v) } -func (r RssDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error { +func (r RSSDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.EncodeElement(r.String(), start) } @@ -91,8 +91,8 @@ type SearchResultChannel struct { Link string `xml:"link,omitempty"` Description string `xml:"description,omitempty"` Language string `xml:"language,omitempty"` - PubDate RssDate `xml:"pubDate,omitempty"` - LastBuildDate RssDate `xml:"lastBuildDate,omitempty"` + PubDate RSSDate `xml:"pubDate,omitempty"` + LastBuildDate RSSDate `xml:"lastBuildDate,omitempty"` Docs string `xml:"docs,omitempty"` Generator string `xml:"generator,omitempty"` Response SearchResultResponse `xml:"http://www.newznab.com/DTD/2010/feeds/attributes/ response"` @@ -107,7 +107,7 @@ type SearchResultResponse struct { type SearchResultItem struct { Title string `xml:"title"` GUID string `xml:"guid,omitempty"` - PubDate RssDate `xml:"pubDate,omitempty"` + PubDate RSSDate `xml:"pubDate,omitempty"` Category string `xml:"category,omitempty"` Link string `xml:"link,omitempty"` Size uint `xml:"size"` diff --git a/internal/torznab/result_test.go b/internal/torznab/result_test.go deleted file mode 100644 index ed876ca..0000000 --- a/internal/torznab/result_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package torznab - -// yep this was a bad idea -// -//import ( -// _ "embed" -//) -// -////go:embed examples/* -//var testExamples embed.FS -// -//func TestResult(t *testing.T) { -// example, readErr := testExamples.ReadFile("examples/result1.xml") -// assert.NoError(t, readErr) -// result := &SearchResult{} -// unmarshalErr := xml.compileCondition(example, &result) -// assert.NoError(t, unmarshalErr) -// marshaled, marshalErr := result.Xml() -// assert.NoError(t, marshalErr) -// t.Logf("marshaled: %s", marshaled) -// result2 := &SearchResult{} -// unmarshalErr = xml.compileCondition(marshaled, &result2) -// assert.NoError(t, unmarshalErr) -// assert.Equal(t, result, result2) -//} diff --git a/internal/torznab/torznabfx/module.go b/internal/torznab/torznabfx/module.go index 873a7de..13adcd2 100644 --- a/internal/torznab/torznabfx/module.go +++ b/internal/torznab/torznabfx/module.go @@ -1,6 +1,10 @@ package torznabfx import ( + "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/config/configfx" + "github.com/bitmagnet-io/bitmagnet/internal/boilerplate/lazy" + "github.com/bitmagnet-io/bitmagnet/internal/database/search" + "github.com/bitmagnet-io/bitmagnet/internal/torznab" "github.com/bitmagnet-io/bitmagnet/internal/torznab/adapter" "github.com/bitmagnet-io/bitmagnet/internal/torznab/httpserver" "go.uber.org/fx" @@ -9,9 +13,25 @@ import ( func New() fx.Option { return fx.Module( "torznab", + configfx.NewConfigModule[torznab.Config]("torznab", torznab.NewDefaultConfig()), fx.Provide( - adapter.New, - httpserver.New, + func(lazySearch lazy.Lazy[search.Search]) lazy.Lazy[torznab.Client] { + return lazy.New[torznab.Client](func() (torznab.Client, error) { + s, err := lazySearch.Get() + if err != nil { + return nil, err + } + return adapter.New(s), nil + }) + }, + fx.Annotate( + httpserver.New, + fx.ResultTags(`group:"http_server_options"`), + ), ), + fx.Decorate( + func(cfg torznab.Config) torznab.Config { + return cfg.MergeDefaults() + }), ) } diff --git a/internal/torznab/xmlutil.go b/internal/torznab/xmlutil.go index 3fc4d2d..460fd2d 100644 --- a/internal/torznab/xmlutil.go +++ b/internal/torznab/xmlutil.go @@ -4,11 +4,11 @@ import ( "encoding/xml" ) -type Xmler interface { - Xml() ([]byte, error) +type XMLer interface { + XML() ([]byte, error) } -func objToXml(obj any) ([]byte, error) { +func objToXML(obj any) ([]byte, error) { body, err := xml.MarshalIndent(obj, "", " ") if err != nil { return nil, err