mirror of
https://github.com/bitmagnet-io/bitmagnet.git
synced 2025-12-27 22:26:05 +00:00
feat(torznab): refactor and add profiles (#408)
This commit is contained in:
parent
4d4042acdd
commit
542fecc33d
@ -16,3 +16,6 @@ packages:
|
||||
github.com/bitmagnet-io/bitmagnet/internal/tmdb:
|
||||
interfaces:
|
||||
Client:
|
||||
github.com/bitmagnet-io/bitmagnet/internal/torznab:
|
||||
interfaces:
|
||||
Client:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
164
internal/torznab/adapter/search_options.go
Normal file
164
internal/torznab/adapter/search_options.go
Normal 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
|
||||
}
|
||||
168
internal/torznab/adapter/search_result.go
Normal file
168
internal/torznab/adapter/search_result.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ package torznab
|
||||
|
||||
const (
|
||||
AttrInfoHash = "infohash"
|
||||
AttrMagnetUrl = "magneturl"
|
||||
AttrMagnetURL = "magneturl"
|
||||
// AttrCategory is the Category ID
|
||||
AttrCategory = "category"
|
||||
AttrSize = "size"
|
||||
|
||||
@ -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 {
|
||||
|
||||
29
internal/torznab/config.go
Normal file
29
internal/torznab/config.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ const (
|
||||
FunctionCaps = "caps"
|
||||
FunctionSearch = "search"
|
||||
FunctionMovie = "movie"
|
||||
FunctionTv = "tvsearch"
|
||||
FunctionTV = "tvsearch"
|
||||
FunctionMusic = "music"
|
||||
FunctionBook = "book"
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
165
internal/torznab/httpserver/handler.go
Normal file
165
internal/torznab/httpserver/handler.go
Normal 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
|
||||
}
|
||||
}
|
||||
204
internal/torznab/httpserver/handler_test.go
Normal file
204
internal/torznab/httpserver/handler_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -3,6 +3,5 @@ package torznab
|
||||
import "context"
|
||||
|
||||
type Client interface {
|
||||
Caps(context.Context) (Caps, error)
|
||||
Search(context.Context, SearchRequest) (SearchResult, error)
|
||||
}
|
||||
|
||||
94
internal/torznab/mocks/Client.go
Normal file
94
internal/torznab/mocks/Client.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
|
||||
89
internal/torznab/profile.go
Normal file
89
internal/torznab/profile.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
//}
|
||||
@ -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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user