From 198de49e5847d3fa7a88b41642cabe1c78b1d803 Mon Sep 17 00:00:00 2001 From: bupro Date: Sat, 23 Aug 2025 00:41:10 +0800 Subject: [PATCH] feat: Add video tag search and random sort with i18n support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added video tag support for enhanced media file handling: * Video files can now use tag-based search functionality * Added support for reading video generation info from txt files * Enhanced EXIF data handling for video media types - Implemented random sort feature for image grid: * Added random sort toggle button with visual indicators (🎲/📅) * Supports both random and date-based sorting modes * Optimized pagination for random sorting with offset-based cursors - Complete internationalization support: * Added translations for random sort and date sort in all languages * Supported languages: zh-hans, en, de, zh-hant * Improved UI consistency across language variants - Backend improvements: * Enhanced database queries to support random sorting * Added random_sort parameter to API endpoints * Improved error handling for video file processing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- javascript/index.js | 4 ++-- scripts/iib/api.py | 20 +++++++++++++---- scripts/iib/db/datamodel.py | 23 +++++++++++++++++--- scripts/iib/db/update_image_data.py | 24 ++++++++++++++++++++- vue/src/api/db.ts | 1 + vue/src/i18n/de.ts | 4 +++- vue/src/i18n/en.ts | 4 +++- vue/src/i18n/zh-hans.ts | 4 +++- vue/src/i18n/zh-hant.ts | 4 +++- vue/src/page/TagSearch/MatchedImageGrid.vue | 24 +++++++++++++++++++-- 10 files changed, 96 insertions(+), 16 deletions(-) diff --git a/javascript/index.js b/javascript/index.js index c497e08..a98e45f 100644 --- a/javascript/index.js +++ b/javascript/index.js @@ -13,8 +13,8 @@ Promise.resolve().then(async () => { Infinite Image Browsing - - + + diff --git a/scripts/iib/api.py b/scripts/iib/api.py index c6bdc53..fd6bc7b 100644 --- a/scripts/iib/api.py +++ b/scripts/iib/api.py @@ -710,13 +710,21 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): @app.get(api_base + "/image_geninfo", dependencies=[Depends(verify_secret)]) async def image_geninfo(path: str): - return parse_image_info(path).raw_info + # 使用 get_exif_data 函数,它已经支持视频文件 + from scripts.iib.db.update_image_data import get_exif_data + try: + result = get_exif_data(path) + return result.raw_info or "" + except Exception as e: + logger.error(f"Failed to get geninfo for {path}: {e}") + return "" class GeninfoBatchReq(BaseModel): paths: List[str] @app.post(api_base + "/image_geninfo_batch", dependencies=[Depends(verify_secret)]) async def image_geninfo_batch(req: GeninfoBatchReq): + from scripts.iib.db.update_image_data import get_exif_data res = {} conn = DataBase.get_conn() for path in req.paths: @@ -725,9 +733,11 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): if img: res[path] = img.exif else: - res[path] = parse_image_info(path).raw_info + result = get_exif_data(path) + res[path] = result.raw_info or "" except Exception as e: - logger.error(e, stack_info=True) + logger.error(f"Failed to get geninfo for {path}: {e}", stack_info=True) + res[path] = "" return res @@ -919,6 +929,7 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): cursor: str folder_paths: List[str] = None size: Optional[int] = 200 + random_sort: Optional[bool] = False @app.post(db_api_base + "/match_images_by_tags", dependencies=[Depends(verify_secret)]) async def match_image_by_tags(req: MatchImagesByTagsReq): @@ -933,7 +944,8 @@ def infinite_image_browsing_api(app: FastAPI, **kwargs): tag_dict={"and": req.and_tags, "or": req.or_tags, "not": req.not_tags}, cursor=req.cursor, folder_paths=folder_paths, - limit=req.size + limit=req.size, + random_sort=req.random_sort ) return { "files": filter_allowed_files([x.to_file_info() for x in imgs]), diff --git a/scripts/iib/db/datamodel.py b/scripts/iib/db/datamodel.py index 3f0b5a5..e7f82da 100644 --- a/scripts/iib/db/datamodel.py +++ b/scripts/iib/db/datamodel.py @@ -533,6 +533,7 @@ class ImageTag: limit: int = 500, cursor="", folder_paths: List[str] = None, + random_sort: bool = False, ) -> tuple[List[Image], Cursor]: query = """ SELECT image.id, image.path, image.size,image.date @@ -583,7 +584,7 @@ class ImageTag: print(folder_path) where_clauses.append("(" + " OR ".join(folder_clauses) + ")") - if cursor: + if cursor and not random_sort: where_clauses.append("(image.date < ?)") params.append(cursor) if where_clauses: @@ -593,7 +594,17 @@ class ImageTag: query += " HAVING COUNT(DISTINCT tag_id) = ?" params.append(len(tag_dict["and"])) - query += " ORDER BY date DESC LIMIT ?" + if random_sort: + query += " ORDER BY RANDOM() LIMIT ?" + # For random sort, use offset-based pagination + if cursor: + try: + offset = int(cursor) + query = query.replace("LIMIT ?", f"LIMIT ? OFFSET {offset}") + except (ValueError, TypeError): + pass # Invalid cursor, start from beginning + else: + query += " ORDER BY date DESC LIMIT ?" params.append(limit) api_cur = Cursor() with closing(conn.cursor()) as cur: @@ -610,7 +621,13 @@ class ImageTag: Image.safe_batch_remove(conn, deleted_ids) api_cur.has_next = len(rows) >= limit if images: - api_cur.next = str(images[-1].date) + if random_sort: + # For random sort, use offset-based cursor + current_offset = int(cursor) if cursor else 0 + api_cur.next = str(current_offset + len(images)) + else: + # For date sort, use date-based cursor + api_cur.next = str(images[-1].date) return images, api_cur @classmethod diff --git a/scripts/iib/db/update_image_data.py b/scripts/iib/db/update_image_data.py index 720a47f..6d9e18f 100644 --- a/scripts/iib/db/update_image_data.py +++ b/scripts/iib/db/update_image_data.py @@ -9,7 +9,9 @@ from scripts.iib.tool import ( is_dev, get_modified_date, is_image_file, - case_insensitive_get + case_insensitive_get, + get_img_geninfo_txt_path, + parse_generation_parameters ) from scripts.iib.parsers.model import ImageGenerationInfo, ImageGenerationParams from scripts.iib.logger import logger @@ -19,6 +21,26 @@ from scripts.iib.plugin import plugin_inst_map # 定义一个函数来获取图片文件的EXIF数据 def get_exif_data(file_path): if get_video_type(file_path): + # 对于视频文件,尝试读取对应的txt标签文件 + txt_path = get_img_geninfo_txt_path(file_path) + if txt_path: + try: + with open(txt_path, 'r', encoding='utf-8') as f: + content = f.read().strip() + if content: + # 复用现有解析逻辑,添加视频标识 + params = parse_generation_parameters(content + "\nSource Identifier: Video Tags") + return ImageGenerationInfo( + content, + ImageGenerationParams( + meta=params["meta"], + pos_prompt=params["pos_prompt"], + extra=params, + ), + ) + except Exception as e: + if is_dev: + logger.error("Failed to read video txt file %s: %s", txt_path, e) return ImageGenerationInfo() try: return parse_image_info(file_path) diff --git a/vue/src/api/db.ts b/vue/src/api/db.ts index 08313a8..8691ccd 100644 --- a/vue/src/api/db.ts +++ b/vue/src/api/db.ts @@ -43,6 +43,7 @@ export interface MatchImageByTagsReq { and_tags: TagId[] or_tags: TagId[] not_tags: TagId[] + random_sort?: boolean } export const getImagesByTags = async (req: MatchImageByTagsReq, cursor: string) => { diff --git a/vue/src/i18n/de.ts b/vue/src/i18n/de.ts index bae8aa5..45a0d56 100644 --- a/vue/src/i18n/de.ts +++ b/vue/src/i18n/de.ts @@ -141,5 +141,7 @@ export const de: Partial = { randomImageSettingNotification: 'Tipp: Sie können in den globalen Einstellungen steuern, ob die Zufallsbild-Option auf der Startseite angezeigt wird', mediaType: 'Medientyp', all: 'Alle', - video: 'Video' + video: 'Video', + randomSort: 'Zufällig sortieren', + sortByDate: 'Nach Datum sortieren' } diff --git a/vue/src/i18n/en.ts b/vue/src/i18n/en.ts index 577fa00..c578d3f 100644 --- a/vue/src/i18n/en.ts +++ b/vue/src/i18n/en.ts @@ -351,5 +351,7 @@ You can specify which snapshot to restore to when starting IIB in the global set tagOperationFailed: 'Tag operation failed', mediaType: 'Media Type', all: 'All', - video: 'Video' + video: 'Video', + randomSort: 'Random Sort', + sortByDate: 'Sort by Date' } diff --git a/vue/src/i18n/zh-hans.ts b/vue/src/i18n/zh-hans.ts index d8cf36f..1d61ef4 100644 --- a/vue/src/i18n/zh-hans.ts +++ b/vue/src/i18n/zh-hans.ts @@ -330,5 +330,7 @@ export const zhHans = { tagOperationFailed: '标签操作失败', mediaType: '媒体类型', all: '全部', - video: '视频' + video: '视频', + randomSort: '随机排序', + sortByDate: '按日期排序' } diff --git a/vue/src/i18n/zh-hant.ts b/vue/src/i18n/zh-hant.ts index f826e4f..ac1d395 100644 --- a/vue/src/i18n/zh-hant.ts +++ b/vue/src/i18n/zh-hant.ts @@ -335,5 +335,7 @@ export const zhHant: Partial = { tagOperationFailed: '標籤操作失敗', mediaType: '媒體類型', all: '全部', - video: '視頻' + video: '視頻', + randomSort: '隨機排序', + sortByDate: '按日期排序' } diff --git a/vue/src/page/TagSearch/MatchedImageGrid.vue b/vue/src/page/TagSearch/MatchedImageGrid.vue index f964011..ea539ed 100644 --- a/vue/src/page/TagSearch/MatchedImageGrid.vue +++ b/vue/src/page/TagSearch/MatchedImageGrid.vue @@ -5,7 +5,7 @@ import '@zanllp/vue-virtual-scroller/dist/vue-virtual-scroller.css' import { RecycleScroller } from '@zanllp/vue-virtual-scroller' import { toRawFileUrl } from '@/util/file' import { getImagesByTags, type MatchImageByTagsReq } from '@/api/db' -import { nextTick, watch } from 'vue' +import { nextTick, watch, ref } from 'vue' import { copy2clipboardI18n } from '@/util' import fullScreenContextMenu from '@/page/fileTransfer/fullScreenContextMenu.vue' import { LeftCircleOutlined, RightCircleOutlined } from '@/icon' @@ -23,7 +23,13 @@ const props = defineProps<{ }>() -const iter = createImageSearchIter(cursor => getImagesByTags(props.selectedTagIds, cursor)) +// 添加随机排序状态 +const randomSort = ref(true) + +// 创建搜索迭代器,根据随机排序状态决定参数 +const iter = createImageSearchIter(cursor => { + return getImagesByTags({...props.selectedTagIds, random_sort: randomSort.value}, cursor) +}) const { queue, images, @@ -67,6 +73,17 @@ watch( { immediate: true } ) +// 监听随机排序状态变化 +watch( + randomSort, + async () => { + await iter.reset() + await nextTick() + scroller.value?.scrollToItem(0) + onScroll() // 重新获取 + } +) + watch( () => props, @@ -114,6 +131,9 @@ const onTiktokViewClick = () => {
+ + {{ randomSort ? '🎲 ' + $t('randomSort') : '📅 ' + $t('sortByDate') }} + {{ $t('tiktokView') }} {{ $t('saveLoadedImageAsJson') }} {{ $t('saveAllAsJson') }}