diff --git a/pkg/doc.go b/pkg/doc.go index b6af485..46968b8 100644 --- a/pkg/doc.go +++ b/pkg/doc.go @@ -7,6 +7,7 @@ type Torrent interface { Start() error Remove() error Save() string + Exists() bool } diff --git a/pkg/transmission/transmission.go b/pkg/transmission/transmission.go index a9f3628..0c1f080 100644 --- a/pkg/transmission/transmission.go +++ b/pkg/transmission/transmission.go @@ -59,6 +59,14 @@ func (t *Torrent) getTorrent() transmissionrpc.Torrent { return r[0] } +func (t *Torrent) Exists() bool { + r, err := t.c.TorrentGetAllFor(context.TODO(), []int64{t.id}) + if err != nil { + log.Errorf("get torrent info for error: %v", err) + } + return len(r) > 0 +} + func (t *Torrent) Name() string { return *t.getTorrent().Name } @@ -91,3 +99,4 @@ func (t *Torrent) Remove() error { func (t *Torrent) Save() string { return strconv.Itoa(int(t.id)) } + diff --git a/server/scheduler.go b/server/scheduler.go index 3dcb264..7e1199d 100644 --- a/server/scheduler.go +++ b/server/scheduler.go @@ -24,6 +24,9 @@ func (s *Server) mustAddCron(spec string, cmd func()) { func (s *Server) checkTasks() { log.Infof("begin check tasks...") for id, t := range s.tasks { + if !t.Exists() { + continue + } log.Infof("task (%s) percentage done: %d%%", t.Name(), t.Progress()) if t.Progress() == 100 { log.Infof("task is done: %v", t.Name()) diff --git a/ui/lib/providers/APIs.dart b/ui/lib/providers/APIs.dart index 4e0e993..584d74f 100644 --- a/ui/lib/providers/APIs.dart +++ b/ui/lib/providers/APIs.dart @@ -13,10 +13,12 @@ class APIs { static final allDownloadClientsUrl = "$_baseUrl/api/v1/downloader"; static final addDownloadClientUrl = "$_baseUrl/api/v1/downloader/add"; static final delDownloadClientUrl = "$_baseUrl/api/v1/downloader/del/"; + static final storageUrl = "$_baseUrl/api/v1/storage/"; static const tmdbImgBaseUrl = "https://image.tmdb.org/t/p/w500/"; static const tmdbApiKey = "tmdb_api_key"; + static const downloadDirKey = "download_dir"; static String baseUrl() { if (kReleaseMode) { diff --git a/ui/lib/providers/series_details.dart b/ui/lib/providers/series_details.dart index a19d8d4..8fe20d7 100644 --- a/ui/lib/providers/series_details.dart +++ b/ui/lib/providers/series_details.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ui/providers/APIs.dart'; -import 'package:ui/server_response.dart'; +import 'package:ui/providers/server_response.dart'; var seriesDetailsProvider = AsyncNotifierProvider.autoDispose .family(SeriesDetailData.new); diff --git a/ui/lib/server_response.dart b/ui/lib/providers/server_response.dart similarity index 100% rename from ui/lib/server_response.dart rename to ui/lib/providers/server_response.dart diff --git a/ui/lib/providers/settings.dart b/ui/lib/providers/settings.dart index 66bfc82..5ab94e3 100644 --- a/ui/lib/providers/settings.dart +++ b/ui/lib/providers/settings.dart @@ -4,10 +4,11 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quiver/strings.dart'; import 'package:ui/providers/APIs.dart'; -import 'package:ui/server_response.dart'; +import 'package:ui/providers/server_response.dart'; -var tmdbApiSettingProvider = - AsyncNotifierProvider(TmdbApiSetting.new); +var settingProvider = + AsyncNotifierProvider.family( + EditSettingData.new); var indexersProvider = AsyncNotifierProvider>(IndexerSetting.new); @@ -16,28 +17,38 @@ var dwonloadClientsProvider = AsyncNotifierProvider>( DownloadClientSetting.new); -class TmdbApiSetting extends AsyncNotifier { +var storageSettingProvider = + AsyncNotifierProvider>( + StorageSettingData.new); + +class EditSettingData extends FamilyAsyncNotifier { + final dio = Dio(); + String? key; + @override - FutureOr build() async { - final dio = Dio(); - var resp = await dio - .get(APIs.settingsUrl, queryParameters: {"key": APIs.tmdbApiKey}); + FutureOr build(String arg) async { + key = arg; + var resp = await dio.get(APIs.settingsUrl, queryParameters: {"key": arg}); var rrr = ServerResponse.fromJson(resp.data); if (rrr.code != 0) { throw rrr.message; } var data = rrr.data as Map; - var key = data[APIs.tmdbApiKey] as String; + var value = data[arg] as String; - return key; + return value; } - Future submitSettings(String v) async { - var resp = await Dio().post(APIs.settingsUrl, data: {APIs.tmdbApiKey: v}); + Future updateSettings(String v) async { + var resp = await dio.post(APIs.settingsUrl, data: { + "key": key, + "value": v, + }); var sp = ServerResponse.fromJson(resp.data as Map); if (sp.code != 0) { throw sp.message; } + ref.invalidateSelf(); } } @@ -189,3 +200,76 @@ class DownloadClient { return data; } } + +class StorageSettingData extends AsyncNotifier> { + final dio = Dio(); + @override + FutureOr> build() async { + var resp = await dio.get(APIs.storageUrl); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + var data = sp.data as List; + List list = List.empty(growable: true); + for (final d in data) { + list.add(Storage.fromJson(d)); + } + return list; + } + + Future deleteStorage(int id) async { + var resp = await dio.delete("${APIs.storageUrl}$id"); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + ref.invalidateSelf(); + } + + Future addStorage(Storage s) async { + var resp = await dio.post(APIs.storageUrl, data: s.toJson()); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + } +} + +class Storage { + Storage({ + this.id, + this.name, + this.implementation, + this.path, + this.user, + this.password, + }); + + final int? id; + final String? name; + final String? implementation; + final String? path; + final String? user; + final String? password; + + factory Storage.fromJson(Map json) { + return Storage( + id: json["id"], + name: json["name"], + implementation: json["implementation"], + path: json["path"], + user: json["user"], + password: json["password"], + ); + } + + Map toJson() => { + "id": id, + "name": name, + "implementation": implementation, + "path": path, + "user": user, + "password": password, + }; +} diff --git a/ui/lib/providers/welcome_data.dart b/ui/lib/providers/welcome_data.dart index b270ccb..80f7616 100644 --- a/ui/lib/providers/welcome_data.dart +++ b/ui/lib/providers/welcome_data.dart @@ -1,7 +1,9 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ui/providers/APIs.dart'; -import 'package:ui/server_response.dart'; +import 'package:ui/providers/server_response.dart'; final welcomePageDataProvider = FutureProvider((ref) async { var resp = await Dio().get(APIs.watchlistUrl); @@ -14,6 +16,96 @@ final welcomePageDataProvider = FutureProvider((ref) async { return favList; }); +var searchPageDataProvider = AsyncNotifierProvider.autoDispose + >(SearchPageData.new); + +class SearchPageData extends AutoDisposeAsyncNotifier> { + final dio = Dio(); + List list = List.empty(growable: true); + + @override + FutureOr> build() async { + return list; + } + + Future submit2Watchlist(int id) async { + var resp = await Dio() + .post(APIs.watchlistUrl, data: {"id": id, "folder": "/downloads"}); + var sp = ServerResponse.fromJson(resp.data); + if (sp.code != 0) { + throw sp.message; + } + ref.invalidate(welcomePageDataProvider); + } + + void queryResults(String q) async { + final dio = Dio(); + var resp = await dio.get(APIs.searchUrl, queryParameters: {"query": q}); + //var dy = jsonDecode(resp.data.toString()); + + print("search page results: ${resp.data}"); + var rsp = ServerResponse.fromJson(resp.data as Map); + if (rsp.code != 0) { + throw rsp.message; + } + + var data = rsp.data as Map; + var results = data["results"] as List; + for (final r in results) { + var res = SearchResult.fromJson(r); + list.add(res); + } + ref.invalidateSelf(); + } +} + +class SearchResult { + String? originalName; + int? id; + String? name; + int? voteCount; + double? voteAverage; + String? posterPath; + String? firstAirDate; + double? popularity; + List? genreIds; + String? originalLanguage; + String? backdropPath; + String? overview; + List? originCountry; + + SearchResult( + {this.originalName, + this.id, + this.name, + this.voteCount, + this.voteAverage, + this.posterPath, + this.firstAirDate, + this.popularity, + this.genreIds, + this.originalLanguage, + this.backdropPath, + this.overview, + this.originCountry}); + + SearchResult.fromJson(Map json) { + originalName = json['original_name']; + id = json['id']; + name = json['name']; + voteCount = json['vote_count']; + voteAverage = json['vote_average']; + posterPath = json['poster_path']; + firstAirDate = json['first_air_date']; + popularity = json['popularity']; + genreIds = json['genre_ids'].cast(); + originalLanguage = json['original_language']; + backdropPath = json['backdrop_path']; + overview = json['overview']; + originCountry = json['origin_country'].cast(); + } +} + class TvSeries { int? id; int? tmdbId; @@ -42,7 +134,3 @@ class TvSeries { posterPath = json["poster_path"]; } } - - - - diff --git a/ui/lib/search.dart b/ui/lib/search.dart index 959dc47..134a81b 100644 --- a/ui/lib/search.dart +++ b/ui/lib/search.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ui/providers/APIs.dart'; import 'package:ui/providers/welcome_data.dart'; -import 'package:ui/server_response.dart'; -import 'package:ui/utils.dart'; class SearchPage extends ConsumerStatefulWidget { const SearchPage({super.key}); @@ -20,76 +17,64 @@ class SearchPage extends ConsumerStatefulWidget { class _SearchPageState extends ConsumerState { List list = List.empty(); - void _queryResults(BuildContext context, String q) async { - final dio = Dio(); - var resp = await dio.get(APIs.searchUrl, queryParameters: {"query": q}); - //var dy = jsonDecode(resp.data.toString()); - - print("search page results: ${resp.data}"); - var rsp = ServerResponse.fromJson(resp.data as Map); - if (rsp.code != 0 && context.mounted) { - Utils.showAlertDialog(context, rsp.message); - return; - } - - var data = rsp.data as Map; - var results = data["results"] as List; - - setState(() { - list = results; - }); - } - @override Widget build(BuildContext context) { - var cards = List.empty(growable: true); - for (final item in list) { - var m = SearchResult.fromJson(item); - cards.add(Card( - margin: const EdgeInsets.all(4), - clipBehavior: Clip.hardEdge, - child: InkWell( - //splashColor: Colors.blue.withAlpha(30), - onTap: () { - //showDialog(context: context, builder: builder) - _showSubmitDialog(context, m); - }, - child: Row( - children: [ - Flexible( - child: SizedBox( - width: 150, - height: 200, - child: Image.network( - APIs.tmdbImgBaseUrl + m.posterPath!, - fit: BoxFit.contain, - ), - ), - ), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "${m.name} (${m.firstAirDate?.split("-")[0]})", - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.bold), + var searchList = ref.watch(searchPageDataProvider); + + List res = searchList.when( + data: (data) { + var cards = List.empty(growable: true); + for (final item in data) { + cards.add(Card( + margin: const EdgeInsets.all(4), + clipBehavior: Clip.hardEdge, + child: InkWell( + //splashColor: Colors.blue.withAlpha(30), + onTap: () { + //showDialog(context: context, builder: builder) + _showSubmitDialog(context, item); + }, + child: Row( + children: [ + Flexible( + child: SizedBox( + width: 150, + height: 200, + child: Image.network( + APIs.tmdbImgBaseUrl + item.posterPath!, + fit: BoxFit.contain, + ), + ), ), - const Text(""), - Text(m.overview!) + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${item.name} (${item.firstAirDate?.split("-")[0]})", + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.bold), + ), + const Text(""), + Text(item.overview!) + ], + ), + ) ], ), - ) - ], - ), - ))); - } - + ))); + } + return cards; + }, + error: (err, trace) => [Text("$err")], + loading: () => [const CircularProgressIndicator()]); return Column( children: [ TextField( autofocus: true, - onSubmitted: (value) => _queryResults(context, value), + onSubmitted: (value) { + ref.read(searchPageDataProvider.notifier).queryResults(value); + }, decoration: const InputDecoration( labelText: "搜索", hintText: "搜索剧集名称", @@ -97,7 +82,7 @@ class _SearchPageState extends ConsumerState { ), Expanded( child: ListView( - children: cards, + children: res, )) ], ); @@ -126,7 +111,9 @@ class _SearchPageState extends ConsumerState { ), child: const Text('确定'), onPressed: () { - _submit2Watchlist(context, item.id!); + ref + .read(searchPageDataProvider.notifier) + .submit2Watchlist(item.id!); Navigator.of(context).pop(); }, ), @@ -134,16 +121,6 @@ class _SearchPageState extends ConsumerState { ); }); } - - void _submit2Watchlist(BuildContext context, int id) async { - var resp = await Dio() - .post(APIs.watchlistUrl, data: {"id": id, "folder": "/downloads"}); - var sp = ServerResponse.fromJson(resp.data); - if (sp.code != 0 && context.mounted) { - Utils.showAlertDialog(context, sp.message); - } - ref.refresh(welcomePageDataProvider); - } } class SearchBarApp extends StatefulWidget { @@ -184,50 +161,3 @@ class _SearchBarAppState extends State { }); } } - -class SearchResult { - String? originalName; - int? id; - String? name; - int? voteCount; - double? voteAverage; - String? posterPath; - String? firstAirDate; - double? popularity; - List? genreIds; - String? originalLanguage; - String? backdropPath; - String? overview; - List? originCountry; - - SearchResult( - {this.originalName, - this.id, - this.name, - this.voteCount, - this.voteAverage, - this.posterPath, - this.firstAirDate, - this.popularity, - this.genreIds, - this.originalLanguage, - this.backdropPath, - this.overview, - this.originCountry}); - - SearchResult.fromJson(Map json) { - originalName = json['original_name']; - id = json['id']; - name = json['name']; - voteCount = json['vote_count']; - voteAverage = json['vote_average']; - posterPath = json['poster_path']; - firstAirDate = json['first_air_date']; - popularity = json['popularity']; - genreIds = json['genre_ids'].cast(); - originalLanguage = json['original_language']; - backdropPath = json['backdrop_path']; - overview = json['overview']; - originCountry = json['origin_country'].cast(); - } -} diff --git a/ui/lib/system_settings.dart b/ui/lib/system_settings.dart index 63754c1..a66f629 100644 --- a/ui/lib/system_settings.dart +++ b/ui/lib/system_settings.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quiver/strings.dart'; import 'package:ui/providers/settings.dart'; import 'package:ui/utils.dart'; +import 'providers/APIs.dart'; + class SystemSettingsPage extends ConsumerStatefulWidget { static const route = "/settings"; @@ -18,73 +21,96 @@ class _SystemSettingsPageState extends ConsumerState { Future? _pendingTmdb; Future? _pendingIndexer; Future? _pendingDownloadClient; + Future? _pendingStorage; + final _tmdbApiController = TextEditingController(); + final _downloadDirController = TextEditingController(); @override Widget build(BuildContext context) { - var key = ref.watch(tmdbApiSettingProvider); + var tmdbKey = ref.watch(settingProvider(APIs.tmdbApiKey)); + var dirKey = ref.watch(settingProvider(APIs.downloadDirKey)); + var tmdbSetting = FutureBuilder( // We listen to the pending operation, to update the UI accordingly. future: _pendingTmdb, builder: (context, snapshot) { - return key.when( - data: (value) => Container( - padding: const EdgeInsets.fromLTRB(40, 10, 40, 0), - child: Form( - key: _formKey, //设置globalKey,用于后面获取FormState - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - children: [ - TextFormField( - autofocus: true, - initialValue: value, - decoration: const InputDecoration( - labelText: "TMDB Api Key", - icon: Icon(Icons.key), - ), - // - validator: (v) { - return v!.trim().isNotEmpty - ? null - : "ApiKey 不能为空"; - }, - onSaved: (newValue) { - var furture = ref - .read(tmdbApiSettingProvider.notifier) - .submitSettings(newValue!); - setState(() { - _pendingTmdb = furture; - }); - if (!showError(snapshot)) { - Navigator.of(context).pop(); - } - }, + return Container( + padding: const EdgeInsets.fromLTRB(40, 10, 40, 0), + child: Form( + key: _formKey, //设置globalKey,用于后面获取FormState + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + tmdbKey.when( + data: (value) { + _tmdbApiController.text = value; + return TextFormField( + autofocus: true, + controller: _tmdbApiController, + decoration: const InputDecoration( + labelText: "TMDB Api Key", + icon: Icon(Icons.key), ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 28.0), - child: ElevatedButton( - child: const Padding( - padding: EdgeInsets.all(16.0), - child: Text("保存"), - ), - - onPressed: () { - // 通过_formKey.currentState 获取FormState后, - // 调用validate()方法校验用户名密码是否合法,校验 - // 通过后再提交数据。 - if ((_formKey.currentState as FormState) - .validate()) { - (_formKey.currentState as FormState).save(); - } - }, - ), - ), - ) - ], + // + validator: (v) { + return v!.trim().isNotEmpty ? null : "ApiKey 不能为空"; + }, + onSaved: (newValue) {}, + ); + }, + error: (err, trace) => Text("$err"), + loading: () => const CircularProgressIndicator()), + dirKey.when( + data: (data) { + _downloadDirController.text = data; + return TextFormField( + autofocus: true, + controller: _downloadDirController, + decoration: const InputDecoration( + labelText: "下载路径", + icon: Icon(Icons.folder), + ), + // + validator: (v) { + return v!.trim().isNotEmpty ? null : "ApiKey 不能为空"; + }, + onSaved: (newValue) {}, + ); + }, + error: (err, trace) => Text("$err"), + loading: () => const CircularProgressIndicator()), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 28.0), + child: ElevatedButton( + child: const Padding( + padding: EdgeInsets.all(16.0), + child: Text("保存"), + ), + onPressed: () { + // 通过_formKey.currentState 获取FormState后, + // 调用validate()方法校验用户名密码是否合法,校验 + // 通过后再提交数据。 + if ((_formKey.currentState as FormState).validate()) { + var furture = ref + .read(settingProvider(APIs.tmdbApiKey).notifier) + .updateSettings(_tmdbApiController.text); + ref + .read(settingProvider(APIs.downloadDirKey) + .notifier) + .updateSettings(_downloadDirController.text); + setState(() { + _pendingTmdb = furture; + }); + showError(snapshot); + } + }, ), ), - ), - error: (err, trace) => Text("$err"), - loading: () => const CircularProgressIndicator()); + ) + ], + ), + ), + ); }); var indexers = ref.watch(indexersProvider); @@ -171,6 +197,47 @@ class _SystemSettingsPageState extends ConsumerState { loading: () => const CircularProgressIndicator()); }); + var storageSettingData = ref.watch(storageSettingProvider); + var storageSetting = FutureBuilder( + // We listen to the pending operation, to update the UI accordingly. + future: _pendingStorage, + builder: (context, snapshot) { + return storageSettingData.when( + data: (value) => GridView.builder( + itemCount: value.length + 1, + scrollDirection: Axis.vertical, + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6), + itemBuilder: (context, i) { + if (i < value.length) { + var storage = value[i]; + return Card( + margin: const EdgeInsets.all(4), + clipBehavior: Clip.hardEdge, + child: InkWell( + //splashColor: Colors.blue.withAlpha(30), + onTap: () { + showStorageDetails(snapshot, context, storage); + }, + child: Center(child: Text(storage.name!)))); + } + return Card( + margin: const EdgeInsets.all(4), + clipBehavior: Clip.hardEdge, + child: InkWell( + //splashColor: Colors.blue.withAlpha(30), + onTap: () { + showStorageDetails(snapshot, context, Storage()); + }, + child: const Center( + child: Icon(Icons.add), + ))); + }), + error: (err, trace) => Text("$err"), + loading: () => const CircularProgressIndicator()); + }); + return ListView( children: [ ExpansionTile( @@ -194,6 +261,13 @@ class _SystemSettingsPageState extends ConsumerState { title: const Text("下载客户端设置"), children: [downloadSetting], ), + ExpansionTile( + tilePadding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + childrenPadding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + initiallyExpanded: true, + title: const Text("存储设置"), + children: [storageSetting], + ), ], ); } @@ -332,6 +406,89 @@ class _SystemSettingsPageState extends ConsumerState { }); } + Future showStorageDetails( + AsyncSnapshot snapshot, BuildContext context, Storage s) { + var nameController = TextEditingController(text: s.name); + var implController = TextEditingController( + text: isBlank(s.implementation) ? "transmission" : s.implementation); + var pathController = TextEditingController(text: s.path); + var userController = TextEditingController(text: s.user); + var passController = TextEditingController(text: s.password); + + return showDialog( + context: context, + barrierDismissible: true, // user must tap button! + builder: (BuildContext context) { + return AlertDialog( + title: const Text('存储'), + content: SingleChildScrollView( + child: ListBody( + children: [ + TextField( + decoration: const InputDecoration(labelText: "名称"), + controller: nameController, + ), + TextField( + decoration: const InputDecoration(labelText: "实现"), + controller: implController, + ), + TextField( + decoration: const InputDecoration(labelText: "路径"), + controller: pathController, + ), + TextField( + decoration: const InputDecoration(labelText: "用户"), + controller: userController, + ), + TextField( + decoration: const InputDecoration(labelText: "密码"), + controller: passController, + ), + ], + ), + ), + actions: [ + s.id == null + ? const Text("") + : TextButton( + onPressed: () { + var f = ref + .read(storageSettingProvider.notifier) + .deleteStorage(s.id!); + setState(() { + _pendingStorage = f; + }); + if (!showError(snapshot)) { + Navigator.of(context).pop(); + } + }, + child: const Text('删除')), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消')), + TextButton( + child: const Text('确定'), + onPressed: () { + var f = ref.read(storageSettingProvider.notifier).addStorage( + Storage( + name: nameController.text, + implementation: implController.text, + path: pathController.text, + user: userController.text, + password: passController.text)); + setState(() { + _pendingStorage = f; + }); + if (!showError(snapshot)) { + Navigator.of(context).pop(); + } + }, + ), + ], + ); + }); + } + bool showError(AsyncSnapshot snapshot) { final isErrored = snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;