From a5d8713ff757c63a5e41844903b9333f13785d7d Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 28 Jul 2023 21:48:20 +0800 Subject: [PATCH] feat: torrent file support on web (#171) --- internal/protocol/bt/fetcher.go | 12 +- internal/protocol/bt/fetcher_test.go | 35 ++++++ pkg/download/downloader.go | 3 - pkg/util/path.go | 10 ++ pkg/util/path_test.go | 40 +++++++ pkg/util/url.go | 35 +++++- pkg/util/url_test.go | 84 +++++++++++++- .../app/controllers/app_controller.dart | 1 - .../create/controllers/create_controller.dart | 13 +++ .../app/modules/create/views/create_view.dart | 104 +++++++++++------- ui/flutter/pubspec.lock | 4 +- ui/flutter/pubspec.yaml | 2 +- 12 files changed, 288 insertions(+), 55 deletions(-) diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 6806c952..bad75c7e 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -1,6 +1,7 @@ package bt import ( + "bytes" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" @@ -223,6 +224,15 @@ func (f *Fetcher) addTorrent(req *base.Request) (err error) { schema := util.ParseSchema(req.URL) if schema == "MAGNET" { f.torrent, err = client.AddMagnet(req.URL) + } else if schema == "APPLICATION/X-BITTORRENT" { + _, data := util.ParseDataUri(req.URL) + buf := bytes.NewBuffer(data) + var metaInfo *metainfo.MetaInfo + metaInfo, err = metainfo.Load(buf) + if err != nil { + return err + } + f.torrent, err = client.AddTorrent(metaInfo) } else { f.torrent, err = client.AddTorrentFromFile(req.URL) } @@ -265,7 +275,7 @@ func (f *Fetcher) addTorrent(req *base.Request) (err error) { type FetcherBuilder struct { } -var schemes = []string{"FILE", "MAGNET"} +var schemes = []string{"FILE", "MAGNET", "APPLICATION/X-BITTORRENT"} func (fb *FetcherBuilder) Schemes() []string { return schemes diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 7b529533..030d78a7 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -1,12 +1,14 @@ package bt import ( + "encoding/base64" "encoding/json" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/test" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/bt" + "os" "reflect" "testing" ) @@ -15,6 +17,39 @@ func TestFetcher_Resolve_Torrent(t *testing.T) { doResolve(t, buildFetcher()) } +func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) { + fetcher := buildFetcher() + buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent") + if err != nil { + t.Fatal(err) + } + // convert to data uri + dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf) + err = fetcher.Resolve(&base.Request{ + URL: dataUri, + }) + if err != nil { + panic(err) + } + + want := &base.Resource{ + Name: "ubuntu-22.04-live-server-amd64.iso", + Size: 1466714112, + Range: true, + RootDir: "ubuntu-22.04-live-server-amd64.iso", + Files: []*base.FileInfo{ + { + Name: "ubuntu-22.04-live-server-amd64.iso", + Size: 1466714112, + }, + }, + Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", + } + if !reflect.DeepEqual(want, fetcher.Meta().Res) { + t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) + } +} + func TestFetcher_Config(t *testing.T) { doResolve(t, buildConfigFetcher()) } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 0067e197..85e6c44d 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -169,9 +169,6 @@ func (d *Downloader) Setup() error { func (d *Downloader) parseFb(url string) (fetcher.FetcherBuilder, error) { schema := util.ParseSchema(url) fetchBuilder, ok := d.fetchBuilders[schema] - if !ok { - fetchBuilder, ok = d.fetchBuilders[util.FileSchema] - } if ok { return fetchBuilder, nil } diff --git a/pkg/util/path.go b/pkg/util/path.go index f594297b..e0572f37 100644 --- a/pkg/util/path.go +++ b/pkg/util/path.go @@ -88,3 +88,13 @@ func CreateDirIfNotExist(dir string) error { } return nil } + +// IsExistsFile check file exists and is a file +func IsExistsFile(path string) bool { + info, err := os.Stat(path) + // 判断路径是否存在,并且是一个文件 + if err == nil && !info.IsDir() { + return true + } + return false +} diff --git a/pkg/util/path_test.go b/pkg/util/path_test.go index 88746539..91c013c7 100644 --- a/pkg/util/path_test.go +++ b/pkg/util/path_test.go @@ -199,3 +199,43 @@ func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, e t.Errorf("CheckDuplicateAndRename() = %v, want %v", got, except) } } + +func TestIsExistsFile(t *testing.T) { + type args struct { + path string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "exist", + args: args{ + path: "./path.go", + }, + want: true, + }, + { + name: "not exist", + args: args{ + path: "./path_not_exist.go", + }, + want: false, + }, + { + name: "is dir", + args: args{ + path: "../util", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsExistsFile(tt.args.path); got != tt.want { + t.Errorf("IsExistsFile() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/util/url.go b/pkg/util/url.go index 3e7d230a..0d9680c8 100644 --- a/pkg/util/url.go +++ b/pkg/util/url.go @@ -1,13 +1,42 @@ package util -import "strings" +import ( + "encoding/base64" + "regexp" + "strings" +) const FileSchema = "FILE" func ParseSchema(url string) string { index := strings.Index(url, ":") if index == -1 { - return FileSchema + // check is file path + if IsExistsFile(url) { + return FileSchema + } + return "" } - return strings.ToUpper(url[:index]) + schema := url[:index] + if schema == "data" { + schema, _ = ParseDataUri(url) + } + return strings.ToUpper(schema) +} + +// ParseDataUri parses a data URI and returns the MIME type and decode data. +func ParseDataUri(uri string) (string, []byte) { + re := regexp.MustCompile(`^data:(.*);base64,(.*)$`) + matches := re.FindStringSubmatch(uri) + if len(matches) != 3 { + return "", nil + } + mime := matches[1] + base64Data := matches[2] + // 解码Base64数据 + data, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return "", nil + } + return mime, data } diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go index e3ffd852..8ee88df7 100644 --- a/pkg/util/url_test.go +++ b/pkg/util/url_test.go @@ -1,6 +1,10 @@ package util -import "testing" +import ( + "encoding/base64" + "reflect" + "testing" +) func TestParseSchema(t *testing.T) { type args struct { @@ -32,13 +36,27 @@ func TestParseSchema(t *testing.T) { }, want: "FILE", }, + { + name: "file-not-exists", + args: args{ + url: "not.txt", + }, + want: "", + }, { name: "file-no-scheme", args: args{ - url: "/home/bt.torrent", + url: "./url.go", }, want: "FILE", }, + { + name: "data-uri", + args: args{ + url: "data:application/x-bittorrent;base64,test", + }, + want: "APPLICATION/X-BITTORRENT", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -48,3 +66,65 @@ func TestParseSchema(t *testing.T) { }) } } + +func TestParseDataUri(t *testing.T) { + type args struct { + uri string + } + type result struct { + mime string + data []byte + } + + testData := []byte("test") + testData64 := base64.StdEncoding.EncodeToString(testData) + + tests := []struct { + name string + args args + want result + }{ + { + name: "success", + args: args{ + uri: "data:application/x-bittorrent;base64," + testData64, + }, + want: result{ + mime: "application/x-bittorrent", + data: testData, + }, + }, + { + name: "fail-dirty-data", + args: args{ + uri: "data::application/x-bittorrent;base64,!@$", + }, + want: result{ + mime: "", + data: nil, + }, + }, + { + name: "fail-miss-data", + args: args{ + uri: ":application/x-bittorrent;base64," + testData64, + }, + want: result{ + mime: "", + data: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mime, data := ParseDataUri(tt.args.uri) + got := result{ + mime: mime, + data: data, + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseDataUri() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index bd5de7b3..7b2e444a 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:ui' as ui; import 'dart:ui'; import 'package:app_links/app_links.dart'; diff --git a/ui/flutter/lib/app/modules/create/controllers/create_controller.dart b/ui/flutter/lib/app/modules/create/controllers/create_controller.dart index 59be4ddd..4c4ddc42 100644 --- a/ui/flutter/lib/app/modules/create/controllers/create_controller.dart +++ b/ui/flutter/lib/app/modules/create/controllers/create_controller.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -10,6 +13,7 @@ class CreateController extends GetxController final isResolving = false.obs; final showAdvanced = false.obs; late TabController advancedTabController; + final fileDataUri = "".obs; @override void onInit() { @@ -22,4 +26,13 @@ class CreateController extends GetxController advancedTabController.dispose(); super.onClose(); } + + void setFileDataUri(Uint8List bytes) { + fileDataUri.value = + "data:application/x-bittorrent;base64,${base64.encode(bytes)}"; + } + + void clearFileDataUri() { + fileDataUri.value = ""; + } } diff --git a/ui/flutter/lib/app/modules/create/views/create_view.dart b/ui/flutter/lib/app/modules/create/views/create_view.dart index 6bbad62c..c73fe181 100644 --- a/ui/flutter/lib/app/modules/create/views/create_view.dart +++ b/ui/flutter/lib/app/modules/create/views/create_view.dart @@ -51,8 +51,14 @@ class CreateView extends GetView { title: Text('create'.tr), ), body: DropTarget( - onDragDone: (details) { - _urlController.text = details.files[0].path; + onDragDone: (details) async { + if (!Util.isWeb()) { + _urlController.text = details.files[0].path; + return; + } + _urlController.text = details.files[0].name; + final bytes = await details.files[0].readAsBytes(); + controller.setFileDataUri(bytes); }, child: Padding( padding: @@ -61,44 +67,54 @@ class CreateView extends GetView { key: _resolveFormKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: Column(children: [ - Row( - children: [ - Expanded( - child: TextFormField( - autofocus: true, - controller: _urlController, - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: _hitText(), - hintStyle: const TextStyle(fontSize: 12), - labelText: 'downloadLink'.tr, - icon: const Icon(Icons.link), - suffixIcon: IconButton( - onPressed: _urlController.clear, - icon: const Icon(Icons.clear), - ), - ), - validator: (v) { - return v!.trim().isNotEmpty - ? null - : 'downloadLinkValid'.tr; - }), + Row(children: [ + Expanded( + child: TextFormField( + autofocus: true, + controller: _urlController, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: _hitText(), + hintStyle: const TextStyle(fontSize: 12), + labelText: 'downloadLink'.tr, + icon: const Icon(Icons.link), + suffixIcon: IconButton( + onPressed: () { + _urlController.clear(); + controller.clearFileDataUri(); + }, + icon: const Icon(Icons.clear), + ), + ), + validator: (v) { + return v!.trim().isNotEmpty + ? null + : 'downloadLinkValid'.tr; + }, + onChanged: (v) async { + controller.clearFileDataUri(); + }, + ), + ), + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () async { + var pr = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ["torrent"]); + if (pr != null) { + if (!Util.isWeb()) { + _urlController.text = pr.files[0].path ?? ""; + return; + } + _urlController.text = pr.files[0].name; + controller.setFileDataUri(pr.files[0].bytes!); + } + }), + ] + //.where((e) => e != null).map((e) => e!).toList(), ), - Util.isWeb() - ? null - : IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () async { - var pr = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ["torrent"]); - if (pr != null) { - _urlController.text = pr.files[0].path ?? ""; - } - }), - ].where((e) => e != null).map((e) => e!).toList(), - ), Obx(() => Visibility( visible: controller.showAdvanced.value, child: Padding( @@ -205,8 +221,11 @@ class CreateView extends GetView { _confirmController.start(); if (_resolveFormKey.currentState!.validate()) { Object? extra; + final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty + ? controller.fileDataUri.value + : _urlController.text; if (controller.showAdvanced.value) { - final u = Uri.parse(_urlController.text); + final u = Uri.parse(submitUrl); if (u.scheme.startsWith("http")) { extra = ReqExtraHttp() ..header = { @@ -220,7 +239,7 @@ class CreateView extends GetView { } } final rr = await resolve(Request( - url: _urlController.text, + url: submitUrl, extra: extra, )); await _showResolveDialog(rr); @@ -235,7 +254,8 @@ class CreateView extends GetView { String _hitText() { return 'downloadLinkHit'.trParams({ - 'append': Util.isDesktop() ? 'downloadLinkHitDesktop'.tr : '', + 'append': + Util.isDesktop() || Util.isWeb() ? 'downloadLinkHitDesktop'.tr : '', }); } diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 65053dde..d66c3fa7 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -245,10 +245,10 @@ packages: dependency: "direct main" description: name: desktop_drop - sha256: "0cd056191b701a2b5ba040f2306349e461fafdaa5df4569b2228cdf87b58eced" + sha256: "4ca4d960f4b11c032e9adfd2a0a8ac615bc3fddb4cbe73dcf840dd8077582186" url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.3" + version: "0.4.1" dio: dependency: "direct main" description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 6271af97..3007d2b9 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -48,7 +48,7 @@ dependencies: url_launcher: ^6.1.6 shared_preferences: ^2.2.0 logger: ^1.3.0 - desktop_drop: ^0.3.3 + desktop_drop: ^0.4.1 package_info_plus: ^4.0.0 fluent_ui: ^4.4.0 path: any