feat: edit media details

This commit is contained in:
Simon Ding
2024-08-06 23:00:56 +08:00
parent 8ab33f3d54
commit 466596345d
12 changed files with 276 additions and 101 deletions

View File

@@ -11,6 +11,7 @@ import (
"polaris/ent/history" "polaris/ent/history"
"polaris/ent/indexers" "polaris/ent/indexers"
"polaris/ent/media" "polaris/ent/media"
"polaris/ent/schema"
"polaris/ent/settings" "polaris/ent/settings"
"polaris/ent/storage" "polaris/ent/storage"
"polaris/log" "polaris/log"
@@ -249,7 +250,6 @@ type TorznabSetting struct {
ApiKey string `json:"api_key"` ApiKey string `json:"api_key"`
} }
func (c *Client) SaveIndexer(in *ent.Indexers) error { func (c *Client) SaveIndexer(in *ent.Indexers) error {
if in.ID != 0 { if in.ID != 0 {
@@ -290,6 +290,7 @@ func (c *Client) GetIndexer(id int) (*TorznabInfo, error) {
} }
return &TorznabInfo{Indexers: res, TorznabSetting: ss}, nil return &TorznabInfo{Indexers: res, TorznabSetting: ss}, nil
} }
type TorznabInfo struct { type TorznabInfo struct {
*ent.Indexers *ent.Indexers
TorznabSetting TorznabSetting
@@ -371,7 +372,6 @@ func (s *StorageInfo) ToWebDavSetting() WebdavSetting {
} }
} }
type WebdavSetting struct { type WebdavSetting struct {
URL string `json:"url"` URL string `json:"url"`
User string `json:"user"` User string `json:"user"`
@@ -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()) return c.ent.DownloadClients.Query().Where(downloadclients.ID(id)).First(context.Background())
} }
func (c *Client) SetEpisodeMonitoring(id int, b bool) error { func (c *Client) SetEpisodeMonitoring(id int, b bool) error {
return c.ent.Episode.Update().Where(episode.ID(id)).SetMonitored(b).Exec(context.Background()) return c.ent.Episode.Update().Where(episode.ID(id)).SetMonitored(b).Exec(context.Background())
} }
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())
}

View File

@@ -26,6 +26,7 @@ func init() {
MaxSize: 50, // megabytes MaxSize: 50, // megabytes
MaxBackups: 3, MaxBackups: 3,
MaxAge: 30, // days MaxAge: 30, // days
Compress: true,
}) })
} }

View File

@@ -10,6 +10,7 @@ import (
"polaris/log" "polaris/log"
"polaris/pkg/notifier" "polaris/pkg/notifier"
"polaris/pkg/storage" "polaris/pkg/storage"
"strings"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -55,6 +56,10 @@ func (c *Client) writePlexmatch(seriesId int, episodeId int, targetDir, name str
} else { } else {
buff.Write(data) 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)) buff.WriteString(fmt.Sprintf("\nep: %d: %s\n", ep.EpisodeNumber, name))
log.Infof("write season plexmatch file content: %s", buff.String()) log.Infof("write season plexmatch file content: %s", buff.String())
return st.WriteFile(seasonPlex, buff.Bytes()) return st.WriteFile(seasonPlex, buff.Bytes())

View File

@@ -116,16 +116,16 @@ func (c *Client) moveCompletedTask(id int) (err1 error) {
return err 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 // .plexmatch file
if err := c.writePlexmatch(r.MediaID, r.EpisodeID, r.TargetDir, torrentName); err != nil { if err := c.writePlexmatch(r.MediaID, r.EpisodeID, r.TargetDir, torrentName); err != nil {
log.Errorf("create .plexmatch file error: %v", err) 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) c.db.SetHistoryStatus(r.ID, history.StatusSuccess)
if r.EpisodeID != 0 { if r.EpisodeID != 0 {
c.db.SetEpisodeStatus(r.EpisodeID, episode.StatusDownloaded) c.db.SetEpisodeStatus(r.EpisodeID, episode.StatusDownloaded)

View File

@@ -80,6 +80,7 @@ func (s *Server) Serve() error {
tv := api.Group("/media") tv := api.Group("/media")
{ {
tv.GET("/search", HttpHandler(s.SearchMedia)) tv.GET("/search", HttpHandler(s.SearchMedia))
tv.POST("/edit", HttpHandler(s.EditMediaMetadata))
tv.POST("/tv/watchlist", HttpHandler(s.AddTv2Watchlist)) tv.POST("/tv/watchlist", HttpHandler(s.AddTv2Watchlist))
tv.GET("/tv/watchlist", HttpHandler(s.GetTvWatchlist)) tv.GET("/tv/watchlist", HttpHandler(s.GetTvWatchlist))
tv.POST("/torrents", HttpHandler(s.SearchAvailableTorrents)) tv.POST("/torrents", HttpHandler(s.SearchAvailableTorrents))

View File

@@ -207,3 +207,15 @@ func (s *Server) ChangeEpisodeMonitoring(c *gin.Context) (interface{}, error) {
s.db.SetEpisodeMonitoring(in.EpisodeID, in.Monitor) s.db.SetEpisodeMonitoring(in.EpisodeID, in.Monitor)
return "success", nil 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
}

View File

@@ -7,6 +7,7 @@ import 'package:ui/providers/server_response.dart';
class APIs { class APIs {
static final _baseUrl = baseUrl(); static final _baseUrl = baseUrl();
static final searchUrl = "$_baseUrl/api/v1/media/search"; 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 settingsUrl = "$_baseUrl/api/v1/setting/do";
static final settingsGeneralUrl = "$_baseUrl/api/v1/setting/general"; static final settingsGeneralUrl = "$_baseUrl/api/v1/setting/general";
static final watchlistTvUrl = "$_baseUrl/api/v1/media/tv/watchlist"; static final watchlistTvUrl = "$_baseUrl/api/v1/media/tv/watchlist";

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/server_response.dart'; import 'package:ui/providers/server_response.dart';
@@ -34,7 +35,7 @@ class SeriesDetailData
Future<String> searchAndDownload( Future<String> searchAndDownload(
String seriesId, int seasonNum, int episodeNum) async { String seriesId, int seasonNum, int episodeNum) async {
final dio = await APIs.getDio(); final dio = APIs.getDio();
var resp = await dio.post(APIs.searchAndDownloadUrl, data: { var resp = await dio.post(APIs.searchAndDownloadUrl, data: {
"id": int.parse(seriesId), "id": int.parse(seriesId),
"season": seasonNum, "season": seasonNum,
@@ -61,6 +62,22 @@ class SeriesDetailData
} }
ref.invalidateSelf(); ref.invalidateSelf();
} }
Future<void> 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 { class SeriesDetails {
@@ -79,6 +96,7 @@ class SeriesDetails {
Storage? storage; Storage? storage;
String? targetDir; String? targetDir;
bool? downloadHistoryEpisodes; bool? downloadHistoryEpisodes;
Limiter? limiter;
SeriesDetails( SeriesDetails(
{this.id, {this.id,
@@ -95,7 +113,8 @@ class SeriesDetails {
this.mediaType, this.mediaType,
this.targetDir, this.targetDir,
this.storage, this.storage,
this.downloadHistoryEpisodes}); this.downloadHistoryEpisodes,
this.limiter});
SeriesDetails.fromJson(Map<String, dynamic> json) { SeriesDetails.fromJson(Map<String, dynamic> json) {
id = json['id']; id = json['id'];
@@ -118,6 +137,19 @@ class SeriesDetails {
episodes!.add(Episodes.fromJson(v)); 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<String, dynamic> json) {
return Limiter(sizeMax: json["size_max"], sizeMin: json["size_min"]);
} }
} }

View File

@@ -125,35 +125,7 @@ class _SubmitSearchResultState extends ConsumerState<SubmitSearchResult> {
}, },
), ),
enabledSizedLimiter enabledSizedLimiter
? FormBuilderRangeSlider( ? const MyRangeSlider(name: "size_limiter")
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 SizedBox(), : const SizedBox(),
widget.item.mediaType == "tv" widget.item.mediaType == "tv"
? SizedBox( ? SizedBox(
@@ -193,7 +165,7 @@ class _SubmitSearchResultState extends ConsumerState<SubmitSearchResult> {
if (_formKey.currentState!.saveAndValidate()) { if (_formKey.currentState!.saveAndValidate()) {
final values = _formKey.currentState!.value; final values = _formKey.currentState!.value;
var f = ref var f = ref
.read(searchPageDataProvider(widget.query ?? "").notifier) .read(searchPageDataProvider(widget.query).notifier)
.submit2Watchlist( .submit2Watchlist(
widget.item.id!, widget.item.id!,
values["storage"], values["storage"],
@@ -223,3 +195,4 @@ class _SubmitSearchResultState extends ConsumerState<SubmitSearchResult> {
return "$v MB"; return "$v MB";
} }
} }

View File

@@ -41,14 +41,29 @@ class WelcomePage extends ConsumerWidget {
] ]
: List.generate(value.length, (i) { : List.generate(value.length, (i) {
final item = value[i]; final item = value[i];
return MediaCard(item: item);
}),
),
),
_ => const MyProgressIndicator(),
};
}
}
class MediaCard extends StatelessWidget {
final MediaDetail item;
const MediaCard({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Card( return Card(
//margin: const EdgeInsets.all(4), //margin: const EdgeInsets.all(4),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
elevation: 5, elevation: 10,
child: InkWell( child: InkWell(
//splashColor: Colors.blue.withAlpha(30), //splashColor: Colors.blue.withAlpha(30),
onTap: () { onTap: () {
if (uri == routeMoivie) { if (item.mediaType == "movie") {
context.go(MovieDetailsPage.toRoute(item.id!)); context.go(MovieDetailsPage.toRoute(item.id!));
} else { } else {
context.go(TvDetailsPage.toRoute(item.id!)); context.go(TvDetailsPage.toRoute(item.id!));
@@ -70,8 +85,7 @@ class WelcomePage extends ConsumerWidget {
children: [ children: [
LinearProgressIndicator( LinearProgressIndicator(
value: 1, value: 1,
color: item.downloadedNum! >= color: item.downloadedNum! >= item.monitoredNum!
item.monitoredNum!
? Colors.green ? Colors.green
: Colors.blue, : Colors.blue,
), ),
@@ -88,10 +102,5 @@ class WelcomePage extends ConsumerWidget {
], ],
), ),
)); ));
}),
),
),
_ => const MyProgressIndicator(),
};
} }
} }

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:go_router/go_router.dart';
import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/APIs.dart';
import 'package:ui/providers/series_details.dart'; import 'package:ui/providers/series_details.dart';
@@ -68,9 +70,11 @@ class _DetailCardState extends ConsumerState<DetailCard> {
const SizedBox( const SizedBox(
width: 30, width: 30,
), ),
Expanded(child: Text( Expanded(
child: Text(
"${widget.details.mediaType == "tv" ? widget.details.storage!.tvPath : widget.details.storage!.moviePath}" "${widget.details.mediaType == "tv" ? widget.details.storage!.tvPath : widget.details.storage!.moviePath}"
"${widget.details.targetDir}"),) "${widget.details.targetDir}"),
)
], ],
), ),
const Divider(thickness: 1, height: 1), const Divider(thickness: 1, height: 1),
@@ -88,6 +92,7 @@ class _DetailCardState extends ConsumerState<DetailCard> {
)), )),
Row( Row(
children: [ children: [
editIcon(widget.details),
deleteIcon(), deleteIcon(),
], ],
) )
@@ -120,4 +125,77 @@ class _DetailCardState extends ConsumerState<DetailCard> {
icon: const Icon(Icons.delete)), 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<FormBuilderState>();
return showDialog<void>(
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("确认"))
],
);
},
);
}
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/APIs.dart';
class Commons { 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<StatefulWidget> createState() {
return _MySliderState();
}
}
class _MySliderState extends State<MyRangeSlider> {
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";
}
}