Compare commits

..

28 Commits
v0.1 ... v0.3.0

Author SHA1 Message Date
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
30 changed files with 1034 additions and 389 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

@@ -305,17 +305,33 @@ type StorageInfo struct {
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 +500,8 @@ 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())
}

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

@@ -0,0 +1,43 @@
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.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
}

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

@@ -0,0 +1,261 @@
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)
if utils.IsASCII(name) { //english name
return parseEnglishName(name)
}
if utils.ContainsChineseChar(name) {
return parseChineseName(name)
}
return nil
}
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
if seasonIndex != 0 {
//name exists
names := newSplits[0:seasonIndex]
meta.NameEn = strings.Join(names, " ")
}
ss := seasonRe.FindAllString(newSplits[seasonIndex], -1)
if len(ss) != 0 {
//season info
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
}
}
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
}
}
if resIndex != -1 {
//resolution exists
meta.Resolution = newSplits[resIndex]
}
if meta.Episode == -1 {
meta.IsSeasonPack = true
}
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,2}(话|話|集)`)
episodeMatches1 := re2.FindAllString(name, -1)
if len(episodeMatches1) > 0 {
re := regexp.MustCompile(`\d{1,2}`)
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

@@ -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

@@ -170,7 +170,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")),
Magnet: 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")
}
@@ -130,5 +134,5 @@ type Result struct {
Seeders int
Peers int
Category int
Source string
Source string
}

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))) {
@@ -60,7 +61,7 @@ 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
return strutil.Similarity(name1, name2, metrics.NewHamming()) > 0.4
}
func FindSeasonEpisodeNum(name string) (se int, ep int, err error) {
@@ -146,3 +147,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,20 @@ 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)
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
}
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 SearchEpisode(db1, seriesId, seasonNum, -1, checkResolution)
}
func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkResolution bool) ([]torznab.Result, error) {
@@ -51,23 +25,30 @@ 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) {
meta := metadata.ParseTv(r.Name)
if meta.Season != seasonNum {
continue
}
if checkResolution && !IsWantedResolution(r.Name, series.Resolution) {
if episodeNum != -1 && meta.Episode != episodeNum { //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 +70,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 +98,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 +132,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

@@ -255,6 +255,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
}
@@ -269,7 +272,7 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
})
}
if len(searchResults) == 0 {
return nil, errors.New("no resource found")
return []TorznabSearchResult{}, nil
}
return searchResults, nil
}

View File

@@ -12,6 +12,7 @@ import (
"polaris/pkg/storage"
"polaris/pkg/utils"
"polaris/server/core"
"time"
"github.com/pkg/errors"
)
@@ -101,7 +102,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 +163,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 +226,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 +235,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)
}
}
}
}

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

@@ -2,6 +2,7 @@ package server
import (
"polaris/db"
"polaris/log"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
@@ -17,12 +18,13 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
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,7 +35,8 @@ 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{
return &GeneralSettings{
TmdbApiKey: tmdb,
DownloadDir: downloadDir,
}, 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,39 +5,88 @@ 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("删除成功"))
.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()
.whenComplete(() => 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.link!)
.whenComplete(() =>
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.autoDispose.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 AutoDisposeFamilyAsyncNotifier<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

@@ -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

@@ -386,12 +386,15 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var urlController = TextEditingController();
var userController = TextEditingController();
var passController = TextEditingController();
bool enablingChangeFileHash = false;
if (s.settings != null) {
tvPathController.text = s.settings!["tv_path"] ?? "";
moviePathController.text = s.settings!["movie_path"] ?? "";
urlController.text = s.settings!["url"] ?? "";
userController.text = s.settings!["user"] ?? "";
passController.text = s.settings!["password"] ?? "";
enablingChangeFileHash =
s.settings!["change_file_hash"] == "true" ? true : false;
}
String selectImpl = s.implementation == null ? "local" : s.implementation!;
@@ -419,7 +422,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
),
selectImpl != "local"
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(labelText: "Webdav地址"),
@@ -433,6 +436,14 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
decoration: const InputDecoration(labelText: "密码"),
controller: passController,
),
CheckboxListTile(
title: const Text("上传时更改文件哈希", style: TextStyle(fontSize: 14),),
value: enablingChangeFileHash,
onChanged: (v) {
setState(() {
enablingChangeFileHash = v??false;
});
}),
],
)
: Container(),
@@ -456,7 +467,8 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
"movie_path": moviePathController.text,
"url": urlController.text,
"user": userController.text,
"password": passController.text
"password": passController.text,
"change_file_hash": enablingChangeFileHash ? "true" : "false"
},
));
}

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??"",
),
],
)),

View File

@@ -110,6 +110,14 @@ 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_lints:
dependency: "direct dev"
description:

View File

@@ -43,6 +43,7 @@ dependencies:
shared_preferences: ^2.2.3
percent_indicator: ^4.2.3
intl: ^0.19.0
flutter_adaptive_scaffold: ^0.1.11+1
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",