package main
import (
"github.com/rs/zerolog/log"
"github.com/roarc0/gofetch/internal/gofetch"
)
func downloadAllNew(gf *gofetch.GoFetch) {
dls, err := gf.Fetch()
if err != nil {
log.Error().Err(err).Msg("Failed to fetch")
}
log.Info().Msgf("Found %d downloadables", len(dls))
for _, dl := range dls {
if dl.Optional {
continue
}
err := gf.Download(dl)
if err != nil {
log.Error().Err(err).Msg("Error")
continue
}
log.Info().Str("name", dl.Name()).Msg("Downloading")
}
}
package main
import (
"flag"
"github.com/roarc0/gofetch/internal/config"
"github.com/roarc0/gofetch/internal/gofetch"
"github.com/roarc0/gofetch/internal/logger"
"github.com/roarc0/gofetch/internal/memory"
"github.com/rs/zerolog/log"
)
func main() {
cfgPath := flag.String("config", ".", "Path to the configuration file")
mode := flag.String("mode", "manual", "Mode to run the application in")
flag.Parse()
logger.SetupLogger()
cfg, err := config.LoadYaml(*cfgPath)
if err != nil {
log.Fatal().Err(err).Msg("config.LoadYaml()")
}
memory, err := memory.NewMemory(cfg.Memory.FilePath, "downloads")
if err != nil {
log.Fatal().Err(err).Msg("memory.NewMemory()")
}
gf, err := gofetch.NewGoFetch(cfg, memory)
if err != nil {
log.Error().Err(err).Msg("Failed to create GoFetch object")
}
switch *mode {
case "auto":
downloadAllNew(gf)
case "manual":
runTea(gf)
}
if err != nil {
log.Error().Err(err).Msg("Failed to create GoFetch object")
}
}
package main
import (
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/rs/zerolog/log"
"github.com/roarc0/gofetch/internal/gofetch"
)
type (
errMsg error
)
func runTea(gf *gofetch.GoFetch) {
p := tea.NewProgram(commandModel(gf))
if _, err := p.Run(); err != nil {
log.Error().Err(err).Msg("Alas, there's been an error")
os.Exit(1)
}
}
package main
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/shlex"
"github.com/rs/zerolog/log"
"github.com/roarc0/gofetch/internal/filter"
"github.com/roarc0/gofetch/internal/gofetch"
)
type model struct {
gf *gofetch.GoFetch
textInput textinput.Model
err error
}
func commandModel(gf *gofetch.GoFetch) model {
ti := textinput.New()
ti.Prompt = "> "
ti.Focus()
ti.ShowSuggestions = true
ti.SetSuggestions([]string{"fetch", "help", "quit", "clear"})
ti.CompletionStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#333"))
ti.PromptStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205"))
ti.CharLimit = 128
ti.Width = 20
return model{
gf: gf,
textInput: ti,
err: nil,
}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC, tea.KeyCtrlD:
return m, tea.Quit
case tea.KeyEnter:
{
args, err := shlex.Split(m.textInput.Value())
if err != nil {
return m, nil
}
log.Info().
Any("args", args).
Msg("Command")
if len(args) == 0 {
return m, nil
}
switch args[0] {
case "fetch", "f":
nm := newDownloadsModel(m.gf, func() ([]gofetch.Downloadable, error) {
return m.gf.Fetch()
})
return nm, nm.Init()
case "search", "s":
if len(args) != 3 {
return m, nil
}
nm := newDownloadsModel(m.gf, func() ([]gofetch.Downloadable, error) {
filter := filter.NewFilter([]filter.Matcher{
&filter.RegexMatcher{
MatchType: filter.MatchTypeRequired,
Regex: args[2],
},
})
return m.gf.Search(args[1], filter)
})
return nm, nm.Init()
case "help", "h":
//nm := newHelpModel(m.gf)
//return nm, nm.Init()
case "quit", "q":
return m, tea.Quit
case "clear", "c":
return m, tea.ClearScreen
}
}
}
case errMsg:
m.err = msg
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m model) View() string {
s := ""
if m.err != nil {
s += m.err.Error() + "\n\n"
}
s += fmt.Sprintf(
"GoFetch\n\n%s\n",
m.textInput.View(),
)
return s
}
package main
import (
"fmt"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/roarc0/gofetch/internal/gofetch"
)
const (
tableHeight = 30
)
type inDlsMsg []gofetch.Downloadable
type outDl struct {
idx int
err error
}
type outDlsMsg []outDl
type downloadsModel struct {
gf *gofetch.GoFetch
err error
fetchingSpinner spinner.Model
fetched bool
fetcher func() ([]gofetch.Downloadable, error)
table table.Model
selections map[int]gofetch.Action
interactive bool
dls []gofetch.Downloadable
}
func newDownloadsModel(gf *gofetch.GoFetch, fetcher func() ([]gofetch.Downloadable, error)) tea.Model {
m := downloadsModel{
gf: gf,
selections: make(map[int]gofetch.Action, 0),
fetcher: fetcher,
}
spn := spinner.New()
spn.Spinner = spinner.Dot
spn.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
m.fetchingSpinner = spn
t := table.New(
table.WithColumns([]table.Column{
{Title: "Action", Width: 10},
{Title: "Name", Width: 80},
}),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
m.table = t
return m
}
func (m downloadsModel) Init() tea.Cmd {
return tea.Batch(
m.fetchingSpinner.Tick,
m.fetchCommand(),
)
}
func (m downloadsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "ctrl+d":
return m, tea.Quit
case "esc", "q":
return commandModel(m.gf), nil
case "up", "k":
m.table.MoveUp(1)
case "down", "j":
m.table.MoveDown(1)
case "ctrl+k":
m.table.MoveUp(5)
case "ctrl+j":
m.table.MoveDown(5)
case " ":
m.selectionChange()
case "a":
m.selectionChangeAll(gofetch.NoAction, gofetch.DownloadAction)
m.selectionChangeAll(gofetch.IgnoreAction, gofetch.DownloadAction)
case "i":
m.selectionChangeAll(gofetch.DownloadAction, gofetch.IgnoreAction)
m.selectionChangeAll(gofetch.NoAction, gofetch.IgnoreAction)
case "u":
m.selectionChangeAll(gofetch.DownloadAction, gofetch.NoAction)
case "ctrl+s":
return m, m.streamCommand()
case "enter":
// if !m.interactive || m.err != nil {
// return commandModel(m.gf), nil
// }
return m, m.downloadCommand()
}
case inDlsMsg:
m.fetchDone(msg)
case errMsg:
m.fetched = true
m.err = msg
case outDlsMsg:
if len(m.dls) == 0 {
return commandModel(m.gf), nil
}
nm := newDownloadsModel(m.gf, m.fetcher)
return nm, nm.Init()
case spinner.TickMsg:
if !m.fetched {
var cmd tea.Cmd
m.fetchingSpinner, cmd = m.fetchingSpinner.Update(msg)
return m, cmd
}
}
return m, nil
}
func (m downloadsModel) View() string {
if !m.fetched {
return fmt.Sprintf("\n\n %s Loading ... press q to quit\n\n", m.fetchingSpinner.View())
}
if m.err != nil {
return "\n" + m.err.Error() + "\n"
}
return m.showTable()
}
func (m downloadsModel) showTable() string {
if len(m.dls) == 0 {
return "\nNo items found.\n"
}
var s string
s += "Press <j>,<k> or use arrow keys to navigate.\n"
if m.interactive {
s += "Press <space> to change the action to perform on the item \n"
s += "Press <u> unselect all, <a> download all, <i> ignore all.\n"
}
s += "Press <Ctrl+s> to stream (needs webtorrent installed)\n\n"
s += m.table.View() + "\n"
s += "\nPress <enter> to proceed, <esc> to abort.\n"
return s
}
func (m *downloadsModel) fetchDone(dls inDlsMsg) {
m.fetched = true
for _, dl := range dls {
if dl.Action.Seen() {
continue
}
i := len(m.dls)
if dl.Optional {
m.selections[i] = gofetch.IgnoreAction
} else {
m.selections[i] = gofetch.DownloadAction
}
m.dls = append(m.dls, dl)
}
m.interactive = len(m.dls) != 0
if !m.interactive {
m.dls = dls
clear(m.selections)
}
m.updateTable()
}
func (m *downloadsModel) updateTable() {
rows := []table.Row{}
for i, dl := range m.dls {
var actionStr string
if !m.interactive {
actionStr = fmt.Sprintf("[%s]", dl.Action.String())
} else {
actionStr = m.selections[i].String()
if len(actionStr) != 0 {
actionStr = fmt.Sprintf("<%s>", actionStr)
}
}
rows = append(rows,
table.Row{
actionStr,
dl.Name(),
},
)
}
m.table.SetRows(rows)
}
func (m downloadsModel) getAction(idx int) gofetch.Action {
action := gofetch.NoAction
if v, ok := m.selections[idx]; ok {
action = v
}
return action
}
func (m downloadsModel) fetchCommand() tea.Cmd {
return func() tea.Msg {
dls, err := m.fetcher()
if err != nil {
return errMsg(err)
}
return inDlsMsg(dls)
}
}
func (m downloadsModel) downloadCommand() tea.Cmd {
return func() tea.Msg {
var msg outDlsMsg
for i, action := range m.selections {
var err error
switch action {
case gofetch.DownloadAction:
err = m.gf.Download(m.dls[i])
case gofetch.IgnoreAction:
err = m.gf.Ignore(m.dls[i])
}
msg = append(msg, outDl{i, err})
}
return msg
}
}
func (m downloadsModel) streamCommand() tea.Cmd {
return func() tea.Msg {
dl := m.dls[m.table.Cursor()]
err := m.gf.Stream(dl)
return errMsg(err)
}
}
func (m *downloadsModel) selectionChangeAll(from gofetch.Action, to gofetch.Action) {
if !m.interactive {
return
}
for i := range m.dls {
if from == gofetch.NoAction {
m.selections[i] = to
continue
}
if to == gofetch.NoAction {
delete(m.selections, i)
continue
}
if v, ok := m.selections[i]; ok && v == from {
m.selections[i] = to
}
}
m.updateTable()
}
func (m *downloadsModel) selectionChange() {
if !m.interactive {
return
}
cursor := m.table.Cursor()
if v, ok := m.selections[cursor]; ok {
switch v {
case gofetch.DownloadAction:
m.selections[cursor] = gofetch.IgnoreAction
case gofetch.IgnoreAction:
delete(m.selections, cursor)
}
} else {
m.selections[cursor] = gofetch.DownloadAction
}
m.updateTable()
}
package collector
import (
"context"
"crypto"
"fmt"
"time"
)
// DownloadableCollector is an interface that defines a method to collect downloadables.
type DownloadableCollector interface {
Collect(ctx context.Context) ([]Downloadable, error)
}
// Downloadable is an interface that defines a method to get the name and URI of a downloadable.
type Downloadable interface {
fmt.Stringer
Name() string
URI() string
Size() uint64
Date() time.Time
}
// Downloader is an interface that defines a method to open a downloadable.
type Downloader interface {
Download(d Downloadable) error
}
func Hash(d Downloadable) string {
h := crypto.SHA1.New()
h.Write([]byte(d.URI()))
return fmt.Sprintf("%x", h.Sum(nil))
}
type CollectorConfig struct {
HTTP HttpConfig
PageCount int
}
type CollectorOption func(*CollectorConfig) error
func WithPageCount(page int) CollectorOption {
return func(cfg *CollectorConfig) error {
cfg.PageCount = page
return nil
}
}
func WithHTTPConfig(http HttpConfig) CollectorOption {
return func(cfg *CollectorConfig) error {
cfg.HTTP = http
return nil
}
}
func processOptions(opts ...CollectorOption) (*CollectorConfig, error) {
cfg := CollectorConfig{}
for _, opt := range opts {
err := opt(&cfg)
if err != nil {
return nil, err
}
}
cfg.HTTP.SetDefaultsOnEmptyFields()
return &cfg, nil
}
package collector
import (
"context"
"crypto/tls"
"net"
"net/http"
"time"
"github.com/gocolly/colly/v2"
"github.com/hashicorp/go-retryablehttp"
)
const (
defaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
defaultDNSResolverIP = "1.1.1.1:53"
defaultDNSResolverProto = "udp"
defaultDNSResolverTimeoutMs = 5000
defaultRetryMax = 2
)
type HttpConfig struct {
UserAgent string
DNSResolver string
DNSProto string
DNSTimeout int
Insecure bool
RetryMax int
}
func (cfg *HttpConfig) SetDefaultsOnEmptyFields() {
if cfg.DNSResolver == "" {
cfg.DNSResolver = defaultDNSResolverIP
}
if cfg.DNSProto == "" {
cfg.DNSProto = defaultDNSResolverProto
}
if cfg.DNSTimeout == 0 {
cfg.DNSTimeout = defaultDNSResolverTimeoutMs
}
if cfg.UserAgent == "" {
cfg.UserAgent = defaultUserAgent
}
if cfg.RetryMax == 0 {
cfg.RetryMax = defaultRetryMax
}
}
func newColly(cfg *HttpConfig) *colly.Collector {
cfg.SetDefaultsOnEmptyFields()
c := colly.NewCollector()
c.UserAgent = defaultUserAgent
c.SetClient(httpClient(cfg))
return c
}
func httpClient(cfg *HttpConfig) *http.Client {
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = cfg.RetryMax
retryClient.HTTPClient.Timeout = 7 * time.Second
dialer := &net.Dialer{
Resolver: &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Duration(cfg.DNSTimeout) * time.Millisecond,
}
return d.DialContext(ctx, cfg.DNSProto, cfg.DNSResolver)
},
},
}
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
}
tr := &http.Transport{
DialContext: dialContext,
}
if cfg.Insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
retryClient.HTTPClient.Transport = tr
retryClient.Logger = nil
retryClient.RequestLogHook = func(l retryablehttp.Logger, req *http.Request, attempt int) {
req.Header.Del("Accept")
}
return retryClient.StandardClient()
}
package collector
func Factory(source Source) (DownloadableCollector, error) {
switch source.Type {
case "nyaa":
return NewNyaaMagnetCollector(source.URIs[0])
case "magnetdl":
return NewMagnetDLMagnetCollector(source.URIs[0])
case "generic":
fallthrough
default:
return NewMagnetCollector(source.URIs[0])
}
}
package collector
import "time"
type Magnet struct {
name string
uri string
size uint64
time time.Time
}
func NewMagnet(name, uri string) *Magnet {
return &Magnet{
name: name,
uri: uri,
}
}
func (m Magnet) Name() string {
return m.name
}
func (m Magnet) URI() string {
return m.uri
}
func (m Magnet) String() string {
return m.name + " " + m.uri
}
func (m Magnet) Size() uint64 {
return m.size
}
func (m Magnet) Date() time.Time {
return m.time
}
package collector
import (
"context"
"strings"
"github.com/gocolly/colly/v2"
"github.com/roarc0/go-magnet"
)
type MagnetCollector struct {
colly *colly.Collector
uri string
}
func NewMagnetCollector(uri string, opts ...CollectorOption) (*MagnetCollector, error) {
c := &MagnetCollector{
uri: uri,
}
cfg, err := processOptions(opts...)
if err != nil {
return nil, err
}
if c.colly == nil {
c.colly = newColly(&cfg.HTTP)
}
return c, nil
}
func (c *MagnetCollector) Collect(ctx context.Context) ([]Downloadable, error) {
dls := []Downloadable{}
c.colly.OnHTML("a",
func(e *colly.HTMLElement) {
href := e.Attr("href")
if !strings.HasPrefix(href, "magnet:?") {
return
}
magnet, err := magnet.Parse(href)
if err != nil {
return
}
name := e.Text
if len(magnet.DisplayNames) > 0 {
name = magnet.DisplayNames[0]
}
dls = append(dls,
Magnet{
name: name,
uri: href,
size: magnet.ExactLength,
})
})
if err := c.colly.Visit(c.uri); err != nil {
return nil, err
}
return dls, nil
}
package collector
import (
"context"
"strings"
"github.com/gocolly/colly/v2"
)
type MagnetDLMagnetCollector struct {
colly *colly.Collector
uri string
}
func NewMagnetDLMagnetCollector(uri string, opts ...CollectorOption) (*MagnetDLMagnetCollector, error) {
c := &MagnetDLMagnetCollector{
uri: uri,
}
cfg, err := processOptions(opts...)
if err != nil {
return nil, err
}
if c.colly == nil {
c.colly = newColly(&cfg.HTTP)
}
return c, nil
}
func (c *MagnetDLMagnetCollector) Collect(ctx context.Context) ([]Downloadable, error) {
dls := []Downloadable{}
tmpMagnet := Magnet{}
c.colly.OnHTML("a",
func(e *colly.HTMLElement) {
href := e.Attr("href")
if strings.HasPrefix(href, "magnet:") {
tmpMagnet.uri = href
return
}
if len(e.Attr("title")) != 0 {
tmpMagnet.name = e.Attr("title")
}
if tmpMagnet.name != "" && tmpMagnet.uri != "" {
dls = append(dls, tmpMagnet)
tmpMagnet = Magnet{}
}
})
// c.colly.OnHTML("td",
// func(e *colly.HTMLElement) {
// text := e.Text
// if strings.HasSuffix(text, "MB") {
// val, err := strconv.Atoi(strings.Split(text, " ")[0])
// if err == nil {
// tmpMagnet.size = uint64(val)
// }
// }
// if strings.HasSuffix(text, "months") {
// t, err := dateparse.ParseAny(fmt.Sprintf("%s ago", text))
// if err != nil {
// return
// }
// tmpMagnet.time = t
// }
// if tmpMagnet.name != "" && tmpMagnet.uri != "" {
// dls = append(dls, tmpMagnet)
// tmpMagnet = Magnet{}
// }
// })
if err := c.colly.Visit(c.uri); err != nil {
return nil, err
}
return dls, nil
}
package collector
import (
"context"
"strings"
"github.com/gocolly/colly/v2"
"github.com/roarc0/go-magnet"
)
type NyaaMagnetCollector struct {
colly *colly.Collector
uri string
}
func NewNyaaMagnetCollector(uri string, opts ...CollectorOption) (*NyaaMagnetCollector, error) {
c := &NyaaMagnetCollector{
uri: uri,
}
cfg, err := processOptions(opts...)
if err != nil {
return nil, err
}
if c.colly == nil {
c.colly = newColly(&cfg.HTTP)
}
return c, nil
}
func (c *NyaaMagnetCollector) Collect(ctx context.Context) ([]Downloadable, error) {
dls := []Downloadable{}
tmpMagnet := Magnet{}
c.colly.OnHTML("a",
func(e *colly.HTMLElement) {
title := e.Attr("title")
if len(title) != 0 {
tmpMagnet.name = title
return // the title should appear before href
}
href := e.Attr("href")
if strings.HasPrefix(href, "magnet:") {
tmpMagnet.uri = href
}
magnet, err := magnet.Parse(href)
if err != nil {
return
}
if magnet.ExactLength != 0 {
tmpMagnet.size = magnet.ExactLength
}
if tmpMagnet.name != "" && tmpMagnet.uri != "" {
dls = append(dls, tmpMagnet)
tmpMagnet = Magnet{}
}
})
if err := c.colly.Visit(c.uri); err != nil {
return nil, err
}
return dls, nil
}
package collector
type Source struct {
Type string
URIs []string
}
func (s Source) Collector() (DownloadableCollector, error) {
return Factory(s)
}
package collector
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"regexp"
"time"
)
// TransmissionDownloader is a type that implements the Downloader interface to open a downloadable using xdg-open.
type TransmissionDownloader struct {
Transmission *TransmissionConfig
client *http.Client
}
type TransmissionConfig struct {
Host string
Port string
User string
Pass string
SSL bool
}
type transmissionRequest struct {
Method string `json:"method"`
Arguments transmissionArguments `json:"arguments"`
}
type transmissionArguments struct {
Paused bool `json:"paused"`
Filename string `json:"filename"`
}
var (
re = regexp.MustCompile("<code>X-Transmission-Session-Id: (.*)</code>")
)
// NewTransmissionDownloader creates a new TransmissionDownloader
func NewTransmissionDownloader(connection *TransmissionConfig) *TransmissionDownloader {
return &TransmissionDownloader{
Transmission: connection,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Download does an http post to a trasmission server to download the torrent file
func (d *TransmissionDownloader) Download(dl Downloadable) error {
sessionID, err := d.getSessionID()
if err != nil {
return err
}
body, err := getBody(dl.URI())
if err != nil {
return err
}
req, err := d.Transmission.newRequest()
if err != nil {
return err
}
req.Header.Set("X-Transmission-Session-Id", *sessionID)
req.Body = io.NopCloser(bytes.NewBuffer(body))
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
func getBody(uri string) ([]byte, error) {
torrentAdd := transmissionRequest{
Method: "torrent-add",
Arguments: transmissionArguments{
Paused: false,
Filename: uri,
},
}
jsonStr, err := json.Marshal(torrentAdd)
if err != nil {
return nil, err
}
return jsonStr, nil
}
func (d *TransmissionDownloader) getSessionID() (*string, error) {
req, err := d.Transmission.newRequest()
if err != nil {
return nil, err
}
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
match := re.FindStringSubmatch(string(body))
if len(match) < 2 {
return nil, fmt.Errorf("no session id found")
}
return &match[1], nil
}
func (tc *TransmissionConfig) newRequest() (*http.Request, error) {
req, err := http.NewRequest(
http.MethodPost,
tc.transmissionURL(),
nil,
)
if err != nil {
return nil, err
}
req.SetBasicAuth(tc.User, tc.Pass)
return req, nil
}
func (tc *TransmissionConfig) transmissionURL() string {
protocol := "http"
if tc.SSL {
protocol = "https"
}
return fmt.Sprintf("%s://%s/transmission/rpc/",
protocol,
net.JoinHostPort(tc.Host, tc.Port),
)
}
package collector
import (
"os/exec"
"github.com/pkg/errors"
)
// WebTorrentDownloader is a type that implements the Downloader interface to open a downloadable using WebTorrent-open.
type WebTorrentDownloader struct{}
// Download opens the URI of the downloadable using WebTorrent-open.
func (d WebTorrentDownloader) Download(dl Downloadable) error {
cmd := exec.Command("webtorrent", "--mpv", "download", dl.URI())
err := cmd.Start()
if err != nil {
return errors.Wrapf(err, "failed to start WebTorrent-open")
}
go func() {
err = cmd.Wait()
// log.Info().Err(err).Msg("Webtorrent finished")
}()
return nil
}
package collector
import (
"os/exec"
"github.com/pkg/errors"
)
// XDGDownloader is a type that implements the Downloader interface to open a downloadable using xdg-open.
type XDGDownloader struct{}
// Download opens the URI of the downloadable using xdg-open.
func (d XDGDownloader) Download(dl Downloadable) error {
cmd := exec.Command("xdg-open", dl.URI())
err := cmd.Start()
if err != nil {
return errors.Wrapf(err, "failed to start xdg-open")
}
return nil
}
package config
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
"github.com/roarc0/gofetch/internal/collector"
"github.com/roarc0/gofetch/internal/filter"
"github.com/roarc0/gofetch/internal/memory"
)
// Config struct contains the configuration for the application.
type Config struct {
Collector collector.CollectorConfig
Downloader collector.TransmissionConfig
Memory memory.Config
Sources map[string]collector.Source
Entries map[string]filter.Entry
}
func LoadYaml(cfgPath string) (*Config, error) {
cfgPath = filepath.Join(cfgPath, "config.yaml")
_, err := os.Stat(cfgPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
} else if errors.Is(err, os.ErrNotExist) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
cfgPath = filepath.Join(home, ".config", "gofetch", "config.yaml")
}
b, err := os.ReadFile(cfgPath)
if err != nil {
return nil, err
}
cfg := &Config{}
err = yaml.Unmarshal(b, cfg)
if err != nil {
return nil, err
}
cfg.Memory.FilePath = filepath.Join(filepath.Dir(cfgPath), cfg.Memory.FilePath)
return cfg, nil
}
// Load initializes the configuration.
func Load(cfgPath string) (*Config, error) {
v, err := LoadViper[Config](cfgPath, "GOFETCH")
if err != nil {
return nil, err
}
cfg := &Config{}
err = v.Unmarshal(&cfg, func(config *mapstructure.DecoderConfig) {
config.TagName = "yaml"
})
if err != nil {
return nil, err
}
return cfg, nil
}
// LoadViper loads the configuration from the file and environment variables.
func LoadViper[T any](cfgPath, prefix string) (*viper.Viper, error) {
v := viper.New()
v.SetConfigType("yaml")
b, err := yaml.Marshal(*new(T))
if err != nil {
return nil, err
}
defaultConfig := bytes.NewReader(b)
if err := v.MergeConfig(defaultConfig); err != nil {
return nil, err
}
v.SetConfigName("config")
v.AddConfigPath(cfgPath)
if err := v.MergeInConfig(); err != nil {
if _, ok := err.(viper.ConfigParseError); ok {
return nil, err
}
}
v.AutomaticEnv()
v.SetEnvPrefix(prefix)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
return v, nil
}
package filter
import (
"errors"
"reflect"
"gopkg.in/yaml.v3"
"github.com/roarc0/gofetch/internal/collector"
)
var (
ErrUnknownMatchType = errors.New("unknown match type")
)
type Filter struct {
matchers []Matcher
}
type MatchedDownloadable struct {
collector.Downloadable
Optional bool
}
// NewFilter creates a new filter to determine which downloadables
// should be kept based on the matchers.
func NewFilter(matchers []Matcher) Filter {
return Filter{
matchers: matchers,
}
}
func (f *Filter) Filter(in []collector.Downloadable) (out []MatchedDownloadable, err error) {
for _, d := range in {
match := true
optionalMatch := false
for _, matcher := range f.matchers {
matchType, m, err := matcher.Match(d)
if err != nil {
return nil, err
}
switch matchType {
case MatchTypeRequired:
if !m {
match = false
goto next
}
case MatchTypeOptional:
if m {
optionalMatch = true
}
case MatchTypeSufficient:
if m {
match = true
optionalMatch = false
goto next
}
case MatchTypeExclude:
if m {
match = false
goto next
}
default:
return nil, ErrUnknownMatchType
}
}
next:
if match {
out = append(out,
MatchedDownloadable{
Downloadable: d,
Optional: optionalMatch,
},
)
}
}
return out, nil
}
func (f Filter) MarshalYAML() (any, error) {
var fields struct {
Matchers []MatcherWrapper
}
for _, m := range f.matchers {
matcherType := reflect.TypeOf(m).String()
switch matcherType {
case reflect.TypeOf(&RegexMatcher{}).String():
matcherType = "regex"
default:
return nil, errors.New("unknown matcher type")
}
fields.Matchers = append(
fields.Matchers,
MatcherWrapper{
Type: matcherType,
Matcher: m,
},
)
}
return fields, nil
}
func (f *Filter) UnmarshalYAML(value *yaml.Node) error {
var fields struct {
Matchers []MatcherWrapper
}
if err := value.Decode(&fields); err != nil {
return err
}
for _, mw := range fields.Matchers {
f.matchers = append(f.matchers, mw.Matcher)
}
return nil
}
package filter
import (
"reflect"
"github.com/pkg/errors"
"github.com/roarc0/gofetch/internal/collector"
"gopkg.in/yaml.v3"
)
// Matcher is an interface that defines a method to match a downloadable to see if it should be used or not.
type Matcher interface {
Match(dl collector.Downloadable) (MatchType, bool, error)
}
type MatcherWrapper struct {
Matcher
Type string
}
func (m MatcherWrapper) MarshalYAML() (any, error) {
matcherType := reflect.TypeOf(m.Matcher).String()
switch matcherType {
case reflect.TypeOf(&RegexMatcher{}).String():
matcherType = "regex"
default:
return nil, errors.New("unknown matcher type")
}
return struct {
Type string
Matcher Matcher
}{
Type: matcherType,
Matcher: m.Matcher,
}, nil
}
func (m *MatcherWrapper) UnmarshalYAML(node *yaml.Node) error {
var tmp struct {
Type string
Matcher map[string]any
}
if err := node.Decode(&tmp); err != nil {
return err
}
m.Type = tmp.Type
switch m.Type {
case "regex":
m.Matcher = &RegexMatcher{}
b, err := yaml.Marshal(tmp.Matcher)
if err != nil {
return err
}
return yaml.Unmarshal(b, m.Matcher)
default:
return errors.Errorf("unknown matcher type %s", m.Type)
}
}
package filter
import "gopkg.in/yaml.v3"
type MatchType int
const (
MatchTypeRequired MatchType = iota
MatchTypeOptional
MatchTypeExclude
MatchTypeSufficient
MatchTypeInvalid
)
func (m MatchType) String() string {
switch m {
case MatchTypeRequired:
return "required"
case MatchTypeOptional:
return "optional"
case MatchTypeExclude:
return "exclude"
case MatchTypeSufficient:
return "sufficient"
default:
return "invalid"
}
}
func (m *MatchType) UnmarshalYAML(value *yaml.Node) error {
var tmp string
if err := value.Decode(&tmp); err != nil {
return err
}
switch tmp {
case "required":
*m = MatchTypeRequired
case "optional":
*m = MatchTypeOptional
case "exclude":
*m = MatchTypeExclude
case "sufficient":
*m = MatchTypeSufficient
default:
*m = MatchTypeInvalid
}
return nil
}
func (m MatchType) MarshalYAML() (any, error) {
return m.String(), nil
}
package filter
import (
"regexp"
"github.com/pkg/errors"
"github.com/roarc0/gofetch/internal/collector"
)
// RegexMatcher is a type that implements the Matcher interface to match a downloadable using a regex.
//
// NOTE: MatchType is used to determine what to do if the regex matches.
// We could use negative lookaheads in the regex, but go doesn't support them.
// This is because they can lead to denial of service attacks.
type RegexMatcher struct {
Regex string
MatchType MatchType
}
func (m *RegexMatcher) Match(dl collector.Downloadable) (MatchType, bool, error) {
matched, err := regexp.Match(m.Regex, []byte(dl.Name()))
if err != nil {
return MatchTypeInvalid, false, errors.Wrap(err, "failed to match regex")
}
return m.MatchType, matched, nil
}
package gofetch
import (
"context"
"errors"
"fmt"
"github.com/roarc0/gofetch/internal/collector"
"github.com/roarc0/gofetch/internal/config"
"github.com/roarc0/gofetch/internal/filter"
"github.com/roarc0/gofetch/internal/memory"
"github.com/rs/zerolog/log"
)
const (
downloadsBucket = "downloads"
)
type Downloadable struct {
collector.Downloadable
Optional bool
Action Action
}
type Action int
const (
NoAction Action = iota
DownloadAction
IgnoreAction
)
func (a Action) String() string {
switch a {
case NoAction:
return ""
case DownloadAction:
return "download"
case IgnoreAction:
return "ignore"
default:
return "unknown"
}
}
func (a Action) Seen() bool {
return a == DownloadAction || a == IgnoreAction
}
type GoFetch struct {
cfg *config.Config
memory memory.Memory
downloader collector.Downloader
}
// NewGoFetch creates a new GoFetch object.
func NewGoFetch(cfg *config.Config, memory memory.Memory) (*GoFetch, error) {
return &GoFetch{
cfg: cfg,
memory: memory,
downloader: collector.NewTransmissionDownloader(&cfg.Downloader),
}, nil
}
func (gf *GoFetch) Fetch() (dls []Downloadable, err error) {
for _, entry := range gf.cfg.Entries {
d, err := gf.Search(entry.SourceName, entry.Filter)
if err != nil {
log.Error().Err(err).Msg("failed to search")
continue
}
dls = append(dls, d...)
}
return dls, nil
}
func (gf *GoFetch) Search(sourceName string, filter filter.Filter) (dls []Downloadable, err error) {
source, ok := gf.cfg.Sources[sourceName]
if !ok {
return nil, errors.New("source not found")
}
c, err := source.Collector()
if err != nil {
return nil, err
}
downloads, err := c.Collect(context.Background())
if err != nil {
return nil, err
}
filteredDownloads, err := filter.Filter(downloads)
if err != nil {
return nil, err
}
for _, dl := range filteredDownloads {
var action Action
actionPtr, err := gf.memory.Get(collector.Hash(dl))
if err != nil {
return nil, err
}
switch *actionPtr {
case DownloadAction.String():
action = DownloadAction
case IgnoreAction.String():
action = IgnoreAction
default:
action = NoAction
}
dls = append(dls, Downloadable{
Downloadable: dl,
Optional: dl.Optional,
Action: action,
})
}
return dls, nil
}
func (g *GoFetch) Download(dl Downloadable) error {
hash := collector.Hash(dl)
if g.memory.Has(hash) {
return fmt.Errorf("already processed: %s", dl.Name())
}
err := g.downloader.Download(dl)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
err = g.memory.Put(hash, DownloadAction.String())
if err != nil {
return fmt.Errorf("failed to save to memory: %w", err)
}
return nil
}
func (gf *GoFetch) Ignore(dl Downloadable) error {
hash := collector.Hash(dl)
if gf.memory.Has(hash) {
return fmt.Errorf("already ignored: %s", dl.Name())
}
err := gf.memory.Put(hash, IgnoreAction.String())
if err != nil {
return fmt.Errorf("failed to save ignore to memory: %w", err)
}
return nil
}
func (gf *GoFetch) Forget(dl Downloadable) error {
hash := collector.Hash(dl)
return gf.memory.Del(hash)
}
func (gf *GoFetch) Stream(dl Downloadable) error {
return collector.WebTorrentDownloader{}.Download(dl)
}
package logger
import (
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// SetupLogger sets up the logger with the desired output format.
func SetupLogger() error {
file, err := os.OpenFile("gofetch.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal().Err(err).Msg("Failed to open log file")
}
log.Logger = log.Output(zerolog.SyncWriter(file))
return nil
}
// SetLevel sets the global log level.
func SetLevel(levelStr string) {
level, err := zerolog.ParseLevel(levelStr)
if err != nil {
level = zerolog.DebugLevel
}
zerolog.SetGlobalLevel(level)
}
package memory
import (
"io"
"log"
bolt "go.etcd.io/bbolt"
)
type Memory interface {
Put(key string, value string) error
Get(key string) (*string, error)
Has(key string) bool
Del(key string) error
io.Closer
}
type memory struct {
db *bolt.DB
bucket []byte
}
type Config struct {
FilePath string
}
func NewMemory(filePath string, bucket string) (Memory, error) {
db, err := bolt.Open(filePath, 0600, nil)
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(bucket))
return err
})
if err != nil {
return nil, err
}
return &memory{
db: db,
bucket: []byte(bucket),
}, nil
}
func (m *memory) Put(key string, value string) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(m.bucket)
err := b.Put([]byte(key), []byte(value))
return err
})
}
func (m *memory) Get(key string) (*string, error) {
var value string
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(m.bucket)
v := b.Get([]byte(key))
value = string(v)
return nil
})
if err != nil {
return nil, err
}
return &value, nil
}
func (m *memory) Has(key string) bool {
var exists bool
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(m.bucket)
v := b.Get([]byte(key))
exists = v != nil
return nil
})
if err != nil {
log.Fatal(err)
}
return exists
}
func (m *memory) Del(key string) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(m.bucket)
err := b.Delete([]byte(key))
return err
})
}
func (m *memory) Close() error {
return m.db.Close()
}
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/roarc0/gofetch/internal/memory (interfaces: Memory)
//
// Generated by this command:
//
// mockgen -destination=mocks/mock_memory.go -package=mocks github.com/roarc0/gofetch/internal/memory Memory
//
// Package mocks is a generated GoMock package.
package mocks
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockMemory is a mock of Memory interface.
type MockMemory struct {
ctrl *gomock.Controller
recorder *MockMemoryMockRecorder
}
// MockMemoryMockRecorder is the mock recorder for MockMemory.
type MockMemoryMockRecorder struct {
mock *MockMemory
}
// NewMockMemory creates a new mock instance.
func NewMockMemory(ctrl *gomock.Controller) *MockMemory {
mock := &MockMemory{ctrl: ctrl}
mock.recorder = &MockMemoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMemory) EXPECT() *MockMemoryMockRecorder {
return m.recorder
}
// Close mocks base method.
func (m *MockMemory) Close() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Close")
ret0, _ := ret[0].(error)
return ret0
}
// Close indicates an expected call of Close.
func (mr *MockMemoryMockRecorder) Close() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockMemory)(nil).Close))
}
// Del mocks base method.
func (m *MockMemory) Del(arg0 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Del", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// Del indicates an expected call of Del.
func (mr *MockMemoryMockRecorder) Del(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Del", reflect.TypeOf((*MockMemory)(nil).Del), arg0)
}
// Get mocks base method.
func (m *MockMemory) Get(arg0 string) (*string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", arg0)
ret0, _ := ret[0].(*string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockMemoryMockRecorder) Get(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMemory)(nil).Get), arg0)
}
// Has mocks base method.
func (m *MockMemory) Has(arg0 string) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Has", arg0)
ret0, _ := ret[0].(bool)
return ret0
}
// Has indicates an expected call of Has.
func (mr *MockMemoryMockRecorder) Has(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Has", reflect.TypeOf((*MockMemory)(nil).Has), arg0)
}
// Put mocks base method.
func (m *MockMemory) Put(arg0, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Put", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// Put indicates an expected call of Put.
func (mr *MockMemoryMockRecorder) Put(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockMemory)(nil).Put), arg0, arg1)
}