diff --git a/db/db.go b/db/db.go index 2b53cfe..cd35743 100644 --- a/db/db.go +++ b/db/db.go @@ -175,14 +175,14 @@ func (c *Client) GetMediaDetails(id int) *MediaDetails { var md = &MediaDetails{ Media: se, } - if se.MediaType == media.MediaTypeTv { - ep, err := se.QueryEpisodes().All(context.Background()) - if err != nil { - log.Errorf("get episodes %d: %v", id, err) - return nil - } - md.Episodes = ep + + ep, err := se.QueryEpisodes().All(context.Background()) + if err != nil { + log.Errorf("get episodes %d: %v", id, err) + return nil } + md.Episodes = ep + return md } @@ -325,7 +325,6 @@ func (c *Client) AddStorage(st *StorageInfo) error { st.Settings["movie_path"] += "/" } - data, err := json.Marshal(st.Settings) if err != nil { return errors.Wrap(err, "json marshal") @@ -477,6 +476,10 @@ func (c *Client) SetEpisodeStatus(id int, status episode.Status) error { return c.ent.Episode.Update().Where(episode.ID(id)).SetStatus(status).Exec(context.TODO()) } +func (c *Client) SetSeasonAllEpisodeStatus(mediaID, seasonNum int, status episode.Status) error { + return c.ent.Episode.Update().Where(episode.MediaID(mediaID), episode.SeasonNumber(seasonNum)).SetStatus(status).Exec(context.TODO()) +} + func (c *Client) TmdbIdInWatchlist(tmdb_id int) bool { return c.ent.Media.Query().Where(media.TmdbID(tmdb_id)).CountX(context.TODO()) > 0 -} \ No newline at end of file +} diff --git a/pkg/storage/webdav.go b/pkg/storage/webdav.go index a2ddb4d..7d7089f 100644 --- a/pkg/storage/webdav.go +++ b/pkg/storage/webdav.go @@ -29,8 +29,8 @@ func NewWebdavStorage(url, user, password, path string) (*WebdavStorage, error) } func (w *WebdavStorage) Move(local, remote string) error { - baseLocal := filepath.Base(local) - remoteBase := filepath.Join(w.dir,remote, baseLocal) + + remoteBase := filepath.Join(w.dir,remote) //log.Infof("remove all content in %s", remoteBase) //w.fs.RemoveAll(remoteBase) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index b6f9f6d..db4a599 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -1,7 +1,6 @@ package utils import ( - "errors" "regexp" "strconv" "strings" @@ -9,8 +8,10 @@ import ( "github.com/adrg/strutil" "github.com/adrg/strutil/metrics" + "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "golang.org/x/exp/rand" + "golang.org/x/sys/unix" ) func isASCII(s string) bool { @@ -84,3 +85,64 @@ func FindSeasonEpisodeNum(name string) (se int, ep int, err error) { seNum1, _ := strconv.Atoi(seNum) return seNum1, epNum1, nil } + +func FindSeasonPackageInfo(name string) (se int, err error) { + seRe := regexp.MustCompile(`S\d+`) + epRe := regexp.MustCompile(`E\d+`) + nameUpper := strings.ToUpper(name) + matchEp := epRe.FindAllString(nameUpper, -1) + if len(matchEp) != 0 { + err = errors.New("episode number should not exist") + } + matchSe := seRe.FindAllString(nameUpper, -1) + if len(matchSe) == 0 { + err = errors.New("no season num") + } + if err != nil { + return 0, err + } + + seNum := strings.TrimPrefix(matchSe[0], "S") + se, _ = strconv.Atoi(seNum) + return se, err +} + +func IsSeasonPackageName(name string) bool { + seRe := regexp.MustCompile(`S\d+`) + epRe := regexp.MustCompile(`E\d+`) + nameUpper := strings.ToUpper(name) + matchEp := epRe.FindAllString(nameUpper, -1) + if len(matchEp) != 0 { + return false //episode number should not exist + } + matchSe := seRe.FindAllString(nameUpper, -1) + if len(matchSe) == 0 { + return false //no season num + } + return true +} + +func ContainsIgnoreCase(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +func SeasonId(seasonName string) (int, error) { + //Season 01 + seRe := regexp.MustCompile(`\d+`) + matchSe := seRe.FindAllString(seasonName, -1) + if len(matchSe) == 0 { + return 0, errors.New("no season number") //no season num + } + num, err := strconv.Atoi(matchSe[0]) + if err != nil { + return 0, errors.Wrap(err, "convert") + } + return num, nil +} + +func AvailableSpace(dir string) uint64 { + var stat unix.Statfs_t + + unix.Statfs(dir, &stat) + return stat.Bavail * uint64(stat.Bsize) +} diff --git a/server/activity.go b/server/activity.go index 1c066f9..b1c1ab3 100644 --- a/server/activity.go +++ b/server/activity.go @@ -4,6 +4,7 @@ import ( "polaris/ent" "polaris/ent/episode" "polaris/log" + "polaris/pkg/utils" "strconv" "github.com/gin-gonic/gin" @@ -12,7 +13,7 @@ import ( type Activity struct { *ent.History - Progress int `json:"progress"` + Progress int `json:"progress"` } func (s *Server) GetAllActivities(c *gin.Context) (interface{}, error) { @@ -51,7 +52,18 @@ func (s *Server) RemoveActivity(c *gin.Context) (interface{}, error) { } delete(s.tasks, his.ID) } - s.db.SetEpisodeStatus(his.EpisodeID, episode.StatusMissing) + if his.EpisodeID != 0 { + s.db.SetEpisodeStatus(his.EpisodeID, episode.StatusMissing) + + } else { + seasonNum, err := utils.SeasonId(his.TargetDir) + if err != nil { + log.Errorf("no season id: %v", his.TargetDir) + seasonNum = -1 + } + s.db.SetSeasonAllEpisodeStatus(his.MediaID, seasonNum, episode.StatusMissing) + + } err = s.db.DeleteHistory(id) if err != nil { diff --git a/server/core/torrent.go b/server/core/torrent.go new file mode 100644 index 0000000..340cbb2 --- /dev/null +++ b/server/core/torrent.go @@ -0,0 +1,159 @@ +package core + +import ( + "fmt" + "polaris/db" + "polaris/ent" + "polaris/ent/media" + "polaris/log" + "polaris/pkg/torznab" + "polaris/pkg/utils" + "sort" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +func SearchSeasonPackage(db1 *db.Client, seriesId, seasonNum int) ([]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 + } + filtered = append(filtered, r) + + } + + if len(filtered) == 0 { + return nil, errors.New("no resource found") + } + return filtered, nil +} + +func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int) ([]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%02dE%02d", series.NameEn, seasonNum, episodeNum) + 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, episodeNum) { + continue + } + filtered = append(filtered, r) + } + + return filtered, nil + +} + +func SearchMovie(db1 *db.Client, movieId int) ([]torznab.Result, error) { + movieDetail := db1.GetMediaDetails(movieId) + if movieDetail == nil { + return nil, errors.New("no media found of id") + } + + res := searchWithTorznab(db1, movieDetail.NameEn) + + res1 := searchWithTorznab(db1, movieDetail.NameCn) + res = append(res, res1...) + + if len(res) == 0 { + return nil, fmt.Errorf("no resource found") + } + var filtered []torznab.Result + for _, r := range res { + if !isNameAcceptable(r.Name, movieDetail.Media, -1, -1) { + continue + } + filtered = append(filtered, r) + + } + if len(filtered) == 0 { + return nil, errors.New("no resource found") + } + + return filtered, nil + +} + +func searchWithTorznab(db *db.Client, q string) []torznab.Result { + + var res []torznab.Result + allTorznab := db.GetAllTorznabInfo() + 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...) + } + sort.Slice(res, func(i, j int) bool { + var s1 = res[i] + var s2 = res[j] + return s1.Seeders > s2.Seeders + }) + + return res +} + +func isNameAcceptable(torrentName string, m *ent.Media, seasonNum, episodeNum int) bool { + if !utils.ContainsIgnoreCase(torrentName,m.NameCn) && !utils.ContainsIgnoreCase(torrentName,m.NameEn) && + !utils.ContainsIgnoreCase(torrentName,m.OriginalName) { + return false + } + 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 +} \ No newline at end of file diff --git a/server/resources.go b/server/resources.go index 6c4fbd1..fce2684 100644 --- a/server/resources.go +++ b/server/resources.go @@ -7,31 +7,15 @@ import ( "polaris/ent/episode" "polaris/ent/history" "polaris/log" - "polaris/pkg/torznab" "polaris/pkg/transmission" "polaris/pkg/utils" + "polaris/server/core" "strconv" - "strings" "github.com/gin-gonic/gin" "github.com/pkg/errors" ) -func (s *Server) searchWithTorznab(q string) []torznab.Result { - - var res []torznab.Result - allTorznab := s.db.GetAllTorznabInfo() - 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...) - } - return res -} - type addTorznabIn struct { Name string `json:"name"` URL string `json:"url"` @@ -83,6 +67,59 @@ func (s *Server) getDownloadClient() (*transmission.Client, error) { } return trc, nil } + +func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*string, error) { + trc, err := s.getDownloadClient() + if err != nil { + return nil, errors.Wrap(err, "connect transmission") + } + + res, err := core.SearchSeasonPackage(s.db, seriesId, seasonNum) + if err != nil { + return nil, err + } + + r1 := res[0] + log.Infof("found resource to download: %v", r1) + + downloadDir := s.db.GetDownloadDir() + size := utils.AvailableSpace(downloadDir) + if size < uint64(r1.Size) { + log.Errorf("space available %v, space needed %v", size, r1.Size) + return nil, errors.New("no enough space") + } + + + torrent, err := trc.Download(r1.Magnet, s.db.GetDownloadDir()) + if err != nil { + return nil, errors.Wrap(err, "downloading") + } + torrent.Start() + + series := s.db.GetMediaDetails(seriesId) + if series == nil { + return nil, fmt.Errorf("no tv series of id %v", seriesId) + } + dir := fmt.Sprintf("%s/Season %02d", series.TargetDir, seasonNum) + + history, err := s.db.SaveHistoryRecord(ent.History{ + MediaID: seriesId, + EpisodeID: 0, + SourceTitle: r1.Name, + TargetDir: dir, + Status: history.StatusRunning, + Size: r1.Size, + Saved: torrent.Save(), + }) + if err != nil { + return nil, errors.Wrap(err, "save record") + } + s.db.SetSeasonAllEpisodeStatus(seriesId, seasonNum, episode.StatusDownloading) + + s.tasks[history.ID] = &Task{Torrent: torrent} + return &r1.Name, nil +} + func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string, error) { trc, err := s.getDownloadClient() if err != nil { @@ -102,13 +139,11 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string return nil, errors.Errorf("no episode of season %d episode %d", seasonNum, episodeNum) } - q := fmt.Sprintf("%s S%02dE%02d", series.OriginalName, seasonNum, episodeNum) - - res := s.searchWithTorznab(q) - if len(res) == 0 { - return nil, fmt.Errorf("no resource found") + res, err := core.SearchEpisode(s.db, seriesId, seasonNum, episodeNum) + if err != nil { + return nil, err } - r1 := s.findBestMatchTv(res, seasonNum, episodeNum, series) + r1 := res[0] log.Infof("found resource to download: %v", r1) torrent, err := trc.Download(r1.Magnet, s.db.GetDownloadDir()) if err != nil { @@ -116,7 +151,7 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string } torrent.Start() - dir := fmt.Sprintf("%s/Season %02d", series.TargetDir, ep.SeasonNumber) + dir := fmt.Sprintf("%s/Season %02d", series.TargetDir, seasonNum) history, err := s.db.SaveHistoryRecord(ent.History{ MediaID: ep.MediaID, @@ -127,64 +162,75 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string Size: r1.Size, Saved: torrent.Save(), }) - s.db.SetEpisodeStatus(ep.ID, episode.StatusDownloading) if err != nil { return nil, errors.Wrap(err, "save record") } + s.db.SetEpisodeStatus(ep.ID, episode.StatusDownloading) + s.tasks[history.ID] = &Task{Torrent: torrent} log.Infof("success add %s to download task", r1.Name) return &r1.Name, nil } -func (s *Server) findBestMatchTv(resources []torznab.Result, season, episode int, series *db.MediaDetails) torznab.Result { - var filtered []torznab.Result - for _, r := range resources { - if !(series.NameEn != "" && strings.Contains(r.Name, series.NameEn)) && !strings.Contains(r.Name, series.OriginalName) { - //name not match - continue - } - - se := fmt.Sprintf("S%02dE%02d", season, episode) - if !strings.Contains(r.Name, se) { - //season or episode not match - continue - } - if !strings.Contains(strings.ToLower(r.Name), series.Resolution) { - //resolution not match - continue - } - filtered = append(filtered, r) - } - - // sort.Slice(filtered, func(i, j int) bool { - // var s1 = filtered[i] - // var s2 = filtered[j] - // return s1.Seeders > s2.Seeders - // }) - - return filtered[0] -} - type searchAndDownloadIn struct { ID int `json:"id" binding:"required"` Season int `json:"season"` Episode int `json:"episode"` } +func (s *Server) SearchAvailableEpisodeResource(c *gin.Context) (interface{}, error) { + var in searchAndDownloadIn + if err := c.ShouldBindJSON(&in); err != nil { + return nil, errors.Wrap(err, "bind json") + } + log.Infof("search episode resources link: %v", in) + res, err := core.SearchEpisode(s.db, in.ID, in.Season, in.Episode) + if err != nil { + return nil, errors.Wrap(err, "search episode") + } + var searchResults []TorznabSearchResult + for _, r := range res { + searchResults = append(searchResults, TorznabSearchResult{ + Name: r.Name, + Size: r.Size, + Seeders: r.Seeders, + Peers: r.Peers, + Link: r.Magnet, + }) + } + if len(searchResults) == 0 { + return nil, errors.New("no resource found") + } + return searchResults, nil +} + func (s *Server) SearchTvAndDownload(c *gin.Context) (interface{}, error) { var in searchAndDownloadIn if err := c.ShouldBindJSON(&in); err != nil { return nil, errors.Wrap(err, "bind json") } log.Infof("search episode resources link: %v", in) - name, err := s.searchAndDownload(in.ID, in.Season, in.Episode) - if err != nil { - return nil, errors.Wrap(err, "download") + var name string + if in.Episode == 0 { + log.Infof("season package search") + //search season package + name1, err := s.searchAndDownloadSeasonPackage(in.ID, in.Season) + if err != nil { + return nil, errors.Wrap(err, "download") + } + name = *name1 + } else { + log.Infof("season episode search") + name1, err := s.searchAndDownload(in.ID, in.Season, in.Episode) + if err != nil { + return nil, errors.Wrap(err, "download") + } + name = *name1 } return gin.H{ - "name": *name, + "name": name, }, nil } @@ -208,26 +254,13 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) { return nil, errors.New("no media found of id " + ids) } - res := s.searchWithTorznab(movieDetail.NameEn) - - res1 := s.searchWithTorznab(movieDetail.NameCn) - res = append(res, res1...) - - if len(res) == 0 { - return nil, fmt.Errorf("no resource found") + res, err := core.SearchMovie(s.db, id) + if err != nil { + return nil, err } - ss := strings.Split(movieDetail.AirDate, "-")[0] - year, _ := strconv.Atoi(ss) var searchResults []TorznabSearchResult for _, r := range res { - if !strings.Contains(r.Name, strconv.Itoa(year)) && !strings.Contains(r.Name, strconv.Itoa(year+1)) && !strings.Contains(r.Name, strconv.Itoa(year-1)) { - continue //not the same movie, if year is not correct - } - - if !utils.IsNameAcceptable(r.Name, movieDetail.NameCn) && !utils.IsNameAcceptable(r.Name, movieDetail.NameEn) { - continue //name not match - } searchResults = append(searchResults, TorznabSearchResult{ Name: r.Name, Size: r.Size, @@ -239,9 +272,7 @@ func (s *Server) SearchAvailableMovies(c *gin.Context) (interface{}, error) { if len(searchResults) == 0 { return nil, errors.New("no resource found") } - return searchResults, nil - } type downloadTorrentIn struct { diff --git a/server/scheduler.go b/server/scheduler.go index 52eadb6..02b305b 100644 --- a/server/scheduler.go +++ b/server/scheduler.go @@ -58,10 +58,18 @@ func (s *Server) moveCompletedTask(id int) (err error) { s.db.SetHistoryStatus(r.ID, history.StatusUploading) defer func() { + seasonNum, err := utils.SeasonId(r.TargetDir) + if err != nil { + log.Errorf("no season id: %v", r.TargetDir) + seasonNum = -1 + } + if err != nil { s.db.SetHistoryStatus(r.ID, history.StatusFail) if r.EpisodeID != 0 { s.db.SetEpisodeStatus(r.EpisodeID, episode.StatusMissing) + } else { + s.db.SetSeasonAllEpisodeStatus(r.MediaID, seasonNum, episode.StatusMissing) } } else { @@ -69,6 +77,8 @@ func (s *Server) moveCompletedTask(id int) (err error) { s.db.SetHistoryStatus(r.ID, history.StatusSuccess) if r.EpisodeID != 0 { s.db.SetEpisodeStatus(r.EpisodeID, episode.StatusDownloaded) + } else { + s.db.SetSeasonAllEpisodeStatus(r.MediaID, seasonNum, episode.StatusDownloaded) } torrent.Remove() @@ -108,8 +118,16 @@ func (s *Server) moveCompletedTask(id int) (err error) { stImpl = storageImpl } - if err := stImpl.Move(filepath.Join(s.db.GetDownloadDir(), torrent.Name()), r.TargetDir); err != nil { - return errors.Wrap(err, "move file") + if r.EpisodeID == 0 { + //season package download + if err := stImpl.Move(filepath.Join(s.db.GetDownloadDir(), torrent.Name()), r.TargetDir); err != nil { + return errors.Wrap(err, "move file") + } + + } else { + if err := stImpl.Move(filepath.Join(s.db.GetDownloadDir(), torrent.Name()), filepath.Join(r.TargetDir, torrent.Name())); err != nil { + return errors.Wrap(err, "move file") + } } log.Infof("move downloaded files to target dir success, file: %v, target dir: %v", torrent.Name(), r.TargetDir) diff --git a/server/server.go b/server/server.go index 57b5285..b9eddbc 100644 --- a/server/server.go +++ b/server/server.go @@ -69,6 +69,7 @@ func (s *Server) Serve() error { tv.GET("/search", HttpHandler(s.SearchMedia)) tv.POST("/tv/watchlist", HttpHandler(s.AddTv2Watchlist)) tv.GET("/tv/watchlist", HttpHandler(s.GetTvWatchlist)) + tv.POST("/tv/torrents", HttpHandler(s.SearchAvailableEpisodeResource)) tv.POST("/movie/watchlist", HttpHandler(s.AddMovie2Watchlist)) tv.GET("/movie/watchlist", HttpHandler(s.GetMovieWatchlist)) tv.GET("/movie/resources/:id", HttpHandler(s.SearchAvailableMovies)) diff --git a/ui/lib/tv_details.dart b/ui/lib/tv_details.dart index 25ff87c..d2781f8 100644 --- a/ui/lib/tv_details.dart +++ b/ui/lib/tv_details.dart @@ -56,14 +56,16 @@ class _TvDetailsPageState extends ConsumerState { Opacity( opacity: 0.7, child: ep.status == "downloading" - ? const Icon(Icons.downloading) + ? const Tooltip(message: "下载中",child: Icon(Icons.downloading),) : (ep.status == "downloaded" - ? const Icon(Icons.download_done) - : const Icon(Icons.warning_amber_rounded))), + ? const Tooltip(message: "已下载",child: Icon(Icons.download_done),) + : const Tooltip(message: "未下载",child: Icon(Icons.warning_amber_rounded),) )), ), DataCell(Row( children: [ - IconButton( + Tooltip( + message: "搜索下载对应剧集", + child: IconButton( onPressed: () async { var f = ref .read(mediaDetailsProvider(widget.seriesId) @@ -79,6 +81,8 @@ class _TvDetailsPageState extends ConsumerState { } }, icon: const Icon(Icons.search)), + ) + , const SizedBox( width: 10, ), @@ -103,14 +107,31 @@ class _TvDetailsPageState extends ConsumerState { title: k == 0 ? const Text("特别篇") : Text("第 $k 季"), expandedCrossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DataTable(columns: const [ - DataColumn(label: Text("#")), - DataColumn( + DataTable(columns: [ + const DataColumn(label: Text("#")), + const DataColumn( label: Text("标题"), ), - DataColumn(label: Text("播出时间")), - DataColumn(label: Text("状态")), - DataColumn(label: Text("操作")) + const DataColumn(label: Text("播出时间")), + const DataColumn(label: Text("状态")), + DataColumn(label: Tooltip( + message: "搜索下载全部剧集", + child: IconButton( + onPressed: () async { + var f = ref + .read(mediaDetailsProvider(widget.seriesId) + .notifier) + .searchAndDownload(widget.seriesId, + k, 0); + setState(() { + _pendingFuture = f; + }); + if (!Utils.showError(context, snapshot)) { + var name = await f; + Utils.showSnakeBar("开始下载: $name"); + } + }, + icon: const Icon(Icons.search)),) ) ], rows: m[k]!), ], );