diff --git a/db/db.go b/db/db.go index 297628c..b39345a 100644 --- a/db/db.go +++ b/db/db.go @@ -11,6 +11,7 @@ import ( "polaris/ent/history" "polaris/ent/indexers" "polaris/ent/media" + "polaris/ent/schema" "polaris/ent/settings" "polaris/ent/storage" "polaris/log" @@ -87,8 +88,8 @@ func (c *Client) generateDefaultLocalStorage() error { return c.AddStorage(&StorageInfo{ Name: "local", Implementation: "local", - TvPath: "/data/tv/", - MoviePath: "/data/movies/", + TvPath: "/data/tv/", + MoviePath: "/data/movies/", Default: true, }) } @@ -249,7 +250,6 @@ type TorznabSetting struct { ApiKey string `json:"api_key"` } - func (c *Client) SaveIndexer(in *ent.Indexers) error { if in.ID != 0 { @@ -265,7 +265,7 @@ func (c *Client) SaveIndexer(in *ent.Indexers) error { _, err := c.ent.Indexers.Create(). SetName(in.Name).SetImplementation(in.Implementation).SetPriority(in.Priority).SetSettings(in.Settings).SetSeedRatio(in.SeedRatio). - SetDisabled(in.Disabled).Save(context.TODO()) + SetDisabled(in.Disabled).Save(context.TODO()) if err != nil { return errors.Wrap(err, "save db") } @@ -285,11 +285,12 @@ func (c *Client) GetIndexer(id int) (*TorznabInfo, error) { var ss TorznabSetting err = json.Unmarshal([]byte(res.Settings), &ss) if err != nil { - + return nil, fmt.Errorf("unmarshal torznab %s error: %v", res.Name, err) } return &TorznabInfo{Indexers: res, TorznabSetting: ss}, nil } + type TorznabInfo struct { *ent.Indexers TorznabSetting @@ -307,7 +308,7 @@ func (c *Client) GetAllTorznabInfo() []*TorznabInfo { continue } l = append(l, &TorznabInfo{ - Indexers: r, + Indexers: r, TorznabSetting: ss, }) } @@ -356,7 +357,7 @@ type StorageInfo struct { Settings map[string]string `json:"settings" binding:"required"` TvPath string `json:"tv_path" binding:"required"` MoviePath string `json:"movie_path" binding:"required"` - Default bool `json:"default"` + Default bool `json:"default"` } func (s *StorageInfo) ToWebDavSetting() WebdavSetting { @@ -371,7 +372,6 @@ func (s *StorageInfo) ToWebDavSetting() WebdavSetting { } } - type WebdavSetting struct { URL string `json:"url"` User string `json:"user"` @@ -472,7 +472,7 @@ func (c *Client) SetDefaultStorageByName(name string) error { func (c *Client) SaveHistoryRecord(h ent.History) (*ent.History, error) { return c.ent.History.Create().SetMediaID(h.MediaID).SetEpisodeID(h.EpisodeID).SetDate(time.Now()). - SetStatus(h.Status).SetTargetDir(h.TargetDir).SetSourceTitle(h.SourceTitle).SetIndexerID(h.IndexerID). + SetStatus(h.Status).SetTargetDir(h.TargetDir).SetSourceTitle(h.SourceTitle).SetIndexerID(h.IndexerID). SetDownloadClientID(h.DownloadClientID).SetSaved(h.Saved).Save(context.TODO()) } @@ -555,7 +555,18 @@ func (c *Client) GetDownloadClient(id int) (*ent.DownloadClients, error) { return c.ent.DownloadClients.Query().Where(downloadclients.ID(id)).First(context.Background()) } - func (c *Client) SetEpisodeMonitoring(id int, b bool) error { return c.ent.Episode.Update().Where(episode.ID(id)).SetMonitored(b).Exec(context.Background()) -} \ No newline at end of file +} + +type EditMediaData struct { + ID int `json:"id"` + Resolution media.Resolution `json:"resolution"` + TargetDir string `json:"target_dir"` + Limiter *schema.MediaLimiter `json:"limiter"` +} + +func (c *Client) EditMediaMetadata(in EditMediaData) error { + return c.ent.Media.Update().Where(media.ID(in.ID)).SetResolution(in.Resolution).SetTargetDir(in.TargetDir).SetLimiter(in.Limiter). + Exec(context.Background()) +} diff --git a/log/log.go b/log/log.go index 62cb826..180bf83 100644 --- a/log/log.go +++ b/log/log.go @@ -26,6 +26,7 @@ func init() { MaxSize: 50, // megabytes MaxBackups: 3, MaxAge: 30, // days + Compress: true, }) } diff --git a/server/core/integration.go b/server/core/integration.go index f4b7e60..4359379 100644 --- a/server/core/integration.go +++ b/server/core/integration.go @@ -10,6 +10,7 @@ import ( "polaris/log" "polaris/pkg/notifier" "polaris/pkg/storage" + "strings" "github.com/pkg/errors" ) @@ -55,6 +56,10 @@ func (c *Client) writePlexmatch(seriesId int, episodeId int, targetDir, name str } else { buff.Write(data) } + if strings.Contains(buff.String(), name) { + log.Debugf("already write plex episode line: %v", name) + return nil + } buff.WriteString(fmt.Sprintf("\nep: %d: %s\n", ep.EpisodeNumber, name)) log.Infof("write season plexmatch file content: %s", buff.String()) return st.WriteFile(seasonPlex, buff.Bytes()) diff --git a/server/core/scheduler.go b/server/core/scheduler.go index e3736b2..8171971 100644 --- a/server/core/scheduler.go +++ b/server/core/scheduler.go @@ -116,16 +116,16 @@ func (c *Client) moveCompletedTask(id int) (err1 error) { return err } - //如果种子是路径,则会把路径展开,只移动文件,类似 move dir/* dir2/, 如果种子是文件,则会直接移动文件,类似 move file dir/ - if err := stImpl.Copy(filepath.Join(c.db.GetDownloadDir(), torrentName), r.TargetDir); err != nil { - return errors.Wrap(err, "move file") - } - // .plexmatch file if err := c.writePlexmatch(r.MediaID, r.EpisodeID, r.TargetDir, torrentName); err != nil { log.Errorf("create .plexmatch file error: %v", err) } + //如果种子是路径,则会把路径展开,只移动文件,类似 move dir/* dir2/, 如果种子是文件,则会直接移动文件,类似 move file dir/ + if err := stImpl.Copy(filepath.Join(c.db.GetDownloadDir(), torrentName), r.TargetDir); err != nil { + return errors.Wrap(err, "move file") + } + c.db.SetHistoryStatus(r.ID, history.StatusSuccess) if r.EpisodeID != 0 { c.db.SetEpisodeStatus(r.EpisodeID, episode.StatusDownloaded) diff --git a/server/server.go b/server/server.go index baad115..8fe2492 100644 --- a/server/server.go +++ b/server/server.go @@ -80,6 +80,7 @@ func (s *Server) Serve() error { tv := api.Group("/media") { tv.GET("/search", HttpHandler(s.SearchMedia)) + tv.POST("/edit", HttpHandler(s.EditMediaMetadata)) tv.POST("/tv/watchlist", HttpHandler(s.AddTv2Watchlist)) tv.GET("/tv/watchlist", HttpHandler(s.GetTvWatchlist)) tv.POST("/torrents", HttpHandler(s.SearchAvailableTorrents)) diff --git a/server/setting.go b/server/setting.go index 432573f..3799d8c 100644 --- a/server/setting.go +++ b/server/setting.go @@ -195,8 +195,8 @@ func (s *Server) DeleteDownloadCLient(c *gin.Context) (interface{}, error) { } type episodeMonitoringIn struct { - EpisodeID int `json:"episode_id"` - Monitor bool `json:"monitor"` + EpisodeID int `json:"episode_id"` + Monitor bool `json:"monitor"` } func (s *Server) ChangeEpisodeMonitoring(c *gin.Context) (interface{}, error) { @@ -206,4 +206,16 @@ func (s *Server) ChangeEpisodeMonitoring(c *gin.Context) (interface{}, error) { } s.db.SetEpisodeMonitoring(in.EpisodeID, in.Monitor) return "success", nil +} + +func (s *Server) EditMediaMetadata(c *gin.Context) (interface{}, error) { + var in db.EditMediaData + if err := c.ShouldBindJSON(&in); err != nil { + return nil, errors.Wrap(err, "bind") + } + err := s.db.EditMediaMetadata(in) + if err != nil { + return nil, errors.Wrap(err, "save db") + } + return "success", nil } \ No newline at end of file diff --git a/ui/lib/providers/APIs.dart b/ui/lib/providers/APIs.dart index 18c536c..f8f66fd 100644 --- a/ui/lib/providers/APIs.dart +++ b/ui/lib/providers/APIs.dart @@ -7,6 +7,7 @@ import 'package:ui/providers/server_response.dart'; class APIs { static final _baseUrl = baseUrl(); static final searchUrl = "$_baseUrl/api/v1/media/search"; + static final editMediaUrl = "$_baseUrl/api/v1/media/edit"; static final settingsUrl = "$_baseUrl/api/v1/setting/do"; static final settingsGeneralUrl = "$_baseUrl/api/v1/setting/general"; static final watchlistTvUrl = "$_baseUrl/api/v1/media/tv/watchlist"; diff --git a/ui/lib/providers/series_details.dart b/ui/lib/providers/series_details.dart index cf6e4ec..03460df 100644 --- a/ui/lib/providers/series_details.dart +++ b/ui/lib/providers/series_details.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/server_response.dart'; @@ -34,7 +35,7 @@ class SeriesDetailData Future searchAndDownload( String seriesId, int seasonNum, int episodeNum) async { - final dio = await APIs.getDio(); + final dio = APIs.getDio(); var resp = await dio.post(APIs.searchAndDownloadUrl, data: { "id": int.parse(seriesId), "season": seasonNum, @@ -61,6 +62,22 @@ class SeriesDetailData } ref.invalidateSelf(); } + + Future edit( + String resolution, String targetDir, RangeValues limiter) async { + final dio = APIs.getDio(); + var resp = await dio.post(APIs.editMediaUrl, data: { + "id": int.parse(id!), + "resolution": resolution, + "target_dir": targetDir, + "limiter": {"size_min": limiter.start.toInt(), "size_max": limiter.end.toInt()}, + }); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + ref.invalidateSelf(); + } } class SeriesDetails { @@ -79,6 +96,7 @@ class SeriesDetails { Storage? storage; String? targetDir; bool? downloadHistoryEpisodes; + Limiter? limiter; SeriesDetails( {this.id, @@ -95,7 +113,8 @@ class SeriesDetails { this.mediaType, this.targetDir, this.storage, - this.downloadHistoryEpisodes}); + this.downloadHistoryEpisodes, + this.limiter}); SeriesDetails.fromJson(Map json) { id = json['id']; @@ -118,6 +137,19 @@ class SeriesDetails { episodes!.add(Episodes.fromJson(v)); }); } + if (json["limiter"] != null) { + limiter = Limiter.fromJson(json["limiter"]); + } + } +} + +class Limiter { + int sizeMax; + int sizeMin; + Limiter({required this.sizeMax, required this.sizeMin}); + + factory Limiter.fromJson(Map json) { + return Limiter(sizeMax: json["size_max"], sizeMin: json["size_min"]); } } diff --git a/ui/lib/search_page/submit_dialog.dart b/ui/lib/search_page/submit_dialog.dart index ed35783..615b1e4 100644 --- a/ui/lib/search_page/submit_dialog.dart +++ b/ui/lib/search_page/submit_dialog.dart @@ -125,35 +125,7 @@ class _SubmitSearchResultState extends ConsumerState { }, ), enabledSizedLimiter - ? FormBuilderRangeSlider( - maxValueWidget: (max) => - Text("${sizeMax / 1000} GB"), - minValueWidget: (min) => Text("0"), - valueWidget: (value) { - final sss = value.split(" "); - return Text( - "${readableSize(sss[0])} - ${readableSize(sss[2])}"); - }, - onChangeEnd: (value) { - if (value.end > sizeMax * 0.9) { - setState( - () { - sizeMax = sizeMax * 5; - }, - ); - } else if (value.end < sizeMax * 0.2) { - if (sizeMax > 5000) { - setState( - () { - sizeMax = sizeMax / 5; - }, - ); - } - } - }, - name: "size_limiter", - min: 0, - max: sizeMax) + ? const MyRangeSlider(name: "size_limiter") : const SizedBox(), widget.item.mediaType == "tv" ? SizedBox( @@ -193,7 +165,7 @@ class _SubmitSearchResultState extends ConsumerState { if (_formKey.currentState!.saveAndValidate()) { final values = _formKey.currentState!.value; var f = ref - .read(searchPageDataProvider(widget.query ?? "").notifier) + .read(searchPageDataProvider(widget.query).notifier) .submit2Watchlist( widget.item.id!, values["storage"], @@ -223,3 +195,4 @@ class _SubmitSearchResultState extends ConsumerState { return "$v MB"; } } + diff --git a/ui/lib/welcome_page.dart b/ui/lib/welcome_page.dart index 2eb1e89..5209a39 100644 --- a/ui/lib/welcome_page.dart +++ b/ui/lib/welcome_page.dart @@ -41,53 +41,7 @@ class WelcomePage extends ConsumerWidget { ] : List.generate(value.length, (i) { final item = value[i]; - return Card( - //margin: const EdgeInsets.all(4), - clipBehavior: Clip.hardEdge, - elevation: 5, - 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: [ - SizedBox( - width: 140, - height: 210, - child: Ink.image( - image: NetworkImage( - "${APIs.imagesUrl}/${item.id}/poster.jpg", - )), - ), - SizedBox( - width: 140, - child: Column( - children: [ - LinearProgressIndicator( - value: 1, - color: item.downloadedNum! >= - item.monitoredNum! - ? Colors.green - : Colors.blue, - ), - Text( - item.name!, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - height: 2.5), - ), - ], - )), - ], - ), - )); + return MediaCard(item: item); }), ), ), @@ -95,3 +49,58 @@ class WelcomePage extends ConsumerWidget { }; } } + +class MediaCard extends StatelessWidget { + final MediaDetail item; + + const MediaCard({super.key, required this.item}); + @override + Widget build(BuildContext context) { + return Card( + //margin: const EdgeInsets.all(4), + clipBehavior: Clip.hardEdge, + elevation: 10, + child: InkWell( + //splashColor: Colors.blue.withAlpha(30), + onTap: () { + if (item.mediaType == "movie") { + context.go(MovieDetailsPage.toRoute(item.id!)); + } else { + context.go(TvDetailsPage.toRoute(item.id!)); + } + }, + child: Column( + children: [ + SizedBox( + width: 140, + height: 210, + child: Ink.image( + image: NetworkImage( + "${APIs.imagesUrl}/${item.id}/poster.jpg", + )), + ), + SizedBox( + width: 140, + child: Column( + children: [ + LinearProgressIndicator( + value: 1, + color: item.downloadedNum! >= item.monitoredNum! + ? Colors.green + : Colors.blue, + ), + Text( + item.name!, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + height: 2.5), + ), + ], + )), + ], + ), + )); + } +} diff --git a/ui/lib/widgets/detail_card.dart b/ui/lib/widgets/detail_card.dart index 9baa458..5a084dc 100644 --- a/ui/lib/widgets/detail_card.dart +++ b/ui/lib/widgets/detail_card.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:go_router/go_router.dart'; import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/series_details.dart'; @@ -68,10 +70,12 @@ class _DetailCardState extends ConsumerState { const SizedBox( width: 30, ), - Expanded(child: Text( - "${widget.details.mediaType == "tv" ? widget.details.storage!.tvPath : widget.details.storage!.moviePath}" - "${widget.details.targetDir}"),) - ], + Expanded( + child: Text( + "${widget.details.mediaType == "tv" ? widget.details.storage!.tvPath : widget.details.storage!.moviePath}" + "${widget.details.targetDir}"), + ) + ], ), const Divider(thickness: 1, height: 1), Text( @@ -88,6 +92,7 @@ class _DetailCardState extends ConsumerState { )), Row( children: [ + editIcon(widget.details), deleteIcon(), ], ) @@ -120,4 +125,77 @@ class _DetailCardState extends ConsumerState { icon: const Icon(Icons.delete)), ); } + + Widget editIcon(SeriesDetails details) { + return IconButton( + onPressed: () => showEditDialog(details), icon: const Icon(Icons.edit)); + } + + showEditDialog(SeriesDetails details) { + final _formKey = GlobalKey(); + return showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return AlertDialog( + title: Text("编辑 ${details.name}"), + content: SelectionArea( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.3, + child: SingleChildScrollView( + child: FormBuilder( + key: _formKey, + initialValue: { + "resolution": details.resolution, + "target_dir": details.targetDir, + "limiter": details.limiter != null + ? RangeValues(details.limiter!.sizeMin.toDouble(), + details.limiter!.sizeMax.toDouble()) + : const RangeValues(0, 0) + }, + child: Column( + children: [ + FormBuilderDropdown( + name: "resolution", + decoration: const InputDecoration(labelText: "清晰度"), + items: const [ + DropdownMenuItem(value: "720p", child: Text("720p")), + DropdownMenuItem(value: "1080p", child: Text("1080p")), + DropdownMenuItem(value: "2160p", child: Text("2160p")), + ], + ), + FormBuilderTextField( + name: "target_dir", + validator: FormBuilderValidators.required(), + ), + const MyRangeSlider(name: "limiter"), + ], + ), + )), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("取消")), + TextButton( + onPressed: () { + if (_formKey.currentState!.saveAndValidate()) { + final values = _formKey.currentState!.value; + var f = ref + .read(mediaDetailsProvider(widget.details.id.toString()) + .notifier) + .edit(values["resolution"], values["target_dir"], values["limiter"]) + .then((v) => Navigator.of(context).pop()); + showLoadingWithFuture(f); + + } + }, + child: const Text("确认")) + ], + ); + }, + ); + } } diff --git a/ui/lib/widgets/widgets.dart b/ui/lib/widgets/widgets.dart index 4fdaf43..e5debb9 100644 --- a/ui/lib/widgets/widgets.dart +++ b/ui/lib/widgets/widgets.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:ui/providers/APIs.dart'; class Commons { @@ -86,3 +87,54 @@ showLoadingWithFuture(Future f) { }, ); } + +class MyRangeSlider extends StatefulWidget { + final String name; + const MyRangeSlider({super.key, required this.name}); + + @override + State createState() { + return _MySliderState(); + } +} + +class _MySliderState extends State { + double sizeMax = 5000; + @override + Widget build(BuildContext context) { + return FormBuilderRangeSlider( + maxValueWidget: (max) => Text("${sizeMax / 1000} GB"), + minValueWidget: (min) => Text("0"), + valueWidget: (value) { + final sss = value.split(" "); + return Text("${readableSize(sss[0])} - ${readableSize(sss[2])}"); + }, + onChangeEnd: (value) { + if (value.end > sizeMax * 0.9) { + setState( + () { + sizeMax = sizeMax * 5; + }, + ); + } else if (value.end < sizeMax * 0.2) { + if (sizeMax > 5000) { + setState( + () { + sizeMax = sizeMax / 5; + }, + ); + } + } + }, + name: widget.name, + min: 0, + max: sizeMax); + } + + String readableSize(String v) { + if (v.endsWith("K")) { + return v.replaceAll("K", " GB"); + } + return "$v MB"; + } +}