Compare commits

...

46 Commits

Author SHA1 Message Date
Simon Ding
961d762f35 feat: add text to empty screen 2024-07-28 16:43:59 +08:00
Simon Ding
7f025a6246 fix: bugs 2024-07-28 15:54:51 +08:00
Simon Ding
fc86a441f4 feat: option to download all episodes 2024-07-28 13:22:12 +08:00
Simon Ding
34fa05e7dd fix: var 2024-07-28 12:31:22 +08:00
Simon Ding
9c3757a1bf feat: season package list 2024-07-28 11:50:58 +08:00
Simon Ding
e63a899df5 chore: remove unused code 2024-07-28 11:13:26 +08:00
Simon Ding
3a4e303d9d feat: log to console in dev 2024-07-28 11:08:17 +08:00
Simon Ding
ef9e4487c6 fix: class to dart3 record 2024-07-28 11:04:51 +08:00
Simon Ding
02f6cfb5b7 fix 2024-07-27 23:42:28 +08:00
Simon Ding
e73ae86801 add parse tv & movie api 2024-07-27 23:42:06 +08:00
Simon Ding
b19938f2df remove 2024-07-27 22:42:29 +08:00
Simon Ding
bb3c4551af fix: api call 2024-07-27 22:35:49 +08:00
Simon Ding
eae35ce862 feat: show episode resource 2024-07-27 22:22:06 +08:00
Simon Ding
feecc9f983 feat: app proxy 2024-07-27 17:18:38 +08:00
Simon Ding
5175e651ee fix: donot use git shallow copy 2024-07-27 16:04:01 +08:00
Simon Ding
f065abfbf9 fix: install git 2024-07-27 15:55:08 +08:00
Simon Ding
cd4d600f5e feat: add app version ui 2024-07-27 15:44:49 +08:00
Simon Ding
741a4024fd feat: add app version 2024-07-27 15:34:58 +08:00
Simon Ding
0433cc7b0a chore: update doc 2024-07-27 11:24:22 +08:00
Simon Ding
accc02c74c chore: update doc 2024-07-27 11:21:34 +08:00
Simon Ding
87b6c99f1f feat: better doc 2024-07-27 11:17:16 +08:00
Simon Ding
b2a092c64e feat: add get all torrents api 2024-07-27 09:26:51 +08:00
Simon Ding
51fc5c3c74 chore: fix typo 2024-07-27 00:08:46 +08:00
Simon Ding
5e6a17f86c change padding 2024-07-27 00:06:18 +08:00
Simon Ding
2fedfd6c76 fix: not display error 2024-07-26 23:56:29 +08:00
Simon Ding
61bc9b72bd feat: disable webgl2 on safari 2024-07-26 23:26:14 +08:00
Simon Ding
a997726a5f feat: size readable 2024-07-26 17:13:43 +08:00
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
71 changed files with 1635 additions and 828 deletions

View File

@@ -25,6 +25,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to image repository
uses: docker/login-action@v2

View File

@@ -22,6 +22,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Up QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -22,9 +22,10 @@ COPY . .
COPY --from=flutter /app/build/web ./ui/build/web/
# 指定OS等并go build
RUN CGO_ENABLED=1 go build -o polaris ./cmd/
RUN CGO_ENABLED=1 go build -o polaris -ldflags="-X polaris/db.Version=$(git describe --tags --long)" ./cmd/
FROM debian:12
ENV TZ="Asia/Shanghai" GIN_MODE=release
WORKDIR /app
RUN apt-get update && apt-get -y install ca-certificates

View File

@@ -2,8 +2,8 @@
Polaris 是一个电视剧和电影的追踪软件。配置好了之后当剧集或者电影播出后会第一时间下载对应的资源。支持本地存储或者webdav。
![main_page](assets/main_page.png)
![detail_page](assets/detail_page.png)
![main_page](./doc/assets/main_page.png)
![detail_page](./doc/assets/detail_page.png)
交流群: https://t.me/+8R2nzrlSs2JhMDgx
@@ -13,75 +13,21 @@ Polaris 是一个电视剧和电影的追踪软件。配置好了之后,当剧
- [x] 电影自动追踪下载
- [x] webdav 存储支持,配合 [alist](https://github.com/alist-org/alist) 或阿里云等实现更多功能
## 使用
使用此程序参考 [【快速开始】](./doc/quick_start.md)
## 对比 sonarr/radarr
* 更好的中文支持
* 对于动漫、日剧的良好支持,配合国内站点基本能匹配上对应资源
* 支持 webdav 后端存储,可以配合 alist 或者阿里云来实现下载后实时传到云上的功能。这样外出就可以不依靠家里的宽带来看电影了,或者实现个轻 NAS 功能,下载功能放在本地,数据放在云盘
* golang 实现后端,相比于 .NET 更节省资源
* 一个程序同时实现了电影、电视剧功能,不需要装两个程序
* 当然 sonarr/radarr 也是非常优秀的开源项目,目前 Polaris 功能还没有 sonarr/radarr 丰富
## 快速开始
最简单部署 Polaris 的方式是使用 docker compose
```yaml
services:
polaris:
image: ghcr.io/simon-ding/polaris:latest
restart: always
volumes:
- ./config/polaris:/app/data #程序配置文件路径
- /downloads:/downloads #下载路径,需要和下载客户端配置一致
- /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 的形式访问
## 配置
要正确使用此程序,需要配置好以下设置:
### TMDB设置
因为此程序需要使用到 TMDB 的数据,使用此程序首先要申请一个 TMDB 的 Api Key
### 索引器
索引器是资源提供者,目前支持 torznab 协议,意味着 polarr 或者 jackett 都可以支持。请自行部署相关程序。
推荐使用 linuxserver 的镜像https://docs.linuxserver.io/images/docker-jackett/
### 下载器
资源由谁下载,目前可支持 tansmission需要配置好对应下载器
### 存储设置
程序默认所有剧集和电影存储在 /data 路径下如果想修改路径或者webdav存储需要在存储配置下修改
## 开始使用
配置完了这些,开始享受使用此程序吧!可以搜索几部自己想看的电影或者电视机,加入想看列表。当剧集有更新或者电影有资源是就会自动下载对应资源了。
-------------
## 请我喝杯咖啡
<img src="assets/wechat.JPG" width=40% height=40%>
<img src="./doc/assets/wechat.JPG" width=40% height=40%>

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -7,6 +7,7 @@ import (
)
func main() {
log.Infof("------------------- Starting Polaris ---------------------")
dbClient, err := db.Open()
if err != nil {
log.Panicf("init db error: %v", err)

View File

@@ -1,11 +1,15 @@
package db
var Version = "undefined"
const (
SettingTmdbApiKey = "tmdb_api_key"
SettingLanguage = "language"
SettingJacketUrl = "jacket_url"
SettingJacketApiKey = "jacket_api_key"
SettingDownloadDir = "download_dir"
SettingLogLevel = "log_level"
SettingProxy = "proxy"
)
const (
@@ -18,6 +22,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
}
@@ -55,8 +56,17 @@ func (c *Client) init() {
downloadDir := c.GetSetting(SettingDownloadDir)
if downloadDir == "" {
log.Infof("set default download dir")
c.SetSetting(downloadDir, "/downloads")
c.SetSetting(SettingDownloadDir, "/downloads")
}
logLevel := c.GetSetting(SettingLogLevel)
if logLevel == "" {
log.Infof("set default log level")
c.SetSetting(SettingLogLevel, "info")
}
// if tr := c.GetTransmission(); tr == nil {
// log.Warnf("no download client, set default download client")
// c.SaveTransmission("transmission", "http://transmission:9091", "", "")
// }
}
func (c *Client) generateJwtSerectIfNotExist() {
@@ -88,7 +98,7 @@ func (c *Client) generateDefaultLocalStorage() error {
func (c *Client) GetSetting(key string) string {
v, err := c.ent.Settings.Query().Where(settings.Key(key)).Only(context.TODO())
if err != nil {
log.Errorf("get setting by key: %s error: %v", key, err)
log.Warnf("get setting by key: %s error: %v", key, err)
return ""
}
return v.Value
@@ -191,6 +201,10 @@ func (c *Client) GetMediaDetails(id int) *MediaDetails {
return md
}
func (c *Client) GetMedia(id int) (*ent.Media, error) {
return c.ent.Media.Query().Where(media.ID(id)).First(context.TODO())
}
func (c *Client) DeleteMedia(id int) error {
_, err := c.ent.Episode.Delete().Where(episode.MediaID(id)).Exec(context.TODO())
if err != nil {
@@ -496,13 +510,13 @@ func (c *Client) GetDownloadDir() string {
return r.Value
}
func (c *Client) UpdateEpisodeFile(mediaID int, seasonNum, episodeNum int, file string) error {
func (c *Client) UpdateEpisodeStatus(mediaID int, seasonNum, episodeNum int) error {
ep, err := c.ent.Episode.Query().Where(episode.MediaID(mediaID)).Where(episode.EpisodeNumber(episodeNum)).
Where(episode.SeasonNumber(seasonNum)).First(context.TODO())
if err != nil {
return errors.Wrap(err, "finding episode")
}
return ep.Update().SetFileInStorage(file).SetStatus(episode.StatusDownloaded).Exec(context.TODO())
return ep.Update().SetStatus(episode.StatusDownloaded).Exec(context.TODO())
}
func (c *Client) SetEpisodeStatus(id int, status episode.Status) error {
@@ -520,3 +534,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
}

BIN
doc/assets/add_indexer.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
doc/assets/add_series.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

BIN
doc/assets/copy_feed.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
doc/assets/downloader.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
doc/assets/main_page.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
doc/assets/search_add.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

54
doc/configuration.md Normal file
View File

@@ -0,0 +1,54 @@
# 配置
要正确使用此程序,需要配置好以下设置:
### TMDB设置
1. 因为此程序需要使用到 TMDB 的数据,使用此程序首先要申请一个 TMDB 的 Api Key. 申请教程请 google [tmdb api key申请](https://www.google.com/search?q=tmdb+api+key%E7%94%B3%E8%AF%B7)
2. 拿到 TMDB Api Key之后请填到 *设置 -> 常规设置 -> TMDB Api Key里*
### 索引器
索引器是资源提供者,目前支持 torznab 协议,意味着 polarr 或者 jackett 都可以支持。请自行部署相关程序,或者使用的 docker compose 配置一起拉起
推荐使用 linuxserver 的镜像https://docs.linuxserver.io/images/docker-jackett/
#### 索引器配置
索引器配置这里以 jackett 为例。使用默认 docker compose 配置拉起后以 http://< ip >:9117 可访问 jackett 的主页。
1. 打开 jackett 主页后,点击页面上面的 Add indexer会出现 BT/PT 站点列表,选择你需要的站点点击+号添加。如果是PT请自行配置好相关配置
![add indexer](./assets/add_indexer.png)
![search add](./assets/search_add.png)
2. 添加后主页即会显示相应的BT/PT站点点击 *Copy Torznab Feed* 即得到了我们需要的地址
![copy feed](./assets/copy_feed.png)
3. 回到我们的主程序 Polaris 当中,点击 *设置 -> 索引器设置* -> 点击+号增加新的索引器输入一个名称拷贝我们第2步得到的地址到地址栏
![polaris add indexer](./assets/polaris_add_indexer.png)
4. 选相框中我们可以看到,还需要一个 API Key我们回到 Jackett 中,在页面右上角,复制我们需要的 API Key
![api key](./assets/jackett_api_key.png)
5. 恭喜!你已经成功完成了索引器配置。如需要更多的站点,请重复相同的操作完成配置
### 下载器
资源下载器,目前可支持 tansmission请配置好对应配置
![transmission](./assets/downloader.png)
### 存储设置
默认配置了名为 local 的本地存储,如果你不知道怎么配置。请使用默认配置
![local_storage](./assets/local_storage.png)
类型里选择 webdav 可以使用 webdav 存储,配合 alist/clouddrive 等,可以实现存储到云盘里的功能。
![webdav](./assets/webdav_storage.png)

69
doc/quick_start.md Normal file
View File

@@ -0,0 +1,69 @@
## 快速开始
最简单部署 Polaris 的方式是使用 docker composePolaris要完整运行另外需要一个索引客户端和一个下载客户端。索引客户端支持 polarr 或 jackett下载客户端目前只支持 transmission。
下面是一个示例 docker-compose 配置,为了简单起见,一起拉起了 transmission 和 jackett你也可选择单独安装
**注意:** transmission 的下载路径映射要和 polaris 保持一致,如果您不知道怎么做,请保持默认设置。
```yaml
services:
polaris:
image: ghcr.io/simon-ding/polaris:latest
restart: always
volumes:
- ./config/polaris:/app/data #程序配置文件路径
- /downloads:/downloads #下载路径,需要和下载客户端配置一致
- /data:/data #媒体数据存储路径也可以启动自己配置webdav存储
ports:
- 8080:8080
transmission: #下载客户端,也可以不安装使用已有的
image: lscr.io/linuxserver/transmission:latest
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
volumes:
- ./config/transmission:/config
- /downloads:/downloads #此路径要与polaris下载路径保持一致
ports:
- 9091:9091
- 51413:51413
- 51413:51413/udp
jackett: #索引客户端,也可以不安装使用已有的
image: lscr.io/linuxserver/jackett:latest
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
volumes:
- ./config/jackett:/config
ports:
- 9117:9117
restart: unless-stopped
```
拉起之后访问 http://< ip >:8080 的形式访问
![](./assets/main_page.png)
## 配置
详细配置请看 [配置篇](./configuration.md)
## 开始使用
1. 完成配置之后,我们就可以在右上角的搜索按钮里输入我们想看的电影、电视剧。
![search](./assets/search_series.png)
2. 找到对应电影电视剧后,点击加入想看列表
![add](./assets/add_series.png)
3. 当电影有资源、或者电视剧有更新时,程序就会自动下载对应资源到指定的存储。对于剧集,您也可以进入剧集的详细页面,点击搜索按钮来自己搜索对应集的资源。
到此,您已经基本掌握了此程序的使用方式,请尽情体验吧!

View File

@@ -31,8 +31,6 @@ type Episode struct {
AirDate string `json:"air_date,omitempty"`
// Status holds the value of the "status" field.
Status episode.Status `json:"status,omitempty"`
// FileInStorage holds the value of the "file_in_storage" field.
FileInStorage string `json:"file_in_storage,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the EpisodeQuery when eager-loading is set.
Edges EpisodeEdges `json:"edges"`
@@ -66,7 +64,7 @@ func (*Episode) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case episode.FieldID, episode.FieldMediaID, episode.FieldSeasonNumber, episode.FieldEpisodeNumber:
values[i] = new(sql.NullInt64)
case episode.FieldTitle, episode.FieldOverview, episode.FieldAirDate, episode.FieldStatus, episode.FieldFileInStorage:
case episode.FieldTitle, episode.FieldOverview, episode.FieldAirDate, episode.FieldStatus:
values[i] = new(sql.NullString)
default:
values[i] = new(sql.UnknownType)
@@ -131,12 +129,6 @@ func (e *Episode) assignValues(columns []string, values []any) error {
} else if value.Valid {
e.Status = episode.Status(value.String)
}
case episode.FieldFileInStorage:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field file_in_storage", values[i])
} else if value.Valid {
e.FileInStorage = value.String
}
default:
e.selectValues.Set(columns[i], values[i])
}
@@ -198,9 +190,6 @@ func (e *Episode) String() string {
builder.WriteString(", ")
builder.WriteString("status=")
builder.WriteString(fmt.Sprintf("%v", e.Status))
builder.WriteString(", ")
builder.WriteString("file_in_storage=")
builder.WriteString(e.FileInStorage)
builder.WriteByte(')')
return builder.String()
}

View File

@@ -28,8 +28,6 @@ const (
FieldAirDate = "air_date"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldFileInStorage holds the string denoting the file_in_storage field in the database.
FieldFileInStorage = "file_in_storage"
// EdgeMedia holds the string denoting the media edge name in mutations.
EdgeMedia = "media"
// Table holds the table name of the episode in the database.
@@ -53,7 +51,6 @@ var Columns = []string{
FieldOverview,
FieldAirDate,
FieldStatus,
FieldFileInStorage,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@@ -136,11 +133,6 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByFileInStorage orders the results by the file_in_storage field.
func ByFileInStorage(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFileInStorage, opts...).ToFunc()
}
// ByMediaField orders the results by media field.
func ByMediaField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

View File

@@ -84,11 +84,6 @@ func AirDate(v string) predicate.Episode {
return predicate.Episode(sql.FieldEQ(FieldAirDate, v))
}
// FileInStorage applies equality check predicate on the "file_in_storage" field. It's identical to FileInStorageEQ.
func FileInStorage(v string) predicate.Episode {
return predicate.Episode(sql.FieldEQ(FieldFileInStorage, v))
}
// MediaIDEQ applies the EQ predicate on the "media_id" field.
func MediaIDEQ(v int) predicate.Episode {
return predicate.Episode(sql.FieldEQ(FieldMediaID, v))
@@ -414,81 +409,6 @@ func StatusNotIn(vs ...Status) predicate.Episode {
return predicate.Episode(sql.FieldNotIn(FieldStatus, vs...))
}
// FileInStorageEQ applies the EQ predicate on the "file_in_storage" field.
func FileInStorageEQ(v string) predicate.Episode {
return predicate.Episode(sql.FieldEQ(FieldFileInStorage, v))
}
// FileInStorageNEQ applies the NEQ predicate on the "file_in_storage" field.
func FileInStorageNEQ(v string) predicate.Episode {
return predicate.Episode(sql.FieldNEQ(FieldFileInStorage, v))
}
// FileInStorageIn applies the In predicate on the "file_in_storage" field.
func FileInStorageIn(vs ...string) predicate.Episode {
return predicate.Episode(sql.FieldIn(FieldFileInStorage, vs...))
}
// FileInStorageNotIn applies the NotIn predicate on the "file_in_storage" field.
func FileInStorageNotIn(vs ...string) predicate.Episode {
return predicate.Episode(sql.FieldNotIn(FieldFileInStorage, vs...))
}
// FileInStorageGT applies the GT predicate on the "file_in_storage" field.
func FileInStorageGT(v string) predicate.Episode {
return predicate.Episode(sql.FieldGT(FieldFileInStorage, v))
}
// FileInStorageGTE applies the GTE predicate on the "file_in_storage" field.
func FileInStorageGTE(v string) predicate.Episode {
return predicate.Episode(sql.FieldGTE(FieldFileInStorage, v))
}
// FileInStorageLT applies the LT predicate on the "file_in_storage" field.
func FileInStorageLT(v string) predicate.Episode {
return predicate.Episode(sql.FieldLT(FieldFileInStorage, v))
}
// FileInStorageLTE applies the LTE predicate on the "file_in_storage" field.
func FileInStorageLTE(v string) predicate.Episode {
return predicate.Episode(sql.FieldLTE(FieldFileInStorage, v))
}
// FileInStorageContains applies the Contains predicate on the "file_in_storage" field.
func FileInStorageContains(v string) predicate.Episode {
return predicate.Episode(sql.FieldContains(FieldFileInStorage, v))
}
// FileInStorageHasPrefix applies the HasPrefix predicate on the "file_in_storage" field.
func FileInStorageHasPrefix(v string) predicate.Episode {
return predicate.Episode(sql.FieldHasPrefix(FieldFileInStorage, v))
}
// FileInStorageHasSuffix applies the HasSuffix predicate on the "file_in_storage" field.
func FileInStorageHasSuffix(v string) predicate.Episode {
return predicate.Episode(sql.FieldHasSuffix(FieldFileInStorage, v))
}
// FileInStorageIsNil applies the IsNil predicate on the "file_in_storage" field.
func FileInStorageIsNil() predicate.Episode {
return predicate.Episode(sql.FieldIsNull(FieldFileInStorage))
}
// FileInStorageNotNil applies the NotNil predicate on the "file_in_storage" field.
func FileInStorageNotNil() predicate.Episode {
return predicate.Episode(sql.FieldNotNull(FieldFileInStorage))
}
// FileInStorageEqualFold applies the EqualFold predicate on the "file_in_storage" field.
func FileInStorageEqualFold(v string) predicate.Episode {
return predicate.Episode(sql.FieldEqualFold(FieldFileInStorage, v))
}
// FileInStorageContainsFold applies the ContainsFold predicate on the "file_in_storage" field.
func FileInStorageContainsFold(v string) predicate.Episode {
return predicate.Episode(sql.FieldContainsFold(FieldFileInStorage, v))
}
// HasMedia applies the HasEdge predicate on the "media" edge.
func HasMedia() predicate.Episode {
return predicate.Episode(func(s *sql.Selector) {

View File

@@ -78,20 +78,6 @@ func (ec *EpisodeCreate) SetNillableStatus(e *episode.Status) *EpisodeCreate {
return ec
}
// SetFileInStorage sets the "file_in_storage" field.
func (ec *EpisodeCreate) SetFileInStorage(s string) *EpisodeCreate {
ec.mutation.SetFileInStorage(s)
return ec
}
// SetNillableFileInStorage sets the "file_in_storage" field if the given value is not nil.
func (ec *EpisodeCreate) SetNillableFileInStorage(s *string) *EpisodeCreate {
if s != nil {
ec.SetFileInStorage(*s)
}
return ec
}
// SetMedia sets the "media" edge to the Media entity.
func (ec *EpisodeCreate) SetMedia(m *Media) *EpisodeCreate {
return ec.SetMediaID(m.ID)
@@ -213,10 +199,6 @@ func (ec *EpisodeCreate) createSpec() (*Episode, *sqlgraph.CreateSpec) {
_spec.SetField(episode.FieldStatus, field.TypeEnum, value)
_node.Status = value
}
if value, ok := ec.mutation.FileInStorage(); ok {
_spec.SetField(episode.FieldFileInStorage, field.TypeString, value)
_node.FileInStorage = value
}
if nodes := ec.mutation.MediaIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -146,26 +146,6 @@ func (eu *EpisodeUpdate) SetNillableStatus(e *episode.Status) *EpisodeUpdate {
return eu
}
// SetFileInStorage sets the "file_in_storage" field.
func (eu *EpisodeUpdate) SetFileInStorage(s string) *EpisodeUpdate {
eu.mutation.SetFileInStorage(s)
return eu
}
// SetNillableFileInStorage sets the "file_in_storage" field if the given value is not nil.
func (eu *EpisodeUpdate) SetNillableFileInStorage(s *string) *EpisodeUpdate {
if s != nil {
eu.SetFileInStorage(*s)
}
return eu
}
// ClearFileInStorage clears the value of the "file_in_storage" field.
func (eu *EpisodeUpdate) ClearFileInStorage() *EpisodeUpdate {
eu.mutation.ClearFileInStorage()
return eu
}
// SetMedia sets the "media" edge to the Media entity.
func (eu *EpisodeUpdate) SetMedia(m *Media) *EpisodeUpdate {
return eu.SetMediaID(m.ID)
@@ -255,12 +235,6 @@ func (eu *EpisodeUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := eu.mutation.Status(); ok {
_spec.SetField(episode.FieldStatus, field.TypeEnum, value)
}
if value, ok := eu.mutation.FileInStorage(); ok {
_spec.SetField(episode.FieldFileInStorage, field.TypeString, value)
}
if eu.mutation.FileInStorageCleared() {
_spec.ClearField(episode.FieldFileInStorage, field.TypeString)
}
if eu.mutation.MediaCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -428,26 +402,6 @@ func (euo *EpisodeUpdateOne) SetNillableStatus(e *episode.Status) *EpisodeUpdate
return euo
}
// SetFileInStorage sets the "file_in_storage" field.
func (euo *EpisodeUpdateOne) SetFileInStorage(s string) *EpisodeUpdateOne {
euo.mutation.SetFileInStorage(s)
return euo
}
// SetNillableFileInStorage sets the "file_in_storage" field if the given value is not nil.
func (euo *EpisodeUpdateOne) SetNillableFileInStorage(s *string) *EpisodeUpdateOne {
if s != nil {
euo.SetFileInStorage(*s)
}
return euo
}
// ClearFileInStorage clears the value of the "file_in_storage" field.
func (euo *EpisodeUpdateOne) ClearFileInStorage() *EpisodeUpdateOne {
euo.mutation.ClearFileInStorage()
return euo
}
// SetMedia sets the "media" edge to the Media entity.
func (euo *EpisodeUpdateOne) SetMedia(m *Media) *EpisodeUpdateOne {
return euo.SetMediaID(m.ID)
@@ -567,12 +521,6 @@ func (euo *EpisodeUpdateOne) sqlSave(ctx context.Context) (_node *Episode, err e
if value, ok := euo.mutation.Status(); ok {
_spec.SetField(episode.FieldStatus, field.TypeEnum, value)
}
if value, ok := euo.mutation.FileInStorage(); ok {
_spec.SetField(episode.FieldFileInStorage, field.TypeString, value)
}
if euo.mutation.FileInStorageCleared() {
_spec.ClearField(episode.FieldFileInStorage, field.TypeString)
}
if euo.mutation.MediaCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -41,6 +41,8 @@ type Media struct {
StorageID int `json:"storage_id,omitempty"`
// TargetDir holds the value of the "target_dir" field.
TargetDir string `json:"target_dir,omitempty"`
// tv series only
DownloadHistoryEpisodes bool `json:"download_history_episodes,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the MediaQuery when eager-loading is set.
Edges MediaEdges `json:"edges"`
@@ -70,6 +72,8 @@ func (*Media) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case media.FieldDownloadHistoryEpisodes:
values[i] = new(sql.NullBool)
case media.FieldID, media.FieldTmdbID, media.FieldStorageID:
values[i] = new(sql.NullInt64)
case media.FieldImdbID, media.FieldMediaType, media.FieldNameCn, media.FieldNameEn, media.FieldOriginalName, media.FieldOverview, media.FieldAirDate, media.FieldResolution, media.FieldTargetDir:
@@ -169,6 +173,12 @@ func (m *Media) assignValues(columns []string, values []any) error {
} else if value.Valid {
m.TargetDir = value.String
}
case media.FieldDownloadHistoryEpisodes:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field download_history_episodes", values[i])
} else if value.Valid {
m.DownloadHistoryEpisodes = value.Bool
}
default:
m.selectValues.Set(columns[i], values[i])
}
@@ -245,6 +255,9 @@ func (m *Media) String() string {
builder.WriteString(", ")
builder.WriteString("target_dir=")
builder.WriteString(m.TargetDir)
builder.WriteString(", ")
builder.WriteString("download_history_episodes=")
builder.WriteString(fmt.Sprintf("%v", m.DownloadHistoryEpisodes))
builder.WriteByte(')')
return builder.String()
}

View File

@@ -39,6 +39,8 @@ const (
FieldStorageID = "storage_id"
// FieldTargetDir holds the string denoting the target_dir field in the database.
FieldTargetDir = "target_dir"
// FieldDownloadHistoryEpisodes holds the string denoting the download_history_episodes field in the database.
FieldDownloadHistoryEpisodes = "download_history_episodes"
// EdgeEpisodes holds the string denoting the episodes edge name in mutations.
EdgeEpisodes = "episodes"
// Table holds the table name of the media in the database.
@@ -67,6 +69,7 @@ var Columns = []string{
FieldResolution,
FieldStorageID,
FieldTargetDir,
FieldDownloadHistoryEpisodes,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@@ -84,6 +87,8 @@ var (
DefaultCreatedAt time.Time
// DefaultAirDate holds the default value on creation for the "air_date" field.
DefaultAirDate string
// DefaultDownloadHistoryEpisodes holds the default value on creation for the "download_history_episodes" field.
DefaultDownloadHistoryEpisodes bool
)
// MediaType defines the type for the "media_type" enum field.
@@ -204,6 +209,11 @@ func ByTargetDir(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTargetDir, opts...).ToFunc()
}
// ByDownloadHistoryEpisodes orders the results by the download_history_episodes field.
func ByDownloadHistoryEpisodes(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDownloadHistoryEpisodes, opts...).ToFunc()
}
// ByEpisodesCount orders the results by episodes count.
func ByEpisodesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

View File

@@ -105,6 +105,11 @@ func TargetDir(v string) predicate.Media {
return predicate.Media(sql.FieldEQ(FieldTargetDir, v))
}
// DownloadHistoryEpisodes applies equality check predicate on the "download_history_episodes" field. It's identical to DownloadHistoryEpisodesEQ.
func DownloadHistoryEpisodes(v bool) predicate.Media {
return predicate.Media(sql.FieldEQ(FieldDownloadHistoryEpisodes, v))
}
// TmdbIDEQ applies the EQ predicate on the "tmdb_id" field.
func TmdbIDEQ(v int) predicate.Media {
return predicate.Media(sql.FieldEQ(FieldTmdbID, v))
@@ -750,6 +755,26 @@ func TargetDirContainsFold(v string) predicate.Media {
return predicate.Media(sql.FieldContainsFold(FieldTargetDir, v))
}
// DownloadHistoryEpisodesEQ applies the EQ predicate on the "download_history_episodes" field.
func DownloadHistoryEpisodesEQ(v bool) predicate.Media {
return predicate.Media(sql.FieldEQ(FieldDownloadHistoryEpisodes, v))
}
// DownloadHistoryEpisodesNEQ applies the NEQ predicate on the "download_history_episodes" field.
func DownloadHistoryEpisodesNEQ(v bool) predicate.Media {
return predicate.Media(sql.FieldNEQ(FieldDownloadHistoryEpisodes, v))
}
// DownloadHistoryEpisodesIsNil applies the IsNil predicate on the "download_history_episodes" field.
func DownloadHistoryEpisodesIsNil() predicate.Media {
return predicate.Media(sql.FieldIsNull(FieldDownloadHistoryEpisodes))
}
// DownloadHistoryEpisodesNotNil applies the NotNil predicate on the "download_history_episodes" field.
func DownloadHistoryEpisodesNotNil() predicate.Media {
return predicate.Media(sql.FieldNotNull(FieldDownloadHistoryEpisodes))
}
// HasEpisodes applies the HasEdge predicate on the "episodes" edge.
func HasEpisodes() predicate.Media {
return predicate.Media(func(s *sql.Selector) {

View File

@@ -141,6 +141,20 @@ func (mc *MediaCreate) SetNillableTargetDir(s *string) *MediaCreate {
return mc
}
// SetDownloadHistoryEpisodes sets the "download_history_episodes" field.
func (mc *MediaCreate) SetDownloadHistoryEpisodes(b bool) *MediaCreate {
mc.mutation.SetDownloadHistoryEpisodes(b)
return mc
}
// SetNillableDownloadHistoryEpisodes sets the "download_history_episodes" field if the given value is not nil.
func (mc *MediaCreate) SetNillableDownloadHistoryEpisodes(b *bool) *MediaCreate {
if b != nil {
mc.SetDownloadHistoryEpisodes(*b)
}
return mc
}
// AddEpisodeIDs adds the "episodes" edge to the Episode entity by IDs.
func (mc *MediaCreate) AddEpisodeIDs(ids ...int) *MediaCreate {
mc.mutation.AddEpisodeIDs(ids...)
@@ -203,6 +217,10 @@ func (mc *MediaCreate) defaults() {
v := media.DefaultResolution
mc.mutation.SetResolution(v)
}
if _, ok := mc.mutation.DownloadHistoryEpisodes(); !ok {
v := media.DefaultDownloadHistoryEpisodes
mc.mutation.SetDownloadHistoryEpisodes(v)
}
}
// check runs all checks and user-defined validators on the builder.
@@ -318,6 +336,10 @@ func (mc *MediaCreate) createSpec() (*Media, *sqlgraph.CreateSpec) {
_spec.SetField(media.FieldTargetDir, field.TypeString, value)
_node.TargetDir = value
}
if value, ok := mc.mutation.DownloadHistoryEpisodes(); ok {
_spec.SetField(media.FieldDownloadHistoryEpisodes, field.TypeBool, value)
_node.DownloadHistoryEpisodes = value
}
if nodes := mc.mutation.EpisodesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,

View File

@@ -229,6 +229,26 @@ func (mu *MediaUpdate) ClearTargetDir() *MediaUpdate {
return mu
}
// SetDownloadHistoryEpisodes sets the "download_history_episodes" field.
func (mu *MediaUpdate) SetDownloadHistoryEpisodes(b bool) *MediaUpdate {
mu.mutation.SetDownloadHistoryEpisodes(b)
return mu
}
// SetNillableDownloadHistoryEpisodes sets the "download_history_episodes" field if the given value is not nil.
func (mu *MediaUpdate) SetNillableDownloadHistoryEpisodes(b *bool) *MediaUpdate {
if b != nil {
mu.SetDownloadHistoryEpisodes(*b)
}
return mu
}
// ClearDownloadHistoryEpisodes clears the value of the "download_history_episodes" field.
func (mu *MediaUpdate) ClearDownloadHistoryEpisodes() *MediaUpdate {
mu.mutation.ClearDownloadHistoryEpisodes()
return mu
}
// AddEpisodeIDs adds the "episodes" edge to the Episode entity by IDs.
func (mu *MediaUpdate) AddEpisodeIDs(ids ...int) *MediaUpdate {
mu.mutation.AddEpisodeIDs(ids...)
@@ -375,6 +395,12 @@ func (mu *MediaUpdate) sqlSave(ctx context.Context) (n int, err error) {
if mu.mutation.TargetDirCleared() {
_spec.ClearField(media.FieldTargetDir, field.TypeString)
}
if value, ok := mu.mutation.DownloadHistoryEpisodes(); ok {
_spec.SetField(media.FieldDownloadHistoryEpisodes, field.TypeBool, value)
}
if mu.mutation.DownloadHistoryEpisodesCleared() {
_spec.ClearField(media.FieldDownloadHistoryEpisodes, field.TypeBool)
}
if mu.mutation.EpisodesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -640,6 +666,26 @@ func (muo *MediaUpdateOne) ClearTargetDir() *MediaUpdateOne {
return muo
}
// SetDownloadHistoryEpisodes sets the "download_history_episodes" field.
func (muo *MediaUpdateOne) SetDownloadHistoryEpisodes(b bool) *MediaUpdateOne {
muo.mutation.SetDownloadHistoryEpisodes(b)
return muo
}
// SetNillableDownloadHistoryEpisodes sets the "download_history_episodes" field if the given value is not nil.
func (muo *MediaUpdateOne) SetNillableDownloadHistoryEpisodes(b *bool) *MediaUpdateOne {
if b != nil {
muo.SetDownloadHistoryEpisodes(*b)
}
return muo
}
// ClearDownloadHistoryEpisodes clears the value of the "download_history_episodes" field.
func (muo *MediaUpdateOne) ClearDownloadHistoryEpisodes() *MediaUpdateOne {
muo.mutation.ClearDownloadHistoryEpisodes()
return muo
}
// AddEpisodeIDs adds the "episodes" edge to the Episode entity by IDs.
func (muo *MediaUpdateOne) AddEpisodeIDs(ids ...int) *MediaUpdateOne {
muo.mutation.AddEpisodeIDs(ids...)
@@ -816,6 +862,12 @@ func (muo *MediaUpdateOne) sqlSave(ctx context.Context) (_node *Media, err error
if muo.mutation.TargetDirCleared() {
_spec.ClearField(media.FieldTargetDir, field.TypeString)
}
if value, ok := muo.mutation.DownloadHistoryEpisodes(); ok {
_spec.SetField(media.FieldDownloadHistoryEpisodes, field.TypeBool, value)
}
if muo.mutation.DownloadHistoryEpisodesCleared() {
_spec.ClearField(media.FieldDownloadHistoryEpisodes, field.TypeBool)
}
if muo.mutation.EpisodesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,

View File

@@ -38,7 +38,6 @@ var (
{Name: "overview", Type: field.TypeString},
{Name: "air_date", Type: field.TypeString},
{Name: "status", Type: field.TypeEnum, Enums: []string{"missing", "downloading", "downloaded"}, Default: "missing"},
{Name: "file_in_storage", Type: field.TypeString, Nullable: true},
{Name: "media_id", Type: field.TypeInt, Nullable: true},
}
// EpisodesTable holds the schema information for the "episodes" table.
@@ -49,7 +48,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "episodes_media_episodes",
Columns: []*schema.Column{EpisodesColumns[8]},
Columns: []*schema.Column{EpisodesColumns[7]},
RefColumns: []*schema.Column{MediaColumns[0]},
OnDelete: schema.SetNull,
},
@@ -103,6 +102,7 @@ var (
{Name: "resolution", Type: field.TypeEnum, Enums: []string{"720p", "1080p", "4k"}, Default: "1080p"},
{Name: "storage_id", Type: field.TypeInt, Nullable: true},
{Name: "target_dir", Type: field.TypeString, Nullable: true},
{Name: "download_history_episodes", Type: field.TypeBool, Nullable: true, Default: false},
}
// MediaTable holds the schema information for the "media" table.
MediaTable = &schema.Table{

View File

@@ -919,7 +919,6 @@ type EpisodeMutation struct {
overview *string
air_date *string
status *episode.Status
file_in_storage *string
clearedFields map[string]struct{}
media *int
clearedmedia bool
@@ -1331,55 +1330,6 @@ func (m *EpisodeMutation) ResetStatus() {
m.status = nil
}
// SetFileInStorage sets the "file_in_storage" field.
func (m *EpisodeMutation) SetFileInStorage(s string) {
m.file_in_storage = &s
}
// FileInStorage returns the value of the "file_in_storage" field in the mutation.
func (m *EpisodeMutation) FileInStorage() (r string, exists bool) {
v := m.file_in_storage
if v == nil {
return
}
return *v, true
}
// OldFileInStorage returns the old "file_in_storage" field's value of the Episode entity.
// If the Episode object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *EpisodeMutation) OldFileInStorage(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldFileInStorage is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldFileInStorage requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldFileInStorage: %w", err)
}
return oldValue.FileInStorage, nil
}
// ClearFileInStorage clears the value of the "file_in_storage" field.
func (m *EpisodeMutation) ClearFileInStorage() {
m.file_in_storage = nil
m.clearedFields[episode.FieldFileInStorage] = struct{}{}
}
// FileInStorageCleared returns if the "file_in_storage" field was cleared in this mutation.
func (m *EpisodeMutation) FileInStorageCleared() bool {
_, ok := m.clearedFields[episode.FieldFileInStorage]
return ok
}
// ResetFileInStorage resets all changes to the "file_in_storage" field.
func (m *EpisodeMutation) ResetFileInStorage() {
m.file_in_storage = nil
delete(m.clearedFields, episode.FieldFileInStorage)
}
// ClearMedia clears the "media" edge to the Media entity.
func (m *EpisodeMutation) ClearMedia() {
m.clearedmedia = true
@@ -1441,7 +1391,7 @@ func (m *EpisodeMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *EpisodeMutation) Fields() []string {
fields := make([]string, 0, 8)
fields := make([]string, 0, 7)
if m.media != nil {
fields = append(fields, episode.FieldMediaID)
}
@@ -1463,9 +1413,6 @@ func (m *EpisodeMutation) Fields() []string {
if m.status != nil {
fields = append(fields, episode.FieldStatus)
}
if m.file_in_storage != nil {
fields = append(fields, episode.FieldFileInStorage)
}
return fields
}
@@ -1488,8 +1435,6 @@ func (m *EpisodeMutation) Field(name string) (ent.Value, bool) {
return m.AirDate()
case episode.FieldStatus:
return m.Status()
case episode.FieldFileInStorage:
return m.FileInStorage()
}
return nil, false
}
@@ -1513,8 +1458,6 @@ func (m *EpisodeMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldAirDate(ctx)
case episode.FieldStatus:
return m.OldStatus(ctx)
case episode.FieldFileInStorage:
return m.OldFileInStorage(ctx)
}
return nil, fmt.Errorf("unknown Episode field %s", name)
}
@@ -1573,13 +1516,6 @@ func (m *EpisodeMutation) SetField(name string, value ent.Value) error {
}
m.SetStatus(v)
return nil
case episode.FieldFileInStorage:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetFileInStorage(v)
return nil
}
return fmt.Errorf("unknown Episode field %s", name)
}
@@ -1640,9 +1576,6 @@ func (m *EpisodeMutation) ClearedFields() []string {
if m.FieldCleared(episode.FieldMediaID) {
fields = append(fields, episode.FieldMediaID)
}
if m.FieldCleared(episode.FieldFileInStorage) {
fields = append(fields, episode.FieldFileInStorage)
}
return fields
}
@@ -1660,9 +1593,6 @@ func (m *EpisodeMutation) ClearField(name string) error {
case episode.FieldMediaID:
m.ClearMediaID()
return nil
case episode.FieldFileInStorage:
m.ClearFileInStorage()
return nil
}
return fmt.Errorf("unknown Episode nullable field %s", name)
}
@@ -1692,9 +1622,6 @@ func (m *EpisodeMutation) ResetField(name string) error {
case episode.FieldStatus:
m.ResetStatus()
return nil
case episode.FieldFileInStorage:
m.ResetFileInStorage()
return nil
}
return fmt.Errorf("unknown Episode field %s", name)
}
@@ -3202,30 +3129,31 @@ func (m *IndexersMutation) ResetEdge(name string) error {
// MediaMutation represents an operation that mutates the Media nodes in the graph.
type MediaMutation struct {
config
op Op
typ string
id *int
tmdb_id *int
addtmdb_id *int
imdb_id *string
media_type *media.MediaType
name_cn *string
name_en *string
original_name *string
overview *string
created_at *time.Time
air_date *string
resolution *media.Resolution
storage_id *int
addstorage_id *int
target_dir *string
clearedFields map[string]struct{}
episodes map[int]struct{}
removedepisodes map[int]struct{}
clearedepisodes bool
done bool
oldValue func(context.Context) (*Media, error)
predicates []predicate.Media
op Op
typ string
id *int
tmdb_id *int
addtmdb_id *int
imdb_id *string
media_type *media.MediaType
name_cn *string
name_en *string
original_name *string
overview *string
created_at *time.Time
air_date *string
resolution *media.Resolution
storage_id *int
addstorage_id *int
target_dir *string
download_history_episodes *bool
clearedFields map[string]struct{}
episodes map[int]struct{}
removedepisodes map[int]struct{}
clearedepisodes bool
done bool
oldValue func(context.Context) (*Media, error)
predicates []predicate.Media
}
var _ ent.Mutation = (*MediaMutation)(nil)
@@ -3838,6 +3766,55 @@ func (m *MediaMutation) ResetTargetDir() {
delete(m.clearedFields, media.FieldTargetDir)
}
// SetDownloadHistoryEpisodes sets the "download_history_episodes" field.
func (m *MediaMutation) SetDownloadHistoryEpisodes(b bool) {
m.download_history_episodes = &b
}
// DownloadHistoryEpisodes returns the value of the "download_history_episodes" field in the mutation.
func (m *MediaMutation) DownloadHistoryEpisodes() (r bool, exists bool) {
v := m.download_history_episodes
if v == nil {
return
}
return *v, true
}
// OldDownloadHistoryEpisodes returns the old "download_history_episodes" field's value of the Media entity.
// If the Media object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *MediaMutation) OldDownloadHistoryEpisodes(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDownloadHistoryEpisodes is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDownloadHistoryEpisodes requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDownloadHistoryEpisodes: %w", err)
}
return oldValue.DownloadHistoryEpisodes, nil
}
// ClearDownloadHistoryEpisodes clears the value of the "download_history_episodes" field.
func (m *MediaMutation) ClearDownloadHistoryEpisodes() {
m.download_history_episodes = nil
m.clearedFields[media.FieldDownloadHistoryEpisodes] = struct{}{}
}
// DownloadHistoryEpisodesCleared returns if the "download_history_episodes" field was cleared in this mutation.
func (m *MediaMutation) DownloadHistoryEpisodesCleared() bool {
_, ok := m.clearedFields[media.FieldDownloadHistoryEpisodes]
return ok
}
// ResetDownloadHistoryEpisodes resets all changes to the "download_history_episodes" field.
func (m *MediaMutation) ResetDownloadHistoryEpisodes() {
m.download_history_episodes = nil
delete(m.clearedFields, media.FieldDownloadHistoryEpisodes)
}
// AddEpisodeIDs adds the "episodes" edge to the Episode entity by ids.
func (m *MediaMutation) AddEpisodeIDs(ids ...int) {
if m.episodes == nil {
@@ -3926,7 +3903,7 @@ func (m *MediaMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *MediaMutation) Fields() []string {
fields := make([]string, 0, 12)
fields := make([]string, 0, 13)
if m.tmdb_id != nil {
fields = append(fields, media.FieldTmdbID)
}
@@ -3963,6 +3940,9 @@ func (m *MediaMutation) Fields() []string {
if m.target_dir != nil {
fields = append(fields, media.FieldTargetDir)
}
if m.download_history_episodes != nil {
fields = append(fields, media.FieldDownloadHistoryEpisodes)
}
return fields
}
@@ -3995,6 +3975,8 @@ func (m *MediaMutation) Field(name string) (ent.Value, bool) {
return m.StorageID()
case media.FieldTargetDir:
return m.TargetDir()
case media.FieldDownloadHistoryEpisodes:
return m.DownloadHistoryEpisodes()
}
return nil, false
}
@@ -4028,6 +4010,8 @@ func (m *MediaMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldStorageID(ctx)
case media.FieldTargetDir:
return m.OldTargetDir(ctx)
case media.FieldDownloadHistoryEpisodes:
return m.OldDownloadHistoryEpisodes(ctx)
}
return nil, fmt.Errorf("unknown Media field %s", name)
}
@@ -4121,6 +4105,13 @@ func (m *MediaMutation) SetField(name string, value ent.Value) error {
}
m.SetTargetDir(v)
return nil
case media.FieldDownloadHistoryEpisodes:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDownloadHistoryEpisodes(v)
return nil
}
return fmt.Errorf("unknown Media field %s", name)
}
@@ -4187,6 +4178,9 @@ func (m *MediaMutation) ClearedFields() []string {
if m.FieldCleared(media.FieldTargetDir) {
fields = append(fields, media.FieldTargetDir)
}
if m.FieldCleared(media.FieldDownloadHistoryEpisodes) {
fields = append(fields, media.FieldDownloadHistoryEpisodes)
}
return fields
}
@@ -4210,6 +4204,9 @@ func (m *MediaMutation) ClearField(name string) error {
case media.FieldTargetDir:
m.ClearTargetDir()
return nil
case media.FieldDownloadHistoryEpisodes:
m.ClearDownloadHistoryEpisodes()
return nil
}
return fmt.Errorf("unknown Media nullable field %s", name)
}
@@ -4254,6 +4251,9 @@ func (m *MediaMutation) ResetField(name string) error {
case media.FieldTargetDir:
m.ResetTargetDir()
return nil
case media.FieldDownloadHistoryEpisodes:
m.ResetDownloadHistoryEpisodes()
return nil
}
return fmt.Errorf("unknown Media field %s", name)
}

View File

@@ -70,6 +70,10 @@ func init() {
mediaDescAirDate := mediaFields[8].Descriptor()
// media.DefaultAirDate holds the default value on creation for the air_date field.
media.DefaultAirDate = mediaDescAirDate.Default.(string)
// mediaDescDownloadHistoryEpisodes is the schema descriptor for download_history_episodes field.
mediaDescDownloadHistoryEpisodes := mediaFields[12].Descriptor()
// media.DefaultDownloadHistoryEpisodes holds the default value on creation for the download_history_episodes field.
media.DefaultDownloadHistoryEpisodes = mediaDescDownloadHistoryEpisodes.Default.(bool)
storageFields := schema.Storage{}.Fields()
_ = storageFields
// storageDescDeleted is the schema descriptor for deleted field.

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(),
}
}

View File

@@ -28,6 +28,7 @@ func (Media) Fields() []ent.Field {
field.Enum("resolution").Values("720p", "1080p", "4k").Default("1080p"),
field.Int("storage_id").Optional(),
field.String("target_dir").Optional(),
field.Bool("download_history_episodes").Optional().Default(false).Comment("tv series only"),
}
}

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
)

18
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=
@@ -91,6 +93,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
@@ -102,6 +106,10 @@ 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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -125,6 +133,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@@ -155,6 +165,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=
@@ -171,6 +183,8 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -182,6 +196,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
@@ -191,6 +207,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,62 @@
package log
import (
"os"
"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.Lock(os.Stdout)
if os.Getenv("GIN_MODE") == "release" {
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", "warning":
atom.SetLevel(zap.WarnLevel)
Warn("set log level to warning")
case "error":
atom.SetLevel(zap.ErrorLevel)
Error("set log level to error")
}
}
func Logger() *zap.SugaredLogger {
return sugar
}
func Info(args ...interface{}) {

View File

@@ -112,25 +112,25 @@ func parseEnglishName(name string) *Metadata {
}
} else { //no episode, maybe like One Punch Man S2 - 08 [1080p].mkv
numRe := regexp.MustCompile(`^\d{1,2}$`)
for i, p := range newSplits {
if numRe.MatchString(p) {
if i > 0 && strings.Contains(newSplits[i-1], "season") { //last word cannot be season
continue
}
if i < seasonIndex {
//episode number most likely should comes alfter season number
continue
}
//episodeIndex = i
n, err := strconv.Atoi(p)
if err != nil {
panic(fmt.Sprintf("convert %s error: %v", p, err))
}
meta.Episode = n
// numRe := regexp.MustCompile(`^\d{1,2}$`)
// for i, p := range newSplits {
// if numRe.MatchString(p) {
// if i > 0 && strings.Contains(newSplits[i-1], "season") { //last word cannot be season
// continue
// }
// if i < seasonIndex {
// //episode number most likely should comes alfter season number
// continue
// }
// //episodeIndex = i
// n, err := strconv.Atoi(p)
// if err != nil {
// panic(fmt.Sprintf("convert %s error: %v", p, err))
// }
// meta.Episode = n
}
}
// }
// }
}
if resIndex != -1 {

View File

@@ -45,6 +45,22 @@ type Client struct {
cfg Config
}
func (c *Client) GetAll() ([]*Torrent, error) {
all, err := c.c.TorrentGetAll(context.TODO())
if err != nil {
return nil, errors.Wrap(err, "get all")
}
var torrents []*Torrent
for _, t := range all {
torrents = append(torrents, &Torrent{
ID: *t.ID,
c: c.c,
Config: c.cfg,
})
}
return torrents, nil
}
func (c *Client) Download(link, dir string) (*Torrent, error) {
if strings.HasPrefix(link, "http") {
client := &http.Client{

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

@@ -93,3 +93,33 @@ func (s *Server) GetMediaDownloadHistory(c *gin.Context) (interface{}, error) {
}
return his, nil
}
type TorrentInfo struct {
Name string `json:"name"`
ID int64 `json:"id"`
SeedRatio float32 `json:"seed_ratio"`
Progress int `json:"progress"`
}
func (s *Server) GetAllTorrents(c *gin.Context) (interface{}, error) {
trc, err := s.getDownloadClient()
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
all, err := trc.GetAll()
if err != nil {
return nil, errors.Wrap(err, "get all")
}
var infos []TorrentInfo
for _, t := range all {
if !t.Exists() {
continue
}
infos = append(infos, TorrentInfo{
Name: t.Name(),
ID: t.ID,
Progress: t.Progress(),
})
}
return infos, nil
}

View File

@@ -31,7 +31,7 @@ func (s *Server) authModdleware(c *gin.Context) {
return
}
auth = strings.TrimPrefix(auth, "Bearer ")
//log.Infof("current token: %v", auth)
//log.Debugf("current token: %v", auth)
token, err := jwt.ParseWithClaims(auth, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(s.jwtSerect), 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.Debug("url %v return: %+v", ctx.Request.URL, r)
ctx.JSON(200, Response{
Code: 0,

View File

@@ -28,10 +28,10 @@ func isNumberedSeries(detail *db.MediaDetails) bool {
if ep.EpisodeNumber == 1 {
season2HasEpisode1 = true
}
}
}
return hasSeason2 && !season2HasEpisode1//only one 1st episode
return hasSeason2 && !season2HasEpisode1 //only one 1st episode
}
func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkResolution bool) ([]torznab.Result, error) {
@@ -54,7 +54,7 @@ func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkRes
if !isNumberedSeries(series) { //do not check season on series that only rely on episode number
if meta.Season != seasonNum {
continue
}
}
}
if isNumberedSeries(series) && episodeNum == -1 {
//should not want season
@@ -63,7 +63,7 @@ func SearchEpisode(db1 *db.Client, seriesId, seasonNum, episodeNum int, checkRes
if episodeNum != -1 && meta.Episode != episodeNum { //not season pack, episode number equals
continue
} else if seasonNum == -1 && !meta.IsSeasonPack { //want season pack, but not season pack
}else if episodeNum == -1 && !meta.IsSeasonPack { //want season pack, but not season pack
continue
}
if checkResolution && meta.Resolution != series.Resolution.String() {
@@ -131,7 +131,7 @@ func searchWithTorznab(db *db.Client, q string) []torznab.Result {
for _, tor := range allTorznab {
wg.Add(1)
go func () {
go func() {
log.Debugf("search torznab %v with %v", tor.Name, q)
defer wg.Done()
resp, err := torznab.Search(tor.URL, tor.ApiKey, q)
@@ -140,7 +140,7 @@ func searchWithTorznab(db *db.Client, q string) []torznab.Result {
return
}
resChan <- resp
}()
}
go func() {

View File

@@ -5,20 +5,17 @@ import (
"polaris/ent"
"polaris/ent/episode"
"polaris/ent/history"
"polaris/ent/media"
"polaris/log"
"polaris/pkg/torznab"
"polaris/pkg/utils"
"polaris/server/core"
"strconv"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
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, true)
if err != nil {
@@ -27,7 +24,15 @@ func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*strin
r1 := res[0]
log.Infof("found resource to download: %+v", r1)
return s.downloadSeasonPackage(r1, seriesId, seasonNum)
}
func (s *Server) downloadSeasonPackage(r1 torznab.Result, seriesId, seasonNum int) (*string, error) {
trc, err := s.getDownloadClient()
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
downloadDir := s.db.GetDownloadDir()
size := utils.AvailableSpace(downloadDir)
if size < uint64(r1.Size) {
@@ -63,9 +68,10 @@ func (s *Server) searchAndDownloadSeasonPackage(seriesId, seasonNum int) (*strin
s.tasks[history.ID] = &Task{Torrent: torrent}
return &r1.Name, nil
}
func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string, error) {
func (s *Server) downloadEpisodeTorrent(r1 torznab.Result, seriesId, seasonNum, episodeNum int) (*string, error) {
trc, err := s.getDownloadClient()
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
@@ -83,13 +89,6 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string
if ep == nil {
return nil, errors.Errorf("no episode of season %d episode %d", seasonNum, episodeNum)
}
res, err := core.SearchEpisode(s.db, seriesId, seasonNum, episodeNum, true)
if err != nil {
return nil, err
}
r1 := res[0]
log.Infof("found resource to download: %+v", r1)
torrent, err := trc.Download(r1.Link, s.db.GetDownloadDir())
if err != nil {
return nil, errors.Wrap(err, "downloading")
@@ -116,6 +115,17 @@ func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string
log.Infof("success add %s to download task", r1.Name)
return &r1.Name, nil
}
func (s *Server) searchAndDownload(seriesId, seasonNum, episodeNum int) (*string, error) {
res, err := core.SearchEpisode(s.db, seriesId, seasonNum, episodeNum, true)
if err != nil {
return nil, err
}
r1 := res[0]
log.Infof("found resource to download: %+v", r1)
return s.downloadEpisodeTorrent(r1, seriesId, seasonNum, episodeNum)
}
type searchAndDownloadIn struct {
@@ -124,15 +134,46 @@ type searchAndDownloadIn struct {
Episode int `json:"episode"`
}
func (s *Server) SearchAvailableEpisodeResource(c *gin.Context) (interface{}, error) {
func (s *Server) SearchAvailableTorrents(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, true)
m, err := s.db.GetMedia(in.ID)
if err != nil {
return nil, errors.Wrap(err, "search episode")
return nil, errors.Wrap(err, "get media")
}
log.Infof("search torrents resources link: %+v", in)
var res []torznab.Result
if m.MediaType == media.MediaTypeTv {
if in.Episode == 0 {
//search season package
log.Infof("search series season package S%02d", in.Season)
res, err = core.SearchSeasonPackage(s.db, in.ID, in.Season, false)
if err != nil {
return nil, errors.Wrap(err, "search season package")
}
} else {
log.Infof("search series episode S%02dE%02d", in.Season, in.Episode)
res, err = core.SearchEpisode(s.db, in.ID, in.Season, in.Episode, false)
if err != nil {
if err.Error() == "no resource found" {
return []TorznabSearchResult{}, nil
}
return nil, errors.Wrap(err, "search episode")
}
}
} else {
log.Info("search movie %d", in.ID)
res, err = core.SearchMovie(s.db, in.ID, false)
if err != nil {
if err.Error() == "no resource found" {
return []TorznabSearchResult{}, nil
}
return nil, err
}
}
var searchResults []TorznabSearchResult
for _, r := range res {
@@ -144,9 +185,6 @@ func (s *Server) SearchAvailableEpisodeResource(c *gin.Context) (interface{}, er
Link: r.Link,
})
}
if len(searchResults) == 0 {
return nil, errors.New("no resource found")
}
return searchResults, nil
}
@@ -185,64 +223,47 @@ 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) {
ids := c.Param("id")
id, err := strconv.Atoi(ids)
if err != nil {
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" {
return []TorznabSearchResult{}, nil
}
return nil, err
}
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.Link,
})
}
if len(searchResults) == 0 {
return []TorznabSearchResult{}, nil
}
return searchResults, nil
}
type downloadTorrentIn struct {
MediaID int `json:"media_id" binding:"required"`
MediaID int `json:"id" binding:"required"`
Season int `json:"season"`
Episode int `json:"episode"`
TorznabSearchResult
}
func (s *Server) DownloadMovieTorrent(c *gin.Context) (interface{}, error) {
func (s *Server) DownloadTorrent(c *gin.Context) (interface{}, error) {
var in downloadTorrentIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind json")
}
log.Infof("download torrent input: %+v", in)
m, err := s.db.GetMedia(in.MediaID)
if err != nil {
return nil, fmt.Errorf("no tv series of id %v", in.MediaID)
}
if m.MediaType == media.MediaTypeTv {
if in.Episode == 0 {
//download season package
name := in.Name
if name == "" {
name = fmt.Sprintf("%v S%02d", m.OriginalName, in.Season)
}
res := torznab.Result{Name: name, Link: in.Link, Size: in.Size}
return s.downloadSeasonPackage(res, in.MediaID, in.Season)
}
name := in.Name
if name == "" {
name = fmt.Sprintf("%v S%02dE%02d", m.OriginalName, in.Season, in.Episode)
}
res := torznab.Result{Name: name, Link: in.Link, Size: in.Size}
return s.downloadEpisodeTorrent(res, in.MediaID, in.Season, in.Episode)
}
trc, err := s.getDownloadClient()
if err != nil {
return nil, errors.Wrap(err, "connect transmission")
}
media := s.db.GetMediaDetails(in.MediaID)
if media == nil {
return nil, fmt.Errorf("no tv series of id %v", in.MediaID)
}
torrent, err := trc.Download(in.Link, s.db.GetDownloadDir())
if err != nil {
@@ -251,12 +272,12 @@ func (s *Server) DownloadMovieTorrent(c *gin.Context) (interface{}, error) {
torrent.Start()
name := in.Name
if name == "" {
name = media.OriginalName
name = m.OriginalName
}
go func() {
ep := media.Episodes[0]
ep, _ := s.db.GetMovieDummyEpisode(m.ID)
history, err := s.db.SaveHistoryRecord(ent.History{
MediaID: media.ID,
MediaID: m.ID,
EpisodeID: ep.ID,
SourceTitle: name,
TargetDir: "./",
@@ -277,4 +298,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)
@@ -192,12 +192,18 @@ func (s *Server) checkDownloadedSeriesFiles(m *ent.Media) error {
log.Errorf("find season episode num error: %v", err)
continue
}
var dirname = filepath.Join(in.Name(), ep.Name())
log.Infof("found match, season num %d, episode num %d", seNum, epNum)
err = s.db.UpdateEpisodeFile(m.ID, seNum, epNum, dirname)
ep, err := s.db.GetEpisode(m.ID, seNum, epNum)
if err != nil {
log.Error("update episode: %v", err)
continue
}
err = s.db.SetEpisodeStatus(ep.ID, episode.StatusDownloaded)
if err != nil {
log.Error("update episode: %v", err)
continue
}
}
}
return nil
@@ -213,50 +219,30 @@ 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)
tvDetail := s.db.GetMediaDetails(series.ID)
for _, ep := range tvDetail.Episodes {
if !series.DownloadHistoryEpisodes { //设置不下载历史已播出剧集,只下载将来剧集
t, err := time.Parse("2006-01-02", ep.AirDate)
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
}
log.Error("air date not known, skip: %v", ep.Title)
continue
}
if series.CreatedAt.Sub(t) > 24*time.Hour { //剧集在加入watchlist之前不去下载
continue
}
}
name, err := s.searchAndDownload(series.ID, lastEpisode.SeasonNumber, lastEpisode.EpisodeNumber)
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", lastEpisode.Title, err)
log.Infof("cannot find resource to download for %s: %v", ep.Title, err)
} else {
log.Infof("begin download torrent resource: %v", name)
}
}
}
}
}
@@ -359,8 +345,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"
@@ -40,15 +43,22 @@ type Server struct {
func (s *Server) Serve() error {
s.scheduler()
s.reloadTasks()
s.restoreProxy()
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,12 +67,17 @@ 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))
setting.POST("/parse/tv", HttpHandler(s.ParseTv))
setting.POST("/parse/movie", HttpHandler(s.ParseMovie))
}
activity := api.Group("/activity")
{
activity.GET("/", HttpHandler(s.GetAllActivities))
activity.DELETE("/:id", HttpHandler(s.RemoveActivity))
activity.GET("/media/:id", HttpHandler(s.GetMediaDownloadHistory))
activity.GET("/torrents", HttpHandler(s.GetAllTorrents))
}
tv := api.Group("/media")
@@ -70,11 +85,10 @@ 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("/torrents", HttpHandler(s.SearchAvailableTorrents))
tv.POST("/torrents/download/", HttpHandler(s.DownloadTorrent))
tv.POST("/movie/watchlist", HttpHandler(s.AddMovie2Watchlist))
tv.GET("/movie/watchlist", HttpHandler(s.GetMovieWatchlist))
tv.GET("/movie/resources/:id", HttpHandler(s.SearchAvailableMovies))
tv.POST("/movie/resources/", HttpHandler(s.DownloadMovieTorrent))
tv.GET("/record/:id", HttpHandler(s.GetMediaDetails))
tv.DELETE("/record/:id", HttpHandler(s.DeleteFromWatchlist))
tv.GET("/resolutions", HttpHandler(s.GetAvailableResolutions))
@@ -146,7 +160,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

@@ -2,6 +2,8 @@ package server
import (
"fmt"
"net/http"
"net/url"
"polaris/db"
"polaris/log"
"polaris/pkg/transmission"
@@ -14,6 +16,8 @@ import (
type GeneralSettings struct {
TmdbApiKey string `json:"tmdb_api_key"`
DownloadDir string `json:"download_dir"`
LogLevel string `json:"log_level"`
Proxy string `json:"proxy"`
}
func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
@@ -28,20 +32,51 @@ func (s *Server) SetSetting(c *gin.Context) (interface{}, error) {
}
}
if in.DownloadDir != "" {
log.Info("set download dir to %s", in.DownloadDir)
if err := s.db.SetSetting(db.SettingDownloadDir, in.DownloadDir); err != nil {
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")
}
}
s.setProxy(in.Proxy)
return nil, nil
}
func (s *Server) setProxy(proxy string) {
proxyUrl, err := url.Parse(proxy)
tp := http.DefaultTransport.(*http.Transport)
if proxy == "" || err != nil {
log.Warnf("proxy url not valid, disabling: %v", proxy)
tp.Proxy = nil
s.db.SetSetting(db.SettingProxy, "")
} else {
log.Infof("set proxy to %v", proxy)
tp.Proxy = http.ProxyURL(proxyUrl)
s.db.SetSetting(db.SettingProxy, proxy)
}
}
func (s *Server) restoreProxy() {
p := s.db.GetSetting(db.SettingProxy)
s.setProxy(p)
}
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,
Proxy: s.db.GetSetting(db.SettingProxy),
}, nil
}
@@ -97,7 +132,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 +147,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 {

View File

@@ -84,5 +84,6 @@ func (s *Server) SuggestedSeriesFolderName(c *gin.Context) (interface{}, error)
if year != "" {
name = fmt.Sprintf("%s (%s)", name, year)
}
log.Infof("tv series of tmdb id %v suggestting name is %v", id, name)
return gin.H{"name": name}, nil
}

74
server/systems.go Normal file
View File

@@ -0,0 +1,74 @@
package server
import (
"os"
"polaris/db"
"polaris/log"
"polaris/pkg/metadata"
"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(),
"version": db.Version,
}, nil
}
type parseIn struct {
S string `json:"s" binding:"required"`
}
func (s *Server) ParseTv(c *gin.Context) (interface{}, error) {
var in parseIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind")
}
return metadata.ParseTv(in.S), nil
}
func (s *Server) ParseMovie(c *gin.Context) (interface{}, error) {
var in parseIn
if err := c.ShouldBindJSON(&in); err != nil {
return nil, errors.Wrap(err, "bind")
}
return metadata.ParseMovie(in.S), 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"
@@ -55,10 +57,11 @@ func (s *Server) SearchMedia(c *gin.Context) (interface{}, error) {
}
type addWatchlistIn struct {
TmdbID int `json:"tmdb_id" binding:"required"`
StorageID int `json:"storage_id" `
Resolution string `json:"resolution" binding:"required"`
Folder string `json:"folder"`
TmdbID int `json:"tmdb_id" binding:"required"`
StorageID int `json:"storage_id" `
Resolution string `json:"resolution" binding:"required"`
Folder string `json:"folder"`
DownloadHistoryEpisodes bool `json:"download_history_episodes"` //for tv
}
func (s *Server) AddTv2Watchlist(c *gin.Context) (interface{}, error) {
@@ -66,7 +69,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 +121,8 @@ 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,
DownloadHistoryEpisodes: in.DownloadHistoryEpisodes,
}, epIds)
if err != nil {
return nil, errors.Wrap(err, "add to list")
@@ -183,7 +187,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 +242,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,12 +228,14 @@ 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>[
NavigationDestination(
icon: Icon(Icons.live_tv),
label: '电视',
label: '',
),
NavigationDestination(
icon: Icon(Icons.movie),
@@ -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

@@ -5,7 +5,6 @@ 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';
import 'package:ui/utils.dart';
import 'package:ui/welcome_page.dart';
import 'package:ui/widgets/progress_indicator.dart';
@@ -172,7 +171,6 @@ class _NestedTabBarState extends ConsumerState<NestedTabBar>
@override
Widget build(BuildContext context) {
var torrents = ref.watch(movieTorrentsDataProvider(widget.id));
var histories = ref.watch(mediaHistoryDataProvider(widget.id));
return Column(
@@ -223,48 +221,58 @@ class _NestedTabBarState extends ConsumerState<NestedTabBar>
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
} else {
return torrents.when(
data: (v) {
if (v.isEmpty) {
return const Center(
child: Text("无可用资源"),
);
}
return Consumer(
builder: (context, ref, child) {
var torrents = ref.watch(mediaTorrentsDataProvider(
(mediaId: widget.id, seasonNumber: 0, episodeNumber: 0)));
return torrents.when(
data: (v) {
if (v.isEmpty) {
return const Center(
child: Text("无可用资源"),
);
}
return DataTable(
columns: const [
DataColumn(label: Text("名称")),
DataColumn(label: Text("大小")),
DataColumn(label: Text("seeders")),
DataColumn(label: Text("peers")),
DataColumn(label: Text("操作"))
],
rows: List.generate(v.length, (i) {
final torrent = v[i];
return DataRow(cells: [
DataCell(Text("${torrent.name}")),
DataCell(Text("${torrent.size?.readableFileSize()}")),
DataCell(Text("${torrent.seeders}")),
DataCell(Text("${torrent.peers}")),
DataCell(IconButton(
icon: const Icon(Icons.download),
onPressed: () {
ref
.read(movieTorrentsDataProvider(widget.id)
.notifier)
.download(torrent)
.then((v) =>
Utils.showSnakeBar("开始下载:${torrent.name}"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
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(mediaTorrentsDataProvider((
mediaId: widget.id,
seasonNumber: 0,
episodeNumber: 0
)).notifier)
.download(torrent)
.then((v) => Utils.showSnakeBar(
"开始下载:${torrent.name}"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
))
]);
}),
);
},
error: (error, trace) => Text("$error"),
loading: () => const MyProgressIndicator());
},
);
}
})
],

View File

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

@@ -13,7 +13,8 @@ class APIs {
static final settingsGeneralUrl = "$_baseUrl/api/v1/setting/general";
static final watchlistTvUrl = "$_baseUrl/api/v1/media/tv/watchlist";
static final watchlistMovieUrl = "$_baseUrl/api/v1/media/movie/watchlist";
static final availableMoviesUrl = "$_baseUrl/api/v1/media/movie/resources/";
static final availableTorrentsUrl = "$_baseUrl/api/v1/media/torrents/";
static final downloadTorrentUrl = "$_baseUrl/api/v1/media/torrents/download";
static final seriesDetailUrl = "$_baseUrl/api/v1/media/record/";
static final suggestedTvName = "$_baseUrl/api/v1/media/suggest/";
static final searchAndDownloadUrl = "$_baseUrl/api/v1/indexer/download";
@@ -29,6 +30,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

@@ -97,7 +97,7 @@ class SeriesDetails {
class Episodes {
int? id;
int? seriesId;
int? mediaId;
int? episodeNumber;
String? title;
String? airDate;
@@ -107,7 +107,7 @@ class Episodes {
Episodes(
{this.id,
this.seriesId,
this.mediaId,
this.episodeNumber,
this.title,
this.airDate,
@@ -117,7 +117,7 @@ class Episodes {
Episodes.fromJson(Map<String, dynamic> json) {
id = json['id'];
seriesId = json['series_id'];
mediaId = json['media_id'];
episodeNumber = json['episode_number'];
title = json['title'];
airDate = json['air_date'];
@@ -126,3 +126,85 @@ class Episodes {
overview = json['overview'];
}
}
var mediaTorrentsDataProvider = AsyncNotifierProvider.autoDispose
.family<MediaTorrentResource, List<TorrentResource>, TorrentQuery>(
MediaTorrentResource.new);
// class TorrentQuery {
// final String mediaId;
// final int seasonNumber;
// final int episodeNumber;
// TorrentQuery(
// {required this.mediaId, this.seasonNumber = 0, this.episodeNumber = 0});
// Map<String, dynamic> toJson() {
// final Map<String, dynamic> data = <String, dynamic>{};
// data["id"] = int.parse(mediaId);
// data["season"] = seasonNumber;
// data["episode"] = episodeNumber;
// return data;
// }
// }
typedef TorrentQuery =({String mediaId, int seasonNumber, int episodeNumber});
class MediaTorrentResource extends AutoDisposeFamilyAsyncNotifier<
List<TorrentResource>, TorrentQuery> {
@override
FutureOr<List<TorrentResource>> build(TorrentQuery arg) async {
final dio = await APIs.getDio();
var resp = await dio.post(APIs.availableTorrentsUrl, data: {
"id": int.parse(arg.mediaId),
"season": arg.seasonNumber,
"episode": arg.episodeNumber
});
var rsp = ServerResponse.fromJson(resp.data);
if (rsp.code != 0) {
throw rsp.message;
}
return (rsp.data as List).map((v) => TorrentResource.fromJson(v)).toList();
}
Future<void> download(TorrentResource res) async {
final data = res.toJson();
data.addAll({
"id": int.parse(arg.mediaId),
"season": arg.seasonNumber,
"episode": arg.episodeNumber
});
final dio = await APIs.getDio();
var resp = await dio.post(APIs.downloadTorrentUrl, data: data);
var rsp = ServerResponse.fromJson(resp.data);
if (rsp.code != 0) {
throw rsp.message;
}
}
}
class TorrentResource {
TorrentResource({this.name, this.size, this.seeders, this.peers, this.link});
String? name;
int? size;
int? seeders;
int? peers;
String? link;
factory TorrentResource.fromJson(Map<String, dynamic> json) {
return TorrentResource(
name: json["name"],
size: json["size"],
seeders: json["seeders"],
peers: json["peers"],
link: json["link"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['name'] = name;
data['size'] = size;
data["link"] = link;
return data;
}
}

View File

@@ -49,18 +49,26 @@ class EditSettingData extends AutoDisposeAsyncNotifier<GeneralSetting> {
class GeneralSetting {
String? tmdbApiKey;
String? downloadDIr;
String? logLevel;
String? proxy;
GeneralSetting({this.tmdbApiKey, this.downloadDIr});
GeneralSetting(
{this.tmdbApiKey, this.downloadDIr, this.logLevel, this.proxy});
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"],
proxy: json["proxy"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['tmdb_api_key'] = tmdbApiKey;
data['download_dir'] = downloadDIr;
data["log_level"] = logLevel;
data["proxy"] = proxy;
return data;
}
}
@@ -257,13 +265,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 +296,69 @@ 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,
required this.version,
});
final String? chatGroup;
final String? goVersion;
final String? homepage;
final String? intro;
final Duration? uptime;
final String? version;
factory About.fromJson(Map<String, dynamic> json) {
return About(
chatGroup: json["chat_group"],
goVersion: json["go_version"],
homepage: json["homepage"],
intro: json["intro"],
version: json["version"],
uptime:
Duration(microseconds: (json["uptime"] / 1000.0 as double).round()),
);
}
}

View File

@@ -44,10 +44,6 @@ final movieWatchlistDataProvider = FutureProvider.autoDispose((ref) async {
var searchPageDataProvider = AsyncNotifierProvider.autoDispose
.family<SearchPageData, List<SearchResult>, String>(SearchPageData.new);
var movieTorrentsDataProvider = AsyncNotifierProvider.autoDispose
.family<MovieTorrentResource, List<TorrentResource>, String>(
MovieTorrentResource.new);
class SearchPageData
extends AutoDisposeFamilyAsyncNotifier<List<SearchResult>, String> {
List<SearchResult> list = List.empty(growable: true);
@@ -88,14 +84,15 @@ class SearchPageData
}
Future<void> submit2Watchlist(int tmdbId, int storageId, String resolution,
String mediaType, String folder) async {
String mediaType, String folder, bool downloadHistoryEpisodes) async {
final dio = await APIs.getDio();
if (mediaType == "tv") {
var resp = await dio.post(APIs.watchlistTvUrl, data: {
"tmdb_id": tmdbId,
"storage_id": storageId,
"resolution": resolution,
"folder": folder
"folder": folder,
"download_history_episodes":downloadHistoryEpisodes
});
var sp = ServerResponse.fromJson(resp.data);
if (sp.code != 0) {
@@ -150,6 +147,7 @@ class MediaDetail {
String? resolution;
int? storageId;
String? airDate;
String? status;
MediaDetail({
this.id,
@@ -163,6 +161,7 @@ class MediaDetail {
this.resolution,
this.storageId,
this.airDate,
this.status,
});
MediaDetail.fromJson(Map<String, dynamic> json) {
@@ -177,6 +176,7 @@ class MediaDetail {
resolution = json["resolution"];
storageId = json["storage_id"];
airDate = json["air_date"];
status = json["status"];
}
}
@@ -242,56 +242,3 @@ class SearchResult {
}
}
class MovieTorrentResource
extends AutoDisposeFamilyAsyncNotifier<List<TorrentResource>, String> {
String? mediaId;
@override
FutureOr<List<TorrentResource>> build(String id) async {
mediaId = id;
final dio = await APIs.getDio();
var resp = await dio.get(APIs.availableMoviesUrl + id);
var rsp = ServerResponse.fromJson(resp.data);
if (rsp.code != 0) {
throw rsp.message;
}
return (rsp.data as List).map((v) => TorrentResource.fromJson(v)).toList();
}
Future<void> download(TorrentResource res) async {
var m = res.toJson();
m["media_id"] = int.parse(mediaId!);
final dio = await APIs.getDio();
var resp = await dio.post(APIs.availableMoviesUrl, data: m);
var rsp = ServerResponse.fromJson(resp.data);
if (rsp.code != 0) {
throw rsp.message;
}
}
}
class TorrentResource {
TorrentResource({this.name, this.size, this.seeders, this.peers, this.link});
String? name;
int? size;
int? seeders;
int? peers;
String? link;
factory TorrentResource.fromJson(Map<String, dynamic> json) {
return TorrentResource(
name: json["name"],
size: json["size"],
seeders: json["seeders"],
peers: json["peers"],
link: json["link"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['name'] = name;
data['size'] = size;
data["link"] = link;
return data;
}
}

View File

@@ -146,6 +146,8 @@ class _SearchPageState extends ConsumerState<SearchPage> {
int storageSelected = 0;
var storage = ref.watch(storageSettingProvider);
var name = ref.watch(suggestNameDataProvider(item.id!));
bool downloadHistoryEpisodes = false;
bool buttonTapped = false;
var pathController = TextEditingController();
return AlertDialog(
@@ -230,6 +232,19 @@ class _SearchPageState extends ConsumerState<SearchPage> {
),
)
: Text(""),
item.mediaType == "tv"
? SizedBox(
width: 250,
child: CheckboxListTile(
title: const Text("是否下载往期剧集"),
value: downloadHistoryEpisodes,
onChanged: (v) {
setState(() {
downloadHistoryEpisodes = v!;
});
}),
)
: const SizedBox(),
],
);
});
@@ -254,18 +269,33 @@ class _SearchPageState extends ConsumerState<SearchPage> {
textStyle: Theme.of(context).textTheme.labelLarge,
),
child: const Text('确定'),
onPressed: () {
ref
onPressed: () async {
if (buttonTapped) {
return;
}
setState(() {
buttonTapped = true;
});
await ref
.read(searchPageDataProvider(widget.query ?? "")
.notifier)
.submit2Watchlist(item.id!, storageSelected,
resSelected, item.mediaType!, pathController.text)
.submit2Watchlist(
item.id!,
storageSelected,
resSelected,
item.mediaType!,
pathController.text,
downloadHistoryEpisodes)
.then((v) {
Utils.showSnakeBar("添加成功");
Navigator.of(context).pop();
}).onError((error, trace) {
Utils.showSnakeBar("添加失败:$error");
});
setState(() {
buttonTapped = false;
});
},
),
],

View File

@@ -30,61 +30,84 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
var tmdbSetting = settings.when(
data: (v) {
return Container(
padding: const EdgeInsets.fromLTRB(40, 10, 40, 0),
child: FormBuilder(
key: _formKey, //globalKeyFormState
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: {
"tmdb_api": v.tmdbApiKey,
"download_dir": v.downloadDIr
},
child: Column(
children: [
FormBuilderTextField(
name: "tmdb_api",
autofocus: true,
decoration: Commons.requiredTextFieldStyle(
text: "TMDB Api Key", icon: const Icon(Icons.key)),
//
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "download_dir",
autofocus: true,
decoration: Commons.requiredTextFieldStyle(
text: "下载路径", icon: const Icon(Icons.folder)),
//
validator: FormBuilderValidators.required(),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 28.0),
child: ElevatedButton(
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text("保存"),
),
onPressed: () {
if (_formKey.currentState!.saveAndValidate()) {
var values = _formKey.currentState!.value;
var f = ref
.read(settingProvider.notifier)
.updateSettings(GeneralSetting(
tmdbApiKey: values["tmdb_api"],
downloadDIr: values["download_dir"]));
f.then((v) {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
});
}
}),
),
)
],
return FormBuilder(
key: _formKey, //globalKeyFormState
autovalidateMode: AutovalidateMode.onUserInteraction,
initialValue: {
"tmdb_api": v.tmdbApiKey,
"download_dir": v.downloadDIr,
"log_level": v.logLevel,
"proxy": v.proxy,
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormBuilderTextField(
name: "tmdb_api",
decoration: Commons.requiredTextFieldStyle(
text: "TMDB Api Key", icon: const Icon(Icons.key)),
//
validator: FormBuilderValidators.required(),
),
));
FormBuilderTextField(
name: "download_dir",
decoration: Commons.requiredTextFieldStyle(
text: "下载路径", icon: const Icon(Icons.folder), helperText: "媒体文件临时下载路径,非最终存储路径"),
//
validator: FormBuilderValidators.required(),
),
FormBuilderTextField(
name: "proxy",
decoration: const InputDecoration(
labelText: "代理地址", icon: Icon(Icons.folder), helperText: "后台联网代理地址,留空表示不启用代理"),
),
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("WARN")),
DropdownMenuItem(value: "error", child: Text("ERROR")),
],
validator: FormBuilderValidators.required(),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 28.0),
child: ElevatedButton(
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text("保存"),
),
onPressed: () {
if (_formKey.currentState!.saveAndValidate()) {
var values = _formKey.currentState!.value;
var f = ref
.read(settingProvider.notifier)
.updateSettings(GeneralSetting(
tmdbApiKey: values["tmdb_api"],
downloadDIr: values["download_dir"],
logLevel: values["log_level"],
proxy: values["proxy"]));
f.then((v) {
Utils.showSnakeBar("更新成功");
}).onError((e, s) {
Utils.showSnakeBar("更新失败:$e");
});
}
}),
),
)
],
),
);
},
error: (err, trace) => Text("$err"),
loading: () => const MyProgressIndicator());
@@ -143,8 +166,6 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
loading: () => const MyProgressIndicator());
var authData = ref.watch(authSettingProvider);
TextEditingController userController = TextEditingController();
TextEditingController passController = TextEditingController();
var authSetting = authData.when(
data: (data) {
if (_enableAuth == null) {
@@ -152,7 +173,6 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
_enableAuth = data.enable;
});
}
userController.text = data.user;
return FormBuilder(
key: _formKey2,
initialValue: {
@@ -224,39 +244,34 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
children: [
ExpansionTile(
expandedAlignment: Alignment.centerLeft,
tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
initiallyExpanded: true,
title: const Text("常规设置"),
children: [tmdbSetting],
),
ExpansionTile(
expandedAlignment: Alignment.centerLeft,
tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
initiallyExpanded: false,
title: const Text("索引器设置"),
children: [indexerSetting],
),
ExpansionTile(
expandedAlignment: Alignment.centerLeft,
tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
initiallyExpanded: false,
title: const Text("下载器设置"),
children: [downloadSetting],
),
ExpansionTile(
expandedAlignment: Alignment.centerLeft,
tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 50, 0),
initiallyExpanded: false,
title: const Text("存储设置"),
children: [storageSetting],
),
ExpansionTile(
tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
initiallyExpanded: false,
title: const Text("认证设置"),
children: [authSetting],
@@ -267,7 +282,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
Future<void> showIndexerDetails(Indexer indexer) {
final _formKey = GlobalKey<FormBuilderState>();
var selectImpl = "torznab";
var body = FormBuilder(
key: _formKey,
initialValue: {
@@ -318,7 +333,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
url: values["url"],
apiKey: values["api_key"]));
} else {
throw "数据校验失败";
throw "validation_error";
}
}
@@ -426,7 +441,7 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
user: _enableAuth ? values["user"] : null,
password: _enableAuth ? values["password"] : null));
} else {
throw "数据校验不通过";
throw "validation_error";
}
}
@@ -549,11 +564,11 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
"user": values["user"],
"password": values["password"],
"change_file_hash":
values["change_file_hash"] as bool ? "true" : "false"
(values["change_file_hash"]??false) as bool ? "true" : "false"
},
));
} else {
throw "数据校验位未通过";
throw "validation_error";
}
}
@@ -573,8 +588,8 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Container(
constraints: const BoxConstraints(maxWidth: 200),
child: SizedBox(
width: 300,
child: body,
),
),
@@ -606,7 +621,9 @@ class _SystemSettingsPageState extends ConsumerState<SystemSettingsPage> {
Utils.showSnakeBar("操作成功");
Navigator.of(context).pop();
}).onError((e, s) {
Utils.showSnakeBar("操作失败:$e");
if (e.toString() != "validation_error") {
Utils.showSnakeBar("操作失败:$e");
}
});
},
),

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

@@ -0,0 +1,141 @@
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/utils.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).readableFileSize())),
DataCell(InkWell(
child: const 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("讨论组", 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)),
Text(v.version ?? "",
style: const TextStyle(height: 2.5)),
InkWell(
child: Text(v.homepage ?? "",
softWrap: false,
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

@@ -26,7 +26,6 @@ class TvDetailsPage extends ConsumerStatefulWidget {
}
class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
@override
void initState() {
super.initState();
@@ -45,7 +44,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(
@@ -80,13 +79,15 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
icon: const Icon(Icons.search)),
icon: const Icon(Icons.download)),
),
const SizedBox(
width: 10,
),
IconButton(
onPressed: () {}, icon: const Icon(Icons.manage_search))
onPressed: () => showAvailableTorrents(widget.seriesId,
ep.seasonNumber ?? 0, ep.episodeNumber ?? 0),
icon: const Icon(Icons.manage_search))
],
))
]);
@@ -113,19 +114,30 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
const DataColumn(label: Text("播出时间")),
const DataColumn(label: Text("状态")),
DataColumn(
label: Tooltip(
message: "搜索下载全部剧集",
child: IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(widget.seriesId)
.notifier)
.searchAndDownload(widget.seriesId, k, 0)
.then((v) => Utils.showSnakeBar("开始下载: $v"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
icon: const Icon(Icons.search)),
label: Row(
children: [
Tooltip(
message: "搜索下载全部剧集",
child: IconButton(
onPressed: () {
ref
.read(mediaDetailsProvider(widget.seriesId)
.notifier)
.searchAndDownload(widget.seriesId, k, 0)
.then((v) => Utils.showSnakeBar("开始下载: $v"))
.onError((error, trace) =>
Utils.showSnakeBar("操作失败: $error"));
},
icon: const Icon(Icons.download)),
),
const SizedBox(
width: 10,
),
IconButton(
onPressed: () =>
showAvailableTorrents(widget.seriesId, k, 0),
icon: const Icon(Icons.manage_search))
],
))
], rows: m[k]!),
],
@@ -198,7 +210,7 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
),
const Text(""),
Text(
details.overview??"",
details.overview ?? "",
),
],
)),
@@ -239,4 +251,71 @@ class _TvDetailsPageState extends ConsumerState<TvDetailsPage> {
},
loading: () => const MyProgressIndicator());
}
Future<void> showAvailableTorrents(String id, int season, int episode) {
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return Consumer(builder: (context, ref, _) {
final torrents = ref.watch(mediaTorrentsDataProvider(
(mediaId: id, seasonNumber: season, episodeNumber: episode)));
return AlertDialog(
//title: Text("资源"),
content: SelectionArea(
child: SizedBox(
width: 800,
height: 400,
child: torrents.when(
data: (v) {
return SingleChildScrollView(
child: DataTable(
dataTextStyle: const TextStyle(fontSize: 12, height: 0),
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: () async {
await ref
.read(mediaTorrentsDataProvider((
mediaId: id,
seasonNumber: season,
episodeNumber: episode
)).notifier)
.download(torrent)
.then((v) {
Navigator.of(context).pop();
Utils.showSnakeBar(
"开始下载:${torrent.name}");
}).onError((error, trace) =>
Utils.showSnakeBar("下载失败:$error"));
},
))
]);
})));
},
error: (err, trace) {
return Text("$err");
},
loading: () => const MyProgressIndicator()),
),
));
});
},
);
}
}

View File

@@ -27,45 +27,59 @@ class WelcomePage extends ConsumerWidget {
return switch (data) {
AsyncData(:final value) => SingleChildScrollView(
child: Wrap(
spacing: 20,
children: List.generate(value.length, (i) {
var item = value[i];
return Card(
margin: const EdgeInsets.all(4),
clipBehavior: Clip.hardEdge,
child: InkWell(
//splashColor: Colors.blue.withAlpha(30),
onTap: () {
if (uri == routeMoivie) {
context.go(MovieDetailsPage.toRoute(item.id!));
} else {
context.go(TvDetailsPage.toRoute(item.id!));
}
},
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,
spacing: 10,
runSpacing: 20,
children: value.isEmpty
? [
Container(
height: MediaQuery.of(context).size.height * 0.6,
alignment: Alignment.center,
child: const Text("啥都没有...", style: TextStyle(fontSize: 16),))
]
: List.generate(value.length, (i) {
var item = value[i];
return Card(
margin: const EdgeInsets.all(4),
clipBehavior: Clip.hardEdge,
child: InkWell(
//splashColor: Colors.blue.withAlpha(30),
onTap: () {
if (uri == routeMoivie) {
context.go(MovieDetailsPage.toRoute(item.id!));
} else {
context.go(TvDetailsPage.toRoute(item.id!));
}
},
child: Column(
children: <Widget>[
SizedBox(
width: 140,
height: 210,
child: Image.network(
"${APIs.imagesUrl}/${item.id}/poster.jpg",
fit: BoxFit.fill,
headers: APIs.authHeaders,
),
),
)),
Text(
item.name!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
height: 2.5),
),
],
),
));
}),
SizedBox(
width: 140,
child: LinearProgressIndicator(
value: 1,
color: item.status == "downloaded"
? Colors.green
: Colors.blue,
)),
Text(
item.name!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
height: 2.5),
),
],
),
));
}),
),
),
_ => const MyProgressIndicator(),

View File

@@ -4,8 +4,10 @@ class Commons {
static InputDecoration requiredTextFieldStyle({
required String text,
Widget? icon,
String? helperText,
}) {
return InputDecoration(
helperText: helperText,
label: Row(
children: [
Text(text),

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:

View File

@@ -34,5 +34,25 @@
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
<script>
if (
navigator.userAgent.indexOf("Safari") !== -1 &&
navigator.userAgent.indexOf("Chrome") === -1
) {
var originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function () {
var contextType = arguments[0];
if (contextType === "webgl2") {
return;
}
return originalGetContext.apply(
this,
[contextType].concat(Array.prototype.slice.call(arguments, 1)),
);
};
}
</script>
</body>
</html>