Compare commits

...

19 Commits

Author SHA1 Message Date
Simon Ding
7a2c67af04 main page 2024-07-26 17:07:36 +08:00
Simon Ding
3698170d0b chore: update main page 2024-07-26 17:03:08 +08:00
Simon Ding
6c38db5248 chore: upper case log level 2024-07-26 17:00:43 +08:00
Simon Ding
b597edab8a feat: add system page 2024-07-26 16:59:33 +08:00
Simon Ding
2e3b67dfce fix: db init 2024-07-26 14:28:58 +08:00
Simon Ding
1dd61ccbca feat: add log level setting 2024-07-26 14:22:08 +08:00
Simon Ding
f5f8434832 feat: support log rotation 2024-07-26 13:59:10 +08:00
Simon Ding
2cb6a15c0b feat: movie ignore sound tracks 2024-07-26 13:45:25 +08:00
Simon Ding
317f5655b8 chore: add space vertical 2024-07-26 13:33:24 +08:00
Simon Ding
00506df5a1 change image width 2024-07-26 13:12:40 +08:00
Simon Ding
57de442eb9 feat: simple tv & movie status 2024-07-26 13:07:54 +08:00
Simon Ding
690ce272c2 chore: change ui 2024-07-26 12:54:37 +08:00
Simon Ding
6a9f63fff6 feat: add movie status to ui 2024-07-26 12:25:23 +08:00
Simon Ding
7b9b619de6 add log 2024-07-25 20:44:04 +08:00
Simon Ding
8bc9076d90 fix: debug level default 2024-07-25 20:34:43 +08:00
Simon Ding
891be34504 feat: log to file 2024-07-25 20:28:13 +08:00
Simon Ding
04df9adfdf feat: change task scheduler 2024-07-25 15:35:44 +08:00
Simon Ding
3c47eba618 feat: add default timezone 2024-07-25 14:52:34 +08:00
Simon Ding
e85bd231c9 feat: add source field 2024-07-25 14:43:45 +08:00
27 changed files with 607 additions and 167 deletions

View File

@@ -25,6 +25,7 @@ COPY --from=flutter /app/build/web ./ui/build/web/
RUN CGO_ENABLED=1 go build -o polaris ./cmd/
FROM debian:12
ENV TZ="Asia/Shanghai"
WORKDIR /app
RUN apt-get update && apt-get -y install ca-certificates

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -6,6 +6,7 @@ const (
SettingJacketUrl = "jacket_url"
SettingJacketApiKey = "jacket_api_key"
SettingDownloadDir = "download_dir"
SettingLogLevel = "log_level"
)
const (
@@ -18,6 +19,7 @@ const (
IndexerTorznabImpl = "torznab"
DataPath = "./data"
ImgPath = DataPath + "/img"
LogPath = DataPath + "/logs"
)
const (

View File

@@ -42,6 +42,7 @@ func Open() (*Client, error) {
c := &Client{
ent: client,
}
c.init()
return c, nil
}
@@ -57,6 +58,11 @@ func (c *Client) init() {
log.Infof("set default download dir")
c.SetSetting(downloadDir, "/downloads")
}
logLevel := c.GetSetting(SettingLogLevel)
if logLevel == "" {
log.Infof("set default log level")
c.SetSetting(SettingLogLevel, "info")
}
}
func (c *Client) generateJwtSerectIfNotExist() {
@@ -520,3 +526,15 @@ func (c *Client) TmdbIdInWatchlist(tmdb_id int) bool {
func (c *Client) GetDownloadHistory(mediaID int) ([]*ent.History, error) {
return c.ent.History.Query().Where(history.MediaID(mediaID)).All(context.TODO())
}
func (c *Client) GetMovieDummyEpisode(movieId int) (*ent.Episode, error) {
_, err := c.ent.Media.Query().Where(media.ID(movieId), media.MediaTypeEQ(media.MediaTypeMovie)).First(context.TODO())
if err != nil {
return nil, errors.Wrap(err, "get movie")
}
ep, err := c.ent.Episode.Query().Where(episode.MediaID(movieId)).First(context.TODO())
if err != nil {
return nil, errors.Wrap(err, "query episode")
}
return ep, nil
}

View File

@@ -21,7 +21,6 @@ func (Episode) Fields() []ent.Field {
field.String("overview"),
field.String("air_date"),
field.Enum("status").Values("missing", "downloading", "downloaded").Default("missing"),
field.String("file_in_storage").Optional(),
}
}

8
go.mod
View File

@@ -13,6 +13,11 @@ require (
require github.com/adrg/strutil v0.3.1
require (
github.com/gin-contrib/zap v1.1.3 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
require (
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
github.com/agext/levenshtein v1.2.1 // indirect
@@ -72,7 +77,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/pkg/errors v0.9.1
github.com/spf13/viper v1.19.0
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
)

8
go.sum
View File

@@ -34,6 +34,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v1.1.2 h1:c3kT4bFkUJn2aoRU3s6XnMjJT8J6nNWJkR0NglqmlZ4=
github.com/gin-contrib/static v1.1.2/go.mod h1:Fw90ozjHCmZBWbgrsqrDvO28YbhKEKzKp8GixhR4yLw=
github.com/gin-contrib/zap v1.1.3 h1:9e/U9fYd4/OBfmSEBs5hHZq114uACn7bpuzvCkcJySA=
github.com/gin-contrib/zap v1.1.3/go.mod h1:+BD/6NYZKJyUpqVoJEvgeq9GLz8pINEQvak9LHNOTSE=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
@@ -102,6 +104,8 @@ 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/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
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=
@@ -155,6 +159,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -191,6 +197,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,18 +1,57 @@
package log
import (
"path/filepath"
"strings"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var sugar *zap.SugaredLogger
var atom zap.AtomicLevel
const dataPath = "./data"
func init() {
config := zap.NewDevelopmentConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
config.DisableStacktrace = true
logger, _ := config.Build(zap.AddCallerSkip(1))
atom = zap.NewAtomicLevel()
atom.SetLevel(zap.DebugLevel)
w := zapcore.AddSync(&lumberjack.Logger{
Filename: filepath.Join(dataPath, "logs", "polaris.log"),
MaxSize: 50, // megabytes
MaxBackups: 3,
MaxAge: 30, // days
})
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
logger := zap.New(zapcore.NewCore(consoleEncoder, w, atom), zap.AddCallerSkip(1))
sugar = logger.Sugar()
}
func SetLogLevel(l string) {
switch strings.TrimSpace(strings.ToLower(l)) {
case "debug":
atom.SetLevel(zap.DebugLevel)
Debug("set log level to debug")
case "info":
atom.SetLevel(zap.InfoLevel)
Info("set log level to info")
case "warn", "warnning":
atom.SetLevel(zap.WarnLevel)
Warn("set log level to warnning")
case "error":
atom.SetLevel(zap.ErrorLevel)
Error("set log level to error")
}
}
func Logger() *zap.SugaredLogger {
return sugar
}
func Info(args ...interface{}) {

13
pkg/uptime/uptime.go Normal file
View File

@@ -0,0 +1,13 @@
package uptime
import "time"
var startTime time.Time
func Uptime() time.Duration {
return time.Since(startTime)
}
func init() {
startTime = time.Now()
}

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.Debug("url %v return: %+v", ctx.Request.URL, r)
ctx.JSON(200, Response{
Code: 0,

View File

@@ -110,6 +110,10 @@ func SearchMovie(db1 *db.Client, movieId int, checkResolution bool) ([]torznab.R
if meta.Year != year && meta.Year != year-1 && meta.Year != year+1 { //year not match
continue
}
if utils.ContainsIgnoreCase(r.Name, "soundtrack") {
//ignore soundtracks
continue
}
filtered = append(filtered, r)

View File

@@ -185,6 +185,7 @@ type TorznabSearchResult struct {
Link string `json:"link"`
Seeders int `json:"seeders"`
Peers int `json:"peers"`
Source string `json:"source"`
}
func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
@@ -194,11 +195,6 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
return nil, errors.Wrap(err, "convert")
}
movieDetail := s.db.GetMediaDetails(id)
if movieDetail == nil {
return nil, errors.New("no media found of id " + ids)
}
res, err := core.SearchMovie(s.db, id, false)
if err != nil {
if err.Error() == "no resource found" {
@@ -215,6 +211,7 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) {
Seeders: r.Seeders,
Peers: r.Peers,
Link: r.Link,
Source: r.Source,
})
}
if len(searchResults) == 0 {
@@ -277,4 +274,3 @@ func (s *Server) DownloadMovieTorrent(c *gin.Context) (interface{}, error) {
return media.NameEn, nil
}

View File

@@ -35,7 +35,7 @@ func (s *Server) mustAddCron(spec string, cmd func()) {
}
func (s *Server) checkTasks() {
log.Infof("begin check tasks...")
log.Debug("begin check tasks...")
for id, t := range s.tasks {
if !t.Exists() {
log.Infof("task no longer exists: %v", id)
@@ -213,50 +213,27 @@ func (s *Server) downloadTvSeries() {
log.Infof("begin check all tv series resources")
allSeries := s.db.GetMediaWatchlist(media.MediaTypeTv)
for _, series := range allSeries {
detail, err := s.MustTMDB().GetTvDetails(series.TmdbID, s.language)
if err != nil {
log.Errorf("get tv details error: %v", err)
continue
}
lastEpisode, err := s.db.GetEpisode(series.ID, detail.LastEpisodeToAir.SeasonNumber, detail.LastEpisodeToAir.EpisodeNumber)
if err != nil {
log.Errorf("get last episode error: %v", err)
continue
}
if lastEpisode.Title != detail.LastEpisodeToAir.Name {
s.db.UpdateEpiode(lastEpisode.ID, detail.LastEpisodeToAir.Name, detail.LastEpisodeToAir.Overview)
}
nextEpisode, err := s.db.GetEpisode(series.ID, detail.NextEpisodeToAir.SeasonNumber, detail.NextEpisodeToAir.EpisodeNumber)
if err == nil {
if nextEpisode.Title != detail.NextEpisodeToAir.Name {
s.db.UpdateEpiode(nextEpisode.ID, detail.NextEpisodeToAir.Name, detail.NextEpisodeToAir.Overview)
log.Errorf("updated next episode name to %v", detail.NextEpisodeToAir.Name)
}
}
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)
tvDetail := s.db.GetMediaDetails(series.ID)
for _, ep := range tvDetail.Episodes {
t, err := time.Parse("2006-01-02", ep.AirDate)
if err != nil {
log.Infof("cannot find resource to download for %s: %v", lastEpisode.Title, err)
log.Error("air date not known, skip: %v", ep.Title)
continue
}
if series.CreatedAt.Sub(t) > 24*time.Hour { //剧集在加入watchlist之前不去下载
continue
}
if ep.Status != episode.StatusMissing { //已经下载的不去下载
continue
}
name, err := s.searchAndDownload(series.ID, ep.SeasonNumber, ep.EpisodeNumber)
if err != nil {
log.Infof("cannot find resource to download for %s: %v", ep.Title, err)
} else {
log.Infof("begin download torrent resource: %v", name)
}
}
}
}
}
@@ -359,8 +336,10 @@ func (s *Server) checkSeiesNewSeason(media *ent.Media) error{
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)
if ep.Name != epDb.Title || ep.Overview != epDb.Overview || ep.AirDate != epDb.AirDate {
log.Infof("update new episode: %+v", ep)
s.db.UpdateEpiode2(epDb.ID, ep.Name, ep.Overview, ep.AirDate)
}
}
}
return nil

View File

@@ -10,6 +10,9 @@ import (
"polaris/pkg/tmdb"
"polaris/pkg/transmission"
"polaris/ui"
"time"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-contrib/static"
"github.com/robfig/cron"
@@ -43,12 +46,17 @@ func (s *Server) Serve() error {
s.jwtSerect = s.db.GetSetting(db.JwtSerectKey)
//st, _ := fs.Sub(ui.Web, "build/web")
s.r.Use(static.Serve("/", static.EmbedFolder(ui.Web, "build/web")))
s.r.Use(ginzap.Ginzap(log.Logger().Desugar(), time.RFC3339, false))
s.r.Use(ginzap.RecoveryWithZap(log.Logger().Desugar(), true))
log.SetLogLevel(s.db.GetSetting(db.SettingLogLevel)) //restore log level
s.r.POST("/api/login", HttpHandler(s.Login))
api := s.r.Group("/api/v1")
api.Use(s.authModdleware)
api.StaticFS("/img", http.Dir(db.ImgPath))
api.StaticFS("/logs", http.Dir(db.LogPath))
api.Any("/posters/*proxyPath", s.proxyPosters)
setting := api.Group("/setting")
@@ -57,6 +65,8 @@ func (s *Server) Serve() error {
setting.GET("/general", HttpHandler(s.GetSetting))
setting.POST("/auth", HttpHandler(s.EnableAuth))
setting.GET("/auth", HttpHandler(s.GetAuthSetting))
setting.GET("/logfiles", HttpHandler(s.GetAllLogs))
setting.GET("/about", HttpHandler(s.About))
}
activity := api.Group("/activity")
{
@@ -146,7 +156,7 @@ func (s *Server) proxyPosters(c *gin.Context) {
req.Host = remote.Host
req.URL.Scheme = remote.Scheme
req.URL.Host = remote.Host
req.URL.Path = fmt.Sprintf("/t/p/w500/%v", c.Param("proxyPath"))
req.URL.Path = fmt.Sprintf("/t/p/w500/%v", c.Param("proxyPath"))
}
proxy.ServeHTTP(c.Writer, c.Request)
}

View File

@@ -14,6 +14,7 @@ import (
type GeneralSettings struct {
TmdbApiKey string `json:"tmdb_api_key"`
DownloadDir string `json:"download_dir"`
LogLevel string `json:"log_level"`
}
func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
@@ -32,16 +33,24 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
return nil, errors.Wrap(err, "save download dir")
}
}
if in.LogLevel != "" {
log.SetLogLevel(in.LogLevel)
if err := s.db.SetSetting(db.SettingLogLevel, in.LogLevel); err != nil {
return nil, errors.Wrap(err, "save log level")
}
}
return nil, nil
}
func (s *Server) GetSetting(c *gin.Context) (interface{}, error) {
tmdb := s.db.GetSetting(db.SettingTmdbApiKey)
downloadDir := s.db.GetSetting(db.SettingDownloadDir)
logLevel := s.db.GetSetting(db.SettingLogLevel)
return &GeneralSettings{
TmdbApiKey: tmdb,
DownloadDir: downloadDir,
LogLevel: logLevel,
}, nil
}
@@ -97,7 +106,6 @@ func (s *Server) getDownloadClient() (*transmission.Client, error) {
return trc, nil
}
type downloadClientIn struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
@@ -113,8 +121,8 @@ func (s *Server) AddDownloadClient(c *gin.Context) (interface{}, error) {
}
//test connection
_, err := transmission.NewClient(transmission.Config{
URL: in.URL,
User: in.User,
URL: in.URL,
User: in.User,
Password: in.Password,
})
if err != nil {

52
server/systems.go Normal file
View File

@@ -0,0 +1,52 @@
package server
import (
"os"
"polaris/db"
"polaris/log"
"polaris/pkg/uptime"
"runtime"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
type LogFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
func (s *Server) GetAllLogs(c *gin.Context) (interface{}, error) {
fs, err := os.ReadDir(db.LogPath)
if err != nil {
return nil, errors.Wrap(err, "read log dir")
}
var logs []LogFile
for _, f := range fs {
if f.IsDir() {
continue
}
info, err := f.Info()
if err != nil {
log.Warnf("get log file error: %v", err)
continue
}
l := LogFile{
Name: f.Name(),
Size: info.Size(),
}
logs = append(logs, l)
}
return logs, nil
}
func (s *Server) About(c *gin.Context) (interface{}, error) {
return gin.H{
"intro": "Polaris © Simon Ding",
"homepage": "https://github.com/simon-ding/polaris",
"uptime": uptime.Uptime(),
"chat_group": "https://t.me/+8R2nzrlSs2JhMDgx",
"go_version": runtime.Version(),
}, nil
}

View File

@@ -8,9 +8,11 @@ import (
"path/filepath"
"polaris/db"
"polaris/ent"
"polaris/ent/episode"
"polaris/ent/media"
"polaris/log"
"strconv"
"time"
tmdb "github.com/cyruzin/golang-tmdb"
"github.com/gin-gonic/gin"
@@ -66,7 +68,7 @@ func (s *Server) AddTv2Watchlist(c *gin.Context) (interface{}, error) {
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind query")
}
if (in.Folder == "") {
if in.Folder == "" {
return nil, errors.New("folder should be provided")
}
detailCn, err := s.MustTMDB().GetTvDetails(in.TmdbID, db.LanguageCN)
@@ -118,7 +120,7 @@ func (s *Server) AddTv2Watchlist(c *gin.Context) (interface{}, error) {
AirDate: detail.FirstAirDate,
Resolution: media.Resolution(in.Resolution),
StorageID: in.StorageID,
TargetDir: in.Folder,
TargetDir: in.Folder,
}, epIds)
if err != nil {
return nil, errors.Wrap(err, "add to list")
@@ -183,7 +185,7 @@ func (s *Server) AddMovie2Watchlist(c *gin.Context) (interface{}, error) {
AirDate: detail.ReleaseDate,
Resolution: media.Resolution(in.Resolution),
StorageID: in.StorageID,
TargetDir: "./",
TargetDir: "./",
}, []int{epid})
if err != nil {
return nil, errors.Wrap(err, "add to list")
@@ -238,14 +240,66 @@ func (s *Server) downloadImage(url string, mediaID int, name string) error {
}
type MediaWithStatus struct {
*ent.Media
Status string `json:"status"`
}
//missing: episode aired missing
//downloaded: all monitored episode downloaded
//monitoring: episode aired downloaded, but still has not aired episode
//for movie, only monitoring/downloaded
func (s *Server) GetTvWatchlist(c *gin.Context) (interface{}, error) {
list := s.db.GetMediaWatchlist(media.MediaTypeTv)
return list, nil
res := make([]MediaWithStatus, len(list))
for i, item := range list {
var ms = MediaWithStatus{
Media: item,
Status: "downloaded",
}
details := s.db.GetMediaDetails(item.ID)
for _, ep := range details.Episodes {
if ep.SeasonNumber == 0 {
continue
}
t, err := time.Parse("2006-01-02", ep.AirDate)
if err != nil { //airdate not exist
ms.Status = "monitoring"
} else {
if item.CreatedAt.Sub(t) > 24*time.Hour { //剧集在加入watchlist之前不去下载
continue
}
if ep.Status == episode.StatusMissing {
ms.Status = "monitoring"
}
}
}
res[i] = ms
}
return res, nil
}
func (s *Server) GetMovieWatchlist(c *gin.Context) (interface{}, error) {
list := s.db.GetMediaWatchlist(media.MediaTypeMovie)
return list, nil
res := make([]MediaWithStatus, len(list))
for i, item := range list {
var ms = MediaWithStatus{
Media: item,
Status: "monitoring",
}
dummyEp, err := s.db.GetMovieDummyEpisode(item.ID)
if err != nil {
log.Errorf("get dummy episode: %v", err)
} else {
if dummyEp.Status != episode.StatusMissing {
ms.Status = "downloaded"
}
}
res[i] = ms
}
return res, nil
}
func (s *Server) GetMediaDetails(c *gin.Context) (interface{}, error) {

View File

@@ -8,7 +8,8 @@ import 'package:ui/login_page.dart';
import 'package:ui/movie_watchlist.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/search.dart';
import 'package:ui/system_settings.dart';
import 'package:ui/settings.dart';
import 'package:ui/system_page.dart';
import 'package:ui/tv_details.dart';
import 'package:ui/welcome_page.dart';
@@ -26,8 +27,6 @@ class MyApp extends ConsumerStatefulWidget {
}
class _MyAppState extends ConsumerState<MyApp> {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
@@ -35,8 +34,9 @@ class _MyAppState extends ConsumerState<MyApp> {
final shellRoute = ShellRoute(
builder: (BuildContext context, GoRouterState state, Widget child) {
return SelectionArea(
child: MainSkeleton(body: Padding(padding: const EdgeInsets.all(20), child: child),
),
child: MainSkeleton(
body: Padding(padding: const EdgeInsets.all(20), child: child),
),
);
},
routes: [
@@ -74,6 +74,10 @@ class _MyAppState extends ConsumerState<MyApp> {
GoRoute(
path: ActivityPage.route,
builder: (context, state) => const ActivityPage(),
),
GoRoute(
path: SystemPage.route,
builder: (context, state) => const SystemPage(),
)
],
);
@@ -95,7 +99,9 @@ class _MyAppState extends ConsumerState<MyApp> {
theme: ThemeData(
fontFamily: "NotoSansSC",
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blueAccent, brightness: Brightness.dark, surface: Colors.black54),
seedColor: Colors.blueAccent,
brightness: Brightness.dark,
surface: Colors.black54),
useMaterial3: true,
//scaffoldBackgroundColor: Color.fromARGB(255, 26, 24, 24)
),
@@ -103,7 +109,6 @@ class _MyAppState extends ConsumerState<MyApp> {
),
);
}
}
class MainSkeleton extends StatefulWidget {
@@ -130,85 +135,85 @@ class _MainSkeletonState extends State<MainSkeleton> {
_selectedTab = 2;
} else if (uri.contains(SystemSettingsPage.route)) {
_selectedTab = 3;
} else if (uri.contains(SystemPage.route)) {
_selectedTab = 4;
}
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"),
],
// 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()),
),
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();
})
],
),
);
}, 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) {
@@ -223,6 +228,8 @@ class _MainSkeletonState extends State<MainSkeleton> {
context.go(ActivityPage.route);
} else if (index == 3) {
context.go(SystemSettingsPage.route);
} else if (index == 4) {
context.go(SystemPage.route);
}
},
destinations: const <NavigationDestination>[
@@ -242,6 +249,10 @@ class _MainSkeletonState extends State<MainSkeleton> {
icon: Icon(Icons.settings),
label: '设置',
),
NavigationDestination(
icon: Icon(Icons.computer_rounded),
label: '系统',
),
],
body: (context) => widget.body,
// Define a default secondaryBody.

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:ui/activity.dart';
import 'package:ui/system_settings.dart';
import 'package:ui/settings.dart';
import 'package:ui/welcome_page.dart';
class NavDrawer extends StatefulWidget {

View File

@@ -29,6 +29,9 @@ class APIs {
static final activityUrl = "$_baseUrl/api/v1/activity/";
static final activityMediaUrl = "$_baseUrl/api/v1/activity/media/";
static final imagesUrl = "$_baseUrl/api/v1/img";
static final logsBaseUrl = "$_baseUrl/api/v1/logs/";
static final logFilesUrl = "$_baseUrl/api/v1/setting/logfiles";
static final aboutUrl = "$_baseUrl/api/v1/setting/about";
static final tmdbImgBaseUrl = "$_baseUrl/api/v1/posters";

View File

@@ -49,18 +49,22 @@ class EditSettingData extends AutoDisposeAsyncNotifier<GeneralSetting> {
class GeneralSetting {
String? tmdbApiKey;
String? downloadDIr;
String? logLevel;
GeneralSetting({this.tmdbApiKey, this.downloadDIr});
GeneralSetting({this.tmdbApiKey, this.downloadDIr, this.logLevel});
factory GeneralSetting.fromJson(Map<String, dynamic> json) {
return GeneralSetting(
tmdbApiKey: json["tmdb_api_key"], downloadDIr: json["download_dir"]);
tmdbApiKey: json["tmdb_api_key"],
downloadDIr: json["download_dir"],
logLevel: json["log_level"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['tmdb_api_key'] = tmdbApiKey;
data['download_dir'] = downloadDIr;
data["log_level"] = logLevel;
return data;
}
}
@@ -257,13 +261,13 @@ class StorageSettingData extends AutoDisposeAsyncNotifier<List<Storage>> {
}
class Storage {
Storage(
{this.id,
this.name,
this.implementation,
this.settings,
this.isDefault,
});
Storage({
this.id,
this.name,
this.implementation,
this.settings,
this.isDefault,
});
final int? id;
final String? name;
@@ -288,3 +292,65 @@ class Storage {
"default": isDefault,
};
}
final logFileDataProvider = FutureProvider.autoDispose((ref) async {
final dio = await APIs.getDio();
var resp = await dio.get(APIs.logFilesUrl);
var sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
throw sp.message;
}
List<LogFile> favList = List.empty(growable: true);
for (var item in sp.data as List) {
var tv = LogFile.fromJson(item);
favList.add(tv);
}
return favList;
});
final aboutDataProvider = FutureProvider.autoDispose((ref) async {
final dio = await APIs.getDio();
var resp = await dio.get(APIs.aboutUrl);
var sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
throw sp.message;
}
return About.fromJson(sp.data);
});
class LogFile {
String? name;
int? size;
LogFile({this.name, this.size});
factory LogFile.fromJson(Map<String, dynamic> json1) {
return LogFile(name: json1["name"], size: json1["size"]);
}
}
class About {
About({
required this.chatGroup,
required this.goVersion,
required this.homepage,
required this.intro,
required this.uptime,
});
final String? chatGroup;
final String? goVersion;
final String? homepage;
final String? intro;
final Duration? uptime;
factory About.fromJson(Map<String, dynamic> json) {
return About(
chatGroup: json["chat_group"],
goVersion: json["go_version"],
homepage: json["homepage"],
intro: json["intro"],
uptime: Duration(microseconds: (json["uptime"]/1000.0 as double).round()),
);
}
}

View File

@@ -150,6 +150,7 @@ class MediaDetail {
String? resolution;
int? storageId;
String? airDate;
String? status;
MediaDetail({
this.id,
@@ -163,6 +164,7 @@ class MediaDetail {
this.resolution,
this.storageId,
this.airDate,
this.status,
});
MediaDetail.fromJson(Map<String, dynamic> json) {
@@ -177,6 +179,7 @@ class MediaDetail {
resolution = json["resolution"];
storageId = json["storage_id"];
airDate = json["air_date"];
status = json["status"];
}
}

View File

@@ -37,9 +37,11 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: {
"tmdb_api": v.tmdbApiKey,
"download_dir": v.downloadDIr
"download_dir": v.downloadDIr,
"log_level": v.logLevel
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormBuilderTextField(
name: "tmdb_api",
@@ -57,6 +59,26 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
//
validator: FormBuilderValidators.required(),
),
SizedBox(
width: 300,
child: FormBuilderDropdown(
name: "log_level",
decoration: const InputDecoration(
labelText: "日志级别",
icon: Icon(Icons.file_present_rounded),
),
items: const [
DropdownMenuItem(
value: "debug", child: Text("DEBUG")),
DropdownMenuItem(value: "info", child: Text("INFO")),
DropdownMenuItem(
value: "warn", child: Text("WARNNING")),
DropdownMenuItem(
value: "error", child: Text("ERROR")),
],
validator: FormBuilderValidators.required(),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 28.0),
@@ -72,7 +94,8 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
.read(settingProvider.notifier)
.updateSettings(GeneralSetting(
tmdbApiKey: values["tmdb_api"],
downloadDIr: values["download_dir"]));
downloadDIr: values["download_dir"],
logLevel: values["log_level"]));
f.then((v) {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {

136
ui/lib/system_page.dart Normal file
View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/settings.dart';
import 'package:ui/widgets/progress_indicator.dart';
import 'package:url_launcher/url_launcher.dart';
class SystemPage extends ConsumerStatefulWidget {
static const route = "/system";
const SystemPage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() {
return _SystemPageState();
}
}
class _SystemPageState extends ConsumerState<SystemPage> {
@override
Widget build(BuildContext context) {
final logs = ref.watch(logFileDataProvider);
final about = ref.watch(aboutDataProvider);
return SingleChildScrollView(
child: Column(
children: [
ExpansionTile(
expandedCrossAxisAlignment: CrossAxisAlignment.stretch,
initiallyExpanded: true,
childrenPadding: EdgeInsets.all(20),
title: Text("日志"),
children: [
logs.when(
data: (list) {
return DataTable(
columns: const [
DataColumn(label: Text("日志")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("*"))
],
rows: List.generate(list.length, (i) {
final item = list[i];
final uri =
Uri.parse("${APIs.logsBaseUrl}${item.name}");
return DataRow(cells: [
DataCell(Text(item.name ?? "")),
DataCell(Text("${item.size ?? 0}")),
DataCell(InkWell(
child: Icon(Icons.download),
onTap: () => launchUrl(uri,
webViewConfiguration: WebViewConfiguration(
headers: APIs.authHeaders)),
))
]);
}));
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator())
],
),
ExpansionTile(
title: Text("关于"),
expandedCrossAxisAlignment: CrossAxisAlignment.center,
initiallyExpanded: true,
children: [
about.when(
data: (v) {
final uri = Uri.parse(v.chatGroup ?? "");
final homepage = Uri.parse(v.homepage ?? "");
return Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
height: 20,
),
Text(
"#",
style: TextStyle(height: 2.5),
),
Text("主页", style: TextStyle(height: 2.5)),
Text("讨论组", style: TextStyle(height: 2.5)),
Text("go version", style: TextStyle(height: 2.5)),
Text("uptime", style: TextStyle(height: 2.5)),
SizedBox(
height: 20,
),
],
)),
const SizedBox(
width: 20,
),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(
height: 20,
),
Text(v.intro ?? "",
style: const TextStyle(height: 2.5)),
InkWell(
child: Text(v.homepage ?? "",
style: const TextStyle(height: 2.5)),
onTap: () => launchUrl(homepage),
),
InkWell(
child: const Text("Telegram",
style: TextStyle(height: 2.5)),
onTap: () => launchUrl(uri),
),
Text("${v.goVersion}",
style: const TextStyle(height: 2.5)),
Text("${v.uptime}",
style: const TextStyle(height: 2.5)),
const SizedBox(
height: 20,
),
],
)),
],
);
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator())
],
)
],
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:quiver/strings.dart';
import 'package:ui/movie_watchlist.dart';
import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/welcome_data.dart';
@@ -27,7 +28,8 @@ class WelcomePage extends ConsumerWidget {
return switch (data) {
AsyncData(:final value) => SingleChildScrollView(
child: Wrap(
spacing: 20,
spacing: 10,
runSpacing: 20,
children: List.generate(value.length, (i) {
var item = value[i];
return Card(
@@ -45,15 +47,21 @@ class WelcomePage extends ConsumerWidget {
child: Column(
children: <Widget>[
SizedBox(
width: 160,
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.network(
"${APIs.imagesUrl}/${item.id}/poster.jpg",
fit: BoxFit.fill,
headers: APIs.authHeaders,
),
width: 140,
height: 210,
child: Image.network(
"${APIs.imagesUrl}/${item.id}/poster.jpg",
fit: BoxFit.fill,
headers: APIs.authHeaders,
),
),
SizedBox(
width: 140,
child: LinearProgressIndicator(
value: 1,
color: item.status == "downloaded"
? Colors.green
: Colors.blue,
)),
Text(
item.name!,

View File

@@ -539,7 +539,7 @@ packages:
source: hosted
version: "1.3.2"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3"

View File

@@ -46,6 +46,7 @@ dependencies:
flutter_adaptive_scaffold: ^0.1.11+1
flutter_form_builder: ^9.3.0
form_builder_validators: ^11.0.0
url_launcher: ^6.3.0
dev_dependencies:
flutter_test: