Compare commits

..

26 Commits

Author SHA1 Message Date
Simon Ding
58e65b21fb feat: check connect before add download client 2024-07-25 14:19:03 +08:00
Simon Ding
520933085d fix: remove url check 2024-07-25 14:09:58 +08:00
Simon Ding
5cc88986d2 feat: better form 2024-07-25 14:02:47 +08:00
Simon Ding
d63a923589 feat: not allow empty fields 2024-07-25 11:16:31 +08:00
Simon Ding
bca68befb1 fix: name not exist 2024-07-25 10:58:58 +08:00
Simon Ding
1be44bff9e fix: add storage 2024-07-25 10:38:37 +08:00
Simon Ding
3998270cbd feat: check new season every 12h 2024-07-25 09:44:02 +08:00
Simon Ding
73e76c2185 feat: change logic 2024-07-25 08:43:49 +08:00
Simon Ding
c72a460509 update 2024-07-25 01:09:44 +08:00
Simon Ding
912293d8e8 feat: match anime that only rely on episode number 2024-07-25 01:07:24 +08:00
Simon Ding
7f2e84ad52 feat: no need auto dispose 2024-07-25 00:36:55 +08:00
Simon Ding
e52ad612c1 fix: extra spaces 2024-07-25 00:11:15 +08:00
Simon Ding
45a212fec5 feat: add msg 2024-07-24 23:51:20 +08:00
Simon Ding
39bfda4cda feat: remove unicode hindden char 2024-07-24 23:11:45 +08:00
Simon Ding
24a4d3152d fix: condition mismatch 2024-07-24 22:47:25 +08:00
Simon Ding
6c6670a8c0 fix name match 2024-07-24 22:35:50 +08:00
Simon Ding
63fc4f277b feat: better episode matching 2024-07-24 22:26:25 +08:00
Simon Ding
45d2a4fb79 feat: better name parser 2024-07-24 22:15:59 +08:00
Simon Ding
5e337871c9 fix: finding season pack 2024-07-24 21:57:50 +08:00
Simon Ding
803dcfeacd fix: replace strings 2024-07-24 21:48:14 +08:00
Simon Ding
c26e61bbee fix: incase name not submitted 2024-07-24 21:35:59 +08:00
Simon Ding
e334acba32 feat: submit all torrent info to server 2024-07-24 21:24:36 +08:00
Simon Ding
1359df599b feat: follow jackett redirect to get real link 2024-07-24 19:30:24 +08:00
Simon Ding
16ca00d19c fix: name cannot parsed 2024-07-24 18:35:01 +08:00
Simon Ding
f4b8d03cfc add log 2024-07-24 18:18:48 +08:00
Simon Ding
8811b89889 fix local storage 2024-07-24 18:12:11 +08:00
24 changed files with 776 additions and 490 deletions

View File

@@ -162,6 +162,10 @@ func (c *Client) UpdateEpiode(episodeId int, name, overview string) error {
return c.ent.Episode.Update().Where(episode.ID(episodeId)).SetTitle(name).SetOverview(overview).Exec(context.TODO())
}
func (c *Client) UpdateEpiode2(episodeId int, name, overview, airdate string) error {
return c.ent.Episode.Update().Where(episode.ID(episodeId)).SetTitle(name).SetOverview(overview).SetAirDate(airdate).Exec(context.TODO())
}
type MediaDetails struct {
*ent.Media
Episodes []*ent.Episode `json:"episodes"`
@@ -207,6 +211,19 @@ func (c *Client) SaveEposideDetail(d *ent.Episode) (int, error) {
return ep.ID, err
}
func (c *Client) SaveEposideDetail2(d *ent.Episode) (int, error) {
ep, err := c.ent.Episode.Create().
SetAirDate(d.AirDate).
SetSeasonNumber(d.SeasonNumber).
SetEpisodeNumber(d.EpisodeNumber).
SetMediaID(d.MediaID).
SetStatus(d.Status).
SetOverview(d.Overview).
SetTitle(d.Title).Save(context.TODO())
return ep.ID, err
}
type TorznabSetting struct {
URL string `json:"url"`
ApiKey string `json:"api_key"`
@@ -299,9 +316,9 @@ func (c *Client) DeleteDownloadCLient(id int) {
// Storage is the model entity for the Storage schema.
type StorageInfo struct {
Name string `json:"name"`
Implementation string `json:"implementation"`
Settings map[string]string `json:"settings"`
Name string `json:"name" binding:"required"`
Implementation string `json:"implementation" binding:"required"`
Settings map[string]string `json:"settings" binding:"required"`
Default bool `json:"default"`
}
@@ -310,16 +327,15 @@ func (s *StorageInfo) ToWebDavSetting() WebdavSetting {
panic("not webdav storage")
}
return WebdavSetting{
URL: s.Settings["url"],
TvPath: s.Settings["tv_path"],
MoviePath: s.Settings["movie_path"],
User: s.Settings["user"],
Password: s.Settings["password"],
URL: s.Settings["url"],
TvPath: s.Settings["tv_path"],
MoviePath: s.Settings["movie_path"],
User: s.Settings["user"],
Password: s.Settings["password"],
ChangeFileHash: s.Settings["change_file_hash"],
}
}
type LocalDirSetting struct {
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
@@ -501,7 +517,6 @@ func (c *Client) TmdbIdInWatchlist(tmdb_id int) bool {
return c.ent.Media.Query().Where(media.TmdbID(tmdb_id)).CountX(context.TODO()) > 0
}
func (c *Client) GetDownloadHistory(mediaID int) ([]*ent.History, error) {
return c.ent.History.Query().Where(history.MediaID(mediaID)).All(context.TODO())
}
}

6
go.mod
View File

@@ -11,7 +11,7 @@ require (
golang.org/x/net v0.25.0
)
require github.com/adrg/strutil v0.3.1 // indirect
require github.com/adrg/strutil v0.3.1
require (
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
@@ -56,9 +56,9 @@ require (
github.com/zclconf/go-cty v1.8.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/sys v0.21.0
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

10
go.sum
View File

@@ -91,8 +91,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
@@ -104,8 +102,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -129,8 +125,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@@ -177,8 +171,6 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -190,8 +182,6 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=

View File

@@ -15,6 +15,7 @@ type MovieMetadata struct {
}
func ParseMovie(name string) *MovieMetadata {
name = strings.Join(strings.Fields(name), " ") //remove unnessary spaces
name = strings.ToLower(strings.TrimSpace(name))
var meta = &MovieMetadata{}
yearRe := regexp.MustCompile(`\(\d{4}\)`)

View File

@@ -9,28 +9,26 @@ import (
)
type Metadata struct {
NameEn string
NameCn string
Season int
Episode int
Resolution string
NameEn string
NameCn string
Season int
Episode int
Resolution string
IsSeasonPack bool
}
func ParseTv(name string) *Metadata {
name = strings.ToLower(name)
if utils.IsASCII(name) { //english name
return parseEnglishName(name)
}
name = strings.ReplaceAll(name, "\u200b", "") //remove unicode hidden character
if utils.ContainsChineseChar(name) {
return parseChineseName(name)
}
return nil
return parseEnglishName(name)
}
func parseEnglishName(name string) *Metadata {
re := regexp.MustCompile(`[^\p{L}\w\s]`)
name = re.ReplaceAllString(strings.ToLower(name), "")
name = re.ReplaceAllString(strings.ToLower(name), " ")
splits := strings.Split(strings.TrimSpace(name), " ")
var newSplits []string
@@ -71,11 +69,6 @@ func parseEnglishName(name string) *Metadata {
}
if seasonIndex != -1 {
//season exists
if seasonIndex != 0 {
//name exists
names := newSplits[0:seasonIndex]
meta.NameEn = strings.Join(names, " ")
}
ss := seasonRe.FindAllString(newSplits[seasonIndex], -1)
if len(ss) != 0 {
//season info
@@ -87,6 +80,24 @@ func parseEnglishName(name string) *Metadata {
}
meta.Season = n
}
} else { //maybe like Season 1?
seasonRe := regexp.MustCompile(`season \d{1,2}`)
matches := seasonRe.FindAllString(name, -1)
if len(matches) > 0 {
for i, s := range newSplits {
if s == "season" {
seasonIndex = i
}
}
numRe := regexp.MustCompile(`\d{1,2}`)
seNum := numRe.FindAllString(matches[0], -1)[0]
n, err := strconv.Atoi(seNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", seNum, err))
}
meta.Season = n
}
}
if episodeIndex != -1 {
ep := episodeRe.FindAllString(newSplits[episodeIndex], -1)
@@ -99,14 +110,46 @@ func parseEnglishName(name string) *Metadata {
}
meta.Episode = n
}
} else { //no episode, maybe like One Punch Man S2 - 08 [1080p].mkv
numRe := regexp.MustCompile(`^\d{1,2}$`)
for i, p := range newSplits {
if numRe.MatchString(p) {
if i > 0 && strings.Contains(newSplits[i-1], "season") { //last word cannot be season
continue
}
if i < seasonIndex {
//episode number most likely should comes alfter season number
continue
}
//episodeIndex = i
n, err := strconv.Atoi(p)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", p, err))
}
meta.Episode = n
}
}
}
if resIndex != -1 {
//resolution exists
meta.Resolution = newSplits[resIndex]
}
if meta.Episode == -1 {
if meta.Episode == -1 || strings.Contains(name, "complete") {
meta.Episode = -1
meta.IsSeasonPack = true
}
if seasonIndex > 0 {
//name exists
names := newSplits[0:seasonIndex]
meta.NameEn = strings.TrimSpace(strings.Join(names, " "))
} else {
meta.NameEn = name
}
return meta
}
@@ -144,10 +187,10 @@ func parseChineseName(name string) *Metadata {
}
meta.Episode = n
} else { //【第09話】
re2 := regexp.MustCompile(`第\d{1,2}(话|話|集)`)
re2 := regexp.MustCompile(`第\d{1,4}(话|話|集)`)
episodeMatches1 := re2.FindAllString(name, -1)
if len(episodeMatches1) > 0 {
re := regexp.MustCompile(`\d{1,2}`)
re := regexp.MustCompile(`\d{1,4}`)
epNum := re.FindAllString(episodeMatches1[0], -1)[0]
n, err := strconv.Atoi(epNum)
if err != nil {
@@ -158,9 +201,9 @@ func parseChineseName(name string) *Metadata {
re3 := regexp.MustCompile(`[^\d\w]\d{1,2}[^\d\w]`)
epNums := re3.FindAllString(name, -1)
if len(epNums) > 0 {
re3 := regexp.MustCompile(`\d{1,2}`)
epNum := re3.FindAllString(epNums[0],-1)[0]
epNum := re3.FindAllString(epNums[0], -1)[0]
n, err := strconv.Atoi(epNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", epNum, err))
@@ -203,10 +246,10 @@ func parseChineseName(name string) *Metadata {
}
}
if meta.IsSeasonPack && meta.Episode != 0{
if meta.IsSeasonPack && meta.Episode != 0 {
meta.Season = meta.Episode
meta.Episode = -1
}
}
//tv name
title := name

View File

@@ -28,7 +28,7 @@ type LocalStorage struct {
func (l *LocalStorage) Move(src, dest string) error {
targetDir := filepath.Join(l.dir, dest)
os.MkdirAll(targetDir, 0655)
os.MkdirAll(filepath.Dir(targetDir), 0655)
err := filepath.Walk(src, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err

View File

@@ -28,6 +28,9 @@ func NewClient(apiKey string) (*Client, error) {
func (c *Client) GetTvDetails(id int, language string) (*tmdb.TVDetails, error) {
d, err := c.tmdbClient.GetTVDetails(id, withLangOption(language))
if err != nil {
return nil, errors.Wrap(err, "get tv detail")
}
log.Infof("tv id %d, language %s", id, language)
if !episodeNameUseful(d.LastEpisodeToAir.Name) {
@@ -38,13 +41,12 @@ func (c *Client) GetTvDetails(id int, language string) (*tmdb.TVDetails, error)
if err != nil {
return d, nil
}
}
if episodeNameUseful(detailEN.LastEpisodeToAir.Name) {
d.LastEpisodeToAir.Name = detailEN.LastEpisodeToAir.Name
d.LastEpisodeToAir.Overview = detailEN.LastEpisodeToAir.Overview
d.NextEpisodeToAir.Name = detailEN.NextEpisodeToAir.Name
d.NextEpisodeToAir.Overview = detailEN.NextEpisodeToAir.Overview
if episodeNameUseful(detailEN.LastEpisodeToAir.Name) {
d.LastEpisodeToAir.Name = detailEN.LastEpisodeToAir.Name
d.LastEpisodeToAir.Overview = detailEN.LastEpisodeToAir.Overview
d.NextEpisodeToAir.Name = detailEN.NextEpisodeToAir.Name
d.NextEpisodeToAir.Overview = detailEN.NextEpisodeToAir.Overview
}
}
}

View File

@@ -76,7 +76,7 @@ func (r *Response) ToResults() []Result {
for _, item := range r.Channel.Item {
r := Result{
Name: item.Title,
Magnet: item.Link,
Link: item.Link,
Size: mustAtoI(item.Size),
Seeders: mustAtoI(item.GetAttr("seeders")),
Peers: mustAtoI(item.GetAttr("peers")),
@@ -129,7 +129,7 @@ func Search(torznabUrl, api, keyWord string) ([]Result, error) {
type Result struct {
Name string
Magnet string
Link string
Size int
Seeders int
Peers int

View File

@@ -3,8 +3,11 @@ package transmission
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"polaris/log"
"strings"
"github.com/hekmon/transmissionrpc/v3"
"github.com/pkg/errors"
@@ -25,6 +28,10 @@ func NewClient(c Config) (*Client, error) {
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
_, err = tbt.TorrentGetAll(context.TODO())
if err != nil {
return nil, errors.Wrap(err, "transmission cannot connect")
}
return &Client{c: tbt, cfg: c}, nil
}
@@ -34,20 +41,42 @@ type Config struct {
Password string `json:"password"`
}
type Client struct {
c *transmissionrpc.Client
c *transmissionrpc.Client
cfg Config
}
func (c *Client) Download(magnet, dir string) (*Torrent, error) {
func (c *Client) Download(link, dir string) (*Torrent, error) {
if strings.HasPrefix(link, "http") {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err:=client.Get(link)
if err == nil {
if resp.StatusCode == http.StatusFound {
loc, err := resp.Location()
if err == nil {
link = loc.String()
log.Warnf("transimision redirect to url: %v", link)
}
}
}
}
t, err := c.c.TorrentAdd(context.TODO(), transmissionrpc.TorrentAddPayload{
Filename: &magnet,
Filename: &link,
DownloadDir: &dir,
})
log.Infof("get torrent info: %+v", t)
if t.ID == nil {
return nil, fmt.Errorf("download torrent error: %v", link)
}
return &Torrent{
ID: *t.ID,
c: c.c,
ID: *t.ID,
c: c.c,
Config: c.cfg,
}, err
}
@@ -95,7 +124,7 @@ func (t *Torrent) Progress() int {
if t.getTorrent().PercentComplete != nil && *t.getTorrent().PercentComplete >= 1 {
return 100
}
if t.getTorrent().PercentComplete != nil {
p := int(*t.getTorrent().PercentComplete * 100)
if p == 100 {
@@ -143,4 +172,4 @@ func ReloadTorrent(s string) (*Torrent, error) {
return nil, errors.Wrap(err, "reload client")
}
return &torrent, nil
}
}

View File

@@ -57,10 +57,14 @@ func RandString(n int) string {
}
func IsNameAcceptable(name1, name2 string) bool {
re := regexp.MustCompile(`[^\p{L}\w\s]`)
name1 = re.ReplaceAllString(strings.ToLower(name1), "")
name2 = re.ReplaceAllString(strings.ToLower(name2), "")
name1 = re.ReplaceAllString(strings.ToLower(name1), " ")
name2 = re.ReplaceAllString(strings.ToLower(name2), " ")
name1 = strings.Join(strings.Fields(name1), " ")
name2 = strings.Join(strings.Fields(name2), " ")
if strings.Contains(name1, name2) || strings.Contains(name2, name1) {
return true
}
return strutil.Similarity(name1, name2, metrics.NewHamming()) > 0.4
}

View File

@@ -19,6 +19,21 @@ func SearchSeasonPackage(db1 *db.Client, seriesId, seasonNum int, checkResolutio
return SearchEpisode(db1, seriesId, seasonNum, -1, checkResolution)
}
func isNumberedSeries(detail *db.MediaDetails) bool {
hasSeason2 := false
season2HasEpisode1 := false
for _, ep := range detail.Episodes {
if ep.SeasonNumber == 2 {
hasSeason2 = true
if ep.EpisodeNumber == 1 {
season2HasEpisode1 = true
}
}
}
return hasSeason2 && !season2HasEpisode1//only one 1st episode
}
func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkResolution bool) ([]torznab.Result, error) {
series := db1.GetMediaDetails(seriesId)
if series == nil {
@@ -31,11 +46,24 @@ func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkRes
var filtered []torznab.Result
for _, r := range res {
//log.Infof("torrent resource: %+v", r)
meta := metadata.ParseTv(r.Name)
if meta.Season != seasonNum {
if meta == nil { //cannot parse name
continue
}
if episodeNum != -1 && meta.Episode != episodeNum { //not season pack
if !isNumberedSeries(series) { //do not check season on series that only rely on episode number
if meta.Season != seasonNum {
continue
}
}
if isNumberedSeries(series) && episodeNum == -1 {
//should not want season
continue
}
if episodeNum != -1 && meta.Episode != episodeNum { //not season pack, episode number equals
continue
} else if seasonNum == -1 && !meta.IsSeasonPack { //want season pack, but not season pack
continue
}
if checkResolution && meta.Resolution != series.Resolution.String() {

View File

@@ -2,12 +2,10 @@ package server
import (
"fmt"
"polaris/db"
"polaris/ent"
"polaris/ent/episode"
"polaris/ent/history"
"polaris/log"
"polaris/pkg/transmission"
"polaris/pkg/utils"
"polaris/server/core"
"strconv"
@@ -16,58 +14,6 @@ import (
"github.com/pkg/errors"
)
type addTorznabIn struct {
Name string `json:"name"`
URL string `json:"url"`
ApiKey string `json:"api_key"`
}
func (s *Server) AddTorznabInfo(c *gin.Context) (interface{}, error) {
var in addTorznabIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
err := s.db.SaveTorznabInfo(in.Name, db.TorznabSetting{
URL: in.URL,
ApiKey: in.ApiKey,
})
if err != nil {
return nil, errors.Wrap(err, "add ")
}
return nil, nil
}
func (s *Server) DeleteTorznabInfo(c *gin.Context) (interface{}, error) {
var ids = c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
return nil, fmt.Errorf("id is not correct: %v", ids)
}
s.db.DeleteTorznab(id)
return "success", nil
}
func (s *Server) GetAllIndexers(c *gin.Context) (interface{}, error) {
indexers := s.db.GetAllTorznabInfo()
if len(indexers) == 0 {
return nil, nil
}
return indexers, nil
}
func (s *Server) getDownloadClient() (*transmission.Client, error) {
tr := s.db.GetTransmission()
trc, err := transmission.NewClient(transmission.Config{
URL: tr.URL,
User: tr.User,
Password: tr.Password,
})
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
return trc, nil
}
func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*string, error) {
trc, err := s.getDownloadClient()
if err != nil {
@@ -80,7 +26,7 @@ func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*strin
}
r1 := res[0]
log.Infof("found resource to download: %v", r1)
log.Infof("found resource to download: %+v", r1)
downloadDir := s.db.GetDownloadDir()
size := utils.AvailableSpace(downloadDir)
@@ -89,7 +35,7 @@ func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*strin
return nil, errors.New("no enough space")
}
torrent, err := trc.Download(r1.Magnet, s.db.GetDownloadDir())
torrent, err := trc.Download(r1.Link, s.db.GetDownloadDir())
if err != nil {
return nil, errors.Wrap(err, "downloading")
}
@@ -99,7 +45,7 @@ func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*strin
if series == nil {
return nil, fmt.Errorf("no tv series of id %v", seriesId)
}
dir := fmt.Sprintf("%s/Season %02d", series.TargetDir, seasonNum)
dir := fmt.Sprintf("%s/Season %02d/", series.TargetDir, seasonNum)
history, err := s.db.SaveHistoryRecord(ent.History{
MediaID: seriesId,
@@ -143,14 +89,14 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string
return nil, err
}
r1 := res[0]
log.Infof("found resource to download: %v", r1)
torrent, err := trc.Download(r1.Magnet, s.db.GetDownloadDir())
log.Infof("found resource to download: %+v", r1)
torrent, err := trc.Download(r1.Link, s.db.GetDownloadDir())
if err != nil {
return nil, errors.Wrap(err, "downloading")
}
torrent.Start()
dir := fmt.Sprintf("%s/Season %02d", series.TargetDir, seasonNum)
dir := fmt.Sprintf("%s/Season %02d/", series.TargetDir, seasonNum)
history, err := s.db.SaveHistoryRecord(ent.History{
MediaID: ep.MediaID,
@@ -195,7 +141,7 @@ func (s *Server) SearchAvailableEpisodeResource(c *gin.Context) (interface{}, er
Size: r.Size,
Seeders: r.Seeders,
Peers: r.Peers,
Link: r.Magnet,
Link: r.Link,
})
}
if len(searchResults) == 0 {
@@ -268,7 +214,7 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
Size: r.Size,
Seeders: r.Seeders,
Peers: r.Peers,
Link: r.Magnet,
Link: r.Link,
})
}
if len(searchResults) == 0 {
@@ -278,7 +224,7 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
}
type downloadTorrentIn struct {
MediaID int `json:"media_id" binding:"required"`
MediaID int `json:"media_id" binding:"required"`
TorznabSearchResult
}
@@ -303,13 +249,16 @@ func (s *Server) DownloadMovieTorrent(c *gin.Context) (interface{}, error) {
return nil, errors.Wrap(err, "downloading")
}
torrent.Start()
name := in.Name
if name == "" {
name = media.OriginalName
}
go func() {
ep := media.Episodes[0]
history, err := s.db.SaveHistoryRecord(ent.History{
MediaID: media.ID,
EpisodeID: ep.ID,
SourceTitle: media.NameCn,
SourceTitle: name,
TargetDir: "./",
Status: history.StatusRunning,
Size: in.Size,
@@ -329,39 +278,3 @@ func (s *Server) DownloadMovieTorrent(c *gin.Context) (interface{}, error) {
}
type downloadClientIn struct {
Name string `json:"name"`
URL string `json:"url"`
User string `json:"user"`
Password string `json:"password"`
Implementation string `json:"implementation"`
}
func (s *Server) AddDownloadClient(c *gin.Context) (interface{}, error) {
var in downloadClientIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
if err := s.db.SaveTransmission(in.Name, in.URL, in.User, in.Password); err != nil {
return nil, errors.Wrap(err, "save transmission")
}
return nil, nil
}
func (s *Server) GetAllDonloadClients(c *gin.Context) (interface{}, error) {
res := s.db.GetAllDonloadClients()
if len(res) == 0 {
return nil, nil
}
return res, nil
}
func (s *Server) DeleteDownloadCLient(c *gin.Context) (interface{}, error) {
var ids = c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
return nil, fmt.Errorf("id is not correct: %v", ids)
}
s.db.DeleteDownloadCLient(id)
return "success", nil
}

View File

@@ -23,6 +23,7 @@ func (s *Server) scheduler() {
s.downloadTvSeries()
s.downloadMovie()
})
s.mustAddCron("@every 12h", s.checkAllSeriesNewSeason)
s.cron.Start()
}
@@ -294,7 +295,7 @@ func (s *Server) downloadMovieSingleEpisode(ep *ent.Episode) error {
}
r1 := res[0]
log.Infof("begin download torrent resource: %v", r1.Name)
torrent, err := trc.Download(r1.Magnet, s.db.GetDownloadDir())
torrent, err := trc.Download(r1.Link, s.db.GetDownloadDir())
if err != nil {
return errors.Wrap(err, "downloading")
}
@@ -318,3 +319,49 @@ func (s *Server) downloadMovieSingleEpisode(ep *ent.Episode) error {
s.db.SetEpisodeStatus(ep.ID, episode.StatusDownloading)
return nil
}
func (s *Server) checkAllSeriesNewSeason() {
log.Infof("begin checking series all new season")
allSeries := s.db.GetMediaWatchlist(media.MediaTypeTv)
for _, series := range allSeries {
err := s.checkSeiesNewSeason(series)
if err != nil {
log.Errorf("check series new season error: series name %v, error: %v", series.NameEn, err)
}
}
}
func (s *Server) checkSeiesNewSeason(media *ent.Media) error{
d, err := s.MustTMDB().GetTvDetails(media.TmdbID, s.language)
if err != nil {
return errors.Wrap(err, "tmdb")
}
lastsSason := d.NumberOfSeasons
seasonDetail, err := s.MustTMDB().GetSeasonDetails(media.TmdbID, lastsSason, s.language)
if err != nil {
return errors.Wrap(err, "tmdb season")
}
for _, ep := range seasonDetail.Episodes {
epDb, err := s.db.GetEpisode(media.ID, ep.SeasonNumber, ep.EpisodeNumber)
if err != nil {
if ent.IsNotFound(err) {
log.Infof("add new episode: %+v", ep)
episode := &ent.Episode{
MediaID: media.ID,
SeasonNumber: ep.SeasonNumber,
EpisodeNumber: ep.EpisodeNumber,
Title: ep.Name,
Overview: ep.Overview,
AirDate: ep.AirDate,
Status: episode.StatusMissing,
}
s.db.SaveEposideDetail2(episode)
}
} else {//update episode
log.Infof("update new episode: %+v", ep)
s.db.UpdateEpiode2(epDb.ID, ep.Name, ep.Overview, ep.AirDate)
}
}
return nil
}

View File

@@ -1,18 +1,21 @@
package server
import (
"fmt"
"polaris/db"
"polaris/log"
"polaris/pkg/transmission"
"strconv"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
type GeneralSettings struct {
TmdbApiKey string `json:"tmdb_api_key"`
TmdbApiKey string `json:"tmdb_api_key"`
DownloadDir string `json:"download_dir"`
}
func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
var in GeneralSettings
if err := c.ShouldBindJSON(&in); err != nil {
@@ -35,9 +38,108 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
func (s *Server) GetSetting(c *gin.Context) (interface{}, error) {
tmdb := s.db.GetSetting(db.SettingTmdbApiKey)
downloadDir := s.db.GetSetting(db.SettingDownloadDir)
return &GeneralSettings{
TmdbApiKey: tmdb,
TmdbApiKey: tmdb,
DownloadDir: downloadDir,
}, nil
}
type addTorznabIn struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
ApiKey string `json:"api_key" binding:"required"`
}
func (s *Server) AddTorznabInfo(c *gin.Context) (interface{}, error) {
var in addTorznabIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
err := s.db.SaveTorznabInfo(in.Name, db.TorznabSetting{
URL: in.URL,
ApiKey: in.ApiKey,
})
if err != nil {
return nil, errors.Wrap(err, "add ")
}
return nil, nil
}
func (s *Server) DeleteTorznabInfo(c *gin.Context) (interface{}, error) {
var ids = c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
return nil, fmt.Errorf("id is not correct: %v", ids)
}
s.db.DeleteTorznab(id)
return "success", nil
}
func (s *Server) GetAllIndexers(c *gin.Context) (interface{}, error) {
indexers := s.db.GetAllTorznabInfo()
if len(indexers) == 0 {
return nil, nil
}
return indexers, nil
}
func (s *Server) getDownloadClient() (*transmission.Client, error) {
tr := s.db.GetTransmission()
trc, err := transmission.NewClient(transmission.Config{
URL: tr.URL,
User: tr.User,
Password: tr.Password,
})
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
return trc, nil
}
type downloadClientIn struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
User string `json:"user"`
Password string `json:"password"`
Implementation string `json:"implementation" binding:"required"`
}
func (s *Server) AddDownloadClient(c *gin.Context) (interface{}, error) {
var in downloadClientIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
//test connection
_, err := transmission.NewClient(transmission.Config{
URL: in.URL,
User: in.User,
Password: in.Password,
})
if err != nil {
return nil, errors.Wrap(err, "tranmission setting")
}
if err := s.db.SaveTransmission(in.Name, in.URL, in.User, in.Password); err != nil {
return nil, errors.Wrap(err, "save transmission")
}
return nil, nil
}
func (s *Server) GetAllDonloadClients(c *gin.Context) (interface{}, error) {
res := s.db.GetAllDonloadClients()
if len(res) == 0 {
return nil, nil
}
return res, nil
}
func (s *Server) DeleteDownloadCLient(c *gin.Context) (interface{}, error) {
var ids = c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
return nil, fmt.Errorf("id is not correct: %v", ids)
}
s.db.DeleteDownloadCLient(id)
return "success", nil
}

View File

@@ -88,7 +88,7 @@ class _ActivityPageState extends ConsumerState<ActivityPage>
ref
.read(activitiesDataProvider("active").notifier)
.deleteActivity(id)
.whenComplete(() => Utils.showSnakeBar("删除成功"))
.then((v) => Utils.showSnakeBar("删除成功"))
.onError((error, trace) => Utils.showSnakeBar("删除失败:$error"));
};
}

View File

@@ -115,7 +115,7 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
widget.id)
.notifier)
.delete()
.whenComplete(() => context
.then((v) => context
.go(WelcomePage.routeMoivie))
.onError((error, trace) =>
Utils.showSnakeBar(
@@ -252,8 +252,8 @@ class _NestedTabBarState extends ConsumerState<NestedTabBar>
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent.link!)
.whenComplete(() =>
.download(torrent)
.then((v) =>
Utils.showSnakeBar("开始下载:${torrent.name}"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));

View File

@@ -5,7 +5,7 @@ import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/server_response.dart';
var activitiesDataProvider =
AsyncNotifierProvider.autoDispose.family<ActivityData, List<Activity>, String>(
AsyncNotifierProvider.family<ActivityData, List<Activity>, String>(
ActivityData.new);
var mediaHistoryDataProvider = FutureProvider.autoDispose.family(
@@ -24,8 +24,7 @@ var mediaHistoryDataProvider = FutureProvider.autoDispose.family(
},
);
class ActivityData
extends AutoDisposeFamilyAsyncNotifier<List<Activity>, String> {
class ActivityData extends FamilyAsyncNotifier<List<Activity>, String> {
@override
FutureOr<List<Activity>> build(String arg) async {
if (arg == "active") {
@@ -35,7 +34,8 @@ class ActivityData
}
final dio = await APIs.getDio();
var resp = await dio.get(APIs.activityUrl, queryParameters: {"status": arg});
var resp =
await dio.get(APIs.activityUrl, queryParameters: {"status": arg});
final sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
throw sp.message;

View File

@@ -41,10 +41,11 @@ class AuthSettingData extends AutoDisposeAsyncNotifier<AuthSetting> {
class AuthSetting {
bool enable;
String user;
String password;
AuthSetting({required this.enable, required this.user});
AuthSetting({required this.enable, required this.user, required this.password});
factory AuthSetting.fromJson(Map<String, dynamic> json) {
return AuthSetting(enable: json["enable"], user: json["user"]);
return AuthSetting(enable: json["enable"], user: json["user"], password: json["password"]);
}
}

View File

@@ -181,24 +181,23 @@ class MediaDetail {
}
class SearchResult {
SearchResult({
required this.backdropPath,
required this.id,
required this.name,
required this.originalName,
required this.overview,
required this.posterPath,
required this.mediaType,
required this.adult,
required this.originalLanguage,
required this.genreIds,
required this.popularity,
required this.firstAirDate,
required this.voteAverage,
required this.voteCount,
required this.originCountry,
this.inWatchlist
});
SearchResult(
{required this.backdropPath,
required this.id,
required this.name,
required this.originalName,
required this.overview,
required this.posterPath,
required this.mediaType,
required this.adult,
required this.originalLanguage,
required this.genreIds,
required this.popularity,
required this.firstAirDate,
required this.voteAverage,
required this.voteCount,
required this.originCountry,
this.inWatchlist});
final String? backdropPath;
final int? id;
@@ -258,10 +257,12 @@ class MovieTorrentResource
return (rsp.data as List).map((v) => TorrentResource.fromJson(v)).toList();
}
Future<void> download(String link) async {
Future<void> download(TorrentResource res) async {
var m = res.toJson();
m["media_id"] = int.parse(mediaId!);
final dio = await APIs.getDio();
var resp = await dio.post(APIs.availableMoviesUrl,
data: {"media_id": int.parse(mediaId!), "link": link});
var resp = await dio.post(APIs.availableMoviesUrl, data: m);
var rsp = ServerResponse.fromJson(resp.data);
if (rsp.code != 0) {
throw rsp.message;
@@ -286,4 +287,11 @@ class TorrentResource {
peers: json["peers"],
link: json["link"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['name'] = name;
data['size'] = size;
data["link"] = link;
return data;
}
}

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/settings.dart';
import 'package:ui/providers/welcome_data.dart';
import 'package:ui/utils.dart';
import 'package:ui/widgets/progress_indicator.dart';
class SearchPage extends ConsumerStatefulWidget {
@@ -257,13 +258,14 @@ class _SearchPageState extends ConsumerState<SearchPage> {
ref
.read(searchPageDataProvider(widget.query ?? "")
.notifier)
.submit2Watchlist(
item.id!,
storageSelected,
resSelected,
item.mediaType!,
pathController.text);
Navigator.of(context).pop();
.submit2Watchlist(item.id!, storageSelected,
resSelected, item.mediaType!, pathController.text)
.then((v) {
Utils.showSnakeBar("添加成功");
Navigator.of(context).pop();
}).onError((error, trace) {
Utils.showSnakeBar("添加失败:$error");
});
},
),
],

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quiver/strings.dart';
import 'package:ui/providers/login.dart';
@@ -6,6 +7,7 @@ import 'package:ui/providers/settings.dart';
import 'package:ui/utils.dart';
import 'package:ui/widgets/progress_indicator.dart';
import 'package:ui/widgets/widgets.dart';
import 'package:form_builder_validators/form_builder_validators.dart';
class SystemSettingsPage extends ConsumerStatefulWidget {
static const route = "/settings";
@@ -18,10 +20,8 @@ class SystemSettingsPage extends ConsumerStatefulWidget {
}
class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
final GlobalKey _formKey = GlobalKey<FormState>();
final _tmdbApiController = TextEditingController();
final _downloadDirController = TextEditingController();
final _formKey = GlobalKey<FormBuilderState>();
final _formKey2 = GlobalKey<FormBuilderState>();
bool? _enableAuth;
@override
@@ -30,62 +30,56 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var tmdbSetting = settings.when(
data: (v) {
_tmdbApiController.text = v.tmdbApiKey!;
_downloadDirController.text = v.downloadDIr!;
return Container(
padding: const EdgeInsets.fromLTRB(40, 10, 40, 0),
child: Form(
child: FormBuilder(
key: _formKey, //设置globalKey用于后面获取FormState
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: {
"tmdb_api": v.tmdbApiKey,
"download_dir": v.downloadDIr
},
child: Column(
children: [
TextFormField(
FormBuilderTextField(
name: "tmdb_api",
autofocus: true,
controller: _tmdbApiController,
decoration: Commons.requiredTextFieldStyle(
text: "TMDB Api Key", icon: const Icon(Icons.key)),
//
validator: (v) {
return v!.trim().isNotEmpty ? null : "ApiKey 不能为空";
},
onSaved: (newValue) {},
validator: FormBuilderValidators.required(),
),
TextFormField(
FormBuilderTextField(
name: "download_dir",
autofocus: true,
controller: _downloadDirController,
decoration: Commons.requiredTextFieldStyle(
text: "下载路径", icon: const Icon(Icons.folder)),
//
validator: (v) {
return v!.trim().isNotEmpty ? null : "下载路径不能为空";
},
onSaved: (newValue) {},
validator: FormBuilderValidators.required(),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 28.0),
child: ElevatedButton(
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text("保存"),
),
onPressed: () {
if ((_formKey.currentState as FormState)
.validate()) {
var f = ref
.read(settingProvider.notifier)
.updateSettings(GeneralSetting(
tmdbApiKey: _tmdbApiController.text,
downloadDIr:
_downloadDirController.text));
f.whenComplete(() {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
});
}
},
),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text("保存"),
),
onPressed: () {
if (_formKey.currentState!.saveAndValidate()) {
var values = _formKey.currentState!.value;
var f = ref
.read(settingProvider.notifier)
.updateSettings(GeneralSetting(
tmdbApiKey: values["tmdb_api"],
downloadDIr: values["download_dir"]));
f.then((v) {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
});
}
}),
),
)
],
@@ -103,7 +97,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var indexer = value[i];
return SettingsCard(
onTap: () => showIndexerDetails(indexer),
child: Text(indexer.name!));
child: Text(indexer.name ?? ""));
}
return SettingsCard(
onTap: () => showIndexerDetails(Indexer()),
@@ -121,7 +115,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var client = value[i];
return SettingsCard(
onTap: () => showDownloadClientDetails(client),
child: Text(client.name!));
child: Text(client.name ?? ""));
}
return SettingsCard(
onTap: () => showDownloadClientDetails(DownloadClient()),
@@ -138,7 +132,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var storage = value[i];
return SettingsCard(
onTap: () => showStorageDetails(storage),
child: Text(storage.name!));
child: Text(storage.name ?? ""));
}
return SettingsCard(
onTap: () => showStorageDetails(Storage()),
@@ -159,53 +153,69 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
});
}
userController.text = data.user;
return Column(
children: [
SwitchListTile(
title: const Text("开启认证"),
value: _enableAuth!,
onChanged: (v) {
setState(() {
_enableAuth = v;
});
}),
_enableAuth!
? Column(
children: [
TextFormField(
controller: userController,
decoration: Commons.requiredTextFieldStyle(
text: "用户名",
icon: const Icon(Icons.account_box),
)),
TextFormField(
obscureText: true,
enableSuggestions: false,
autocorrect: false,
controller: passController,
decoration: Commons.requiredTextFieldStyle(
text: "密码",
icon: const Icon(Icons.password),
))
],
)
: const Column(),
Center(
child: ElevatedButton(
child: const Text("保存"),
onPressed: () {
var f = ref
.read(authSettingProvider.notifier)
.updateAuthSetting(_enableAuth!,
userController.text, passController.text);
f.whenComplete(() {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
return FormBuilder(
key: _formKey2,
initialValue: {
"user": data.user,
"password": data.password,
"enable": data.enable
},
child: Column(
children: [
FormBuilderSwitch(
name: "enable",
title: const Text("开启认证"),
onChanged: (v) {
setState(() {
_enableAuth = v;
});
}))
],
);
}),
_enableAuth!
? Column(
children: [
FormBuilderTextField(
name: "user",
autovalidateMode:
AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
decoration: Commons.requiredTextFieldStyle(
text: "用户名",
icon: const Icon(Icons.account_box),
)),
FormBuilderTextField(
name: "password",
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autovalidateMode:
AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
decoration: Commons.requiredTextFieldStyle(
text: "密码",
icon: const Icon(Icons.password),
))
],
)
: const Column(),
Center(
child: ElevatedButton(
child: const Text("保存"),
onPressed: () {
if (_formKey2.currentState!.saveAndValidate()) {
var values = _formKey2.currentState!.value;
var f = ref
.read(authSettingProvider.notifier)
.updateAuthSetting(_enableAuth!,
values["user"], values["password"]);
f.then((v) {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
});
}
}))
],
));
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator());
@@ -256,109 +266,149 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
}
Future<void> showIndexerDetails(Indexer indexer) {
var nameController = TextEditingController(text: indexer.name);
var urlController = TextEditingController(text: indexer.url);
var apiKeyController = TextEditingController(text: indexer.apiKey);
final _formKey = GlobalKey<FormBuilderState>();
var selectImpl = "torznab";
final children = <Widget>[
DropdownMenu(
label: const Text("类型"),
onSelected: (value) {
setState(() {
selectImpl = value!;
});
},
initialSelection: selectImpl,
dropdownMenuEntries: const [
DropdownMenuEntry(value: "torznab", label: "Torznab"),
var body = FormBuilder(
key: _formKey,
initialValue: {
"name": indexer.name,
"url": indexer.url,
"api_key": indexer.apiKey,
"impl": "torznab"
},
child: Column(
children: [
FormBuilderDropdown(
name: "impl",
decoration: const InputDecoration(labelText: "类型"),
items: const [
DropdownMenuItem(value: "torznab", child: Text("Torznab")),
],
),
FormBuilderTextField(
name: "name",
decoration: Commons.requiredTextFieldStyle(text: "名称"),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "url",
decoration: Commons.requiredTextFieldStyle(text: "地址"),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "api_key",
decoration: Commons.requiredTextFieldStyle(text: "API Key"),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
),
],
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "名称"),
controller: nameController,
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "地址"),
controller: urlController,
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "API Key"),
controller: apiKeyController,
),
];
);
onDelete() async {
return ref.read(indexersProvider.notifier).deleteIndexer(indexer.id!);
}
onSubmit() async {
return ref.read(indexersProvider.notifier).addIndexer(Indexer(
name: nameController.text,
url: urlController.text,
apiKey: apiKeyController.text));
if (_formKey.currentState!.saveAndValidate()) {
var values = _formKey.currentState!.value;
return ref.read(indexersProvider.notifier).addIndexer(Indexer(
name: values["name"],
url: values["url"],
apiKey: values["api_key"]));
} else {
throw "数据校验失败";
}
}
return showSettingDialog(
"索引器", indexer.id != null, children, onSubmit, onDelete);
"索引器", indexer.id != null, body, onSubmit, onDelete);
}
Future<void> showDownloadClientDetails(DownloadClient client) {
var nameController = TextEditingController(text: client.name);
var urlController = TextEditingController(text: client.url);
var userController = TextEditingController(text: client.user);
var passController = TextEditingController(text: client.password);
final _formKey = GlobalKey<FormBuilderState>();
var _enableAuth = isNotBlank(client.user);
String selectImpl = "transmission";
var body = <Widget>[
DropdownMenu(
label: const Text("类型"),
onSelected: (value) {
setState(() {
selectImpl = value!;
});
},
initialSelection: selectImpl,
dropdownMenuEntries: const [
DropdownMenuEntry(value: "transmission", label: "Transmission"),
],
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "名称"),
controller: nameController,
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "地址"),
controller: urlController,
),
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Column(
children: [
SwitchListTile(
title: const Text("需要认证"),
value: _enableAuth,
onChanged: (v) {
final body =
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return FormBuilder(
key: _formKey,
initialValue: {
"name": client.name,
"url": client.url,
"user": client.user,
"password": client.password,
"impl": "transmission"
},
child: Column(
children: [
FormBuilderDropdown<String>(
name: "impl",
decoration: const InputDecoration(labelText: "类型"),
onChanged: (value) {
setState(() {
_enableAuth = v;
selectImpl = value!;
});
}),
_enableAuth
? Column(
children: [
TextField(
decoration: Commons.requiredTextFieldStyle(text: "用户"),
controller: userController,
),
TextField(
decoration: Commons.requiredTextFieldStyle(text: "密码"),
controller: passController,
),
],
)
: Container()
],
);
})
];
},
items: const [
DropdownMenuItem(
value: "transmission", child: Text("Transmission")),
],
),
FormBuilderTextField(
name: "name",
decoration: const InputDecoration(labelText: "名称"),
validator: FormBuilderValidators.required(),
autovalidateMode: AutovalidateMode.onUserInteraction),
FormBuilderTextField(
name: "url",
decoration: const InputDecoration(
labelText: "地址", hintText: "http://127.0.0.1:9091"),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: FormBuilderValidators.required(),
),
StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Column(
children: [
FormBuilderSwitch(
name: "auth",
title: const Text("需要认证"),
initialValue: _enableAuth,
onChanged: (v) {
setState(() {
_enableAuth = v!;
});
}),
_enableAuth
? Column(
children: [
FormBuilderTextField(
name: "user",
decoration: Commons.requiredTextFieldStyle(
text: "用户"),
validator: FormBuilderValidators.required(),
autovalidateMode:
AutovalidateMode.onUserInteraction),
FormBuilderTextField(
name: "password",
decoration: Commons.requiredTextFieldStyle(
text: "密码"),
validator: FormBuilderValidators.required(),
obscureText: true,
autovalidateMode:
AutovalidateMode.onUserInteraction),
],
)
: Container()
],
);
})
],
));
});
onDelete() async {
return ref
.read(dwonloadClientsProvider.notifier)
@@ -366,13 +416,18 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
}
onSubmit() async {
return ref.read(dwonloadClientsProvider.notifier).addDownloadClients(
DownloadClient(
name: nameController.text,
implementation: "transmission",
url: urlController.text,
user: _enableAuth ? userController.text : null,
password: _enableAuth ? passController.text : null));
if (_formKey.currentState!.saveAndValidate()) {
var values = _formKey.currentState!.value;
return ref.read(dwonloadClientsProvider.notifier).addDownloadClients(
DownloadClient(
name: values["name"],
implementation: values["impl"],
url: values["url"],
user: _enableAuth ? values["user"] : null,
password: _enableAuth ? values["password"] : null));
} else {
throw "数据校验不通过";
}
}
return showSettingDialog(
@@ -380,112 +435,137 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
}
Future<void> showStorageDetails(Storage s) {
var nameController = TextEditingController(text: s.name);
var tvPathController = TextEditingController();
var moviePathController = TextEditingController();
var urlController = TextEditingController();
var userController = TextEditingController();
var passController = TextEditingController();
bool enablingChangeFileHash = false;
if (s.settings != null) {
tvPathController.text = s.settings!["tv_path"] ?? "";
moviePathController.text = s.settings!["movie_path"] ?? "";
urlController.text = s.settings!["url"] ?? "";
userController.text = s.settings!["user"] ?? "";
passController.text = s.settings!["password"] ?? "";
enablingChangeFileHash =
s.settings!["change_file_hash"] == "true" ? true : false;
}
final _formKey = GlobalKey<FormBuilderState>();
String selectImpl = s.implementation == null ? "local" : s.implementation!;
final widgets =
StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
DropdownMenu(
label: const Text("类型"),
onSelected: (value) {
setState(() {
selectImpl = value!;
});
},
initialSelection: selectImpl,
dropdownMenuEntries: const [
DropdownMenuEntry(value: "local", label: "本地存储"),
DropdownMenuEntry(value: "webdav", label: "webdav")
return FormBuilder(
key: _formKey,
autovalidateMode: AutovalidateMode.disabled,
initialValue: {
"name": s.name,
"impl": s.implementation == null ? "local" : s.implementation!,
"user": s.settings != null ? s.settings!["user"] ?? "" : "",
"password": s.settings != null ? s.settings!["password"] ?? "" : "",
"tv_path": s.settings != null ? s.settings!["tv_path"] ?? "" : "",
"url": s.settings != null ? s.settings!["url"] ?? "" : "",
"movie_path":
s.settings != null ? s.settings!["movie_path"] ?? "" : "",
"change_file_hash": s.settings != null
? s.settings!["change_file_hash"] == "true"
? true
: false
: false,
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
FormBuilderDropdown<String>(
name: "impl",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(labelText: "类型"),
onChanged: (value) {
setState(() {
selectImpl = value!;
});
},
items: const [
DropdownMenuItem(
value: "local",
child: Text("本地存储"),
),
DropdownMenuItem(
value: "webdav",
child: Text("webdav"),
)
],
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "name",
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: s.name,
decoration: const InputDecoration(labelText: "名称"),
validator: FormBuilderValidators.required(),
),
selectImpl != "local"
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormBuilderTextField(
name: "url",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration:
const InputDecoration(labelText: "Webdav地址"),
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "user",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(labelText: "用户"),
),
FormBuilderTextField(
name: "password",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(labelText: "密码"),
obscureText: true,
),
FormBuilderCheckbox(
name: "change_file_hash",
title: const Text(
"上传时更改文件哈希",
style: TextStyle(fontSize: 14),
),
),
],
)
: Container(),
FormBuilderTextField(
name: "tv_path",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(labelText: "电视剧路径"),
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "movie_path",
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(labelText: "电影路径"),
validator: FormBuilderValidators.required(),
)
],
),
TextField(
decoration: const InputDecoration(labelText: "名称"),
controller: nameController,
),
selectImpl != "local"
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(labelText: "Webdav地址"),
controller: urlController,
),
TextField(
decoration: const InputDecoration(labelText: "用户"),
controller: userController,
),
TextField(
decoration: const InputDecoration(labelText: "密码"),
controller: passController,
),
CheckboxListTile(
title: const Text("上传时更改文件哈希", style: TextStyle(fontSize: 14),),
value: enablingChangeFileHash,
onChanged: (v) {
setState(() {
enablingChangeFileHash = v??false;
});
}),
],
)
: Container(),
TextField(
decoration: const InputDecoration(labelText: "电视剧路径"),
controller: tvPathController,
),
TextField(
decoration: const InputDecoration(labelText: "电影路径"),
controller: moviePathController,
)
],
);
));
});
onSubmit() async {
return ref.read(storageSettingProvider.notifier).addStorage(Storage(
name: nameController.text,
implementation: selectImpl,
settings: {
"tv_path": tvPathController.text,
"movie_path": moviePathController.text,
"url": urlController.text,
"user": userController.text,
"password": passController.text,
"change_file_hash": enablingChangeFileHash ? "true" : "false"
},
));
if (_formKey.currentState!.saveAndValidate()) {
final values = _formKey.currentState!.value;
return ref.read(storageSettingProvider.notifier).addStorage(Storage(
name: values["name"],
implementation: selectImpl,
settings: {
"tv_path": values["tv_path"],
"movie_path": values["movie_path"],
"url": values["url"],
"user": values["user"],
"password": values["password"],
"change_file_hash":
values["change_file_hash"] as bool ? "true" : "false"
},
));
} else {
throw "数据校验位未通过";
}
}
onDelete() async {
return ref.read(storageSettingProvider.notifier).deleteStorage(s.id!);
}
return showSettingDialog('存储', s.id != null, [widgets], onSubmit, onDelete);
return showSettingDialog('存储', s.id != null, widgets, onSubmit, onDelete);
}
Future<void> showSettingDialog(
String title,
bool showDelete,
List<Widget> children,
Future Function() onSubmit,
Future Function() onDelete) {
Future<void> showSettingDialog(String title, bool showDelete, Widget body,
Future Function() onSubmit, Future Function() onDelete) {
return showDialog<void>(
context: context,
barrierDismissible: true,
@@ -495,9 +575,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
content: SingleChildScrollView(
child: Container(
constraints: const BoxConstraints(maxWidth: 200),
child: ListBody(
children: children,
),
child: body,
),
),
actions: <Widget>[
@@ -505,7 +583,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
? TextButton(
onPressed: () {
final f = onDelete();
f.whenComplete(() {
f.then((v) {
Utils.showSnakeBar("删除成功");
Navigator.of(context).pop();
}).onError((e, s) {
@@ -524,7 +602,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
child: const Text('确定'),
onPressed: () {
final f = onSubmit();
f.whenComplete(() {
f.then((v) {
Utils.showSnakeBar("操作成功");
Navigator.of(context).pop();
}).onError((e, s) {

View File

@@ -211,7 +211,7 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
widget.seriesId)
.notifier)
.delete()
.whenComplete(() =>
.then((v) =>
context.go(WelcomePage.routeTv))
.onError((error, trace) =>
Utils.showSnakeBar(

View File

@@ -118,6 +118,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.11+1"
flutter_form_builder:
dependency: "direct main"
description:
name: flutter_form_builder
sha256: "447f8808f68070f7df968e8063aada3c9d2e90e789b5b70f3b44e4b315212656"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.3.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -126,6 +134,11 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_login:
dependency: "direct main"
description:
@@ -160,6 +173,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.7.0"
form_builder_validators:
dependency: "direct main"
description:
name: form_builder_validators
sha256: c61ed7b1deecf0e1ebe49e2fa79e3283937c5a21c7e48e3ed9856a4a14e1191a
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.0"
go_router:
dependency: "direct main"
description:

View File

@@ -44,6 +44,8 @@ dependencies:
percent_indicator: ^4.2.3
intl: ^0.19.0
flutter_adaptive_scaffold: ^0.1.11+1
flutter_form_builder: ^9.3.0
form_builder_validators: ^11.0.0
dev_dependencies:
flutter_test: