gmantaos/Goirate

View on GitHub
src/series.go

Summary

Maintainability
B
4 hrs
Test Coverage
package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "log"
    "os"
    "path"
    "regexp"
    "sort"
    "strconv"
    "strings"
    "time"

    "goirate/series"
    "goirate/torrents"
    "goirate/utils"

    "github.com/BurntSushi/toml"
    "github.com/olekukonko/tablewriter"
)

// SeriesCommand is the command used to add or remove series from the watchlist
// as well as perform a scan for new episodes.
type SeriesCommand struct {
    Add    addCommand    `command:"add" description:"Add a series to the watchlist."`
    Remove removeCommand `command:"remove" alias:"rm" description:"Remove a series from the watchlist."`
    Search searchCommand `command:"search" alias:"s" description:"Search for a series given a query string."`
    Show   showCommand   `command:"show" alias:"ls" description:"Print out the current series watchlist."`
    Scan   scanCommand   `command:"scan" description:"Perform a scan for new episodes on the existing watchlist."`
}

type addCommand struct {
    LastEpisode      string                `long:"last-episode" short:"e" description:"The last episode that came out."`
    MinQuality       torrents.VideoQuality `long:"min-quality" description:"The minimum video quality to accept when scanning for torrents of this series."`
    VerifiedUploader bool                  `long:"trusted" description:"Only accepted torrents from trusted or verified uploaders for this series."`
    Force            bool                  `long:"force" short:"f" description:"Overwrite this series if it already exists in the watchlist."`
    Show             bool                  `long:"ls" description:"Execute the show command after adding."`
    Args             struct {
        Title string `positional-arg-name:"<title | imdbID>"`
    } `positional-args:"1" required:"1"`
}
type removeCommand struct {
    Show bool `long:"ls" description:"Execute the show command after removing."`
    Args struct {
        Title string `positional-arg-name:"<title | id>"`
    } `positional-args:"1" required:"1"`
}
type searchCommand struct {
    Count int `long:"count" short:"c" description:"Limit the number of results."`
    Args  struct {
        Query string `positional-arg-name:"<title | imdb | tvmaze id>"`
    } `positional-args:"1" required:"1"`
}
type showCommand struct{}
type scanCommand struct {
    torrentSearchArgs

    DryRun   bool `long:"dry-run" description:"Perform the scan for new episodes without downloading torrents, sending notifications or updating the episode numbers in the watchlist."`
    NoUpdate bool `long:"no-update" description:"Perform the scan for new episodes without updating the last episode aired in the watchlist."`
    Quiet    bool `long:"quiet" short:"q" description:"Do not print anything to the standard output."`
    Quick    bool `long:"quick" description:"Perform a quick scan, only searching for torrents for episodes that were found on the tvmaze API."`
}

type seriesTorrent struct {
    Episode series.Episode   `json:"episode"`
    Torrent torrents.Torrent `json:"torrent"`
}
type seriesTorrents struct {
    Series   *series.Series  `json:"series"`
    Torrents []seriesTorrent `json:"torrents"`
}

// Execute is the callback of the series add command.
func (cmd *addCommand) Execute(args []string) error {

    tvmazeToken, err := tvmazeLogin()

    if err != nil {
        return err
    }

    show, err := tvmazeToken.SearchFirst(cmd.Args.Title)

    if err != nil {
        return err
    }

    var episode series.Episode

    if cmd.LastEpisode != "" {

        episode = series.ParseEpisodeString(cmd.LastEpisode)

        if episode.Season == 0 && episode.Episode == 0 {

            return fmt.Errorf("unable to parse the last episode number from: %v", cmd.LastEpisode)
        }

    } else {

        episode, err = tvmazeToken.LastEpisode(show.ID)

        if err != nil {
            return err
        }
    }

    ser := series.Series{
        ID:               show.ID,
        Title:            show.Name,
        MinQuality:       cmd.MinQuality,
        VerifiedUploader: cmd.VerifiedUploader,
        LastEpisode:      episode,
    }
    ser.Actions.Emails = []string{}

    seriesList := loadSeries()

    if containsID(seriesList, ser.ID) {

        if cmd.Force {

            remove(&seriesList, ser.ID, "")

        } else {

            return fmt.Errorf("series %v already on the watchlist", ser.Title)

        }
    }

    seriesList = append(seriesList, ser)

    storeSeries(seriesList)

    if cmd.Show {
        var showCmd showCommand
        return showCmd.Execute(args)
    }

    return nil
}

// Execute is the callback of the series remove command.
func (cmd *removeCommand) Execute(args []string) error {

    cmd.Args.Title = utils.NormalizeQuery(cmd.Args.Title)
    id, _ := strconv.Atoi(cmd.Args.Title)

    seriesList := loadSeries()

    if !remove(&seriesList, id, cmd.Args.Title) {

        return fmt.Errorf("no series found on the watchlist matching: %v\nhint: goirate series show", cmd.Args.Title)
    }

    storeSeries(seriesList)

    if cmd.Show {
        var showCmd showCommand
        return showCmd.Execute(args)
    }

    return nil
}

// Execute is the callback of the series search command.
func (cmd *searchCommand) Execute(args []string) error {

    tvmazeToken, err := tvmazeLogin()
    if err != nil {
        return err
    }

    searchResult, err := tvmazeToken.Search(cmd.Args.Query)
    if err != nil {
        return err
    }

    if Options.JSON {

        seriesJSON, err := json.MarshalIndent(searchResult, "", "   ")

        if err != nil {
            return err
        }

        log.Println(string(seriesJSON))

    } else {

        log.Println(getSeriesSearchTable(searchResult, cmd.Count))
    }

    return nil
}

// Execute is the callback of the series show command.
func (cmd *showCommand) Execute(args []string) error {

    seriesList := loadSeries()

    if Options.JSON {

        seriesJSON, err := json.MarshalIndent(seriesList, "", "   ")

        if err != nil {
            return err
        }

        log.Println(string(seriesJSON))

    } else {

        log.Print(getSeriesTable(seriesList))

    }

    return nil
}

// Execute is the callback of the series scan command.
func (cmd *scanCommand) Execute(args []string) error {

    if cmd.Quiet {
        disableOutput()
    }

    tvmazeToken, err := tvmazeLogin()

    if err != nil {
        return err
    }

    var torrentList []seriesTorrents

    seriesList := loadSeries()

    for i := range seriesList {

        ser := &seriesList[i]

        found := true

        for found && (cmd.Count == 0 || seriesTorrentCount(torrentList) < cmd.Count) {

            found, err = cmd.scanSeries(tvmazeToken, ser, &torrentList)

            if err != nil && os.Getenv("GOIRATE_DEBUG") == "true" {
                log.Println(err)
            }
        }
    }

    if !cmd.DryRun && !cmd.NoUpdate {

        storeSeries(seriesList)
    }

    if Options.JSON {

        torrentsJSON, err := json.MarshalIndent(torrentList, "", "   ")

        if err != nil {
            return err
        }

        log.Println(string(torrentsJSON))
    }

    if !cmd.DryRun {
        err = cmd.handleSeriesTorrents(torrentList)

        if err != nil {
            return err
        }
    }

    if cmd.Quiet {
        enableOutput()
    }

    return nil
}

func (cmd *scanCommand) scanSeries(tvmazeToken *series.TVmazeToken, ser *series.Series, torrentList *[]seriesTorrents) (bool, error) {

    filters := cmd.GetFilters()

    if ser.MinQuality != "" {
        filters.MinQuality = ser.MinQuality
    }
    filters.VerifiedUploader = filters.VerifiedUploader || ser.VerifiedUploader

    nextEpisode, err := ser.NextEpisode(tvmazeToken)

    if err != nil {

        return false, err
    }

    if cmd.Quick && !nextEpisode.HasAired() {

        return false, nil
    }

    if !cmd.MagnetLink && !Options.JSON && !cmd.TorrentURL {

        log.Printf("Searching for: %s %s\n", ser.Title, nextEpisode)
    }

    allTorrents, err := ser.GetTorrents(*filters, nextEpisode)

    if err != nil {

        return false, err
    }

    torrent, err := torrents.PickVideoTorrent(allTorrents, *filters)

    if err != nil || torrent == nil {

        return false, err
    }

    if cmd.MagnetLink {

        log.Println(torrent.Magnet)

    } else if Options.JSON {

        // Do nothing, the torrentList will be updated and printed by the calling func

    } else if cmd.TorrentURL {

        log.Println(torrent.FullURL())

    } else {

        log.Printf("Torrent found for: %s %s\n%s\n%s\n\n", ser.Title, nextEpisode, torrent.FullURL(), torrent.Magnet)
    }

    appendSeriesTorrent(torrentList, ser, nextEpisode, *torrent)

    ser.LastEpisode = nextEpisode

    return true, err
}

func (cmd *scanCommand) handleSeriesTorrents(seriesTorrentsList []seriesTorrents) error {

    for _, seriesTorrents := range seriesTorrentsList {

        /*
            Send e-mails, groupins episode torrents per series
        */
        if Config.Watchlist.SendEmail.OverridenBy(seriesTorrents.Series.Actions.SendEmail) {

            // Send e-mail

            notify := Config.Watchlist.Emails

            if len(seriesTorrents.Series.Actions.Emails) > 0 {

                notify = seriesTorrents.Series.Actions.Emails
            }

            if len(notify) == 0 {

                return fmt.Errorf("sending e-mails is enabled, but no recipients are specified")
            }

            log.Printf("Sending e-mail to: %s\n", notify)

            body, err := LoadSeriesTemplate(seriesTorrents)

            if err != nil {
                return err
            }

            var subject string

            if len(seriesTorrents.Torrents) > 1 {

                subject = fmt.Sprintf("Episodes out for %s (%s)", seriesTorrents.Series.Title, episodeRangeString(seriesTorrents))

            } else {

                subject = fmt.Sprintf("Episode out for %s (%s)", seriesTorrents.Series.Title, seriesTorrents.Torrents[0].Episode)
            }

            err = Config.SMTPConfig.SendEmail(subject, body, notify...)

            if err != nil {
                return err
            }
        }

        /*
            Loop over individual torrents to send each of them to qBittorrent for download
        */
        for _, seriesTorrent := range seriesTorrents.Torrents {

            if Config.Watchlist.Download.OverridenBy(seriesTorrents.Series.Actions.Download) {

                // Send the torrent to the qBittorrent daemon for download

                qbt, err := Config.QBittorrentConfig.GetClient()

                if err != nil {
                    return err
                }

                downloadPath := Config.DownloadDir.Series

                if Config.KodiMediaPaths {

                    downloadPath = path.Join(
                        downloadPath,
                        seriesTorrents.Series.Title,
                        fmt.Sprintf("Season %d", seriesTorrent.Episode.Season),
                    )
                }

                log.Printf("Downloading: %s %s (%s)\n", seriesTorrents.Series.Title, seriesTorrent.Episode, downloadPath)

                err = qbt.AddTorrent(seriesTorrent.Torrent.Magnet, downloadPath)

                if err != nil {
                    return err
                }

            }

        }

    }

    return nil
}

func appendSeriesTorrent(torrentList *[]seriesTorrents, ser *series.Series, episode series.Episode, torrent torrents.Torrent) {

    serTorrent := seriesTorrent{Torrent: torrent, Episode: episode}

    for i := range *torrentList {

        item := (*torrentList)[i]

        if item.Series.ID == ser.ID {

            (*torrentList)[i].Torrents = append(item.Torrents, serTorrent)

            return
        }
    }

    *torrentList = append(*torrentList, seriesTorrents{
        Series:   ser,
        Torrents: []seriesTorrent{serTorrent},
    })
}

func seriesTorrentCount(torrentList []seriesTorrents) uint {

    var count uint
    for _, seriesTorrents := range torrentList {

        count += uint(len(seriesTorrents.Torrents))
    }
    return count
}

func episodeRangeString(seriesTorrents seriesTorrents) string {

    min := series.Episode{Season: ^uint(0), Episode: ^uint(0)}
    max := series.Episode{Season: 0, Episode: 0}

    if len(seriesTorrents.Torrents) == 0 {

        return ""
    }

    for _, seriesTorrent := range seriesTorrents.Torrents {

        if min.IsAfter(seriesTorrent.Episode) {

            min = seriesTorrent.Episode
        }

        if seriesTorrent.Episode.IsAfter(max) {

            max = seriesTorrent.Episode
        }
    }

    if min.Season == max.Season && min.Episode == max.Episode {

        return min.String()
    }

    return fmt.Sprintf("%s - %s", min, max)
}

func loadSeries() []series.Series {

    var seriesList struct {
        Series []series.Series `toml:"series"`
    }

    if _, err := os.Stat(seriesConfigPath()); err == nil {

        tomlBytes, err := ioutil.ReadFile(seriesConfigPath())

        if err != nil {
            log.Fatal(err)
        }

        tomlString := string(tomlBytes)

        if _, err := toml.Decode(tomlString, &seriesList); err != nil {
            log.Fatal(err)
        }

    }

    sort.Slice(seriesList.Series, func(i, j int) bool {
        return seriesList.Series[i].Title < seriesList.Series[j].Title
    })

    return seriesList.Series
}

func storeSeries(seriesList []series.Series) {

    file, err := os.OpenFile(seriesConfigPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0777)

    if err != nil {
        log.Fatal(err)
    }

    defer file.Close()

    encoder := toml.NewEncoder(file)

    encoder.Encode(struct {
        Series []series.Series `toml:"series"`
    }{seriesList})
}

func seriesConfigPath() string {

    return path.Join(configDir(), "series.toml")
}

func capitalize(str string) string {

    // Function replacing words (assuming lower case input)
    replace := func(word string) string {
        switch word {
        case "with", "in", "a", "to", "of":
            return word
        }
        return strings.Title(word)
    }

    r := regexp.MustCompile(`\w+`)
    str = r.ReplaceAllStringFunc(strings.ToLower(str), replace)

    return str
}

func getSeriesTable(seriesList []series.Series) string {
    buf := bytes.NewBufferString("")

    table := tablewriter.NewWriter(buf)
    table.SetHeader([]string{"ID", "Series", "Season", "Last Episode", "Min. Quality"})
    table.SetColumnAlignment([]int{tablewriter.ALIGN_CENTER, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER})
    table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
    table.SetCenterSeparator("|")
    table.SetAutoFormatHeaders(false)

    for _, series := range seriesList {

        table.Append([]string{strconv.Itoa(series.ID), series.Title,
            fmt.Sprint(series.LastEpisode.Season), fmt.Sprint(series.LastEpisode.Episode), string(series.MinQuality)})
    }

    table.Render()

    return buf.String()
}

func tvmazeLogin() (*series.TVmazeToken, error) {

    cred := series.EnvTVmazeCredentials()

    tkn, err := cred.Login()

    if err != nil {
        return nil, errors.New("Error logging into the TVmaze API.: " + err.Error())
    }

    return &tkn, nil
}

func containsID(seriesList []series.Series, id int) bool {

    for _, ser := range seriesList {

        if ser.ID == id {
            return true
        }
    }

    return false
}

func remove(seriesList *[]series.Series, id int, title string) bool {

    var newList []series.Series
    var found bool

    for _, ser := range *seriesList {

        normalizedTitle := utils.NormalizeQuery(ser.Title)

        if (id == 0 && title != "" && strings.Contains(normalizedTitle, title)) || ser.ID == id {

            found = true
            continue
        }

        newList = append(newList, ser)
    }
    *seriesList = newList
    return found
}

func getSeriesSearchTable(searchResult []series.TVmazeSeries, count int) string {
    buf := bytes.NewBufferString("")

    table := tablewriter.NewWriter(buf)
    table.SetHeader([]string{"ID", "Title", "Premiered"})
    table.SetColumnAlignment([]int{tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_DEFAULT, tablewriter.ALIGN_CENTER})
    table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
    table.SetCenterSeparator("|")
    table.SetAutoFormatHeaders(false)

    for i, show := range searchResult {

        if count > 0 && i >= count {
            break
        }

        var premierYear string
        premierDate, err := time.Parse("2006-01-02", show.Premiered)
        if err == nil {
            premierYear = strconv.Itoa(premierDate.Year())
        }

        table.Append([]string{fmt.Sprint(show.ID), show.Name, premierYear})
    }

    table.Render()

    return buf.String()
}

func disableOutput() {
    var buf bytes.Buffer
    log.SetOutput(&buf)
}

func enableOutput() {
    log.SetOutput(os.Stdout)
}