diff --git a/cmd/api/main.go b/cmd/api/main.go index fbbb7d174..c9ad67594 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -8,9 +8,10 @@ import ( // only for local development func main() { cfg := &model.StartConfig{ - Network: "tcp", - Address: "127.0.0.1:9999", - Storage: model.StorageBolt, + Network: "tcp", + Address: "127.0.0.1:9999", + Storage: model.StorageBolt, + WebEnable: true, } cmd.Start(cfg) } diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index a31929e12..f8bd64c01 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -57,6 +57,15 @@ func (m *FetcherMeta) SingleFilepath() string { return path.Join(m.Opts.Path, file.Path, fileName) } +// RootDirPath return the root dir path of the task file. +func (m *FetcherMeta) RootDirPath() string { + if m.Res.Name != "" { + return m.FolderPath() + } else { + return m.Opts.Path + } +} + // FetcherBuilder defines the interface for a fetcher builder. type FetcherBuilder interface { // Schemes returns the schemes supported by the fetcher. diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index d1225ba9a..85674ad13 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -540,18 +540,7 @@ func (d *Downloader) GetTask(id string) *Task { } func (d *Downloader) GetTasks() []*Task { - tasks := make([]*Task, len(d.tasks)) - for i := 0; i < len(d.tasks); i++ { - tasks[i] = d.tasks[i] - } - // sort tasks by status, order by Status(if(running,1,0)) desc, CreatedAt desc - sort.Slice(tasks, func(i, j int) bool { - if tasks[i].Status != tasks[j].Status { - return tasks[i].Status == base.DownloadStatusRunning - } - return tasks[i].CreatedAt.After(tasks[j].CreatedAt) - }) - return tasks + return d.tasks } func (d *Downloader) GetConfig() (*DownloaderStoreConfig, error) { diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 326a9e596..695706d56 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -105,9 +105,10 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) - r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) r.Path("/api/v1/proxy").HandlerFunc(DoProxy) if startCfg.WebEnable { + r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem))) + r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS))) } @@ -138,30 +139,56 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error return srv, listener, nil } -// handle extension file resource -type extensionFileSystem struct { -} - -func (e *extensionFileSystem) Open(name string) (http.File, error) { +func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { // remove prefix - path := strings.TrimPrefix(name, "/fs/extensions") + clearPath := strings.TrimPrefix(urlPath, prefix) // match extension identity, eg: /fs/extensions/identity/xxx reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) - if !reg.MatchString(path) { - return nil, os.ErrNotExist + if !reg.MatchString(clearPath) { + err = os.ErrNotExist + return } - matched := reg.FindStringSubmatch(path) + matched := reg.FindStringSubmatch(clearPath) if len(matched) != 3 { + err = os.ErrNotExist + return + } + return matched[1], matched[2], nil +} + +// handle task file resource +type taskFileSystem struct { +} + +func (e *taskFileSystem) Open(name string) (http.File, error) { + // get extension identity + identity, path, err := resolvePath(name, "/fs/tasks") + if err != nil { + return nil, err + } + task := Downloader.GetTask(identity) + if task == nil { return nil, os.ErrNotExist } + return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) +} + +// handle extension file resource +type extensionFileSystem struct { +} + +func (e *extensionFileSystem) Open(name string) (http.File, error) { // get extension identity - identity := matched[1] + identity, path, err := resolvePath(name, "/fs/extensions") + if err != nil { + return nil, err + } extension, err := Downloader.GetExtension(identity) if err != nil { return nil, os.ErrNotExist } extensionPath := Downloader.ExtensionPath(extension) - return os.Open(filepath.Join(extensionPath, matched[2])) + return os.Open(filepath.Join(extensionPath, path)) } func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 0d4150e68..894f10165 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -491,6 +491,7 @@ func doTest(handler func()) { cfg.Init() cfg.Storage = storage cfg.StorageDir = ".test_storage" + cfg.WebEnable = true fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { diff --git a/ui/flutter/lib/app/modules/task/controllers/task_downloaded_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_downloaded_controller.dart index 6f4dfd113..c33a6eb14 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_downloaded_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_downloaded_controller.dart @@ -1,8 +1,7 @@ - import 'package:gopeed/app/modules/task/controllers/task_list_controller.dart'; import '../../../../api/model/task.dart'; class TaskDownloadedController extends TaskListController { - TaskDownloadedController() : super([Status.done]); + TaskDownloadedController() : super([Status.done], SortDirection.asc); } diff --git a/ui/flutter/lib/app/modules/task/controllers/task_downloading_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_downloading_controller.dart index f430c2e94..f41011428 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_downloading_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_downloading_controller.dart @@ -9,5 +9,5 @@ class TaskDownloadingController extends TaskListController { Status.pause, Status.wait, Status.error - ]); + ], SortDirection.desc); } diff --git a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart index ac2ca1f15..3fba10117 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart @@ -5,10 +5,13 @@ import 'package:get/get.dart'; import '../../../../api/api.dart'; import '../../../../api/model/task.dart'; +enum SortDirection { asc, desc } + abstract class TaskListController extends GetxController { List statuses; + SortDirection sortDirection; - TaskListController(this.statuses); + TaskListController(this.statuses, this.sortDirection); final tasks = [].obs; final isRunning = false.obs; @@ -43,6 +46,15 @@ abstract class TaskListController extends GetxController { } getTasksState() async { - tasks.value = await getTasks(statuses); + final tasks = await getTasks(statuses); + // sort tasks by create time + tasks.sort((a, b) { + if (sortDirection == SortDirection.asc) { + return a.createdAt.compareTo(b.createdAt); + } else { + return b.createdAt.compareTo(a.createdAt); + } + }); + this.tasks.value = tasks; } } diff --git a/ui/flutter/lib/app/modules/task/views/task_files_view.dart b/ui/flutter/lib/app/modules/task/views/task_files_view.dart index ed8c3421b..548ac0d5c 100644 --- a/ui/flutter/lib/app/modules/task/views/task_files_view.dart +++ b/ui/flutter/lib/app/modules/task/views/task_files_view.dart @@ -3,7 +3,10 @@ import 'package:get/get.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../../api/api.dart' as api; +import '../../../../util/browser_download/browser_download.dart'; import '../../../../util/file_icon.dart'; import '../../../../util/icons.dart'; import '../../../../util/util.dart'; @@ -21,7 +24,7 @@ class TaskFilesView extends GetView { icon: const Icon(Icons.arrow_back), onPressed: () => Get.rootDelegate.popRoute()), // actions: [], - title: Obx(() => Text(controller.task.value!.meta.res!.name)), + title: Obx(() => Text(controller.task.value?.meta.res?.name ?? "")), ), body: Obx(() { final fileList = controller.fileList; @@ -53,11 +56,12 @@ class TaskFilesView extends GetView { itemBuilder: (context, index) { final meta = controller.task.value!.meta; final file = fileList[index]; - final filePath = Util.safePathJoin([ - meta.opts.path, - meta.res!.name, - file.filePath(meta.opts.name) - ]); + // if resource is single file, use opts.name as file name + final realFileName = + meta.res!.name.isEmpty ? file.name : ""; + final fileRelativePath = file.filePath(realFileName); + final filePath = Util.safePathJoin( + [meta.opts.path, meta.res!.name, fileRelativePath]); final fileName = basename(filePath); return ListTile( leading: file.isDirectory @@ -77,19 +81,39 @@ class TaskFilesView extends GetView { width: 100, child: Row( mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: const Icon(Icons.play_circle), - onPressed: () { - OpenFile.open(filePath); - }), - IconButton( - icon: const Icon(Icons.share), - onPressed: () { - final xfile = XFile(filePath); - Share.shareXFiles([xfile]); - }) - ], + children: Util.isWeb() + ? () { + final accessUrl = api.join( + "/fs/tasks/${controller.task.value!.id}$fileRelativePath"); + return [ + IconButton( + icon: + const Icon(Icons.play_circle), + onPressed: () { + launchUrl(Uri.parse(accessUrl), + webOnlyWindowName: + "_blank"); + }), + IconButton( + icon: const Icon(Icons.download), + onPressed: () { + download(accessUrl, fileName); + }) + ]; + }() + : [ + IconButton( + icon: const Icon(Icons.play_circle), + onPressed: () { + OpenFile.open(filePath); + }), + IconButton( + icon: const Icon(Icons.share), + onPressed: () { + final xfile = XFile(filePath); + Share.shareXFiles([xfile]); + }) + ], ), ), onTap: () { diff --git a/ui/flutter/lib/app/modules/task/views/task_view.dart b/ui/flutter/lib/app/modules/task/views/task_view.dart index 18a439999..d99f2fc9e 100644 --- a/ui/flutter/lib/app/modules/task/views/task_view.dart +++ b/ui/flutter/lib/app/modules/task/views/task_view.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:gopeed/app/modules/task/controllers/task_downloading_controller.dart'; -import '../controllers/task_downloaded_controller.dart'; -import 'task_downloading_view.dart'; -import 'task_downloaded_view.dart'; import '../controllers/task_controller.dart'; +import '../controllers/task_downloaded_controller.dart'; +import '../controllers/task_downloading_controller.dart'; +import 'task_downloaded_view.dart'; +import 'task_downloading_view.dart'; class TaskView extends GetView { const TaskView({Key? key}) : super(key: key); diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index 01e103277..c6804425a 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:gopeed/api/model/meta.dart'; import 'package:path/path.dart' as path; import 'package:styled_widget/styled_widget.dart'; import '../../api/api.dart'; +import '../../api/model/meta.dart'; import '../../api/model/task.dart'; import '../../util/file_explorer.dart'; import '../../util/file_icon.dart'; @@ -110,19 +110,17 @@ class BuildTaskListView extends GetView { List buildActions() { final list = []; if (isDone()) { - if (Util.isDesktop() || Util.isMobile()) { - list.add(IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () { - if (Util.isDesktop()) { - FileExplorer.openAndSelectFile(buildExplorerUrl(task)); - } else { - Get.rootDelegate - .toNamed(Routes.TASK_FILES, parameters: {'id': task.id}); - } - }, - )); - } + list.add(IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () { + if (Util.isDesktop()) { + FileExplorer.openAndSelectFile(buildExplorerUrl(task)); + } else { + Get.rootDelegate + .toNamed(Routes.TASK_FILES, parameters: {'id': task.id}); + } + }, + )); } else { if (isRunning()) { list.add(IconButton( diff --git a/ui/flutter/lib/util/browser_download/browser_download.dart b/ui/flutter/lib/util/browser_download/browser_download.dart new file mode 100644 index 000000000..57b6a5ed3 --- /dev/null +++ b/ui/flutter/lib/util/browser_download/browser_download.dart @@ -0,0 +1,4 @@ +import 'browser_download_stub.dart' + if (dart.library.html) 'entry/browser_download_browser.dart'; + +void download(String url, String name) => doDownload(url, name); diff --git a/ui/flutter/lib/util/browser_download/browser_download_stub.dart b/ui/flutter/lib/util/browser_download/browser_download_stub.dart new file mode 100644 index 000000000..4b5e03a09 --- /dev/null +++ b/ui/flutter/lib/util/browser_download/browser_download_stub.dart @@ -0,0 +1 @@ +void doDownload(String url, String name) => throw UnimplementedError(); diff --git a/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart b/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart new file mode 100644 index 000000000..00619828f --- /dev/null +++ b/ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart @@ -0,0 +1,9 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html; + +void doDownload(String url, String name) { + final anchorElement = html.AnchorElement(href: url); + anchorElement.download = name; + anchorElement.target = '_blank'; + anchorElement.click(); +}