commit f1191196d5c258d09568803989f7d84732452460 Author: Kenneth Jao Date: Mon Aug 5 18:48:28 2024 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f52742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +*.txt +anitoru diff --git a/anitoru.service b/anitoru.service new file mode 100644 index 0000000..3770dd5 --- /dev/null +++ b/anitoru.service @@ -0,0 +1,14 @@ +[Unit] +Description=AniToru System Daemon +After=network.target + +[Service] +Type=simple +User=anitoru +Group=anitoru +ExecStart=/usr/bin/anitoru -daemon -config=/path/to/config.yml +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..2075ef0 --- /dev/null +++ b/cli.go @@ -0,0 +1,199 @@ +package main + +import ( + "fmt" + "net" + "os" + "strconv" + "strings" + + e "git.kjao.me/kjao/anitoru/err" +) + +type SubscriptionManager struct { + Subscriptions []string +} + +func NewSubscriptionManager() *SubscriptionManager { + var m SubscriptionManager + m.Get() + return &m +} + +func (m *SubscriptionManager) UpdateSubs(conn net.Conn) { + rawData, err := ReadData(conn) + e.ExitIf(err, "Unable to read from socket.") + m.Subscriptions = strings.Split(string(rawData), "\n") + if m.Subscriptions[0] == "" { + m.Subscriptions = []string{} + } +} + +func (m *SubscriptionManager) Get() { + conn, err := net.Dial("unix", SocketPath) + e.ExitIf(err, "Unable to open socket. Is the daemon running?") + + err = SendData(conn, []byte{'V'}) + e.ExitIf(err, "Unable to write to socket.") + m.UpdateSubs(conn) +} + +func (m *SubscriptionManager) View() { + fmt.Println() + fmt.Println("Current subscriptions:") + for i, sub := range m.Subscriptions { + fmt.Println(fmt.Sprintf("\t[%02d] %v", i+1, sub)) + } + if len(m.Subscriptions) == 0 { + fmt.Println("\t...Empty\n") + return + } + fmt.Println() +} + +func (m *SubscriptionManager) Add() { + fmt.Println() + + // Get available subscriptions. + fmt.Println("Available subscriptions:") + titles, err := GetReleaseSchedule() + if err != nil { + fmt.Println("Unable to get release schedule.") + return + } + + subMap := GetSubMap(&m.Subscriptions) + + var available []string + i := 0 + for _, title := range titles { + _, exists := subMap[title] + if !exists { + i++ + available = append(available, title) + fmt.Println(fmt.Sprintf("\t[%02d] %v", i, title)) + } + } + fmt.Println() + + // Get comma-separated list of additions. + newSubs := []string{} + var add string + var name string + for { + fmt.Print("Add subscriptions. (M for manual): ") + fmt.Scan(&add) + add = strings.ToLower(add) + if add == "m" { + fmt.Print("Name: ") + fmt.Scan(&name) + newSubs = append(newSubs, name) + } else { + addSplit := strings.Split(add, ",") + retry := false + for _, indexString := range addSplit { + index, err := strconv.Atoi(indexString) + if err != nil { + fmt.Println("Error in indices. Try again.") + retry = true + break + } + if index <= 0 || index > len(available) { + fmt.Fprintln(os.Stdout, "Error in indices. %v is invalid. Try again", index) + retry = true + break + } + + newSubs = append(newSubs, available[index-1]) + } + if retry { + continue + } + } + break + } + + // Send addition update to socket + data := []byte{'A'} + data = append(data, []byte(strings.Join(newSubs, "\n"))...) + conn, err := net.Dial("unix", SocketPath) + e.ExitIf(err, "Unable to open socket. Is the daemon running?") + + err = SendData(conn, data) + e.ExitIf(err, "Unable to write to socket. Is the daemon running?") + m.UpdateSubs(conn) +} + +func (m *SubscriptionManager) Remove() { + fmt.Println() + m.View() + fmt.Println() + + subMap := GetSubMap(&m.Subscriptions) + + // Get comma-separated list of removals. + var remove string + removeSubs := []string{} + for { + fmt.Print("Remove subscriptions: ") + fmt.Scan(&remove) + addSplit := strings.Split(remove, ",") + skip := false + for _, indexString := range addSplit { + index, err := strconv.Atoi(indexString) + if err != nil { + fmt.Println("Error in indices. Try again.") + skip = true + break + } + if index <= 0 || index > len(m.Subscriptions) { + fmt.Fprintln(os.Stdout, "Error in indices. %v is invalid. Try again", index) + skip = true + break + } + removeSubs = append(removeSubs, (m.Subscriptions)[index-1]) + delete(subMap, (m.Subscriptions)[index-1]) + } + if skip { + continue + } + break + } + + // Send removal update to socket + data := []byte{'R'} + data = append(data, []byte(strings.Join(removeSubs, "\n"))...) + conn, err := net.Dial("unix", SocketPath) + e.ExitIf(err, "Unable to open socket. Is the daemon running?") + + err = SendData(conn, data) + e.ExitIf(err, "Unable to write to socket. Is the daemon running?") + m.UpdateSubs(conn) + m.View() +} + +func userCLI() { + manager := NewSubscriptionManager() + + var action string + for { + fmt.Print("View, Add, Remove subscriptions or Quit: ") + fmt.Scan(&action) + action = strings.ToLower(action) + switch action { + case "v": + manager.View() + break + case "a": + manager.Add() + break + case "r": + manager.Remove() + break + case "q": + os.Exit(1) + default: + fmt.Println("Invalid command, try again.") + } + } +} diff --git a/err/err.go b/err/err.go new file mode 100644 index 0000000..8c06ab5 --- /dev/null +++ b/err/err.go @@ -0,0 +1,68 @@ +package err + +import ( + "fmt" + "log" + "os" +) + +type LogOption func(string) + +type ErrorCallback struct { + Error error +} + +func If(err error) *ErrorCallback { + return &ErrorCallback{err} +} + +func (err *ErrorCallback) ThenIf(f func() error) *ErrorCallback { + if err.Error == nil { + err.Error = f() + } + return err +} + +func (err *ErrorCallback) Then(f func()) *ErrorCallback { + if err.Error == nil { + f() + } + return err +} + +func (err *ErrorCallback) End() error { + return err.Error +} + +func Panic(err error) bool { + if err != nil { + log.Panic(err) + return true + } + return false +} + +func LogIf(err error, opts ...LogOption) bool { + if err != nil { + for _, opt := range opts { + opt(err.Error()) + } + log.Println(err.Error()) + return true + } + return false +} + +func Log(msg string, opts ...LogOption) { + for _, opt := range opts { + opt(msg) + } + log.Println(msg) +} + +func ExitIf(err error, msg string) { + if err != nil { + fmt.Fprintln(os.Stderr, msg, "Error:", err.Error()) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b19d34 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module git.kjao.me/kjao/anitoru + +go 1.22.5 + +require ( + github.com/PuerkitoBio/goquery v1.9.2 + github.com/gookit/config/v2 v2.2.5 + github.com/mmcdole/gofeed v1.3.0 + github.com/mrobinsn/go-rtorrent v1.8.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/fatih/color v1.14.1 // indirect + github.com/goccy/go-yaml v1.11.2 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/gookit/goutil v0.6.15 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13a2911 --- /dev/null +++ b/go.sum @@ -0,0 +1,103 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= +github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ= +github.com/goccy/go-yaml v1.11.2/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/config/v2 v2.2.5 h1:RECbYYbtherywmzn3LNeu9NA5ZqhD7MSKEMsJ7l+MpU= +github.com/gookit/config/v2 v2.2.5/go.mod h1:NeX+yiNYn6Ei10eJvCQFXuHEPIE/IPS8bqaFIsszzaM= +github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo= +github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mrobinsn/go-rtorrent v1.8.0 h1:+61aDIP0asy57lRD/uZtmxfE0/gjkHnt3uddOhMKUJ8= +github.com/mrobinsn/go-rtorrent v1.8.0/go.mod h1:CdVq2IwM+JU9D6TnWiQSg9lqZWu6zUfK67YXET2LqIM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..09b086e --- /dev/null +++ b/main.go @@ -0,0 +1,546 @@ +package main + +import ( + "encoding/binary" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + e "git.kjao.me/kjao/anitoru/err" + + "github.com/PuerkitoBio/goquery" + "github.com/gookit/config/v2" + "github.com/gookit/config/v2/yamlv3" + "github.com/mmcdole/gofeed" + "github.com/mrobinsn/go-rtorrent/rtorrent" +) + +const ReleaseURL string = "https://www.erai-raws.info/release-schedule/" +const RSSURL string = "https://www.erai-raws.info/feed/?res=1080p&type=magnet&subs%5B0%5D=us&v0=no&d157edc6b50f28b2776442c03d067d56" +const NyaaURL string = "https://nyaa.si/" + +const SocketPath string = "/tmp/anitoru.sock" +const SubscriptionsFile string = "subscriptions.txt" + +var InvalidDotfile error = errors.New("Dotfile invalid.") + +type Configuration struct { + BaseDir string `config:"baseDir"` + RTorrentHost string `config:"rTorrentHost"` + PollingRate int `config:"pollingRate"` + DownloadDir string `config:"downloadDir"` + LogPath string `config:"logPath"` + NotifyError bool `config:"notifyError"` +} + +type Anime struct { + Name string + Episode int + Magnet string + Version int +} + +func NewAnime(link string) Anime { + return Anime{Magnet: link} +} + +func NTFYMessage(msg string) { + http.Post("https://ntfy.kjao.me/anitoru", "text/plain", strings.NewReader(msg)) +} + +func LogNTFY() e.LogOption { + return func(msg string) { + NTFYMessage(msg) + } +} + +func LogStd() e.LogOption { + return func(msg string) { + fmt.Println(msg) + } +} + +func GetSubMap(subscriptions *[]string) map[string]struct{} { + subMap := make(map[string]struct{}) + for _, sub := range *subscriptions { + var d struct{} + subMap[sub] = d + } + return subMap +} + +func GetSubList(subMap *map[string]struct{}) []string { + subscriptions := make([]string, len(*subMap), len(*subMap)) + i := 0 + for sub, _ := range *subMap { + subscriptions[i] = sub + i++ + } + return subscriptions +} + +func (ani *Anime) InfoFromTitle(title string) error { + titleSplit := strings.Split(title, " ") + ani.Name = strings.Join(titleSplit[:len(titleSplit)-2], " ") + + epInfo := strings.Split(titleSplit[len(titleSplit)-1], "v") + ep, err := strconv.Atoi(epInfo[0]) + return e.If(err).ThenIf(func() error { + ani.Episode = ep + if len(epInfo) > 1 { + ani.Version, err = strconv.Atoi(epInfo[1]) + return e.If(err).End() + } else { + ani.Version = 1 + return nil + } + }).End() +} + +// func Scheduler(clock string) <-chan int { +// c := make(chan int) +// clockTime, err := time.Parse("05:15", clock) +// FatalErr(err) + +// year, month, day := time.Now().Date() +// scheduleDate := time.Date(year, month, day, clockTime.Hour(), clockTime.Minute(), +// 0, 0, time.UTC).AddDate(0, 0, 1) + +// go func(date time.Time) { +// for { +// if time.Now().After(date) { +// c <- 1 +// date = date.AddDate(0, 0, 1) +// } else { +// time.Sleep(time.Minute) +// } +// } +// }(scheduleDate) +// return c +// } + +func GetReleaseSchedule() ([]string, error) { + var doc *goquery.Document + var titles []string + + res, err := http.Get(ReleaseURL) + defer res.Body.Close() + + return titles, e.If(err).ThenIf(func() error { // The function will run first before return. + doc, err = goquery.NewDocumentFromReader(res.Body) + return err + }).Then(func() { + doc.Find("table .aa_ss_ops_new").Each(func(i int, s *goquery.Selection) { + titles = append(titles, strings.TrimSpace(s.Text())) + }) + }).End() +} + +func GetRSS() ([]Anime, error) { + var animes []Anime + + fp := gofeed.NewParser() + feed, err := fp.ParseURL(RSSURL) + + return animes, e.If(err).Then(func() { + for _, item := range feed.Items { + title := strings.Split(item.Title, "[Magnet] ")[1] + title = strings.Split(title, " [1080p]")[0] + if title[len(title)-6:] != "(HEVC)" { + continue + } + title = title[:len(title)-7] + anime := NewAnime(item.Link) + e.LogIf(anime.InfoFromTitle(title)) + animes = append(animes, anime) + } + }).End() +} + +func GetAllAvailable(name string) ([]Anime, error) { + var animes []Anime + var res *http.Response + var doc *goquery.Document + + req, err := http.NewRequest("GET", NyaaURL, nil) + + return animes, e.If(err).ThenIf(func() error { + q := req.URL.Query() + query := "[Erai-raws] " + name + " [HEVC]" + query = strings.Replace(query, ":", " ", -1) + query = strings.Replace(query, ".", " ", -1) + q.Add("q", query) + q.Add("s", "id") // Sort by date and descending + q.Add("o", "desc") + req.URL.RawQuery = q.Encode() + + res, err = (&http.Client{}).Do(req) + return err + }).ThenIf(func() error { + defer res.Body.Close() + doc, err = goquery.NewDocumentFromReader(res.Body) + return err + }).Then(func() { + var epMap = make(map[int]struct{}) + doc.Find(".success").Each(func(i int, s *goquery.Selection) { + cols := s.Children() + cols = cols.Next() // Filename + title := cols.Text() + + if !strings.Contains(title, "[HEVC]") || !strings.Contains(title, "[ENG]") { + return + } + title = strings.Split(title, "[Erai-raws] ")[1] + if strings.Contains(title, "(AAC 2.0)") { + title = strings.Split(title, " (AAC 2.0)")[0] + } else { + title = strings.Split(title, " [1080p]")[0] + } + + cols = cols.Next() // Download link + link := cols.Children().Next() + anime := NewAnime(link.AttrOr("href", "none")) + e.LogIf(anime.InfoFromTitle(title)) + anime.Name = name // Actual name, since filenames remove special characters. + + _, exists := epMap[anime.Episode] + if !exists { + animes = append(animes, anime) + var s struct{} + epMap[anime.Episode] = s + } + }) + }).End() +} + +func SocketListener(sock net.Listener) chan net.Conn { + c := make(chan net.Conn) + + go func() { + for { + conn, err := sock.Accept() + if e.LogIf(err) { + continue + } + c <- conn + } + }() + + return c +} + +type Daemon struct { + Tor *rtorrent.RTorrent + PollingRate time.Duration + DownloadDir string + Listener chan net.Conn + Subscriptions map[string]struct{} + LogOptions []e.LogOption + SubscriptionsPath string +} + +func NewDaemon(server string, polling int, download string, socket net.Listener, + notifyError bool, subPath string) Daemon { + // Connect to rTorrent + tor := rtorrent.New(server, false) + name, err := tor.Name() + + e.ExitIf(err, "Unable to connect to rTorrent.") + fmt.Fprintln(os.Stdout, "Connected to", name, "at", server) + + // Create simple socket for subscriptions + listener := SocketListener(socket) + + // Make subscriptions if doesn't exist. + subFile, err := os.OpenFile(subPath, os.O_RDONLY|os.O_CREATE, 0644) + e.ExitIf(err, "Unable to open subscriptions file.") + subFile.Close() + + data, err := os.ReadFile(subPath) + e.ExitIf(err, "Unable to read subscriptions file.") + var subscriptions []string + if len(data) == 0 { + subscriptions = []string{} + } else { + subscriptions = strings.Split(string(data), "\n") + } + subMap := GetSubMap(&subscriptions) + + var opt []e.LogOption + if notifyError { + opt = []e.LogOption{LogNTFY(), LogStd()} + } else { + opt = []e.LogOption{LogStd()} + } + + return Daemon{ + Tor: tor, + PollingRate: time.Duration(polling), + DownloadDir: download, + Listener: listener, + Subscriptions: subMap, + LogOptions: opt, + SubscriptionsPath: subPath, + } +} + +func (d *Daemon) Serve() { + //d.CheckRSS() + for { + select { + case <-time.After(d.PollingRate * time.Minute): + d.CheckRSS() + case <-time.After(10 * time.Second): + d.CheckTorrents() + case conn := <-d.Listener: + d.ReadSocket(conn) + conn.Close() + } + } +} + +func (d *Daemon) CheckRSS() { + animes, err := GetRSS() + if e.LogIf(err, d.LogOptions...) { + return + } + + for _, anime := range animes { + _, exists := d.Subscriptions[anime.Name] + if !exists { + continue + } + e.LogIf(d.Download(&anime), d.LogOptions...) + } +} + +func (d *Daemon) CheckTorrents() { + torrents, err := d.Tor.GetTorrents(rtorrent.ViewMain) + e.LogIf(err, d.LogOptions...) + for _, torrent := range torrents { + if torrent.Completed { + d.Tor.Delete(torrent) + arr := strings.Split(torrent.Label, "\n") + name := arr[0] + dir := arr[1] + ext := filepath.Ext(torrent.Name) + err := os.Rename(torrent.Path+"/"+torrent.Name, dir+"/"+name+ext) + e.LogIf(err, d.LogOptions...) + e.Log(fmt.Sprintf("Finished downloading %v.", name), LogNTFY()) + } else { + active, err := d.Tor.IsActive(torrent) + e.LogIf(err, d.LogOptions...) + fmt.Println(torrent.Name, "Active:", active) + d.Tor.ResumeTorrent(torrent) + } + } +} + +func (d *Daemon) SendSubs(conn net.Conn) { + subs := strings.Join(GetSubList(&d.Subscriptions), "\n") + err := SendData(conn, []byte(subs)) + e.LogIf(err, d.LogOptions...) +} + +func (d *Daemon) ReadSocket(conn net.Conn) { + rawData, err := ReadData(conn) + if e.LogIf(err, d.LogOptions...) { + return + } + + command := rawData[0] + data := string(rawData[1:len(rawData)]) + e.Log("Socket Command: " + string(command)) + newSubs := strings.Split(data, "\n") + + var write bool + switch command { + case 'A': + // Add each subscription + for _, sub := range newSubs { + var s struct{} + d.Subscriptions[sub] = s + } + d.SendSubs(conn) + + for _, sub := range newSubs { + // For each new subscription, get all available animes. + animes, err := GetAllAvailable(sub) + if e.LogIf(err, d.LogOptions...) { + continue + } + + for _, anime := range animes { + e.LogIf(d.Download(&anime), d.LogOptions...) + } + } + write = true + case 'R': + for _, sub := range strings.Split(data, "\n") { + delete(d.Subscriptions, sub) + } + write = true + d.SendSubs(conn) + case 'V': + write = false + d.SendSubs(conn) + default: + e.Log("Invalid Command") + } + + if write { // Write subscriptions to file. + f, err := os.Create(d.SubscriptionsPath) + if e.LogIf(err, d.LogOptions...) { + return + } + defer f.Close() + f.Write([]byte(strings.Join(GetSubList(&d.Subscriptions), "\n"))) + f.Sync() + } +} + +func (d *Daemon) Download(ani *Anime) error { + aniDir := filepath.Join(d.DownloadDir, ani.Name) + dotPath := filepath.Join(aniDir, ".anitoru") + var dot map[int]int + + err := os.MkdirAll(aniDir, os.ModePerm) + return e.If(err).ThenIf(func() error { + dot, err = LoadDotfile(dotPath) + return err + }).Then(func() { + vers, exists := dot[ani.Episode] + if exists || vers >= ani.Version { // No need to download. + return + } + name := fmt.Sprintf("%v - %02d", ani.Name, ani.Episode) + d.Tor.Add(ani.Magnet, + rtorrent.DLabel.SetValue(name+"\n"+aniDir), + ) + e.Log(fmt.Sprintf("Queueing download of %v...", name), LogNTFY()) + dot[ani.Episode] = ani.Version + SaveDotfile(dotPath, dot) + }).End() +} + +func SendData(conn net.Conn, data []byte) error { + length := make([]byte, 4) + binary.LittleEndian.PutUint32(length, uint32(len(data))) + _, err := conn.Write(append(length, data...)) + return err +} + +func ReadData(conn net.Conn) ([]byte, error) { + var data []byte + lengthBuf := make([]byte, 4) + _, err := conn.Read(lengthBuf) + return data, e.If(err).Then(func() { + length := binary.LittleEndian.Uint32(lengthBuf) + data = make([]byte, length) + }).ThenIf(func() error { + _, err = conn.Read(data) + return err + }).End() +} + +func LoadDotfile(path string) (map[int]int, error) { + dot := make(map[int]int) + + // Check if exists. + _, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return dot, nil + } + + data, err := os.ReadFile(path) + return dot, e.If(err).ThenIf(func() error { + eps := strings.Split(string(data), "\n") + for _, epInfo := range eps { + arr := strings.Split(epInfo, " ") + if len(arr) != 2 { + return InvalidDotfile + } + ep, err1 := strconv.Atoi(arr[0]) + v, err2 := strconv.Atoi(arr[1]) + if err1 != nil || err2 != nil { + return InvalidDotfile + } + dot[ep] = v + } + return nil + }).End() +} + +func SaveDotfile(path string, dot map[int]int) error { + var epInfo = make([]string, len(dot), len(dot)) + i := 0 + for ep, v := range dot { + epInfo[i] = fmt.Sprintf("%v %v", ep, v) + i++ + } + return os.WriteFile(path, []byte(strings.Join(epInfo, "\n")), 0666) +} + +func loadConfig(configPath string) Configuration { + config.WithOptions(func(options *config.Options) { + options.DecoderConfig.TagName = "config" + options.ParseEnv = true + options.Readonly = true + }) + config.AddDriver(yamlv3.Driver) + var conf Configuration + e.ExitIf(config.LoadFiles(configPath), "Unable to load config.") + config.Decode(&conf) + return conf +} + +func main() { + daemonize := flag.Bool("daemon", false, "Run the daemon") + config := flag.String("config", "server.yml", "Config path") + flag.Parse() + + if *daemonize { + conf := loadConfig(*config) + + f, err := os.OpenFile(conf.LogPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0744) + e.ExitIf(err, "Unable to open log file.") + defer f.Close() + log.SetOutput(f) + + os.Remove(SocketPath) + sock, err := net.Listen("unix", SocketPath) + e.ExitIf(err, "Unable to open socket.") + defer sock.Close() + err = os.Chmod(SocketPath, 0777) + + // For cleanup, in case of signal. + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + go func() { + for _ = range c { + f.Close() + sock.Close() + os.Exit(1) + } + }() + + // Run daemon + daemon := NewDaemon( + conf.RTorrentHost, conf.PollingRate, conf.DownloadDir, sock, + conf.NotifyError, filepath.Join(conf.BaseDir, SubscriptionsFile), + ) + daemon.Serve() + } else { + userCLI() + } +} diff --git a/server.yml b/server.yml new file mode 100644 index 0000000..3b3602e --- /dev/null +++ b/server.yml @@ -0,0 +1,6 @@ +baseDir: ./ +rTorrentHost: http://localhost/rTorrent +pollingRate: 15 +downloadDir: /data/Videos/Anime/ +logPath: log.log +notifyError: true