feat(torznab): refactor and add profiles (#408)

This commit is contained in:
mgdigital 2025-03-23 10:58:45 +00:00 committed by GitHub
parent 4d4042acdd
commit 542fecc33d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1105 additions and 697 deletions

View File

@ -16,3 +16,6 @@ packages:
github.com/bitmagnet-io/bitmagnet/internal/tmdb:
interfaces:
Client:
github.com/bitmagnet-io/bitmagnet/internal/torznab:
interfaces:
Client:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ package torznab
const (
AttrInfoHash = "infohash"
AttrMagnetUrl = "magneturl"
AttrMagnetURL = "magneturl"
// AttrCategory is the Category ID
AttrCategory = "category"
AttrSize = "size"

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ const (
FunctionCaps = "caps"
FunctionSearch = "search"
FunctionMovie = "movie"
FunctionTv = "tvsearch"
FunctionTV = "tvsearch"
FunctionMusic = "music"
FunctionBook = "book"
)

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,5 @@ package torznab
import "context"
type Client interface {
Caps(context.Context) (Caps, error)
Search(context.Context, SearchRequest) (SearchResult, error)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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"`

View File

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

View File

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

View File

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