autobrr/internal/domain/release.go
soup d153ac44b8
feat(filters): RED and OPS fetch record label from API (#1881)
* feat(filters): RED and OPS fetch record label from API

* test: add record label to RED and OPS test data

* refactor: record label check

---------

Co-authored-by: ze0s <ze0s@riseup.net>
2024-12-25 13:39:03 +01:00

994 lines
30 KiB
Go

// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later
package domain
import (
"bufio"
"bytes"
"context"
"fmt"
"html"
"io"
"math"
"net/http"
"net/http/cookiejar"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/sharedhttp"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"github.com/avast/retry-go"
"github.com/dustin/go-humanize"
"github.com/moistari/rls"
"golang.org/x/net/publicsuffix"
)
type ReleaseRepo interface {
Store(ctx context.Context, release *Release) error
Update(ctx context.Context, r *Release) error
Find(ctx context.Context, params ReleaseQueryParams) (*FindReleasesResponse, error)
Get(ctx context.Context, req *GetReleaseRequest) (*Release, error)
GetIndexerOptions(ctx context.Context) ([]string, error)
Stats(ctx context.Context) (*ReleaseStats, error)
Delete(ctx context.Context, req *DeleteReleaseRequest) error
CheckSmartEpisodeCanDownload(ctx context.Context, p *SmartEpisodeParams) (bool, error)
UpdateBaseURL(ctx context.Context, indexer string, oldBaseURL, newBaseURL string) error
GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error)
StoreReleaseActionStatus(ctx context.Context, status *ReleaseActionStatus) error
}
type Release struct {
ID int64 `json:"id"`
FilterStatus ReleaseFilterStatus `json:"filter_status"`
Rejections []string `json:"rejections"`
Indexer IndexerMinimal `json:"indexer"`
FilterName string `json:"filter"`
Protocol ReleaseProtocol `json:"protocol"`
Implementation ReleaseImplementation `json:"implementation"` // irc, rss, api
Timestamp time.Time `json:"timestamp"`
AnnounceType AnnounceType `json:"announce_type"`
InfoURL string `json:"info_url"`
DownloadURL string `json:"download_url"`
MagnetURI string `json:"-"`
GroupID string `json:"group_id"`
TorrentID string `json:"torrent_id"`
TorrentTmpFile string `json:"-"`
TorrentDataRawBytes []byte `json:"-"`
TorrentHash string `json:"-"`
TorrentName string `json:"name"` // full release name
Size uint64 `json:"size"`
Title string `json:"title"` // Parsed title
Description string `json:"-"`
Category string `json:"category"`
Categories []string `json:"categories,omitempty"`
Season int `json:"season"`
Episode int `json:"episode"`
Year int `json:"year"`
Month int `json:"month"`
Day int `json:"day"`
Resolution string `json:"resolution"`
Source string `json:"source"`
Codec []string `json:"codec"`
Container string `json:"container"`
HDR []string `json:"hdr"`
Audio []string `json:"-"`
AudioChannels string `json:"-"`
AudioFormat string `json:"-"`
Bitrate string `json:"-"`
Group string `json:"group"`
Region string `json:"-"`
Language []string `json:"-"`
Proper bool `json:"proper"`
Repack bool `json:"repack"`
Website string `json:"website"`
Artists string `json:"-"`
Type string `json:"type"` // Album,Single,EP
LogScore int `json:"-"`
HasCue bool `json:"-"`
HasLog bool `json:"-"`
Origin string `json:"origin"` // P2P, Internal
Tags []string `json:"-"`
ReleaseTags string `json:"-"`
Freeleech bool `json:"-"`
FreeleechPercent int `json:"-"`
Bonus []string `json:"-"`
Uploader string `json:"uploader"`
RecordLabel string `json:"record_label"`
PreTime string `json:"pre_time"`
Other []string `json:"-"`
RawCookie string `json:"-"`
Seeders int `json:"-"`
Leechers int `json:"-"`
AdditionalSizeCheckRequired bool `json:"-"`
AdditionalUploaderCheckRequired bool `json:"-"`
AdditionalRecordLabelCheckRequired bool `json:"-"`
FilterID int `json:"-"`
Filter *Filter `json:"-"`
ActionStatus []ReleaseActionStatus `json:"action_status"`
}
func (r *Release) Raw(s string) rls.Release {
return rls.ParseString(s)
}
type AnnounceType string
const (
// AnnounceTypeNew Default announce type
AnnounceTypeNew AnnounceType = "NEW"
// AnnounceTypeChecked Checked release
AnnounceTypeChecked AnnounceType = "CHECKED"
// AnnounceTypePromo Marked as promotion (neutral/half/feeeleech etc.)
AnnounceTypePromo AnnounceType = "PROMO"
// AnnounceTypePromoGP Marked Golden Popcorn, PTP specific
AnnounceTypePromoGP AnnounceType = "PROMO_GP"
// AnnounceTypeResurrect Reseeded/revived from dead
AnnounceTypeResurrect AnnounceType = "RESURRECTED"
)
func (a AnnounceType) String() string {
switch a {
case AnnounceTypeNew:
return "NEW"
case AnnounceTypeChecked:
return "CHECKED"
case AnnounceTypePromo:
return "PROMO"
case AnnounceTypePromoGP:
return "PROMO_GP"
case AnnounceTypeResurrect:
return "RESURRECTED"
}
return ""
}
// ParseAnnounceType parse AnnounceType from string
func ParseAnnounceType(s string) (AnnounceType, error) {
switch s {
case string(AnnounceTypeNew):
return AnnounceTypeNew, nil
case string(AnnounceTypeChecked):
return AnnounceTypeChecked, nil
case string(AnnounceTypePromo):
return AnnounceTypePromo, nil
case string(AnnounceTypePromoGP):
return AnnounceTypePromoGP, nil
case string(AnnounceTypeResurrect):
return AnnounceTypeResurrect, nil
default:
return "", fmt.Errorf("invalid AnnounceType: %s", s)
}
}
type ReleaseActionStatus struct {
ID int64 `json:"id"`
Status ReleasePushStatus `json:"status"`
Action string `json:"action"`
ActionID int64 `json:"action_id"`
Type ActionType `json:"type"`
Client string `json:"client"`
Filter string `json:"filter"`
FilterID int64 `json:"filter_id"`
Rejections []string `json:"rejections"`
ReleaseID int64 `json:"release_id"`
Timestamp time.Time `json:"timestamp"`
}
type DeleteReleaseRequest struct {
OlderThan int
Indexers []string
ReleaseStatuses []string
}
func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStatus {
s := &ReleaseActionStatus{
ID: 0,
Status: ReleasePushStatusPending,
Action: action.Name,
ActionID: int64(action.ID),
Type: action.Type,
Filter: release.FilterName,
FilterID: int64(release.FilterID),
Rejections: []string{},
Timestamp: time.Now(),
ReleaseID: release.ID,
}
if action.Client != nil {
s.Client = action.Client.Name
}
return s
}
type DownloadTorrentFileResponse struct {
MetaInfo *metainfo.MetaInfo
TmpFileName string
}
type ReleaseStats struct {
TotalCount int64 `json:"total_count"`
FilteredCount int64 `json:"filtered_count"`
FilterRejectedCount int64 `json:"filter_rejected_count"`
PushApprovedCount int64 `json:"push_approved_count"`
PushRejectedCount int64 `json:"push_rejected_count"`
PushErrorCount int64 `json:"push_error_count"`
}
type ReleasePushStatus string
const (
ReleasePushStatusPending ReleasePushStatus = "PENDING" // Initial status
ReleasePushStatusApproved ReleasePushStatus = "PUSH_APPROVED"
ReleasePushStatusRejected ReleasePushStatus = "PUSH_REJECTED"
ReleasePushStatusErr ReleasePushStatus = "PUSH_ERROR"
)
func (r ReleasePushStatus) String() string {
switch r {
case ReleasePushStatusPending:
return "Pending"
case ReleasePushStatusApproved:
return "Approved"
case ReleasePushStatusRejected:
return "Rejected"
case ReleasePushStatusErr:
return "Error"
default:
return "Unknown"
}
}
func ValidReleasePushStatus(s string) bool {
switch s {
case string(ReleasePushStatusPending):
return true
case string(ReleasePushStatusApproved):
return true
case string(ReleasePushStatusRejected):
return true
case string(ReleasePushStatusErr):
return true
default:
return false
}
}
type ReleaseFilterStatus string
const (
ReleaseStatusFilterApproved ReleaseFilterStatus = "FILTER_APPROVED"
ReleaseStatusFilterPending ReleaseFilterStatus = "PENDING"
//ReleaseStatusFilterRejected ReleaseFilterStatus = "FILTER_REJECTED"
)
type ReleaseProtocol string
const (
ReleaseProtocolTorrent ReleaseProtocol = "torrent"
ReleaseProtocolNzb ReleaseProtocol = "usenet"
)
func (r ReleaseProtocol) String() string {
switch r {
case ReleaseProtocolTorrent:
return "torrent"
case ReleaseProtocolNzb:
return "usenet"
default:
return "torrent"
}
}
type ReleaseImplementation string
const (
ReleaseImplementationIRC ReleaseImplementation = "IRC"
ReleaseImplementationTorznab ReleaseImplementation = "TORZNAB"
ReleaseImplementationNewznab ReleaseImplementation = "NEWZNAB"
ReleaseImplementationRSS ReleaseImplementation = "RSS"
)
func (r ReleaseImplementation) String() string {
switch r {
case ReleaseImplementationIRC:
return "IRC"
case ReleaseImplementationTorznab:
return "TORZNAB"
case ReleaseImplementationNewznab:
return "NEWZNAB"
case ReleaseImplementationRSS:
return "RSS"
default:
return "IRC"
}
}
type ReleaseQueryParams struct {
Limit uint64
Offset uint64
Cursor uint64
Sort map[string]string
Filters struct {
Indexers []string
PushStatus string
}
Search string
}
type FindReleasesResponse struct {
Data []*Release `json:"data"`
TotalCount uint64 `json:"count"`
NextCursor int64 `json:"next_cursor"`
}
type ReleaseActionRetryReq struct {
ReleaseId int
ActionStatusId int
ActionId int
}
type ReleaseProcessReq struct {
IndexerIdentifier string `json:"indexer_identifier"`
IndexerImplementation string `json:"indexer_implementation"`
AnnounceLines []string `json:"announce_lines"`
}
type GetReleaseRequest struct {
Id int
}
type GetReleaseActionStatusRequest struct {
Id int
}
func NewRelease(indexer IndexerMinimal) *Release {
r := &Release{
Indexer: indexer,
FilterStatus: ReleaseStatusFilterPending,
Rejections: []string{},
Protocol: ReleaseProtocolTorrent,
Implementation: ReleaseImplementationIRC,
Timestamp: time.Now(),
Tags: []string{},
Size: 0,
AnnounceType: AnnounceTypeNew,
}
return r
}
func (r *Release) ParseString(title string) {
rel := rls.ParseString(title)
r.Type = rel.Type.String()
r.TorrentName = title
r.Source = rel.Source
r.Resolution = rel.Resolution
r.Region = rel.Region
r.Audio = rel.Audio
r.AudioChannels = rel.Channels
r.Codec = rel.Codec
r.Container = rel.Container
r.HDR = rel.HDR
r.Artists = rel.Artist
r.Language = rel.Language
r.Other = rel.Other
r.Proper = slices.Contains(r.Other, "PROPER")
r.Repack = slices.Contains(r.Other, "REPACK")
if r.Title == "" {
r.Title = rel.Title
}
if r.Season == 0 {
r.Season = rel.Series
}
if r.Episode == 0 {
r.Episode = rel.Episode
}
if r.Year == 0 {
r.Year = rel.Year
}
if r.Month == 0 {
r.Month = rel.Month
}
if r.Day == 0 {
r.Day = rel.Day
}
if r.Group == "" {
r.Group = rel.Group
}
r.ParseReleaseTagsString(r.ReleaseTags)
r.extraParseSource(rel)
}
func (r *Release) extraParseSource(rel rls.Release) {
if rel.Type != rls.Movie && rel.Type != rls.Series && rel.Type != rls.Episode {
return
}
tags := rel.Tags()
if len(tags) < 3 {
return
}
// handle special cases like -VHS
if r.Group == "" {
// check the next to last item separator to be - or whitespace then check the next and use as group if empty
//if tags[len(tags)-1].TagType() == rls.TagTypeSource && (tags[len(tags)-2].TagType() == rls.TagTypeDelim && (tags[len(tags)-2].Delim() == "-" || tags[len(tags)-2].Delim() == " ")) {
lastItem := tags[len(tags)-1]
if lastItem.TagType() == rls.TagTypeSource && lastItem.Prev() == rls.TagTypeWhitespace {
group := lastItem.Text()
// handle special cases like -VHS
if r.Source == group {
r.Source = ""
}
r.Group = group
}
}
if basicContainsSlice(r.Source, []string{"WEB-DL", "BluRay", "UHD.BluRay"}) {
return
}
// check res to be 1080p or 2160p and codec to be AVC, HEVC or if other contains Remux, then set source to BluRay if it differs
if !basicContainsSlice(r.Source, []string{"WEB-DL", "BluRay", "UHD.BluRay"}) && basicContainsSlice(r.Resolution, []string{"1080p", "2160p"}) && basicContainsMatch(r.Codec, []string{"AVC", "HEVC"}) && basicContainsMatch(r.Other, []string{"REMUX"}) {
// handle missing or unexpected source for some bluray releases
if r.Resolution == "1080p" {
r.Source = "BluRay"
} else if r.Resolution == "2160p" {
r.Source = "UHD.BluRay"
}
}
}
func (r *Release) ParseReleaseTagsString(tags string) {
cleanTags := CleanReleaseTags(tags)
t := ParseReleaseTagString(cleanTags)
if len(t.Audio) > 0 {
//r.Audio = getUniqueTags(r.Audio, t.Audio)
r.Audio = t.Audio
}
if t.AudioBitrate != "" {
r.Bitrate = t.AudioBitrate
}
if t.AudioFormat != "" {
r.AudioFormat = t.AudioFormat
}
if r.AudioChannels == "" && t.Channels != "" {
r.AudioChannels = t.Channels
}
if t.HasLog {
r.HasLog = true
if t.LogScore > 0 {
r.LogScore = t.LogScore
}
}
if t.HasCue {
r.HasCue = true
}
if len(t.Bonus) > 0 {
if sliceContainsSlice([]string{"Freeleech", "Freeleech!"}, t.Bonus) {
r.Freeleech = true
}
// TODO handle percent and other types
r.Bonus = append(r.Bonus, t.Bonus...)
}
if len(t.Codec) > 0 {
r.Codec = getUniqueTags(r.Codec, append(make([]string, 0, 1), t.Codec))
}
if len(t.Other) > 0 {
r.Other = getUniqueTags(r.Other, t.Other)
}
if r.Origin == "" && t.Origin != "" {
r.Origin = t.Origin
}
if r.Container == "" && t.Container != "" {
r.Container = t.Container
}
if r.Resolution == "" && t.Resolution != "" {
r.Resolution = t.Resolution
}
if r.Source == "" && t.Source != "" {
r.Source = t.Source
}
}
// ParseSizeBytesString If there are parsing errors, then it keeps the original (or default size 0)
// Otherwise, it will update the size only if the new size is bigger than the previous one.
func (r *Release) ParseSizeBytesString(size string) {
s, err := humanize.ParseBytes(size)
if err == nil && s > r.Size {
r.Size = s
}
}
func (r *Release) OpenTorrentFile() error {
tmpFile, err := os.ReadFile(r.TorrentTmpFile)
if err != nil {
return errors.Wrap(err, "could not read torrent file: %v", r.TorrentTmpFile)
}
r.TorrentDataRawBytes = tmpFile
return nil
}
func (r *Release) DownloadTorrentFileCtx(ctx context.Context) error {
return r.downloadTorrentFile(ctx)
}
func (r *Release) downloadTorrentFile(ctx context.Context) error {
if r.HasMagnetUri() {
return errors.New("downloading magnet links is not supported: %s", r.MagnetURI)
} else if r.Protocol != ReleaseProtocolTorrent {
return errors.New("could not download file: protocol %s is not supported", r.Protocol)
}
if r.DownloadURL == "" {
return errors.New("download_file: url can't be empty")
} else if r.TorrentTmpFile != "" {
// already downloaded
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.DownloadURL, nil)
if err != nil {
return errors.Wrap(err, "error downloading file")
}
req.Header.Set("User-Agent", "autobrr")
client := http.Client{
Timeout: time.Second * 60,
Transport: sharedhttp.TransportTLSInsecure,
}
if r.RawCookie != "" {
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
return errors.Wrap(err, "could not create cookiejar")
}
client.Jar = jar
// set the cookie on the header instead of req.AddCookie
// since we have a raw cookie like "uid=10; pass=000"
req.Header.Set("Cookie", r.RawCookie)
}
tmpFilePattern := "autobrr-"
tmpDir := os.TempDir()
// Create tmp file
tmpFile, err := os.CreateTemp(tmpDir, tmpFilePattern)
if err != nil {
// inverse the err check to make it a bit cleaner
if !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "error creating tmp file")
}
if mkdirErr := os.MkdirAll(tmpDir, os.ModePerm); mkdirErr != nil {
return errors.Wrap(mkdirErr, "could not create TMP dir: %s", tmpDir)
}
tmpFile, err = os.CreateTemp(tmpDir, tmpFilePattern)
if err != nil {
return errors.Wrap(err, "error creating tmp file in: %s", tmpDir)
}
}
defer tmpFile.Close()
errFunc := retry.Do(func() error {
// Get the data
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "error downloading file")
}
defer resp.Body.Close()
// Check server response
switch resp.StatusCode {
case http.StatusOK:
// Continue processing the response
//case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
// // Handle redirect
// return retry.Unrecoverable(errors.New("redirect encountered for torrent (%s) file (%s) - status code: %d - check indexer keys for %s", r.TorrentName, r.DownloadURL, resp.StatusCode, r.Indexer.Name))
case http.StatusUnauthorized, http.StatusForbidden:
return retry.Unrecoverable(errors.New("unrecoverable error downloading torrent (%s) file (%s) - status code: %d - check indexer keys for %s", r.TorrentName, r.DownloadURL, resp.StatusCode, r.Indexer.Name))
case http.StatusMethodNotAllowed:
return retry.Unrecoverable(errors.New("unrecoverable error downloading torrent (%s) file (%s) from '%s' - status code: %d. Check if the request method is correct", r.TorrentName, r.DownloadURL, r.Indexer.Name, resp.StatusCode))
case http.StatusNotFound:
return errors.New("torrent %s not found on %s (%d) - retrying", r.TorrentName, r.Indexer.Name, resp.StatusCode)
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return errors.New("server error (%d) encountered while downloading torrent (%s) file (%s) from '%s' - retrying", resp.StatusCode, r.TorrentName, r.DownloadURL, r.Indexer.Name)
case http.StatusInternalServerError:
return errors.New("server error (%d) encountered while downloading torrent (%s) file (%s) - check indexer keys for %s", resp.StatusCode, r.TorrentName, r.DownloadURL, r.Indexer.Name)
default:
return retry.Unrecoverable(errors.New("unexpected status code %d: check indexer keys for %s", resp.StatusCode, r.Indexer.Name))
}
resetTmpFile := func() {
tmpFile.Seek(0, io.SeekStart)
tmpFile.Truncate(0)
}
// Read the body into bytes
bodyBytes, err := io.ReadAll(bufio.NewReader(resp.Body))
if err != nil {
return errors.Wrap(err, "error reading response body")
}
// Create a new reader for bodyBytes
bodyReader := bytes.NewReader(bodyBytes)
// Try to decode as torrent file
meta, err := metainfo.Load(bodyReader)
if err != nil {
resetTmpFile()
// explicitly check for unexpected content type that match html
var bse *bencode.SyntaxError
if errors.As(err, &bse) {
// regular error so we can retry if we receive html first run
return errors.Wrap(err, "metainfo unexpected content type, got HTML expected a bencoded torrent. check indexer keys for %s - %s", r.Indexer.Name, r.TorrentName)
}
return retry.Unrecoverable(errors.Wrap(err, "metainfo unexpected content type. check indexer keys for %s - %s", r.Indexer.Name, r.TorrentName))
}
// Write the body to file
if _, err := tmpFile.Write(bodyBytes); err != nil {
resetTmpFile()
return errors.Wrap(err, "error writing downloaded file: %s", tmpFile.Name())
}
torrentMetaInfo, err := meta.UnmarshalInfo()
if err != nil {
resetTmpFile()
return retry.Unrecoverable(errors.Wrap(err, "metainfo could not unmarshal info from torrent: %s", tmpFile.Name()))
}
hashInfoBytes := meta.HashInfoBytes().Bytes()
if len(hashInfoBytes) < 1 {
resetTmpFile()
return retry.Unrecoverable(errors.New("could not read infohash"))
}
r.TorrentTmpFile = tmpFile.Name()
r.TorrentHash = meta.HashInfoBytes().String()
r.Size = uint64(torrentMetaInfo.TotalLength())
return nil
},
retry.Delay(time.Second*3),
retry.Attempts(3),
retry.MaxJitter(time.Second*1),
)
return errFunc
}
func (r *Release) CleanupTemporaryFiles() {
if r.TorrentTmpFile == "" {
return
}
os.Remove(r.TorrentTmpFile)
r.TorrentTmpFile = ""
}
// HasMagnetUri check uf MagnetURI is set and valid or empty
func (r *Release) HasMagnetUri() bool {
if r.MagnetURI != "" && strings.HasPrefix(r.MagnetURI, MagnetURIPrefix) {
return true
}
return false
}
const MagnetURIPrefix = "magnet:?"
// MapVars map vars from regex captures to fields on release
func (r *Release) MapVars(def *IndexerDefinition, varMap map[string]string) error {
if torrentName, err := getStringMapValue(varMap, "torrentName"); err != nil {
return errors.Wrap(err, "failed parsing required field")
} else {
r.TorrentName = html.UnescapeString(torrentName)
}
if torrentID, err := getStringMapValue(varMap, "torrentId"); err == nil {
r.TorrentID = torrentID
}
if category, err := getStringMapValue(varMap, "category"); err == nil {
r.Category = category
}
if announceType, err := getStringMapValue(varMap, "announceType"); err == nil {
annType, parseErr := ParseAnnounceType(announceType)
if parseErr == nil {
r.AnnounceType = annType
}
}
if freeleech, err := getStringMapValue(varMap, "freeleech"); err == nil {
fl := StringEqualFoldMulti(freeleech, "1", "fl", "free", "freeleech", "freeleech!", "yes", "VIP", "★")
if fl {
r.Freeleech = true
// default to 100 and override if freeleechPercent is present in next function
r.FreeleechPercent = 100
r.Bonus = append(r.Bonus, "Freeleech")
}
}
if freeleechPercent, err := getStringMapValue(varMap, "freeleechPercent"); err == nil {
// special handling for BHD to map their freeleech into percent
if def.Identifier == "beyondhd" {
if freeleechPercent == "Capped FL" {
freeleechPercent = "100%"
} else if strings.Contains(freeleechPercent, "% FL") {
freeleechPercent = strings.Replace(freeleechPercent, " FL", "", -1)
}
}
// remove % and trim spaces
freeleechPercent = strings.Replace(freeleechPercent, "%", "", -1)
freeleechPercent = strings.Trim(freeleechPercent, " ")
freeleechPercentInt, parseErr := strconv.Atoi(freeleechPercent)
if parseErr == nil {
if freeleechPercentInt > 0 {
r.Freeleech = true
r.FreeleechPercent = freeleechPercentInt
r.Bonus = append(r.Bonus, "Freeleech")
switch freeleechPercentInt {
case 25:
r.Bonus = append(r.Bonus, "Freeleech25")
case 50:
r.Bonus = append(r.Bonus, "Freeleech50")
case 75:
r.Bonus = append(r.Bonus, "Freeleech75")
case 100:
r.Bonus = append(r.Bonus, "Freeleech100")
}
}
}
}
if downloadVolumeFactorVar, ok := varMap["downloadVolumeFactor"]; ok {
// special handling for BHD to map their freeleech into percent
//if def.Identifier == "beyondhd" {
// if freeleechPercent == "Capped FL" {
// freeleechPercent = "100%"
// } else if strings.Contains(freeleechPercent, "% FL") {
// freeleechPercent = strings.Replace(freeleechPercent, " FL", "", -1)
// }
//}
//r.downloadVolumeFactor = downloadVolumeFactor
// Parse the value as decimal number
downloadVolumeFactor, parseErr := strconv.ParseFloat(downloadVolumeFactorVar, 64)
if parseErr == nil {
// Values below 0.0 and above 1.0 are rejected
if downloadVolumeFactor >= 0 || downloadVolumeFactor <= 1 {
// Multiply by 100 to convert from ratio to percentage and round it
// to the nearest integer value
downloadPercentage := math.Round(downloadVolumeFactor * 100)
// To convert from download percentage to freeleech percentage the
// value is inverted
r.FreeleechPercent = 100 - int(downloadPercentage)
if r.FreeleechPercent > 0 {
r.Freeleech = true
}
}
}
}
//if uploadVolumeFactor, err := getStringMapValue(varMap, "uploadVolumeFactor"); err == nil {
// // special handling for BHD to map their freeleech into percent
// //if def.Identifier == "beyondhd" {
// // if freeleechPercent == "Capped FL" {
// // freeleechPercent = "100%"
// // } else if strings.Contains(freeleechPercent, "% FL") {
// // freeleechPercent = strings.Replace(freeleechPercent, " FL", "", -1)
// // }
// //}
//
// r.uploadVolumeFactor = uploadVolumeFactor
//
// //freeleechPercentInt, err := strconv.Atoi(freeleechPercent)
// //if err != nil {
// // //log.Debug().Msgf("bad freeleechPercent var: %v", year)
// //}
// //
// //if freeleechPercentInt > 0 {
// // r.Freeleech = true
// // r.FreeleechPercent = freeleechPercentInt
// //}
//}
if uploader, err := getStringMapValue(varMap, "uploader"); err == nil {
r.Uploader = uploader
}
if recordLabel, err := getStringMapValue(varMap, "recordLabel"); err == nil {
r.RecordLabel = recordLabel
}
if torrentSize, err := getStringMapValue(varMap, "torrentSize"); err == nil {
// Some indexers like BTFiles announces size with comma. Humanize does not handle that well and strips it.
torrentSize = strings.Replace(torrentSize, ",", ".", 1)
// handling for indexer who doesn't explicitly set which size unit is used like (AR)
if def.IRC != nil && def.IRC.Parse != nil && def.IRC.Parse.ForceSizeUnit != "" {
torrentSize = fmt.Sprintf("%s %s", torrentSize, def.IRC.Parse.ForceSizeUnit)
}
size, parseErr := humanize.ParseBytes(torrentSize)
if parseErr == nil {
r.Size = size
}
}
if torrentSizeBytes, err := getStringMapValue(varMap, "torrentSizeBytes"); err == nil {
size, parseErr := strconv.ParseUint(torrentSizeBytes, 10, 64)
if parseErr == nil {
r.Size = size
}
}
if scene, err := getStringMapValue(varMap, "scene"); err == nil {
if StringEqualFoldMulti(scene, "true", "yes", "1") {
r.Origin = "SCENE"
}
}
// set origin. P2P, SCENE, O-SCENE and Internal
if origin, err := getStringMapValue(varMap, "origin"); err == nil {
r.Origin = origin
}
if internal, err := getStringMapValue(varMap, "internal"); err == nil {
if StringEqualFoldMulti(internal, "internal", "yes", "1") {
r.Origin = "INTERNAL"
}
}
if yearVal, err := getStringMapValue(varMap, "year"); err == nil {
year, parseErr := strconv.Atoi(yearVal)
if parseErr == nil {
r.Year = year
}
}
if tags, err := getStringMapValue(varMap, "tags"); err == nil {
if tags != "" && tags != "*" {
tagsArr := []string{}
s := strings.Split(tags, ",")
for _, t := range s {
tagsArr = append(tagsArr, strings.Trim(t, " "))
}
r.Tags = tagsArr
}
}
if title, err := getStringMapValue(varMap, "title"); err == nil {
if title != "" && title != "*" {
r.Title = title
}
}
// handle releaseTags. Most of them are redundant but some are useful
if releaseTags, err := getStringMapValue(varMap, "releaseTags"); err == nil {
r.ReleaseTags = releaseTags
}
if resolution, err := getStringMapValue(varMap, "resolution"); err == nil {
r.Resolution = resolution
}
if releaseGroup, err := getStringMapValue(varMap, "releaseGroup"); err == nil {
r.Group = releaseGroup
}
if episodeVal, err := getStringMapValue(varMap, "releaseEpisode"); err == nil {
episode, _ := strconv.Atoi(episodeVal)
r.Episode = episode
}
//if metaImdb, err := getStringMapValue(varMap, "imdb"); err == nil {
// r.MetaIMDB = metaImdb
//}
return nil
}
func getStringMapValue(stringMap map[string]string, key string) (string, error) {
lowerKey := strings.ToLower(key)
// case-insensitive match
for k, v := range stringMap {
if strings.ToLower(k) == lowerKey {
return v, nil
}
}
return "", errors.New("key was not found in map: %q", lowerKey)
}
func SplitAny(s string, seps string) []string {
splitter := func(r rune) bool {
return strings.ContainsRune(seps, r)
}
return strings.FieldsFunc(s, splitter)
}
func StringEqualFoldMulti(s string, values ...string) bool {
for _, value := range values {
if strings.EqualFold(s, value) {
return true
}
}
return false
}
func getUniqueTags(target []string, source []string) []string {
toAppend := make([]string, 0, len(source))
for _, t := range source {
found := false
norm := rls.MustNormalize(t)
for _, s := range target {
if rls.MustNormalize(s) == norm {
found = true
break
}
}
if !found {
toAppend = append(toAppend, t)
}
}
target = append(target, toAppend...)
return target
}