Compare commits

...

54 Commits
v0.1 ... v0.4.0

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
Simon Ding
daff2cfcfc feat: telegram chat 2024-07-24 17:13:23 +08:00
Simon Ding
79ec63bfdb feat: test webdav 2024-07-24 16:43:20 +08:00
Simon Ding
bd0ada5897 chore: remove useless logs 2024-07-24 15:57:39 +08:00
Simon Ding
a7dfa2d0f0 feat: fetch torznab results cocurrently 2024-07-24 15:55:33 +08:00
Simon Ding
33f0a5b53f fix: revert main 2024-07-24 15:23:27 +08:00
Simon Ding
1878d6b679 feat: japan anime support 2024-07-24 15:21:48 +08:00
Simon Ding
627f838ab9 feat: null operation 2024-07-23 22:34:45 +08:00
Simon Ding
215511fab0 remove log 2024-07-23 22:25:46 +08:00
Simon Ding
730db5c94a feat: seperate active and archived activities 2024-07-23 22:22:53 +08:00
Simon Ding
55f5ce329c feat: do not return error when no resource 2024-07-23 21:27:06 +08:00
Simon Ding
5b2d86d301 fix: cn name 2024-07-23 21:17:54 +08:00
Simon Ding
95708a4c0c fix: chinese name 2024-07-23 21:15:54 +08:00
Simon Ding
c41b3026df fix: airdate is null 2024-07-23 20:56:04 +08:00
Simon Ding
fa84f881a4 feat: do not download episode aired more than 24h 2024-07-23 20:52:07 +08:00
Simon Ding
90ac4cddff fix: no overview 2024-07-23 20:35:46 +08:00
Simon Ding
2c5e4d0530 feat: better readable text when no resources 2024-07-23 20:24:14 +08:00
Simon Ding
fb638dff8b fix: download dir 2024-07-23 19:50:50 +08:00
Simon Ding
11f7b51eb5 add movie download history 2024-07-23 19:01:24 +08:00
Simon Ding
d2439480c8 update redame 2024-07-23 15:49:48 +08:00
Simon Ding
6826422c2b feat: option to change file hash when update to webdav 2024-07-23 15:18:42 +08:00
Simon Ding
8d2ce9752b feat: all in adaptive scafford & change seed color 2024-07-23 14:02:27 +08:00
Simon Ding
7e5feaf998 feat: adaptive ui & change colors 2024-07-23 13:42:42 +08:00
Simon Ding
e0bdd88706 feat: change name 2024-07-23 10:23:09 +08:00
Simon Ding
74d5bf54b9 chore: update readme 2024-07-22 17:55:50 +08:00
Simon Ding
03a3bf6d90 fix: entrypoint 2024-07-22 17:21:30 +08:00
Simon Ding
ee23b75390 update readme 2024-07-22 16:32:09 +08:00
Simon Ding
6e9b88b09b chore: rename 2024-07-22 15:56:52 +08:00
Simon Ding
93525ae883 chore: rename 2024-07-22 15:50:55 +08:00
37 changed files with 1751 additions and 820 deletions

View File

@@ -1,4 +1,4 @@
name: Create and publish a Docker image
name: build docker image
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Create and publish a Docker image
name: release docker image
on:
workflow_dispatch:
@@ -12,7 +12,7 @@ env:
jobs:
build-and-push-image:
build-and-release-image:
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -32,4 +32,6 @@ RUN apt-get update && apt-get -y install ca-certificates
# 将上一个阶段publish文件夹下的所有文件复制进来
COPY --from=builder /app/polaris .
EXPOSE 8080
EXPOSE 8080
ENTRYPOINT ["./polaris"]

View File

@@ -5,6 +5,8 @@ Polaris 是一个电视剧和电影的追踪软件。配置好了之后,当剧
![main_page](assets/main_page.png)
![detail_page](assets/detail_page.png)
交流群: https://t.me/+8R2nzrlSs2JhMDgx
## 功能
- [x] 电视剧自动追踪下载
@@ -23,15 +25,30 @@ Polaris 是一个电视剧和电影的追踪软件。配置好了之后,当剧
最简单部署 Polaris 的方式是使用 docker compose
```yaml
services:
polaris:
image: ghcr.io/simon-ding/polaris:latest
restart: always
volumes:
- ./config/polaris:/app/data #程序配置文件路径
- /downloads:/downloads #下载路径,需要和下载客户端配置一致
- /data:/data #数据存储路径
- /data:/data #媒体数据存储路径也可以启动自己配置webdav存储
ports:
- 8080:8080
transmission: #下载客户端,也可以不安装使用已有的
image: lscr.io/linuxserver/transmission:latest
container_name: transmission
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
volumes:
- ./config/transmission:/config
- /downloads:/downloads #此路径要与polaris下载路径保持一致
ports:
- 9091:9091
- 51413:51413
- 51413:51413/udp
```
拉起之后访问 http://< ip >:8080 的形式访问

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,23 +316,38 @@ 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"`
}
func (s *StorageInfo) ToWebDavSetting() WebdavSetting {
if s.Implementation != storage.ImplementationWebdav.String() {
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"],
ChangeFileHash: s.Settings["change_file_hash"],
}
}
type LocalDirSetting struct {
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
}
type WebdavSetting struct {
URL string `json:"url"`
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
User string `json:"user"`
Password string `json:"password"`
URL string `json:"url"`
TvPath string `json:"tv_path"`
MoviePath string `json:"movie_path"`
User string `json:"user"`
Password string `json:"password"`
ChangeFileHash string `json:"change_file_hash"`
}
func (c *Client) AddStorage(st *StorageInfo) error {
@@ -484,3 +516,7 @@ func (c *Client) SetSeasonAllEpisodeStatus(mediaID, seasonNum int, status episod
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=

44
pkg/metadata/movie.go Normal file
View File

@@ -0,0 +1,44 @@
package metadata
import (
"fmt"
"regexp"
"strconv"
"strings"
)
type MovieMetadata struct {
NameEn string
NameCN string
Year int
Resolution string
}
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}\)`)
yearMatches := yearRe.FindAllString(name, -1)
var yearIndex = -1
if len(yearMatches) > 0 {
yearIndex = strings.Index(name, yearMatches[0])
y := yearMatches[0][1 : len(yearMatches[0])-1]
n, err := strconv.Atoi(y)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", y, err))
}
meta.Year = n
}
if yearIndex != -1 {
meta.NameEn = name[:yearIndex]
} else {
meta.NameEn = name
}
resRe := regexp.MustCompile(`\d{3,4}p`)
resMatches := resRe.FindAllString(name, -1)
if len(resMatches) > 0 {
meta.Resolution = resMatches[0]
}
return meta
}

304
pkg/metadata/tv.go Normal file
View File

@@ -0,0 +1,304 @@
package metadata
import (
"fmt"
"polaris/pkg/utils"
"regexp"
"strconv"
"strings"
)
type Metadata struct {
NameEn string
NameCn string
Season int
Episode int
Resolution string
IsSeasonPack bool
}
func ParseTv(name string) *Metadata {
name = strings.ToLower(name)
name = strings.ReplaceAll(name, "\u200b", "") //remove unicode hidden character
if utils.ContainsChineseChar(name) {
return parseChineseName(name)
}
return parseEnglishName(name)
}
func parseEnglishName(name string) *Metadata {
re := regexp.MustCompile(`[^\p{L}\w\s]`)
name = re.ReplaceAllString(strings.ToLower(name), " ")
splits := strings.Split(strings.TrimSpace(name), " ")
var newSplits []string
for _, p := range splits {
p = strings.TrimSpace(p)
if p == "" {
continue
}
newSplits = append(newSplits, p)
}
seasonRe := regexp.MustCompile(`^s\d{1,2}`)
resRe := regexp.MustCompile(`^\d{3,4}p`)
episodeRe := regexp.MustCompile(`e\d{1,2}`)
var seasonIndex = -1
var episodeIndex = -1
var resIndex = -1
for i, p := range newSplits {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if seasonRe.MatchString(p) {
//season part
seasonIndex = i
} else if resRe.MatchString(p) {
resIndex = i
}
if episodeRe.MatchString(p) {
episodeIndex = i
}
}
meta := &Metadata{
Season: -1,
Episode: -1,
}
if seasonIndex != -1 {
//season exists
ss := seasonRe.FindAllString(newSplits[seasonIndex], -1)
if len(ss) != 0 {
//season info
ssNum := strings.TrimLeft(ss[0], "s")
n, err := strconv.Atoi(ssNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", ssNum, err))
}
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)
if len(ep) > 0 {
//episode info exists
epNum := strings.TrimLeft(ep[0], "e")
n, err := strconv.Atoi(epNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", epNum, err))
}
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 || 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
}
func parseChineseName(name string) *Metadata {
var meta = &Metadata{
Season: 1,
}
//season pack
packRe := regexp.MustCompile(`(\d{1,2}-\d{1,2})|(全集)`)
if packRe.MatchString(name) {
meta.IsSeasonPack = true
}
//resolution
resRe := regexp.MustCompile(`\d{3,4}p`)
resMatches := resRe.FindAllString(name, -1)
if len(resMatches) != 0 {
meta.Resolution = resMatches[0]
} else {
if strings.Contains(name, "720") {
meta.Resolution = "720p"
} else if strings.Contains(name, "1080") {
meta.Resolution = "1080p"
}
}
//episode number
re1 := regexp.MustCompile(`\[\d{1,2}\]`)
episodeMatches1 := re1.FindAllString(name, -1)
if len(episodeMatches1) > 0 { //[11] [1080p]
epNum := strings.TrimRight(strings.TrimLeft(episodeMatches1[0], "["), "]")
n, err := strconv.Atoi(epNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", epNum, err))
}
meta.Episode = n
} else { //【第09話】
re2 := regexp.MustCompile(`第\d{1,4}(话|話|集)`)
episodeMatches1 := re2.FindAllString(name, -1)
if len(episodeMatches1) > 0 {
re := regexp.MustCompile(`\d{1,4}`)
epNum := re.FindAllString(episodeMatches1[0], -1)[0]
n, err := strconv.Atoi(epNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", epNum, err))
}
meta.Episode = n
} else { //SHY 靦腆英雄 / Shy -05 ( CR 1920x1080 AVC AAC MKV)
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]
n, err := strconv.Atoi(epNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", epNum, err))
}
meta.Episode = n
}
}
}
//season numner
seasonRe1 := regexp.MustCompile(`s\d{1,2}`)
seasonMatches := seasonRe1.FindAllString(name, -1)
if len(seasonMatches) > 0 {
seNum := seasonMatches[0][1:]
n, err := strconv.Atoi(seNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", seNum, err))
}
meta.Season = n
} else {
seasonRe1 := regexp.MustCompile(`season \d{1,2}`)
seasonMatches := seasonRe1.FindAllString(name, -1)
if len(seasonMatches) > 0 {
re3 := regexp.MustCompile(`\d{1,2}`)
seNum := re3.FindAllString(seasonMatches[0], -1)[0]
n, err := strconv.Atoi(seNum)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", seNum, err))
}
meta.Season = n
} else {
seasonRe1 := regexp.MustCompile(`第.{1}季`)
seasonMatches := seasonRe1.FindAllString(name, -1)
if len(seasonMatches) > 0 {
se := []rune(seasonMatches[0])
seNum := se[1]
meta.Season = chinese2Num[string(seNum)]
}
}
}
if meta.IsSeasonPack && meta.Episode != 0 {
meta.Season = meta.Episode
meta.Episode = -1
}
//tv name
title := name
fields := strings.FieldsFunc(title, func(r rune) bool {
return r == '[' || r == ']' || r == '【' || r == '】'
})
title = ""
for _, p := range fields { //寻找匹配的最长的字符串,最有可能是名字
if len([]rune(p)) > len([]rune(title)) {
title = p
}
}
re := regexp.MustCompile(`[^\p{L}\w\s]`)
title = re.ReplaceAllString(strings.TrimSpace(strings.ToLower(title)), "")
meta.NameCn = title
cnRe := regexp.MustCompile(`\p{Han}.*\p{Han}`)
cnmatches := cnRe.FindAllString(title, -1)
if len(cnmatches) > 0 {
for _, t := range cnmatches {
if len([]rune(t)) > len([]rune(meta.NameCn)) {
meta.NameCn = strings.ToLower(t)
}
}
}
enRe := regexp.MustCompile(`[[:ascii:]]*`)
enM := enRe.FindAllString(title, -1)
if len(enM) > 0 {
for _, t := range enM {
if len(t) > len(meta.NameEn) {
meta.NameEn = strings.ToLower(t)
}
}
}
return meta
}
var chinese2Num = map[string]int{
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}

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

@@ -7,6 +7,7 @@ import (
"path/filepath"
"polaris/log"
"polaris/pkg/gowebdav"
"polaris/pkg/utils"
"github.com/gabriel-vasile/mimetype"
"github.com/pkg/errors"
@@ -15,9 +16,10 @@ import (
type WebdavStorage struct {
fs *gowebdav.Client
dir string
changeMediaHash bool
}
func NewWebdavStorage(url, user, password, path string) (*WebdavStorage, error) {
func NewWebdavStorage(url, user, password, path string, changeMediaHash bool) (*WebdavStorage, error) {
c := gowebdav.NewClient(url, user, password)
if err := c.Connect(); err != nil {
return nil, errors.Wrap(err, "connect webdav")
@@ -53,6 +55,11 @@ func (w *WebdavStorage) Move(local, remote string) error {
// }
} else { //is file
if w.changeMediaHash {
if err := utils.ChangeFileHash(path); err != nil {
log.Errorf("change file %v hash error: %v", path, err)
}
}
if f, err := os.OpenFile(path, os.O_RDONLY, 0666); err != nil {
return errors.Wrapf(err, "read file %v", path)
} else { //open success

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
}
}
}
@@ -170,7 +172,7 @@ func (c *Client) GetSeasonDetails(id, seasonNumber int, language string) (*tmdb.
}
for i, ep := range detailCN.Episodes {
if episodeNameUseful(ep.Name){
if !episodeNameUseful(ep.Name) && episodeNameUseful(detailEN.Episodes[i].Name){
detailCN.Episodes[i].Name = detailEN.Episodes[i].Name
detailCN.Episodes[i].Overview = detailEN.Episodes[i].Overview
}

View File

@@ -1,12 +1,14 @@
package torznab
import (
"context"
"encoding/xml"
"io"
"net/http"
"net/url"
"polaris/log"
"strconv"
"time"
"github.com/pkg/errors"
)
@@ -59,7 +61,6 @@ type Item struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
} `xml:"attr"`
}
func (i *Item) GetAttr(key string) string {
@@ -75,12 +76,12 @@ func (r *Response) ToResults() []Result {
for _, item := range r.Channel.Item {
r := Result{
Name: item.Title,
Magnet: item.Link,
Size: mustAtoI(item.Size),
Seeders: mustAtoI(item.GetAttr("seeders")),
Peers: mustAtoI(item.GetAttr("peers")),
Link: item.Link,
Size: mustAtoI(item.Size),
Seeders: mustAtoI(item.GetAttr("seeders")),
Peers: mustAtoI(item.GetAttr("peers")),
Category: mustAtoI(item.GetAttr("category")),
Source: r.Channel.Title,
Source: r.Channel.Title,
}
res = append(res, r)
}
@@ -96,7 +97,10 @@ func mustAtoI(key string) int {
return i
}
func Search(torznabUrl, api, keyWord string) ([]Result, error) {
req, err := http.NewRequest(http.MethodGet, torznabUrl, nil)
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, torznabUrl, nil)
if err != nil {
return nil, errors.Wrap(err, "new request")
}
@@ -125,10 +129,10 @@ func Search(torznabUrl, api, keyWord string) ([]Result, error) {
type Result struct {
Name string
Magnet string
Link string
Size int
Seeders int
Peers int
Category int
Source string
Source string
}

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

@@ -1,6 +1,7 @@
package utils
import (
"os"
"regexp"
"strconv"
"strings"
@@ -14,7 +15,7 @@ import (
"golang.org/x/sys/unix"
)
func isASCII(s string) bool {
func IsASCII(s string) bool {
for _, c := range s {
if c > unicode.MaxASCII {
return false
@@ -35,7 +36,7 @@ func VerifyPassword(password, hash string) bool {
return err == nil
}
func IsChineseChar(str string) bool {
func ContainsChineseChar(str string) bool {
for _, r := range str {
if unicode.Is(unicode.Han, r) || (regexp.MustCompile("[\u3002\uff1b\uff0c\uff1a\u201c\u201d\uff08\uff09\u3001\uff1f\u300a\u300b]").MatchString(string(r))) {
@@ -56,11 +57,15 @@ 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), "")
return strutil.Similarity(name1, name2, metrics.NewHamming()) > 0.1
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
}
func FindSeasonEpisodeNum(name string) (se int, ep int, err error) {
@@ -146,3 +151,16 @@ func AvailableSpace(dir string) uint64 {
unix.Statfs(dir, &stat)
return stat.Bavail * uint64(stat.Bsize)
}
func ChangeFileHash(name string) error {
f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0655)
if err != nil {
return errors.Wrap(err, "open file")
}
defer f.Close()
_, err = f.Write([]byte("\000"))
if err != nil {
return errors.Wrap(err, "write file")
}
return nil
}

View File

@@ -1,8 +1,10 @@
package server
import (
"fmt"
"polaris/ent"
"polaris/ent/episode"
"polaris/ent/history"
"polaris/log"
"polaris/pkg/utils"
"strconv"
@@ -17,9 +19,16 @@ type Activity struct {
}
func (s *Server) GetAllActivities(c *gin.Context) (interface{}, error) {
q := c.Query("status")
his := s.db.GetHistories()
var activities = make([]Activity, 0, len(his))
for _, h := range his {
if q == "active" && (h.Status != history.StatusRunning && h.Status != history.StatusUploading) {
continue //active downloads
} else if q == "archive" && (h.Status == history.StatusRunning || h.Status == history.StatusUploading) {
continue //archived downloads
}
a := Activity{
History: h,
}
@@ -72,3 +81,15 @@ func (s *Server) RemoveActivity(c *gin.Context) (interface{}, error) {
log.Infof("history record successful deleted: %v", his.SourceTitle)
return nil, nil
}
func (s *Server) GetMediaDownloadHistory(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)
}
his, err := s.db.GetDownloadHistory(id)
if err != nil {
return nil, errors.Wrap(err, "db")
}
return his, nil
}

View File

@@ -19,7 +19,7 @@ func HttpHandler(f func(*gin.Context) (interface{}, error)) gin.HandlerFunc {
})
return
}
log.Infof("url %v return: %+v", ctx.Request.URL, r)
//log.Infof("url %v return: %+v", ctx.Request.URL, r)
ctx.JSON(200, Response{
Code: 0,

View File

@@ -3,46 +3,35 @@ package core
import (
"fmt"
"polaris/db"
"polaris/ent"
"polaris/ent/media"
"polaris/log"
"polaris/pkg/metadata"
"polaris/pkg/torznab"
"polaris/pkg/utils"
"sort"
"strconv"
"strings"
"sync"
"github.com/pkg/errors"
)
func SearchSeasonPackage(db1 *db.Client, seriesId, seasonNum int, checkResolution bool) ([]torznab.Result, error) {
series := db1.GetMediaDetails(seriesId)
if series == nil {
return nil, fmt.Errorf("no tv series of id %v", seriesId)
}
q := fmt.Sprintf("%s S%02d", series.NameEn, seasonNum)
return SearchEpisode(db1, seriesId, seasonNum, -1, checkResolution)
}
res := searchWithTorznab(db1, q)
if len(res) == 0 {
return nil, fmt.Errorf("no resource found")
}
var filtered []torznab.Result
for _, r := range res {
if !isNameAcceptable(r.Name, series.Media, seasonNum, -1) {
continue
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
}
}
if checkResolution && !IsWantedResolution(r.Name, series.Resolution) {
continue
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
return nil, errors.New("no resource found")
}
return filtered, nil
return hasSeason2 && !season2HasEpisode1//only one 1st episode
}
func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkResolution bool) ([]torznab.Result, error) {
@@ -51,23 +40,43 @@ func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkRes
return nil, fmt.Errorf("no tv series of id %v", seriesId)
}
q := fmt.Sprintf("%s S%02dE%02d", series.NameEn, seasonNum, episodeNum)
res := searchWithTorznab(db1, q)
if len(res) == 0 {
return nil, fmt.Errorf("no resource found")
}
res := searchWithTorznab(db1, series.NameEn)
resCn := searchWithTorznab(db1, series.NameCn)
res = append(res, resCn...)
var filtered []torznab.Result
for _, r := range res {
if !isNameAcceptable(r.Name, series.Media, seasonNum, episodeNum) {
//log.Infof("torrent resource: %+v", r)
meta := metadata.ParseTv(r.Name)
if meta == nil { //cannot parse name
continue
}
if checkResolution && !IsWantedResolution(r.Name, series.Resolution) {
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() {
continue
}
if !utils.IsNameAcceptable(meta.NameEn, series.NameEn) && !utils.IsNameAcceptable(meta.NameCn, series.NameCn) {
continue
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
return nil, errors.New("no resource found")
}
return filtered, nil
@@ -89,10 +98,16 @@ func SearchMovie(db1 *db.Client, movieId int, checkResolution bool) ([]torznab.R
}
var filtered []torznab.Result
for _, r := range res {
if !isNameAcceptable(r.Name, movieDetail.Media, -1, -1) {
meta := metadata.ParseMovie(r.Name)
if !utils.IsNameAcceptable(meta.NameEn, movieDetail.NameEn) {
continue
}
if checkResolution && !IsWantedResolution(r.Name, movieDetail.Resolution) {
if checkResolution && meta.Resolution != movieDetail.Resolution.String() {
continue
}
ss := strings.Split(movieDetail.AirDate, "-")[0]
year, _ := strconv.Atoi(ss)
if meta.Year != year && meta.Year != year-1 && meta.Year != year+1 { //year not match
continue
}
@@ -111,14 +126,32 @@ func searchWithTorznab(db *db.Client, q string) []torznab.Result {
var res []torznab.Result
allTorznab := db.GetAllTorznabInfo()
resChan := make(chan []torznab.Result)
var wg sync.WaitGroup
for _, tor := range allTorznab {
resp, err := torznab.Search(tor.URL, tor.ApiKey, q)
if err != nil {
log.Errorf("search %s error: %v", tor.Name, err)
continue
}
res = append(res, resp...)
wg.Add(1)
go func () {
log.Debugf("search torznab %v with %v", tor.Name, q)
defer wg.Done()
resp, err := torznab.Search(tor.URL, tor.ApiKey, q)
if err != nil {
log.Errorf("search %s error: %v", tor.Name, err)
return
}
resChan <- resp
}()
}
go func() {
wg.Wait()
close(resChan) // 在所有的worker完成后关闭Channel
}()
for result := range resChan {
res = append(res, result...)
}
sort.Slice(res, func(i, j int) bool {
var s1 = res[i]
var s2 = res[j]
@@ -127,53 +160,3 @@ func searchWithTorznab(db *db.Client, q string) []torznab.Result {
return res
}
func isNameAcceptable(torrentName string, m *ent.Media, seasonNum, episodeNum int) bool {
if !utils.IsNameAcceptable(torrentName, m.NameCn) && !utils.IsNameAcceptable(torrentName, m.NameEn) && !utils.IsNameAcceptable(torrentName, m.OriginalName){
return false //name not match
}
ss := strings.Split(m.AirDate, "-")[0]
year, _ := strconv.Atoi(ss)
if m.MediaType == media.MediaTypeMovie {
if !strings.Contains(torrentName, strconv.Itoa(year)) && !strings.Contains(torrentName, strconv.Itoa(year+1)) && !strings.Contains(torrentName, strconv.Itoa(year-1)) {
return false //not the same movie, if year is not correct
}
}
if m.MediaType == media.MediaTypeTv {
if episodeNum != -1 {
se := fmt.Sprintf("S%02dE%02d", seasonNum, episodeNum)
if !utils.ContainsIgnoreCase(torrentName, se) {
return false
}
} else {
//season package
if !utils.IsSeasonPackageName(torrentName) {
return false
}
seNum, err := utils.FindSeasonPackageInfo(torrentName)
if err != nil {
return false
}
if seNum != seasonNum {
return false
}
}
}
return true
}
func IsWantedResolution(name string, res media.Resolution) bool {
switch res {
case media.Resolution720p:
return utils.ContainsIgnoreCase(name, "720p")
case media.Resolution1080p:
return utils.ContainsIgnoreCase(name, "1080p")
case media.Resolution4k:
return utils.ContainsIgnoreCase(name, "4k") || utils.ContainsIgnoreCase(name, "2160p")
}
return false
}

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 {
@@ -255,6 +201,9 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
res, err := core.SearchMovie(s.db, id, false)
if err != nil {
if err.Error() == "no resource found" {
return []TorznabSearchResult{}, nil
}
return nil, err
}
@@ -265,17 +214,17 @@ 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 {
return nil, errors.New("no resource found")
return []TorznabSearchResult{}, nil
}
return searchResults, nil
}
type downloadTorrentIn struct {
MediaID int `json:"media_id" binding:"required"`
MediaID int `json:"media_id" binding:"required"`
TorznabSearchResult
}
@@ -300,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,
@@ -326,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

@@ -12,6 +12,7 @@ import (
"polaris/pkg/storage"
"polaris/pkg/utils"
"polaris/server/core"
"time"
"github.com/pkg/errors"
)
@@ -22,6 +23,7 @@ func (s *Server) scheduler() {
s.downloadTvSeries()
s.downloadMovie()
})
s.mustAddCron("@every 12h", s.checkAllSeriesNewSeason)
s.cron.Start()
}
@@ -101,7 +103,7 @@ func (s *Server) moveCompletedTask(id int) (err1 error) {
if series.MediaType == media.MediaTypeMovie {
targetPath = ws.MoviePath
}
storageImpl, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath)
storageImpl, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath, ws.ChangeFileHash == "true")
if err != nil {
return errors.Wrap(err, "new webdav")
}
@@ -162,7 +164,7 @@ func (s *Server) checkDownloadedSeriesFiles(m *ent.Media) error {
case storage1.ImplementationWebdav:
ws := st.ToWebDavSetting()
targetPath := ws.TvPath
storageImpl1, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath)
storageImpl1, err := storage.NewWebdavStorage(ws.URL, ws.User, ws.Password, targetPath, ws.ChangeFileHash == "true")
if err != nil {
return errors.Wrap(err, "new webdav")
}
@@ -225,14 +227,6 @@ func (s *Server) downloadTvSeries() {
if lastEpisode.Title != detail.LastEpisodeToAir.Name {
s.db.UpdateEpiode(lastEpisode.ID, detail.LastEpisodeToAir.Name, detail.LastEpisodeToAir.Overview)
}
if lastEpisode.Status == episode.StatusMissing {
name, err := s.searchAndDownload(series.ID, lastEpisode.SeasonNumber, lastEpisode.EpisodeNumber)
if err != nil {
log.Infof("cannot find resource to download for %s: %v", lastEpisode.Title, err)
} else {
log.Infof("begin download torrent resource: %v", name)
}
}
nextEpisode, err := s.db.GetEpisode(series.ID, detail.NextEpisodeToAir.SeasonNumber, detail.NextEpisodeToAir.EpisodeNumber)
if err == nil {
@@ -242,6 +236,28 @@ func (s *Server) downloadTvSeries() {
}
}
if lastEpisode.Status == episode.StatusMissing {
if lastEpisode.AirDate != "" {
t, err := time.ParseInLocation("2006-01-02", lastEpisode.AirDate, time.Local)
if err != nil {
log.Errorf("parse air date error: airdate %v, error %v",lastEpisode.AirDate, err)
} else {
if series.CreatedAt.Sub(t) > 24*time.Hour { //24h容错时间
log.Infof("episode were aired 24h before monitoring, skipping: %v", lastEpisode.Title)
return
}
}
}
name, err := s.searchAndDownload(series.ID, lastEpisode.SeasonNumber, lastEpisode.EpisodeNumber)
if err != nil {
log.Infof("cannot find resource to download for %s: %v", lastEpisode.Title, err)
} else {
log.Infof("begin download torrent resource: %v", name)
}
}
}
}
@@ -279,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")
}
@@ -303,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

@@ -62,6 +62,7 @@ func (s *Server) Serve() error {
{
activity.GET("/", HttpHandler(s.GetAllActivities))
activity.DELETE("/:id", HttpHandler(s.RemoveActivity))
activity.GET("/media/:id", HttpHandler(s.GetMediaDownloadHistory))
}
tv := api.Group("/media")

View File

@@ -1,28 +1,33 @@
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 {
return nil, errors.Wrap(err, "bind json")
}
log.Infof("set setting input: %+v", in)
if in.TmdbApiKey != "" {
if err := s.db.SetSetting(db.SettingTmdbApiKey, in.TmdbApiKey); err != nil {
return nil, errors.Wrap(err, "save tmdb api")
}
}
if in.DownloadDir == "" {
if in.DownloadDir != "" {
if err := s.db.SetSetting(db.SettingDownloadDir, in.DownloadDir); err != nil {
return nil, errors.Wrap(err, "save download dir")
}
@@ -33,8 +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,
return &GeneralSettings{
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

@@ -4,6 +4,7 @@ import (
"fmt"
"polaris/db"
"polaris/log"
"polaris/pkg/storage"
"polaris/pkg/utils"
"strconv"
"strings"
@@ -23,6 +24,21 @@ func (s *Server) AddStorage(c *gin.Context) (interface{}, error) {
return nil, errors.Wrap(err, "bind json")
}
if in.Implementation == "webdav" {
//test webdav
wd := in.ToWebDavSetting()
st, err := storage.NewWebdavStorage(wd.URL, wd.User, wd.Password, wd.TvPath, false)
if err != nil {
return nil, errors.Wrap(err, "new webdav")
}
fs, err := st.ReadDir(".")
if err != nil {
return nil, errors.Wrap(err, "test read")
}
for _, f := range fs {
log.Infof("file name: %v", f.Name())
}
}
log.Infof("received add storage input: %v", in)
err := s.db.AddStorage(&in)
return nil, err
@@ -62,7 +78,7 @@ func (s *Server) SuggestedSeriesFolderName(c *gin.Context) (interface{}, error)
}
name = fmt.Sprintf("%s %s", name, originalName)
if !utils.IsChineseChar(name) {
if !utils.ContainsChineseChar(name) {
name = originalName
}
if year != "" {

View File

@@ -5,41 +5,90 @@ import 'package:ui/providers/activity.dart';
import 'package:ui/utils.dart';
import 'package:ui/widgets/progress_indicator.dart';
class ActivityPage extends ConsumerWidget {
class ActivityPage extends ConsumerStatefulWidget {
const ActivityPage({super.key});
static const route = "/activities";
const ActivityPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var activitiesWatcher = ref.watch(activitiesDataProvider);
_ActivityPageState createState() => _ActivityPageState();
}
return activitiesWatcher.when(
data: (activities) {
return SingleChildScrollView(
child: PaginatedDataTable(
rowsPerPage: 10,
columns: const [
DataColumn(label: Text("#"), numeric: true),
DataColumn(label: Text("名称")),
DataColumn(label: Text("开始时间")),
DataColumn(label: Text("状态")),
DataColumn(label: Text("操作"))
],
source: ActivityDataSource(
activities: activities, onDelete: onDelete(ref)),
),
);
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator());
class _ActivityPageState extends ConsumerState<ActivityPage>
with TickerProviderStateMixin {
late TabController _nestedTabController;
@override
void initState() {
super.initState();
_nestedTabController = new TabController(length: 2, vsync: this);
}
Function(int) onDelete(WidgetRef ref) {
@override
void dispose() {
super.dispose();
_nestedTabController.dispose();
}
int selectedTab = 0;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
controller: _nestedTabController,
isScrollable: true,
onTap: (value) {
setState(() {
selectedTab = value;
});
},
tabs: const <Widget>[
Tab(
text: "下载中",
),
Tab(
text: "历史记录",
),
],
),
Builder(builder: (context) {
var activitiesWatcher = ref.watch(activitiesDataProvider("active"));
if (selectedTab == 1) {
activitiesWatcher = ref.watch(activitiesDataProvider("archive"));
}
return activitiesWatcher.when(
data: (activities) {
return SingleChildScrollView(
child: PaginatedDataTable(
rowsPerPage: 10,
columns: const [
DataColumn(label: Text("#"), numeric: true),
DataColumn(label: Text("名称")),
DataColumn(label: Text("开始时间")),
DataColumn(label: Text("状态")),
DataColumn(label: Text("操作"))
],
source: ActivityDataSource(
activities: activities,
onDelete: selectedTab == 0 ? onDelete() : null),
),
);
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator());
})
],
);
}
Function(int) onDelete() {
return (id) {
ref
.read(activitiesDataProvider.notifier)
.read(activitiesDataProvider("active").notifier)
.deleteActivity(id)
.whenComplete(() => Utils.showSnakeBar("删除成功"))
.then((v) => Utils.showSnakeBar("删除成功"))
.onError((error, trace) => Utils.showSnakeBar("删除失败:$error"));
};
}
@@ -47,8 +96,8 @@ class ActivityPage extends ConsumerWidget {
class ActivityDataSource extends DataTableSource {
List<Activity> activities;
Function(int) onDelete;
ActivityDataSource({required this.activities, required this.onDelete});
Function(int)? onDelete;
ActivityDataSource({required this.activities, this.onDelete});
@override
int get rowCount => activities.length;
@@ -96,11 +145,13 @@ class ActivityDataSource extends DataTableSource {
progressColor: Colors.green,
);
}()),
DataCell(Tooltip(
message: "删除任务",
child: IconButton(
onPressed: () => onDelete(activity.id!),
icon: const Icon(Icons.delete))))
onDelete != null
? DataCell(Tooltip(
message: "删除任务",
child: IconButton(
onPressed: () => onDelete!(activity.id!),
icon: const Icon(Icons.delete))))
: const DataCell(Text("-"))
]);
}

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ui/activity.dart';
import 'package:ui/login_page.dart';
import 'package:ui/movie_watchlist.dart';
import 'package:ui/navdrawer.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/search.dart';
import 'package:ui/system_settings.dart';
@@ -16,9 +16,18 @@ void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
class MyApp extends ConsumerStatefulWidget {
const MyApp({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() {
return _MyAppState();
}
}
class _MyAppState extends ConsumerState<MyApp> {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
@@ -26,95 +35,8 @@ class MyApp extends StatelessWidget {
final shellRoute = ShellRoute(
builder: (BuildContext context, GoRouterState state, Widget child) {
return SelectionArea(
child: Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: const Row(
children: [
Text("Polaris"),
],
),
actions: [
SearchAnchor(builder:
(BuildContext context, SearchController controller) {
return Container(
constraints:
const BoxConstraints(maxWidth: 300, maxHeight: 40),
child: Opacity(
opacity: 0.8,
child: SearchBar(
hintText: "搜索...",
leading: const Icon(Icons.search),
controller: controller,
shadowColor: WidgetStateColor.transparent,
backgroundColor: const WidgetStatePropertyAll(Color.fromARGB(255, 29, 78, 119)),
onSubmitted: (value) => context.go(Uri(
path: SearchPage.route,
queryParameters: {'query': value}).toString()),
),
),
);
}, suggestionsBuilder:
(BuildContext context, SearchController controller) {
return [Text("dadada")];
}),
FutureBuilder(
future: APIs.isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.exit_to_app),
child: const Text("登出"),
onPressed: () async {
final SharedPreferences prefs =
await SharedPreferences.getInstance();
await prefs.remove('token');
if (context.mounted) {
context.go(LoginScreen.route);
}
},
),
],
builder: (context, controller, child) {
return TextButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(Icons.account_circle),
);
},
);
}
return Container();
})
],
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Flex(direction: Axis.horizontal, children: <Widget>[
const Flexible(
flex: 1,
child: NavDrawer(),
),
const VerticalDivider(thickness: 1, width: 1),
Flexible(
flex: 7,
child:
Padding(padding: const EdgeInsets.all(20), child: child),
)
]))),
child: MainSkeleton(body: Padding(padding: const EdgeInsets.all(20), child: child),
),
);
},
routes: [
@@ -173,24 +95,159 @@ class MyApp extends StatelessWidget {
theme: ThemeData(
fontFamily: "NotoSansSC",
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue, brightness: Brightness.dark),
seedColor: Colors.blueAccent, brightness: Brightness.dark, surface: Colors.black54),
useMaterial3: true,
//scaffoldBackgroundColor: Color.fromARGB(255, 26, 24, 24)
),
routerConfig: router,
),
);
}
}
CustomTransitionPage buildPageWithDefaultTransition<T>({
required BuildContext context,
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
class MainSkeleton extends StatefulWidget {
final Widget body;
const MainSkeleton({super.key, required this.body});
@override
State<StatefulWidget> createState() {
return _MainSkeletonState();
}
}
class _MainSkeletonState extends State<MainSkeleton> {
var _selectedTab;
@override
Widget build(BuildContext context) {
var uri = GoRouterState.of(context).uri.toString();
if (uri.contains(WelcomePage.routeTv)) {
_selectedTab = 0;
} else if (uri.contains(WelcomePage.routeMoivie)) {
_selectedTab = 1;
} else if (uri.contains(ActivityPage.route)) {
_selectedTab = 2;
} else if (uri.contains(SystemSettingsPage.route)) {
_selectedTab = 3;
}
return AdaptiveScaffold(
appBarBreakpoint: Breakpoints.standard,
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: const Row(
children: [
Text("Polaris"),
],
),
actions: [
SearchAnchor(builder:
(BuildContext context, SearchController controller) {
return Container(
constraints:
const BoxConstraints(maxWidth: 300, maxHeight: 40),
child: Opacity(
opacity: 0.8,
child: SearchBar(
hintText: "搜索...",
leading: const Icon(Icons.search),
controller: controller,
shadowColor: WidgetStateColor.transparent,
backgroundColor: WidgetStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer
),
onSubmitted: (value) => context.go(Uri(
path: SearchPage.route,
queryParameters: {'query': value}).toString()),
),
),
);
}, suggestionsBuilder:
(BuildContext context, SearchController controller) {
return [Text("dadada")];
}),
FutureBuilder(
future: APIs.isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.exit_to_app),
child: const Text("登出"),
onPressed: () async {
final SharedPreferences prefs =
await SharedPreferences.getInstance();
await prefs.remove('token');
if (context.mounted) {
context.go(LoginScreen.route);
}
},
),
],
builder: (context, controller, child) {
return TextButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(Icons.account_circle),
);
},
);
}
return Container();
})
],
),
useDrawer: false,
selectedIndex: _selectedTab,
onSelectedIndexChange: (int index) {
setState(() {
_selectedTab = index;
});
if (index == 0) {
context.go(WelcomePage.routeTv);
} else if (index == 1) {
context.go(WelcomePage.routeMoivie);
} else if (index == 2) {
context.go(ActivityPage.route);
} else if (index == 3) {
context.go(SystemSettingsPage.route);
}
},
destinations: const <NavigationDestination>[
NavigationDestination(
icon: Icon(Icons.live_tv),
label: '电视剧',
),
NavigationDestination(
icon: Icon(Icons.movie),
label: '电影',
),
NavigationDestination(
icon: Icon(Icons.download),
label: '活动',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: '设置',
),
],
body: (context) => widget.body,
// Define a default secondaryBody.
// Override the default secondaryBody during the smallBreakpoint to be
// empty. Must use AdaptiveScaffold.emptyBuilder to ensure it is properly
// overridden.
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/activity.dart';
import 'package:ui/providers/series_details.dart';
import 'package:ui/providers/settings.dart';
import 'package:ui/providers/welcome_data.dart';
@@ -30,7 +31,6 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
@override
Widget build(BuildContext context) {
var seriesDetails = ref.watch(mediaDetailsProvider(widget.id));
var torrents = ref.watch(movieTorrentsDataProvider(widget.id));
var storage = ref.watch(storageSettingProvider);
return seriesDetails.when(
@@ -40,122 +40,100 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
Card(
margin: const EdgeInsets.all(4),
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: <Widget>[
Flexible(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(10),
child: Image.network(
"${APIs.imagesUrl}/${details.id}/poster.jpg",
fit: BoxFit.contain,
headers: APIs.authHeaders,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fitWidth,
opacity: 0.5,
image: NetworkImage(
"${APIs.imagesUrl}/${details.id}/backdrop.jpg",
headers: APIs.authHeaders))),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: <Widget>[
Flexible(
flex: 1,
child: Padding(
padding: const EdgeInsets.all(10),
child: Image.network(
"${APIs.imagesUrl}/${details.id}/poster.jpg",
fit: BoxFit.contain,
headers: APIs.authHeaders,
),
),
),
),
),
Expanded(
flex: 6,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expanded(
flex: 6,
child: Row(
children: [
Row(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("${details.resolution}"),
const SizedBox(
width: 30,
Row(
children: [
Text("${details.resolution}"),
const SizedBox(
width: 30,
),
storage.when(
data: (value) {
for (final s in value) {
if (s.id == details.storageId) {
return Text(
"${s.name}(${s.implementation})");
}
}
return const Text("未知存储");
},
error: (error, stackTrace) =>
Text("$error"),
loading: () =>
const MyProgressIndicator()),
],
),
const Divider(thickness: 1, height: 1),
Text(
"${details.name} (${details.airDate!.split("-")[0]})",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold),
),
const Text(""),
Text(
details.overview!,
),
storage.when(
data: (value) {
for (final s in value) {
if (s.id == details.storageId) {
return Text(
"${s.name}(${s.implementation})");
}
}
return const Text("未知存储");
},
error: (error, stackTrace) =>
Text("$error"),
loading: () =>
const MyProgressIndicator()),
],
),
const Divider(thickness: 1, height: 1),
Text(
"${details.name} (${details.airDate!.split("-")[0]})",
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold),
),
const Text(""),
Text(
details.overview!,
),
)),
Column(
children: [
IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(
widget.id)
.notifier)
.delete()
.then((v) => context
.go(WelcomePage.routeMoivie))
.onError((error, trace) =>
Utils.showSnakeBar(
"删除失败:$error"));
},
icon: const Icon(Icons.delete))
],
)
],
)),
Column(
children: [
IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(widget.id)
.notifier)
.delete()
.whenComplete(() => context
.go(WelcomePage.routeMoivie))
.onError((error, trace) =>
Utils.showSnakeBar(
"删除失败:$error"));
},
icon: const Icon(Icons.delete))
],
)
],
),
),
),
],
),
],
),
),
)),
),
torrents.when(
data: (v) {
return DataTable(
columns: const [
DataColumn(label: Text("名称")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("seeders")),
DataColumn(label: Text("peers")),
DataColumn(label: Text("操作"))
],
rows: List.generate(v.length, (i) {
final torrent = v[i];
return DataRow(cells: [
DataCell(Text("${torrent.name}")),
DataCell(Text("${torrent.size?.readableFileSize()}")),
DataCell(Text("${torrent.seeders}")),
DataCell(Text("${torrent.peers}")),
DataCell(IconButton(
icon: const Icon(Icons.download),
onPressed: () {
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent.link!)
.whenComplete(() => Utils.showSnakeBar(
"开始下载:${torrent.name}")).onError((error, trace) => Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator()),
NestedTabBar(
id: widget.id,
)
],
);
},
@@ -165,3 +143,131 @@ class _MovieDetailsPageState extends ConsumerState<MovieDetailsPage> {
loading: () => const MyProgressIndicator());
}
}
class NestedTabBar extends ConsumerStatefulWidget {
final String id;
const NestedTabBar({super.key, required this.id});
@override
_NestedTabBarState createState() => _NestedTabBarState();
}
class _NestedTabBarState extends ConsumerState<NestedTabBar>
with TickerProviderStateMixin {
late TabController _nestedTabController;
@override
void initState() {
super.initState();
_nestedTabController = new TabController(length: 2, vsync: this);
}
@override
void dispose() {
super.dispose();
_nestedTabController.dispose();
}
int selectedTab = 0;
@override
Widget build(BuildContext context) {
var torrents = ref.watch(movieTorrentsDataProvider(widget.id));
var histories = ref.watch(mediaHistoryDataProvider(widget.id));
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TabBar(
controller: _nestedTabController,
isScrollable: true,
onTap: (value) {
setState(() {
selectedTab = value;
});
},
tabs: const <Widget>[
Tab(
text: "下载记录",
),
Tab(
text: "资源",
),
],
),
Builder(builder: (context) {
if (selectedTab == 0) {
return histories.when(
data: (v) {
if (v.isEmpty) {
return const Center(
child: Text("无下载记录"),
);
}
return DataTable(
columns: const [
DataColumn(label: Text("#"), numeric: true),
DataColumn(label: Text("名称")),
DataColumn(label: Text("下载时间")),
],
rows: List.generate(v.length, (i) {
final activity = v[i];
return DataRow(cells: [
DataCell(Text("${activity.id}")),
DataCell(Text("${activity.sourceTitle}")),
DataCell(Text("${activity.date!.toLocal()}")),
]);
}));
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
} else {
return torrents.when(
data: (v) {
if (v.isEmpty) {
return const Center(
child: Text("无可用资源"),
);
}
return DataTable(
columns: const [
DataColumn(label: Text("名称")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("seeders")),
DataColumn(label: Text("peers")),
DataColumn(label: Text("操作"))
],
rows: List.generate(v.length, (i) {
final torrent = v[i];
return DataRow(cells: [
DataCell(Text("${torrent.name}")),
DataCell(Text("${torrent.size?.readableFileSize()}")),
DataCell(Text("${torrent.seeders}")),
DataCell(Text("${torrent.peers}")),
DataCell(IconButton(
icon: const Icon(Icons.download),
onPressed: () {
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent)
.then((v) =>
Utils.showSnakeBar("开始下载:${torrent.name}"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
}
})
],
);
}
}

View File

@@ -27,6 +27,7 @@ class APIs {
static final loginUrl = "$_baseUrl/api/login";
static final loginSettingUrl = "$_baseUrl/api/v1/setting/auth";
static final activityUrl = "$_baseUrl/api/v1/activity/";
static final activityMediaUrl = "$_baseUrl/api/v1/activity/media/";
static final imagesUrl = "$_baseUrl/api/v1/img";
static final tmdbImgBaseUrl = "$_baseUrl/api/v1/posters";

View File

@@ -5,16 +5,37 @@ import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/server_response.dart';
var activitiesDataProvider =
AsyncNotifierProvider.autoDispose<ActivityData, List<Activity>>(
AsyncNotifierProvider.family<ActivityData, List<Activity>, String>(
ActivityData.new);
class ActivityData extends AutoDisposeAsyncNotifier<List<Activity>> {
var mediaHistoryDataProvider = FutureProvider.autoDispose.family(
(ref, arg) async {
final dio = await APIs.getDio();
var resp = await dio.get("${APIs.activityMediaUrl}$arg");
final sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
throw sp.message;
}
List<Activity> activities = List.empty(growable: true);
for (final a in sp.data as List) {
activities.add(Activity.fromJson(a));
}
return activities;
},
);
class ActivityData extends FamilyAsyncNotifier<List<Activity>, String> {
@override
FutureOr<List<Activity>> build() async {
Timer(const Duration(seconds: 5), ref.invalidateSelf);//Periodically Refresh
FutureOr<List<Activity>> build(String arg) async {
if (arg == "active") {
//refresh active downloads
Timer(const Duration(seconds: 5),
ref.invalidateSelf); //Periodically Refresh
}
final dio = await APIs.getDio();
var resp = await dio.get(APIs.activityUrl);
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

@@ -258,7 +258,12 @@ class StorageSettingData extends AutoDisposeAsyncNotifier<List<Storage>> {
class Storage {
Storage(
{this.id, this.name, this.implementation, this.settings, this.isDefault});
{this.id,
this.name,
this.implementation,
this.settings,
this.isDefault,
});
final int? id;
final String? name;

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,100 +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();
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"] ?? "";
}
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,
),
],
)
: 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
},
));
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,
@@ -483,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>[
@@ -493,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) {
@@ -512,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

@@ -45,7 +45,7 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
DataCell(Text("${ep.title}")),
DataCell(Opacity(
opacity: 0.5,
child: Text("${ep.airDate}"),
child: Text(ep.airDate??"-"),
)),
DataCell(
Opacity(
@@ -198,7 +198,7 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
),
const Text(""),
Text(
details.overview!,
details.overview??"",
),
],
)),
@@ -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

@@ -110,6 +110,22 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_adaptive_scaffold:
dependency: "direct main"
description:
name: flutter_adaptive_scaffold
sha256: "56d4d81fe88ecffe8ae96b8d89a1ae793c0a85035bb9b74ff28f20eea0cdbdc2"
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:
@@ -118,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:
@@ -152,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

@@ -43,6 +43,9 @@ dependencies:
shared_preferences: ^2.2.3
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:

View File

@@ -1,6 +1,6 @@
{
"name": "ui",
"short_name": "ui",
"name": "Polaris",
"short_name": "Polaris",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",