Kazumi icon indicating copy to clipboard operation
Kazumi copied to clipboard

在源网站直接进行搜索的新功能

Open 1312853182 opened this issue 8 months ago • 6 comments

issue 内容

当前软件的搜索功能是拿到用户输入的关键词去bangumi去搜索相关条目 然后再通过用户选择的条目拿到影片名去源网站搜索. 这样会出现一种情况:搜索不到源网站里有影片但bangumi中没有收录的影片. 每次遇到这种情况都需要搜索一个冷门条目再去使用别名搜索功能才能获取到

我觉得添加一个直接通过用户输入去源网站搜索的功能是有需求的.

因此我添加了一个单独的页面,在我使用了一段时间后我觉得还是很方便的. #925

提交前确认

  • [x] issue 列表中,没有我的新功能需求 / 问题

1312853182 avatar Apr 24 '25 12:04 1312853182

如果你的意思是根据 #925 中我的提议进行讨论

  1. 我们现在的架构完全是根据 bangumi API 返回的番剧信息作为索引的,当通过片源站点进行检索时,我们的 bangumiItem 来自哪里,我们使用什么东西填充需要 bangumiItem 填充的地方 (例如 历史记录)

  2. 我们不应该新增一条规则来抓取封面图片,因为这会有各种烦人的问题,例如损坏的图片,或是 referer 相关问题

还是说这个 Issue 只是一个提议,你倾向于完全由我们来实现相关功能?

Predidit avatar Apr 24 '25 12:04 Predidit

我自己已经写好了这个功能 对于一我的处理方法是把通过规则爬取到的searchitem的信息赋值给一个bangumiItem对象,然后把这个对象赋值给infoController.bangumiItem 这样做就可以播放影片了,只是无法通过bangumi获取简介评论等信息. 对于二我只是通过一条xpath在源网站获取具体的封面链接然后把链接交给Image.network去渲染了,并没有遇到referer问题,从原网站爬取到的图片链接绝大部分都是可以正常被Image.network渲染的,无法正常显示的图片我目前只是留白. 因为我修改的目的一开始只是自己有需求,所以做的简单,

我会在下面提供我做出的一些主要修改: 在class Plugin中添加了一个String searchImg; 在class SearchItem中添加了一个String img;

queryBangumi方法中修改了从htmlElement中解析searchItem的部分为: htmlElement.queryXPath(searchList).nodes.forEach((element) { try { final pattern = RegExp( r'^(.?)\s@start-xpath\s+(.?)\s+@end-xpath\s(.*)$', multiLine: false, caseSensitive: false); final match = pattern.firstMatch(searchImg); var fullImgUrl = ''; if (match != null) { final prefix = match.group(1)?.trim() ?? ''; // 第一部分:@start-xpath 之前的内容 final xpath = match.group(2)?.trim() ?? ''; // 第二部分:中间的 XPath final suffix = match.group(3)?.trim() ?? ''; // 第三部分:@end-xpath 之后的内容 // 构建完整图片 URL final relativePath = element.queryXPath(xpath).attrs.firstOrNull ?? ''; fullImgUrl = '$prefix$relativePath$suffix'; } else { fullImgUrl = element.queryXPath(searchImg).attrs.firstOrNull ?? ''; } SearchItem searchItem = SearchItem( name: (element.queryXPath(searchName).node!.text ?? '') .replaceAll(RegExp(r'\s+'), ' ') // 将连续空白替换为单个空格 .trim(), // 去除首尾空格 img: fullImgUrl ?? '', src: element.queryXPath(searchResult).node!.attributes['href'] ?? '', ); searchItems.add(searchItem); KazumiLogger().log(Level.info, '$name ${element.queryXPath(searchName).node!.text ?? ''} $baseUrl${element.queryXPath(searchResult).node!.attributes['href'] ?? ''}'); } catch (_) {} }); 在上面使用正则表达式是因为有些网站的图片链接并没有域名,而且域名与网站域名也不同,需要在一个string中存储域名与xpath两部分并在解析前分离出来.

在plugin的searchURL添加了一个非必须的关键字@pagenum: 同时修改Future<PluginSearchResponse> queryBangumi(String keyword, {bool shouldRethrow = false, int page = 1})添加了一个非必须的page参数 String queryURL = searchURL.replaceAll('@keyword', keyword); if (queryURL.contains('@pagenum')) { queryURL = queryURL.replaceAll('@pagenum', page > 0 ? page.toString() : '1'); }同时添加了这个对pagenum的处理

添加了一个search页面(当时版本为1.6.0还没有根据tag搜索的模块) search_module.dart

import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/pages/search/search_page.dart';

class SearchModule extends Module { @override void routes(r) { r.child("/", child: (_) => const SearchPage()); } }

search_page.dart

import` 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/bean/dialog/dialog_helper.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/pages/info/info_controller.dart'; import 'package:kazumi/plugins/plugins.dart'; import 'package:kazumi/plugins/plugins_controller.dart'; import 'package:kazumi/request/query_manager.dart'; import 'package:kazumi/utils/logger.dart'; import 'package:logger/logger.dart'; import 'package:crypto/crypto.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../bean/appbar/sys_app_bar.dart'; import '../../bean/widget/error_widget.dart'; import '../video/video_controller.dart';

class SearchPage extends StatefulWidget { const SearchPage({super.key});

@override State<SearchPage> createState() => _SearchPageState(); }

class _SearchPageState extends State<SearchPage> with SingleTickerProviderStateMixin { final InfoController infoController = Modular.get<InfoController>(); late QueryManager queryManager;

final TextEditingController _searchController = TextEditingController(); final FocusNode _focusNode = FocusNode(); bool showSearchBar = true; // 默认显示搜索栏 final VideoPageController videoPageController = Modular.get<VideoPageController>(); final PluginsController pluginsController = Modular.get<PluginsController>(); late TabController tabController;

//分页信息 final Map<String, int> _currentPages = {}; // 当前页码 final Map<String, int> _totalPages = {}; // 总页数

@override void initState() { super.initState();

for (var plugin in pluginsController.pluginList) {
  _currentPages[plugin.name] = 1;
  _totalPages[plugin.name] = 0;
}

queryManager = QueryManager();
queryManager.queryAllSource('');
tabController =
    TabController(length: pluginsController.pluginList.length, vsync: this);

}

int _generateUniqueId(String name) { // 将字符串编码为UTF-8字节 final bytes = utf8.encode(name);

// 生成SHA-256哈希
final digest = sha256.convert(bytes);

// 取前8字节(64位)转换为无符号整数
final hashInt = BigInt.parse(
  '0x${digest.bytes.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}',
);

// 取模约束到小于20亿的范围
return (hashInt % BigInt.from(2000000000)).toInt() + 100000000;

}

@override void dispose() { queryManager.cancel(); _searchController.dispose(); videoPageController.currentEpisode = 1; _focusNode.dispose(); tabController.dispose(); super.dispose(); }

void _search(String keyword) { queryManager.queryAllSource(keyword); for (var plugin in pluginsController.pluginList) { _currentPages[plugin.name] = 1; _totalPages[plugin.name] = 0; } }

@override Widget build(BuildContext context) { final isLight = Theme.of(context).brightness == Brightness.light;

return Scaffold(
    appBar: SysAppBar(
      title: Visibility(
        visible: showSearchBar,
        child: TextField(
          controller: _searchController,
          focusNode: _focusNode,
          cursorColor: Theme.of(context).colorScheme.primary,
          decoration: InputDecoration(
            floatingLabelBehavior: FloatingLabelBehavior.never,
            labelText: '输入搜索内容',
            alignLabelWithHint: true,
            contentPadding:
                const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
            border: const OutlineInputBorder(
              borderRadius: BorderRadius.all(Radius.circular(8)),
            ),
            suffixIcon: IconButton(
              icon: const Icon(Icons.search),
              onPressed: () {
                _search(_searchController.text);
              },
            ),
          ),
          style:
              TextStyle(color: isLight ? Colors.black87 : Colors.white70),
          onSubmitted: (value) => _search(value),
        ),
      ),
      actions: [
        IconButton(
          icon: showSearchBar
              ? const Icon(Icons.close)
              : const Icon(Icons.search),
          onPressed: () {
            setState(() {
              showSearchBar = !showSearchBar;
              if (showSearchBar) {
                _focusNode.requestFocus();
              } else {
                _focusNode.unfocus();
                _searchController.clear();
              }
            });
          },
        ),
      ],
    ),
    body: Scaffold(
      appBar: AppBar(
        toolbarHeight: 0, // 隐藏顶部区域
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(27), // 固定 TabBar 高度
          child: TabBar(
            isScrollable: true,
            controller: tabController,
            tabs: pluginsController.pluginList
                .map((plugin) => Observer(
                      builder: (context) => Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(
                            plugin.name,
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              fontSize: Theme.of(context)
                                  .textTheme
                                  .titleMedium!
                                  .fontSize,
                              color:
                                  Theme.of(context).colorScheme.onSurface,
                            ),
                          ),
                          const SizedBox(width: 5.0),
                          Container(
                            width: 8.0,
                            height: 8.0,
                            decoration: BoxDecoration(
                              color: infoController.pluginSearchStatus[
                                          plugin.name] ==
                                      'success'
                                  ? Colors.green
                                  : (infoController.pluginSearchStatus[
                                              plugin.name] ==
                                          'pending'
                                      ? Colors.grey
                                      : Colors.red),
                              shape: BoxShape.circle,
                            ),
                          ),
                        ],
                      ),
                    ))
                .toList(),
          ),
        ),
      ),
      body: Observer(
        builder: (context) => TabBarView(
          controller: tabController,
          children: List.generate(pluginsController.pluginList.length,
              (pluginIndex) {
            var plugin = pluginsController.pluginList[pluginIndex];
            var cardList = <Widget>[];
            for (var searchResponse
                in infoController.pluginSearchResponseList) {
              if (searchResponse.pluginName == plugin.name) {
                for (var searchItem in searchResponse.data) {
                  cardList.add(Card(
                    color: Colors.transparent,
                    child: SizedBox(
                      height: 100, // 固定卡片高度
                      child: Row(
                        crossAxisAlignment:
                            CrossAxisAlignment.center, // 垂直居中
                        children: [
                          // 左侧固定高度图片
                          _buildImageWidget(searchItem.img,plugin, searchItem.src),
                          // 右侧文字区域
                          Expanded(
                            child: Container(
                              height: 100,
                              alignment: Alignment.centerLeft,
                              child: ListTile(
                                contentPadding: EdgeInsets.zero,
                                title: Text(
                                  searchItem.name,
                                  style: TextStyle(
                                    fontSize: 16,
                                    color: plugin.chapterRoads.isEmpty ? Colors.white : null,
                                  ),
                                ),
                                // 关键修改部分:添加点击条件判断
                                onTap: plugin.chapterRoads.isEmpty ? null : () async { // 根据变量实际情况可能需要用空字符串判断
                                  KazumiDialog.showLoading(msg: '获取中');
                                  String todayDate = DateTime.now().toString().split(' ')[0];
                                  infoController.bangumiItem = BangumiItem(
                                    id: _generateUniqueId(searchItem.name),
                                    type: _generateUniqueId(searchItem.name),
                                    name: searchItem.name,
                                    nameCn: searchItem.name,
                                    summary: "影片《${searchItem.name}》是通过规则${plugin.name}直接搜索得到。\r无法获取bangumi的数据,但支持除此以外包括追番,观看记录之外的绝大部分功能。",
                                    airDate: todayDate,
                                    airWeekday: 0,
                                    rank: 0,
                                    images: {
                                      'small': searchItem.img,
                                      'grid': searchItem.img,
                                      'large': searchItem.img,
                                      'medium': searchItem.img,
                                      'common': searchItem.img,
                                    },
                                    tags: [],
                                    alias: [],
                                    ratingScore: 0.0,
                                  );

                                  videoPageController.currentPlugin = plugin;
                                  videoPageController.title = searchItem.name;
                                  videoPageController.src = searchItem.src;
                                  try {
                                    await infoController.queryRoads(searchItem.src, plugin.name);
                                    KazumiDialog.dismiss();
                                    Modular.to.pushNamed('/video/');
                                  } catch (e) {
                                    KazumiLogger().log(Level.error, e.toString());
                                    KazumiDialog.dismiss();
                                  }
                                },
                              ),
                            ),
                          )
                        ],
                      ),
                    ),
                  ));
                }
              }
            }
            return infoController.pluginSearchStatus[plugin.name] ==
                    'pending'
                ? const Center(child: CircularProgressIndicator())
                : Column(
                    children: [
                      if (infoController.pluginSearchStatus[plugin.name] ==
                          'error')
                        Expanded(
                          child: GeneralErrorWidget(
                            errMsg: '${plugin.name} 检索失败 重试或切换到其他视频来源',
                            actions: [
                              GeneralErrorButton(
                                onPressed: () {
                                  queryManager.querySource(
                                      _searchController.text, plugin.name);
                                },
                                text: '重试',
                              ),
                              GeneralErrorButton(
                                onPressed: () {
                                  KazumiDialog.show(builder: (context) {
                                    return AlertDialog(
                                      title: const Text('退出确认'),
                                      content: const Text('您想要离开 Kazumi 并在浏览器中打开此视频链接吗?'),
                                      actions: [
                                        TextButton(
                                            onPressed: () => KazumiDialog.dismiss(),
                                            child: const Text('取消')),
                                        TextButton(
                                            onPressed: () {
                                              KazumiDialog.dismiss();
                                              launchUrl(Uri.parse(plugin.baseUrl));
                                            },
                                            child: const Text('确认')),
                                      ],
                                    );
                                  });
                                },
                                text: 'web',
                              ),
                            ],
                          ),
                        )
                      else if (cardList.isEmpty)
                        Expanded(
                          child: GeneralErrorWidget(
                            errMsg:
                                '${plugin.name} 本页无结果 使用其他搜索词或切换到其他视频来源',
                            actions: [
                              GeneralErrorButton(
                                onPressed: () {
                                  KazumiDialog.show(builder: (context) {
                                    return AlertDialog(
                                      title: const Text('退出确认'),
                                      content: const Text('您想要离开 Kazumi 并在浏览器中打开此视频链接吗?'),
                                      actions: [
                                        TextButton(
                                            onPressed: () => KazumiDialog.dismiss(),
                                            child: const Text('取消')),
                                        TextButton(
                                            onPressed: () {
                                              KazumiDialog.dismiss();
                                              launchUrl(Uri.parse(plugin.baseUrl));
                                            },
                                            child: const Text('确认')),
                                      ],
                                    );
                                  });
                                },
                                text: 'web',
                              ),
                            ],
                          ),
                        )
                      else
                        Expanded(
                          child: ListView(children: cardList),
                        ),
                      buildPagination(plugin), // 所有状态都显示分页控件
                    ],
                  );
          }),
        ),
      ),
    ));

}

Widget _buildImageWidget(String imgUrl,Plugin plugin,String resultUrl) { return Container( margin: const EdgeInsets.only(right: 8), height: 100, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: GestureDetector( // 新增手势检测层 onTap: () { String url = ''; if (resultUrl.contains(plugin.baseUrl)) { url = resultUrl; } else { url = plugin.baseUrl + resultUrl; } KazumiDialog.show(builder: (context) { return AlertDialog( title: const Text('退出确认'), content: const Text('您想要离开 Kazumi 并在浏览器中打开此视频链接吗?'), actions: [ TextButton( onPressed: () => KazumiDialog.dismiss(), child: const Text('取消')), TextButton( onPressed: () { KazumiDialog.dismiss(); launchUrl(Uri.parse(url)); }, child: const Text('确认')), ], ); }); }, child: Image.network( imgUrl, fit: BoxFit.fitHeight, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.grey[300], width: 70, child: const Icon(Icons.broken_image), ); }, ), ), ), ); }

Widget buildPagination(Plugin plugin) { int currentPage = _currentPages[plugin.name] ?? 1; int totalPage = _totalPages[plugin.name] ?? 0;

return Padding(
  padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
  child: Row(
    mainAxisAlignment: MainAxisAlignment.center,
    mainAxisSize: MainAxisSize.min,
    children: [
      IconButton(
        iconSize: 20,
        padding: EdgeInsets.zero,
        constraints: const BoxConstraints(
          minWidth: 35,
          minHeight: 35,
        ),
        style: IconButton.styleFrom(
          visualDensity: VisualDensity.compact,
          tapTargetSize: MaterialTapTargetSize.shrinkWrap,
        ),
        onPressed: currentPage > 1
            ? () {
                _currentPages[plugin.name] = currentPage - 1;
                queryManager.querySourceWithPage(
                    _searchController.text, plugin.name, currentPage - 1);
              }
            : null,
        icon: const Icon(Icons.arrow_back),
        color: Theme.of(context).primaryColor,
        disabledColor: Colors.grey[400],
      ),
      SizedBox(
        width: 56, // 缩小输入框宽度
        child: TextField(
          controller: TextEditingController(text: currentPage.toString()),
          textAlign: TextAlign.center,
          style: TextStyle(
            fontSize: 12, // 缩小字体
          ),
          keyboardType: TextInputType.number,
          decoration: InputDecoration(
            isDense: true,
            // 关键设置!压缩输入框高度
            contentPadding: const EdgeInsets.symmetric(vertical: 4),
            // 缩小垂直内边距
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(6), // 缩小圆角
              borderSide: BorderSide(
                color: Colors.grey[400]!,
                width: 1,
              ),
            ),
            enabledBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(6),
              borderSide: BorderSide(
                color: Colors.grey[400]!,
                width: 1,
              ),
            ),
            focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(6),
              borderSide: BorderSide(
                color: Theme.of(context).primaryColor,
                width: 1.0, // 缩小边框宽度
              ),
            ),
          ),
          onSubmitted: (value) {
            int page = int.tryParse(value) ?? 1;
            _currentPages[plugin.name] = page;
            queryManager.querySourceWithPage(
                _searchController.text, plugin.name, page);
          },
        ),
      ),
      IconButton(
        iconSize: 20,
        padding: EdgeInsets.zero,
        constraints: const BoxConstraints(
          minWidth: 35,
          minHeight: 35,
        ),
        style: IconButton.styleFrom(
          visualDensity: VisualDensity.compact,
          tapTargetSize: MaterialTapTargetSize.shrinkWrap,
        ),
        onPressed: currentPage < totalPage || totalPage == 0
            ? () {
                _currentPages[plugin.name] = currentPage + 1;
                queryManager.querySourceWithPage(
                    _searchController.text, plugin.name, currentPage + 1);
              }
            : null,
        icon: const Icon(Icons.arrow_forward),
        color: Theme.of(context).primaryColor,
        disabledColor: Colors.grey[400],
      ),
    ],
  ),
);

} }

1312853182 avatar Apr 24 '25 13:04 1312853182

同求,我平时在 AGE 上看番,但是体验跟 Kazumi 完全没法比,尤其是追番管理的体验 但是 Kazumi 上能看什么番,完全依赖 bangumi 如果 bangumi 上没有收录即使 AGE 上本身能看的番,也无法在 Kazumi 上看 所以我还得留着 AGE...

本来以为可以基于自定义规则从任何番剧网站播放并管理番剧 但实际是从 bangumi 获取番剧信息根据自定义规则看哪些网站能播放

我从 AGE 换成 Kazumi 就是为了能够一个 APP 追所有番,不管出自哪里

CrazyBunQnQ avatar Apr 29 '25 11:04 CrazyBunQnQ

同求

sdgsgsdf avatar May 06 '25 12:05 sdgsgsdf

如果你的意思是根据 #925 中我的提议进行讨论

  1. 我们现在的架构完全是根据 bangumi API 返回的番剧信息作为索引的,当通过片源站点进行检索时,我们的 bangumiItem 来自哪里,我们使用什么东西填充需要 bangumiItem 填充的地方 (例如 历史记录)
  2. 我们不应该新增一条规则来抓取封面图片,因为这会有各种烦人的问题,例如损坏的图片,或是 referer 相关问题

还是说这个 Issue 只是一个提议,你倾向于完全由我们来实现相关功能?

是否可以像漫画 App mihon 一样,加入规则,用户自己添加从 bangumiItem 以外的站点获取番剧信息?

至少对于我个人而言我不在乎图片是否能显示,当我去搜它的时候我是完全了解其番剧信息的,我只是不想来回切换各种 APP 或网站去看了,也不想回忆哪个平台的哪个番还没看,看到第几集等

CrazyBunQnQ avatar May 07 '25 08:05 CrazyBunQnQ

欸,我有个想法。我看番剧搜索不到的时候可以在对应规则那里搜索别名,也就是说已经有自定义搜索这个功能了,所以直接在首页或者搜索或者其他什么地方直接加一个空的番剧,默认封面就是个➕不就好了,用户直接从这个入口跳转到一个空的默认的番剧详情,然后从开始观看那里进入对应的规则进行搜索。要新加的功能也不是很多,详情页面保留标题和封面就行了,也不用针对每个网站爬取,直接留给用户自定义,实际上就是实现一个添加自定义番剧的功能

misaka10023 avatar May 16 '25 08:05 misaka10023