From 6e34a089b226640d81628f9bd43ebecf250c30d3 Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:28:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(links):=20stable=20web=20links=20(/open=3F?= =?UTF-8?q?=E2=80=A6)=20alongside=20vixel://=20deep=20links=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep links only open on machines with Vixel installed; the issue asked for a network service so search results can live in notes. With the Local API on (or in the NAS web UI), Copy Web Link / Copy Markdown with Thumbnail give http://:/open?hash=…&t=…, addressed by content hash like deep links: the web UI resolves it (resolveLink, now shared by desktop and server), opens the item and seeks video; signed-out visits go through login and back (same-site next only). Markdown embeds /media/thumbh/. MCP httpUrl is now this page link, raw file as mediaUrl. Clipboard falls back to execCommand on plain-http NAS. Also fixes the video-id fallback when resolving links: link hashes are the representative frame's, so look up the representative by video id instead of the video file hash. --- CHANGELOG.md | 7 ++ README.en.md | 2 +- README.md | 2 +- docs/server/README.md | 4 +- src/core/mcp/server.test.ts | 6 +- src/core/mcp/tools.ts | 22 ++++-- src/main/deeplink.ts | 12 ++-- src/main/index.ts | 31 ++------ src/main/menu.ts | 7 +- src/preload/index.ts | 7 +- src/renderer/src/App.tsx | 40 +++++++++-- .../src/components/inspector/Inspector.tsx | 39 +++++++--- src/renderer/src/lib/webLinks.ts | 56 +++++++++++++++ src/server/apiSurface.ts | 1 + src/server/media.ts | 13 ++-- src/server/webapp.ts | 18 ++++- src/services/api.ts | 27 +++++++ src/services/media.ts | 8 +++ src/shared/deeplink.test.ts | 27 ++++++- src/shared/deeplink.ts | 72 ++++++++++++++----- src/shared/i18n/menu.ts | 4 ++ src/shared/types.ts | 3 +- src/web/api.ts | 1 + 23 files changed, 323 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/lib/webLinks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fe48e..35959b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `127.0.0.1:41721` (port configurable), optional LAN access (binds 0.0.0.0, password required — browse your desktop library from a phone), and a read-only MCP endpoint at `/mcp` (Streamable HTTP, bearer token with copy/regenerate in Settings). +- **Web links that survive re-indexing.** With the Local API on (or in the NAS web UI), Copy Web + Link / Copy Markdown with Thumbnail gives `http://:/open?hash=…&t=…`: it opens the + item in any browser on the network — phones and web-based notes included — and video jumps to + the timestamp; the Markdown form embeds a thumbnail (`/media/thumbh/`) so notes show a + preview. Like deep links they are addressed by content hash. Opening one while signed out goes + to the login page and back. MCP results now carry this page link as `httpUrl` (raw file: + `mediaUrl`). - **MCP for AI clients.** Read-only tools `search_media` / `get_media` / `find_similar` (results carry scores, matched video time ranges, `vixel://` deep links and http media links). Available three ways: desktop Local API `/mcp`, server edition `/mcp` diff --git a/README.en.md b/README.en.md index f508215..6dbc147 100644 --- a/README.en.md +++ b/README.en.md @@ -29,7 +29,7 @@ Local AI search for photos, videos and audio. Fully offline, no account, nothing - **Clean up similar photos.** Finds burst shots, re-exports and lightly edited copies, suggests which one to keep, and hides the rest in Vixel or moves them to the Trash after you confirm. - **Offline map, similar photos, de-duplication, HEIC / RAW, quick look, dark mode.** - **CLI for scripts and agents:** `vixel search "sunset at the beach" --json`. -- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link gives a `vixel://` deep link to paste into Obsidian, Notes or any editor; clicking it opens Vixel at that item (video/audio jumps to the timestamp). Links are addressed by content hash, so they survive re-indexing. +- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link gives a `vixel://` deep link to paste into Obsidian, Notes or any editor; clicking it opens Vixel at that item (video/audio jumps to the timestamp). Links are addressed by content hash, so they survive re-indexing. With the Local API on you can also Copy Web Link / Copy Markdown with Thumbnail: an `http://…/open?…` link that opens on phones and in web-based notes, with an inline preview in Markdown (the NAS web UI offers the same). - **Local API (off by default).** Settings → General → Local API serves a web UI and HTTP API for your desktop library — enable LAN access (password required) to browse it from your phone — plus a read-only MCP endpoint at `/mcp` for AI clients (see below). Video segment search diff --git a/README.md b/README.md index d4b22f8..4be1ff3 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ SCRFD 人脸检测 + MobileFaceNet 512 维特征 + sqlite-vec 近邻索引。 - **HEIC / RAW** —— macOS 上通过系统 `sips` 解码 CR2 / CR3 / NEF / ARW / DNG / RAF / ORF / RW2,保留 EXIF。 - **快速查看** —— 空格预览,⌘1–5 切换 全部 / 图片 / 视频 / 音频 / 地图。 - **命令行** —— `vixel search "海边" --json`,方便脚本和 Agent 调用(见下文);结果里的 `matchedBy` 说明是画面、图中文字还是文件名命中。 -- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接」得到 `vixel://` 深链接,贴进 Obsidian、备忘录等任意笔记,点击回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。 +- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接」得到 `vixel://` 深链接,贴进 Obsidian、备忘录等任意笔记,点击回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。打开了「本地接口」时还能「拷贝网页链接 / 拷贝 Markdown(带缩略图)」:`http://…/open?…` 在手机、网页笔记等装不了 Vixel 的地方也能点开,Markdown 里直接显示缩略图;NAS 版网页里同样能拷贝。 - **本地接口(默认关闭)** —— 设置 → 通用 → 本地接口:在本机开出网页界面和 HTTP API,手机浏览器也能访问桌面的资料库(局域网访问必须设口令);同一服务的 `/mcp` 是给 AI 客户端的只读 MCP 接口(见下文)。 - **中英文界面** —— 跟随系统语言,也可在 设置 → 通用 里切换。 - **诊断** —— `vixel doctor` / `vixel bench` 体检和测速,`VIXEL_PROFILE=1` 打印慢操作,见 [docs/debugging.md](docs/debugging.md)。 diff --git a/docs/server/README.md b/docs/server/README.md index edc7fa1..64ac9d2 100644 --- a/docs/server/README.md +++ b/docs/server/README.md @@ -125,7 +125,7 @@ volumes: 设置 `VIXEL_MCP_TOKEN` 后,服务器在 `/mcp` 提供一个 [MCP](https://modelcontextprotocol.io) 端点(Streamable HTTP),AI 客户端 / Agent 可以用自然语言查询媒体库。工具**全部只读**: -`search_media`(语义 + 图内文字 + 文件名搜索,命中视频时间段,带 vixel:// 深链接和 http 媒体链接)、 +`search_media`(语义 + 图内文字 + 文件名搜索,命中视频时间段,带 vixel:// 深链接、网页查看链接 httpUrl(/open?…,按内容哈希定位)和原文件链接 mediaUrl)、 `get_media`(单项详情:OCR 文字、标签、人物等)、`find_similar`(找相似)。 ```yaml @@ -283,7 +283,7 @@ search instant. Lower `VIXEL_THREADS` (e.g. `2`) for a gentler, slower index. With `VIXEL_MCP_TOKEN` set, the server exposes an [MCP](https://modelcontextprotocol.io) endpoint (Streamable HTTP) at `/mcp`, so AI clients and agents can query the library in natural language. All tools are **read-only**: `search_media` (semantic + text-in-image + -filename search, with matched video time ranges, a vixel:// deep link and an http media link), +filename search, with matched video time ranges, a vixel:// deep link, a web page link `httpUrl` (/open?…, addressed by content hash) and the raw file `mediaUrl`), `get_media` (details of one item: OCR text, tags, people, …) and `find_similar`. ```yaml diff --git a/src/core/mcp/server.test.ts b/src/core/mcp/server.test.ts index c6b61d1..73af29b 100644 --- a/src/core/mcp/server.test.ts +++ b/src/core/mcp/server.test.ts @@ -79,13 +79,15 @@ describe('MCP server core', () => { expect(items[0]).toMatchObject({ id: 1, name: 'beach.jpg', type: 'image', link: 'vixel://open?hash=aa11bb22cc33dd44&id=1', - httpUrl: 'http://127.0.0.1:41721/media/image/1', + httpUrl: 'http://127.0.0.1:41721/open?hash=aa11bb22cc33dd44&id=1', + mediaUrl: 'http://127.0.0.1:41721/media/image/1', }) expect(items[1]).toMatchObject({ videoId: 7, timeRange: { startSec: 32, endSec: 64 }, link: 'vixel://open?hash=deadbeef00112233&video=7&t=32', - httpUrl: 'http://127.0.0.1:41721/media/media/7', + httpUrl: 'http://127.0.0.1:41721/open?hash=deadbeef00112233&video=7&t=32', + mediaUrl: 'http://127.0.0.1:41721/media/media/7', }) }) diff --git a/src/core/mcp/tools.ts b/src/core/mcp/tools.ts index c95f3ff..b0d200f 100644 --- a/src/core/mcp/tools.ts +++ b/src/core/mcp/tools.ts @@ -10,7 +10,7 @@ import type { DatabaseInstance } from '../db' import type { SearchEngine } from '../search' import { TagStore } from '../tagStore' -import { deepLinkForItem } from '../../shared/deeplink' +import { deepLinkForItem, httpLinkForItem } from '../../shared/deeplink' import type { MediaKind, Photo, SearchResult } from '../../shared/types' export interface McpToolContext { @@ -93,11 +93,19 @@ const posInt = (v: unknown): number | undefined => { return Number.isInteger(n) && n > 0 ? n : undefined } -/** http 媒体链接:图片用原图路由,音视频用源文件路由(支持 Range),浏览器里打开仍需登录 */ -function httpUrlFor(photo: Photo, base: string | null | undefined): string | undefined { - if (!base) return undefined +/** + * http 链接(有本地接口 / 服务器地址时才带): + * httpUrl —— 网页查看页 /open?…(按内容 hash 定位,活过重建索引;音视频跳到时间点),给人点、贴进笔记 + * mediaUrl —— 原文件(图片原图 / 音视频源文件,支持 Range),给要下载 / 处理文件的 Agent + * 浏览器里打开仍需登录(有口令时) + */ +function httpLinks(photo: Photo, base: string | null | undefined, timeMs?: number | null): Record { + if (!base) return {} const root = base.replace(/\/$/, '') - return photo.videoId != null ? `${root}/media/media/${photo.videoId}` : `${root}/media/image/${photo.id}` + return { + httpUrl: httpLinkForItem(root, photo, timeMs), + mediaUrl: photo.videoId != null ? `${root}/media/media/${photo.videoId}` : `${root}/media/image/${photo.id}`, + } } function resultItem(ctx: McpToolContext, r: SearchResult, extra: McpCallExtra): Record { @@ -117,7 +125,7 @@ function resultItem(ctx: McpToolContext, r: SearchResult, extra: McpCallExtra): ...(r.segment ? { timeRange: { startSec: r.segment.startMs / 1000, endSec: r.segment.endMs / 1000 } } : {}), ...(p.takenAt ? { takenAt: p.takenAt } : {}), link: deepLinkForItem(p, r.segment?.startMs), - ...(httpUrlFor(p, extra.httpBase) ? { httpUrl: httpUrlFor(p, extra.httpBase) } : {}), + ...httpLinks(p, extra.httpBase, r.segment?.startMs), } } @@ -176,7 +184,7 @@ function getMedia(ctx: McpToolContext, args: Record, extra: Mcp favorite: !!photo.favorite, ...(media ? { segments: media.segments.map((s) => ({ startSec: s.startMs / 1000, endSec: s.endMs / 1000 })) } : {}), link: deepLinkForItem(photo), - ...(httpUrlFor(photo, extra.httpBase) ? { httpUrl: httpUrlFor(photo, extra.httpBase) } : {}), + ...httpLinks(photo, extra.httpBase), } } diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts index 7cac99c..882168e 100644 --- a/src/main/deeplink.ts +++ b/src/main/deeplink.ts @@ -20,7 +20,7 @@ import { t } from '../shared/i18n' export interface DeepLinkDeps { /** 按 target 查库拼出结果(含命中片段);找不到返回 null */ - resolve(target: DeepLinkTarget): SearchResult | null + resolve(target: DeepLinkTarget): Promise /** 在主窗口打开(quickPanel.openResult:窗口不存在时创建,加载完前暂存) */ open(result: SearchResult): void /** 链接指向的内容不在库里:把主窗口带到前台再说明 */ @@ -34,7 +34,7 @@ function isDeepLink(arg: string): boolean { return typeof arg === 'string' && arg.startsWith('vixel://') } -function handle(url: string): void { +async function handle(url: string): Promise { if (!deps) { pendingUrls.push(url) return @@ -44,7 +44,7 @@ function handle(url: string): void { console.warn(`[deeplink] ignoring malformed link: ${url}`) return } - const result = deps.resolve(target) + const result = await deps.resolve(target) if (result) { console.log(`[deeplink] opening ${url}`) deps.open(result) @@ -64,7 +64,7 @@ export function initDeepLinks(): boolean { if (process.platform === 'darwin') { app.on('open-url', (event, url) => { event.preventDefault() - if (isDeepLink(url)) handle(url) + if (isDeepLink(url)) void handle(url) }) return true } @@ -72,7 +72,7 @@ export function initDeepLinks(): boolean { if (!app.requestSingleInstanceLock()) return false app.on('second-instance', (_event, argv) => { const url = argv.find(isDeepLink) - if (url) handle(url) + if (url) void handle(url) else deps?.ensureMainWindow() }) return true @@ -94,5 +94,5 @@ export function activateDeepLinks(d: DeepLinkDeps): void { if (argvUrl && !pendingUrls.includes(argvUrl)) pendingUrls.push(argvUrl) const queued = pendingUrls pendingUrls = [] - for (const url of queued) handle(url) + for (const url of queued) void handle(url) } diff --git a/src/main/index.ts b/src/main/index.ts index a71e38c..a1044a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,10 +34,10 @@ import { setInferenceTransport } from '../core/inference/transport' import { initDeepLinks, activateDeepLinks } from './deeplink' import { LocalApiServer } from './localApi' import { LOCALAPI_CHANNELS, type LocalApiPatch } from '../shared/localapi' -import type { DeepLinkTarget } from '../shared/deeplink' import { createServices, META_KEY_INDEX_ON_BATTERY, type VixelServices } from '../services/api' import { createServiceEvents } from '../services/events' -import { IPC_CHANNELS, type MediaFilters, type ThemeMode, type IndexProgress, type EmbeddingConfig, type ImageQueryInput, type SearchResult } from '../shared/types' +import type { DeepLinkTarget } from '../shared/deeplink' +import { IPC_CHANNELS, type MediaFilters, type ThemeMode, type IndexProgress, type EmbeddingConfig, type ImageQueryInput } from '../shared/types' import { TAG_CHANNELS } from '../shared/tags' import { AUTOTAG_CHANNELS } from '../shared/autotags' import { CLEANUP_CHANNELS } from '../shared/cleanup' @@ -203,28 +203,6 @@ function ensureMainWindow(): BrowserWindow { return win } -/** - * 深链接 → 结果:hash(内容哈希,清库重建 / 移动文件后仍有效)优先,video / photo id 兜底。 - * 时间点尽量落到包含它的已索引片段(详情页时间轴整段高亮),不在任何片段里就按原时间点 seek。 - */ -function resolveDeepLinkTarget(target: DeepLinkTarget): SearchResult | null { - let photo = target.hash ? db.getRepresentativeByHash(target.hash) : undefined - if (!photo && target.videoId != null) { - const video = db.getVideoById(target.videoId) - if (video) photo = db.getRepresentativeByHash(video.fileHash) - } - if (!photo && target.photoId != null) photo = db.getPhoto(target.photoId) - if (!photo) return null - const result: SearchResult = { photo, score: 1 } - if (target.timeMs != null && photo.videoId != null) { - const ms = target.timeMs - // 正好是某个已索引片段的起点("拷贝链接"产出的就是)→ 用整段,详情页时间轴能整段高亮; - // 任意时间点 → 起点保持精确(播放器按 startMs seek),终点借所在片段的 - const seg = db.getMediaDetail(photo.videoId)?.segments.find((s) => ms >= s.startMs && ms < s.endMs) - result.segment = seg && seg.startMs === ms ? seg : { startMs: ms, endMs: Math.max(ms, seg?.endMs ?? ms) } - } - return result -} /** * Windows / Linux:最后一个"真窗口"(主窗口 / 设置)关掉后,若启用了快速搜索快捷键, @@ -533,6 +511,7 @@ function registerIpcHandlers(): void { handle(IPC_CHANNELS.CLEAR_FAILED_TASKS, async () => api.clearFailedTasks()) handle(IPC_CHANNELS.GET_MEDIA_DETAIL, async (_event, videoId: number) => api.getMediaDetail(videoId)) + handle(IPC_CHANNELS.RESOLVE_LINK, async (_event, target: DeepLinkTarget) => api.resolveLink(target)) handle(IPC_CHANNELS.GET_PHOTO_DETAIL, async (_event, photoId: number) => api.getPhotoDetail(photoId)) handle(IPC_CHANNELS.GET_THUMBNAIL, async (_event, photoId: number) => api.getThumbnail(photoId)) handle(IPC_CHANNELS.GET_PHOTO_LOCATIONS, async (_event, photoId: number) => api.getPhotoLocations(photoId)) @@ -549,7 +528,7 @@ function registerIpcHandlers(): void { return win ? popupSmartAlbumMenu(win, String(name ?? '')) : null }) - handle(IPC_CHANNELS.SHOW_ITEM_MENU, async (event, opts: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean }) => { + handle(IPC_CHANNELS.SHOW_ITEM_MENU, async (event, opts: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean; webLinks?: boolean }) => { const win = BrowserWindow.fromWebContents(event.sender) return win ? popupItemMenu(win, opts) : null }) @@ -822,7 +801,7 @@ app.whenReady().then(async () => { createWindow() // 深链接:服务和窗口通道就绪,开始处理(启动期间收到的链接在 deeplink.ts 的队列里等到这一刻) activateDeepLinks({ - resolve: resolveDeepLinkTarget, + resolve: (target) => services.api.resolveLink(target), open: (r) => quickPanel!.openResult(r), ensureMainWindow, }) diff --git a/src/main/menu.ts b/src/main/menu.ts index 42fe86d..40d143e 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -129,7 +129,7 @@ export function installAppMenu( /** 网格项右键菜单:动作由渲染进程执行(它知道选中了哪些项),这里只负责弹出并回传选择 */ export function popupItemMenu( win: BrowserWindow, - { count, isMedia, favorite, canImageSearch }: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean } + { count, isMedia, favorite, canImageSearch, webLinks }: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean; webLinks?: boolean } ): Promise { return new Promise((resolve) => { const pick = (action: ItemMenuAction) => (): void => resolve(action) @@ -146,6 +146,11 @@ export function popupItemMenu( ...(multi ? [] : [ { label: t.menu.copyLink, click: pick('copy-link') }, { label: t.menu.copyMarkdownLink, click: pick('copy-md-link') }, + // 网页链接(http://…/open?…):本地接口开着时才有 —— 手机、网页笔记等装不了 Vixel 的地方也能点开 + ...(webLinks ? [ + { label: t.menu.copyWebLink, click: pick('copy-web-link') }, + { label: t.menu.copyWebMarkdown, click: pick('copy-web-md') }, + ] : []), ] as MenuItemConstructorOptions[]), { label: favorite ? t.organize.unfavorite : t.organize.favorite, accelerator: 'F', registerAccelerator: false, click: pick('toggle-favorite') }, ...(multi ? [] : [ diff --git a/src/preload/index.ts b/src/preload/index.ts index fefc4d7..4739f4d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' import { IPC_CHANNELS, type MediaFilters, type SmartAlbum, type SmartAlbumMenuAction, type SearchResult, type WatchedFolder, type PhotoDetail, type PhotoLocation, type IndexProgress, type ModelStatus, type EmbeddingConfig, type Person, type FaceRecord, type BackupStatus, type MediaKind, type TaskOverview, type MediaDetail, type LibraryCounts, type MenuCommand, type ItemMenuAction, type ThemeMode, type PersonSuggestion, type PersonFace, type ImageQueryInput, type ImageSearchResponse, type SkippedFolder, type VoiceStatus, type VoiceTranscribeResult } from '../shared/types' import type { Locale, LocaleSetting } from '../shared/i18n' +import type { DeepLinkTarget } from '../shared/deeplink' import { CLEANUP_CHANNELS, type DupScanResult, type DupScanProgress, type HiddenItem, type TrashResult } from '../shared/cleanup' import { COLLECT_CHANNELS, type CollectResult, type CollectServerStatus, type InboxInfo, type ItemSource } from '../shared/collect' import { TAG_CHANNELS, type Tag, type TagMenuAction, type TagsForSelection } from '../shared/tags' @@ -76,7 +77,7 @@ const api = { } }, /** 弹出原生右键菜单,resolve 为选中的动作;菜单关闭未选则为 null */ - showItemMenu: (opts: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean }): Promise => { + showItemMenu: (opts: { count: number; isMedia: boolean; favorite: boolean; canImageSearch: boolean; webLinks?: boolean }): Promise => { return ipcRenderer.invoke(IPC_CHANNELS.SHOW_ITEM_MENU, opts) }, /** 收藏 / 取消收藏(按内容 hash,同内容的副本一起变) */ @@ -191,6 +192,10 @@ const api = { getMediaDetail: (videoId: number): Promise => { return ipcRenderer.invoke(IPC_CHANNELS.GET_MEDIA_DETAIL, videoId) }, + /** 链接(vixel://open / 网页 /open 的定位参数)→ 结果;找不到返回 null */ + resolveLink: (target: DeepLinkTarget): Promise => { + return ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_LINK, target) + }, // 系统 / 模型状态 getAppPath: (): Promise => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0f25fee..1f4f859 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -20,7 +20,8 @@ import { thumbUrl } from './lib/mediaUrl' import { sourcePathOf } from './lib/resultActions' import { useSearchHistory, normalizeQuery } from './lib/searchHistory' import { t } from '../../shared/i18n' -import { deepLinkForItem, markdownLinkForItem } from '../../shared/deeplink' +import { deepLinkForItem, markdownLinkForItem, parseLinkParams, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../shared/deeplink' +import { copyText, useWebLinkBase } from './lib/webLinks' import { FilterBar, hasExtraFilters, countExtraFilters, describeExtraFilters, useTags, type ExtraFilters } from './components/shell/FilterBar' import { useSmartAlbums, sameAlbumState, splitAlbumFilters } from './lib/smartAlbums' import type { Tag } from '../../shared/tags' @@ -482,6 +483,22 @@ function App(): JSX.Element { return () => { unsubResult(); unsubSearch() } }, [handleSearch]) + // 网页版的网页链接(/open?hash=…&video=…&t=…,见 shared/deeplink.ts):定位到那一项打开详情, + // 然后把地址栏还原成 /(刷新不会再跳一次)。桌面版走 vixel:// 深链接,由主进程处理 + useEffect(() => { + if (!IS_WEB || location.pathname !== '/open') return + const target = parseLinkParams(new URLSearchParams(location.search)) + window.history.replaceState(null, '', '/') + if (!target) return + const missing = (): void => showCollectToast(`${t.main.deepLinkMissingTitle}${t.common.sep}${t.main.deepLinkMissingDetail}`) + window.api.resolveLink(target).then((r) => { + if (!r) return missing() + setQuickLookId(null) + setSelected(r) + }).catch(missing) + // eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开页面时处理一次 + }, []) + // 搜索历史:查询停稳 1.5 秒且有结果才记为"最近",边打字边出的中间结果不算;以图搜图不记 const history = useSearchHistory() const { addRecent, removeRecent } = history @@ -863,6 +880,19 @@ function App(): JSX.Element { try { await navigator.clipboard.writeText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) } }, []) + // 网页链接(http://…/open?…):本地接口 / 网页版才有;Markdown 形态带按 hash 取的缩略图,笔记里直接显示预览 + const webBase = useWebLinkBase() + const copyWebLinks = useCallback(async (items: SearchResult[], markdown: boolean) => { + if (!webBase) return + const lines = items.map((r) => { + const url = httpLinkForItem(webBase, r.photo, r.segment?.startMs) + return markdown && r.photo.fileHash + ? markdownThumbLinkForItem(r.photo.fileName, url, thumbUrlForHash(webBase, r.photo.fileHash), r.segment?.startMs) + : url + }) + try { await copyText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) } + }, [webBase]) + // 以此图搜索:直接用库里已存的向量(不再过模型),连视频片段一起搜;芯片缩略图用现成的 vixel://thumb const searchWithItem = useCallback((r: SearchResult) => { startImageQuery({ fileHash: r.photo.fileHash }, r.photo.fileName, thumbUrl(r.photo)) @@ -896,11 +926,13 @@ function App(): JSX.Element { case 'copy-path': copyPaths(items); break case 'copy-link': copyDeepLinks(items, false); break case 'copy-md-link': copyDeepLinks(items, true); break + case 'copy-web-link': copyWebLinks(items, false); break + case 'copy-web-md': copyWebLinks(items, true); break case 'search-by-image': searchWithItem(first); break case 'open-external': if (first.photo.videoId != null) window.api.openSourceVideo(first.photo.videoId); break case 'toggle-favorite': toggleFavorite(items); break } - }, [revealItems, copyPaths, copyDeepLinks, searchWithItem, toggleFavorite]) + }, [revealItems, copyPaths, copyDeepLinks, copyWebLinks, searchWithItem, toggleFavorite]) const handleItemContextMenu = useCallback(async (index: number, e: React.MouseEvent) => { e.preventDefault() @@ -919,9 +951,9 @@ function App(): JSX.Element { const isMedia = items.length === 1 && mediaKindOf(r.photo) !== 'image' && r.photo.videoId != null // 音频的代表图是封面 / 波形,没有图片向量,不给"以此图搜索" const canImageSearch = items.length === 1 && mediaKindOf(r.photo) !== 'audio' - const action = await window.api.showItemMenu({ count: items.length, isMedia, favorite: items.every((x) => x.photo.favorite), canImageSearch }) + const action = await window.api.showItemMenu({ count: items.length, isMedia, favorite: items.every((x) => x.photo.favorite), canImageSearch, webLinks: !!webBase }) if (action) runItemAction(action, items) - }, [selectOnly, runItemAction]) + }, [selectOnly, runItemAction, webBase]) // 当前"主项":焦点项,否则第一个选中项 const primary = useMemo(() => { diff --git a/src/renderer/src/components/inspector/Inspector.tsx b/src/renderer/src/components/inspector/Inspector.tsx index c248d99..20978c3 100644 --- a/src/renderer/src/components/inspector/Inspector.tsx +++ b/src/renderer/src/components/inspector/Inspector.tsx @@ -4,7 +4,8 @@ import type { ItemSource } from '../../../../shared/collect' import { formatDuration, formatFileSize, mediaKindOf } from '../../lib/format' import { downloadOriginal, imageUrl, mediaSrcUrl, thumbUrl } from '../../lib/mediaUrl' import { IS_WEB, revealLabel } from '../../lib/platform' -import { deepLinkForItem, markdownLinkForItem } from '../../../../shared/deeplink' +import { deepLinkForItem, markdownLinkForItem, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../../../shared/deeplink' +import { copyText, useWebLinkBase } from '../../lib/webLinks' import { Icon } from '../shell/icons' import { TagEditor } from './TagEditor' import { designBadgeOf } from '../media/MediaCard' @@ -89,7 +90,17 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: const copyDeepLink = (markdown: boolean): void => { const url = deepLinkForItem(photo, segment?.startMs) const text = markdown ? markdownLinkForItem(media?.fileName ?? photo.fileName, url, segment?.startMs) : url - void navigator.clipboard.writeText(text).catch((e) => console.error('Copy failed:', e)) + void copyText(text).catch((e) => console.error('Copy failed:', e)) + } + // 网页链接(http://…/open?…,同样按 hash 定位):网页版总有;桌面版本地接口开着时才有 + const webBase = useWebLinkBase() + const copyWebLink = (markdown: boolean): void => { + if (!webBase) return + const url = httpLinkForItem(webBase, photo, segment?.startMs) + const text = markdown && photo.fileHash + ? markdownThumbLinkForItem(media?.fileName ?? photo.fileName, url, thumbUrlForHash(webBase, photo.fileHash), segment?.startMs) + : url + void copyText(text).catch((e) => console.error('Copy failed:', e)) } // 音频代表图没有图片向量,embed_status 永远 pending,不能算"索引中" const indexState = kind === 'audio' || photo.embedStatus === 'done' @@ -262,13 +273,17 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution:
{IS_WEB ? ( - // 网页版:"在访达中显示"换成下载原文件;系统播放器不可用,隐藏 - downloadOriginal(isMedia ? mediaSrcUrl(photo.videoId!) : imageUrl(photo.id), media?.fileName ?? photo.fileName)} - > - {t.web.downloadOriginal} - + <> + {/* 网页版:"在访达中显示"换成下载原文件;系统播放器不可用,隐藏 */} + downloadOriginal(isMedia ? mediaSrcUrl(photo.videoId!) : imageUrl(photo.id), media?.fileName ?? photo.fileName)} + > + {t.web.downloadOriginal} + + copyWebLink(false)}>{t.menu.copyLink} + copyWebLink(true)}>{t.menu.copyWebMarkdown} + ) : ( <> sourcePath && window.api.showInFinder(sourcePath)}>{revealLabel} @@ -280,6 +295,12 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: {/* 深链接(vixel://open?…):贴进笔记后点击回到 Vixel 定位;网页版隐藏(等价物是 http 链接) */} copyDeepLink(false)}>{t.menu.copyLink} copyDeepLink(true)}>{t.menu.copyMarkdownLink} + {webBase && ( + <> + copyWebLink(false)}>{t.menu.copyWebLink} + copyWebLink(true)}>{t.menu.copyWebMarkdown} + + )} )}
diff --git a/src/renderer/src/lib/webLinks.ts b/src/renderer/src/lib/webLinks.ts new file mode 100644 index 0000000..3c50e7f --- /dev/null +++ b/src/renderer/src/lib/webLinks.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { IS_WEB } from './platform' + +/** + * 网页链接(http://…/open?…,见 shared/deeplink.ts)的基址: + * - 网页版:当前站点(NAS 地址 / 桌面"本地接口"地址),浏览器里打开就是这个 + * - 桌面版:"本地接口"在跑时取它的地址 —— 开了局域网访问优先给局域网地址(手机、别的电脑能点开), + * 否则 127.0.0.1;没开本地接口返回 null,界面不出"拷贝网页链接" + */ +export async function webLinkBase(): Promise { + if (IS_WEB) return location.origin + try { + const s = await window.api.localApi.status() + if (!s.running || s.urls.length === 0) return null + const lan = s.lan ? s.urls.find((u) => !/\/\/(127\.0\.0\.1|localhost|\[::1\])/.test(u)) : undefined + return lan ?? s.urls[0] + } catch { + return null + } +} + +/** 组件里用:挂载时取一次,窗口重新获得焦点时刷新(设置里开关本地接口后回来就是新的) */ +export function useWebLinkBase(): string | null { + const [base, setBase] = useState(IS_WEB ? location.origin : null) + useEffect(() => { + if (IS_WEB) return + let alive = true + const refresh = (): void => { void webLinkBase().then((b) => { if (alive) setBase(b) }) } + refresh() + window.addEventListener('focus', refresh) + return () => { alive = false; window.removeEventListener('focus', refresh) } + }, []) + return base +} + +/** + * 写剪贴板:navigator.clipboard 只在安全上下文(HTTPS / localhost)可用 —— + * NAS 走 http://192.168.x.x 时拿不到,退回隐藏 textarea + execCommand('copy') + */ +export async function copyText(text: string): Promise { + if (window.isSecureContext && navigator.clipboard) { + await navigator.clipboard.writeText(text) + return + } + const ta = document.createElement('textarea') + ta.value = text + ta.setAttribute('readonly', '') + ta.style.cssText = 'position:fixed;top:-1000px;opacity:0' + document.body.appendChild(ta) + ta.select() + try { + if (!document.execCommand('copy')) throw new Error('copy command rejected') + } finally { + ta.remove() + } +} diff --git a/src/server/apiSurface.ts b/src/server/apiSurface.ts index f7536b9..4a02a08 100644 --- a/src/server/apiSurface.ts +++ b/src/server/apiSurface.ts @@ -40,6 +40,7 @@ export const IMPLEMENTED_METHODS = [ 'retryFailedTasks', 'clearFailedTasks', 'getMediaDetail', + 'resolveLink', 'getAppPath', 'getModelStatus', 'getEmbeddingConfig', diff --git a/src/server/media.ts b/src/server/media.ts index c9b23f5..8472a73 100644 --- a/src/server/media.ts +++ b/src/server/media.ts @@ -61,18 +61,21 @@ export interface MediaRouteDeps { } /** - * 处理 GET /media//。返回 true 表示本函数已经响应(无论成败)。 + * 处理 GET /media//,以及按内容哈希的缩略图 GET /media/thumbh/(网页链接里内嵌的预览, + * 活过重建索引)。返回 true 表示本函数已经响应(无论成败)。 */ export async function handleMediaRequest(pathname: string, res: ServerResponse, rangeHeader: string | null, deps: MediaRouteDeps): Promise { - const m = /^\/media\/(thumb|image|media|sprite|face)\/(\d+)$/.exec(pathname) - if (!m) return false - const kind = m[1] - const id = parseInt(m[2], 10) + const byHash = /^\/media\/thumbh\/([0-9a-f]{8,64})$/i.exec(pathname) + const m = byHash ? null : /^\/media\/(thumb|image|media|sprite|face)\/(\d+)$/.exec(pathname) + if (!byHash && !m) return false + const kind = byHash ? 'thumbh' : m![1] + const id = m ? parseInt(m[2], 10) : 0 const { services } = deps try { let path: string | null = null switch (kind) { + case 'thumbh': path = services.media.thumbPathByHash(byHash![1]); break case 'thumb': path = services.media.thumbPath(id); break case 'image': path = services.media.imagePath(id); break case 'media': path = services.media.mediaPath(id); break diff --git a/src/server/webapp.ts b/src/server/webapp.ts index 2f1fa28..1f76bfc 100644 --- a/src/server/webapp.ts +++ b/src/server/webapp.ts @@ -127,7 +127,10 @@ export function renderLoginPage(locale: 'zh' | 'en'): string { headers: { 'content-type': 'application/json', 'x-vixel': '1' }, body: JSON.stringify({ password: pw.value }), }) - if (res.ok) { location.replace('/'); return } + // 从 /open?… 这类链接被导过来的:登录后回到原地(只认站内路径,防开放重定向) + const next = new URLSearchParams(location.search).get('next') + const ok = next && next.charAt(0) === '/' && next.charAt(1) !== '/' && next.charCodeAt(1) !== 92 // 92 = 反斜杠:浏览器把「/反斜杠…」也当成 //… + if (res.ok) { location.replace(ok ? next : '/'); return } err.textContent = res.status === 429 ? ${js(s.rateLimited)} : ${js(s.wrongPassword)} } catch { err.textContent = ${js(s.networkError)} @@ -147,6 +150,12 @@ export interface WebAppDeps { isAuthed(req: IncomingMessage): boolean } +/** 登录页的 next 参数:只接受站内路径(/ 开头,第二个字符不是 / 或 \\ —— 浏览器把 /\\x 当成 //x),防开放重定向 */ +function safeNext(req: IncomingMessage): string | null { + const next = new URL(req.url ?? '/', 'http://x').searchParams.get('next') + return next && /^\/(?![\/\\])/.test(next) ? next : null +} + /** * 处理 GET/HEAD 的网页前端请求(/api、/media、/healthz 之外的所有路径)。 * 总是响应(含 404),调用方不再兜底。 @@ -155,13 +164,16 @@ export async function handleWebRequest(pathname: string, req: IncomingMessage, r const authed = deps.isAuthed(req) if (pathname === '/login') { - if (authed) return redirect(res, '/') + if (authed) return redirect(res, safeNext(req) ?? '/') return sendHtml(res, 200, renderLoginPage(loginLocale(req))) } if (!authed) { // 浏览器导航去登录页;fetch/资源请求与 API 同样给 401 - if ((req.headers.accept ?? '').includes('text/html')) return redirect(res, '/login') + // 打开的是网页链接(/open?…):带上原地址,登录后回来 + if ((req.headers.accept ?? '').includes('text/html')) { + return redirect(res, pathname === '/open' ? `/login?next=${encodeURIComponent(req.url ?? '/')}` : '/login') + } sendJson(res, 401, { ok: false, error: 'Not authenticated' }) return } diff --git a/src/services/api.ts b/src/services/api.ts index c040cae..dcad0a0 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -26,6 +26,7 @@ import { CleanupStore } from '../core/cleanupStore' import { CollectStore } from '../core/collectStore' import { resolveLocale, setLocale, type LocaleSetting } from '../shared/i18n' import type { MediaFilters, ThemeMode, EmbeddingConfig, PersonSuggestion, PersonFace, ImageQueryInput, ImageSearchResponse, SearchResult } from '../shared/types' +import type { DeepLinkTarget } from '../shared/deeplink' import type { DupScanProgress } from '../shared/cleanup' import type { ServiceEvents } from './events' import { BackupService } from './backup' @@ -219,6 +220,32 @@ function buildApi(ctx: ServiceContext, deps: { }, // ─── 照片 / 媒体详情 ───────────────────────────────────── + /** + * 链接(vixel://open 深链接 / 网页 /open)→ 结果:hash(内容哈希,清库重建 / 移动文件后仍有效)优先, + * video / photo id 兜底。时间点尽量落到包含它的已索引片段(详情页时间轴整段高亮), + * 不在任何片段里就按原时间点 seek。找不到返回 null。 + */ + resolveLink: async (target: DeepLinkTarget): Promise => { + if (!target || typeof target !== 'object') return null + let photo = target.hash ? db.getRepresentativeByHash(target.hash) : undefined + if (!photo && target.videoId != null) { + // 链接里的 hash 是代表图(首帧 / 封面)的,不是视频文件的 —— 按视频 id 找它的代表图行 + const frame = db.getFramePhotosByVideo(target.videoId)[0] + if (frame?.fileHash) photo = db.getRepresentativeByHash(frame.fileHash) + } + if (!photo && target.photoId != null) photo = db.getPhoto(target.photoId) + if (!photo) return null + const result: SearchResult = { photo, score: 1 } + if (target.timeMs != null && photo.videoId != null) { + const ms = target.timeMs + // 正好是某个已索引片段的起点("拷贝链接"产出的就是)→ 用整段,详情页时间轴能整段高亮; + // 任意时间点 → 起点保持精确(播放器按 startMs seek),终点借所在片段的 + const seg = db.getMediaDetail(photo.videoId)?.segments.find((s) => ms >= s.startMs && ms < s.endMs) + result.segment = seg && seg.startMs === ms ? seg : { startMs: ms, endMs: Math.max(ms, seg?.endMs ?? ms) } + } + return result + }, + getMediaDetail: async (videoId: number) => { const detail = db.getMediaDetail(videoId) // 老版本索引的视频没记分辨率:第一次打开时探一下补上(ffmpeg -i 只读文件头,几十毫秒) diff --git a/src/services/media.ts b/src/services/media.ts index c83bb45..c7cc461 100644 --- a/src/services/media.ts +++ b/src/services/media.ts @@ -17,6 +17,8 @@ import type { FaceBbox } from '../shared/types' export interface MediaResolvers { /** vixel://thumb/ → /thumbnails/.webp;没生成好时 null(调用方 404,卡片会重试) */ thumbPath(photoId: number): string | null + /** 按内容哈希取缩略图(网页链接 / Markdown 内嵌预览用,活过重建索引);不在库里或没生成时 null */ + thumbPathByHash(hash: string): string | null /** vixel://image/ → 原图路径 */ imagePath(photoId: number): string | null /** vixel://media/ → 音视频源文件路径(调用方负责 Range) */ @@ -64,6 +66,12 @@ export function createMediaResolvers(db: DatabaseInstance, indexer: Indexer, dat return existsSync(thumbPath) ? thumbPath : null }, + thumbPathByHash(hash) { + if (!/^[0-9a-f]{8,64}$/i.test(hash) || !db.getRepresentativeByHash(hash.toLowerCase())) return null + const thumbPath = indexer.getThumbnailPath(hash.toLowerCase()) + return existsSync(thumbPath) ? thumbPath : null + }, + imagePath(photoId) { const photo = db.getPhoto(photoId) if (!photo || !existsSync(photo.filePath)) return null diff --git a/src/shared/deeplink.test.ts b/src/shared/deeplink.test.ts index 0754bf9..93239f6 100644 --- a/src/shared/deeplink.test.ts +++ b/src/shared/deeplink.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest' -import { buildDeepLink, deepLinkForItem, formatLinkTime, markdownLinkForItem, parseDeepLink } from './deeplink' +import { + buildDeepLink, deepLinkForItem, formatLinkTime, markdownLinkForItem, parseDeepLink, + buildHttpLink, httpLinkForItem, parseLinkParams, thumbUrlForHash, markdownThumbLinkForItem, +} from './deeplink' describe('buildDeepLink / parseDeepLink', () => { it('round-trips a photo link (hash + id)', () => { @@ -65,3 +68,25 @@ describe('markdown link', () => { .toBe('[a\\[b\\].jpg](vixel://open?id=1)') }) }) + +describe('web links (/open?…)', () => { + const base = 'http://192.168.1.15:41721/' + it('builds the same params as the deep link, on an http base (trailing slash tolerated)', () => { + expect(buildHttpLink(base, { hash: 'deadbeef00112233', videoId: 7, timeMs: 32_000 })) + .toBe('http://192.168.1.15:41721/open?hash=deadbeef00112233&video=7&t=32') + expect(httpLinkForItem('http://nas:8080', { id: 5, fileHash: 'aa11bb22cc33dd44', videoId: null })) + .toBe('http://nas:8080/open?hash=aa11bb22cc33dd44&id=5') + }) + it('round-trips through parseLinkParams (what the web UI does with location.search)', () => { + const url = new URL(buildHttpLink(base, { hash: 'deadbeef00112233', videoId: 7, timeMs: 32_000 })) + expect(parseLinkParams(url.searchParams)).toEqual({ hash: 'deadbeef00112233', videoId: 7, timeMs: 32_000 }) + expect(parseLinkParams(new URLSearchParams('t=5'))).toBeNull() + expect(parseLinkParams(new URLSearchParams('hash=not-hex'))).toBeNull() + }) + it('thumbnail by hash and the Markdown embed', () => { + const thumb = thumbUrlForHash(base, 'aa11bb22cc33dd44') + expect(thumb).toBe('http://192.168.1.15:41721/media/thumbh/aa11bb22cc33dd44') + expect(markdownThumbLinkForItem('a [b].mp4', 'http://x/open?hash=1', thumb, 42_000)) + .toBe('[![a \\[b\\].mp4 @ 0:42](http://192.168.1.15:41721/media/thumbh/aa11bb22cc33dd44)](http://x/open?hash=1)') + }) +}) diff --git a/src/shared/deeplink.ts b/src/shared/deeplink.ts index 1021896..989f849 100644 --- a/src/shared/deeplink.ts +++ b/src/shared/deeplink.ts @@ -11,6 +11,9 @@ * * hash 是内容哈希(xxHash64):清库重建、移动文件、换文件夹后 id 都会变,hash 只随内容变 —— * 解析时 hash 优先,id 只作内容已变 / 旧版链接的兜底,所以链接能活过重建索引。 + * + * 网页链接用同一套参数:http://<主机>:<端口>/open?hash=…&video=…&t=… —— 网页版(NAS / 桌面"本地接口") + * 打开 /open 时按同样规则定位,链接同样活过重建索引;能在手机、网页笔记等装不了 Vixel 的地方点开。 */ import type { Photo } from './types' @@ -38,8 +41,8 @@ const posInt = (v: string | null): number | undefined => { /** 内容哈希:xxHash64 的 16 位十六进制(别把任意字符串塞进 SQL 查询参数) */ const isHash = (v: string): boolean => /^[0-9a-f]{8,64}$/i.test(v) -/** 构建 vixel://open 链接;timeMs 只在有 videoId 时写入(取整到秒,链接可读) */ -export function buildDeepLink(target: DeepLinkTarget): string { +/** 定位参数(vixel://open 和网页 /open 共用);timeMs 只在有 videoId 时写入(取整到秒,链接可读) */ +function linkParams(target: DeepLinkTarget): URLSearchParams { const params = new URLSearchParams() if (target.hash) params.set('hash', target.hash) if (target.videoId != null) { @@ -48,16 +51,39 @@ export function buildDeepLink(target: DeepLinkTarget): string { } else if (target.photoId != null) { params.set('id', String(target.photoId)) } - return `${DEEPLINK_SCHEME}://${DEEPLINK_HOST}?${params.toString()}` + return params +} + +/** 构建 vixel://open 链接 */ +export function buildDeepLink(target: DeepLinkTarget): string { + return `${DEEPLINK_SCHEME}://${DEEPLINK_HOST}?${linkParams(target).toString()}` } -/** 结果项 → 链接:媒体(videoId 非空)带时间点,图片不带 */ +/** 构建网页链接 /open?…(base 如 http://192.168.1.15:41721,末尾斜杠可有可无) */ +export function buildHttpLink(base: string, target: DeepLinkTarget): string { + return `${base.replace(/\/+$/, '')}/open?${linkParams(target).toString()}` +} + +/** 结果项 → 定位目标:媒体(videoId 非空)带时间点,图片不带 */ +export function linkTargetForItem(photo: Pick, timeMs?: number | null): DeepLinkTarget { + return photo.videoId != null + ? { hash: photo.fileHash || undefined, videoId: photo.videoId, timeMs: timeMs ?? undefined } + : { hash: photo.fileHash || undefined, photoId: photo.id } +} + +/** 结果项 → vixel:// 链接 */ export function deepLinkForItem(photo: Pick, timeMs?: number | null): string { - return buildDeepLink( - photo.videoId != null - ? { hash: photo.fileHash || undefined, videoId: photo.videoId, timeMs: timeMs ?? undefined } - : { hash: photo.fileHash || undefined, photoId: photo.id } - ) + return buildDeepLink(linkTargetForItem(photo, timeMs)) +} + +/** 结果项 → 网页链接 */ +export function httpLinkForItem(base: string, photo: Pick, timeMs?: number | null): string { + return buildHttpLink(base, linkTargetForItem(photo, timeMs)) +} + +/** 按内容哈希取缩略图的网页地址(Markdown 内嵌预览用;同样活过重建索引) */ +export function thumbUrlForHash(base: string, hash: string): string { + return `${base.replace(/\/+$/, '')}/media/thumbh/${hash}` } /** @@ -72,15 +98,20 @@ export function parseDeepLink(url: string): DeepLinkTarget | null { return null } if (u.protocol !== `${DEEPLINK_SCHEME}:` || u.host !== DEEPLINK_HOST) return null + return parseLinkParams(u.searchParams) +} + +/** 解析定位参数(网页 /open 的 location.search 也走这里);缺所有定位参数、数值非法返回 null */ +export function parseLinkParams(params: URLSearchParams): DeepLinkTarget | null { const target: DeepLinkTarget = {} - const hash = u.searchParams.get('hash') + const hash = params.get('hash') if (hash && isHash(hash)) target.hash = hash.toLowerCase() - const photoId = posInt(u.searchParams.get('id')) + const photoId = posInt(params.get('id')) if (photoId) target.photoId = photoId - const videoId = posInt(u.searchParams.get('video')) + const videoId = posInt(params.get('video')) if (videoId) target.videoId = videoId if (!target.hash && !target.photoId && !target.videoId) return null - const t = u.searchParams.get('t') + const t = params.get('t') if (t) { const sec = Number(t) if (Number.isFinite(sec) && sec >= 0) target.timeMs = Math.round(sec * 1000) @@ -97,8 +128,17 @@ export function formatLinkTime(ms: number): string { return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}` } -/** `[文件名 @ 0:42](vixel://open?…)`;名字里的 [ ] 转义,别破坏 Markdown 结构 */ +/** Markdown 链接文字:文件名([ ] 和反斜杠转义,别破坏 Markdown 结构)+ 时间点 */ +function linkLabel(fileName: string, timeMs?: number | null): string { + return `${fileName.replace(/([[\]\\])/g, '\\$1')}${timeMs != null && timeMs > 0 ? ` @ ${formatLinkTime(timeMs)}` : ''}` +} + +/** `[文件名 @ 0:42](vixel://open?…)` */ export function markdownLinkForItem(fileName: string, url: string, timeMs?: number | null): string { - const label = `${fileName.replace(/([[\]\\])/g, '\\$1')}${timeMs != null && timeMs > 0 ? ` @ ${formatLinkTime(timeMs)}` : ''}` - return `[${label}](${url})` + return `[${linkLabel(fileName, timeMs)}](${url})` +} + +/** 带缩略图的 Markdown:`[![文件名 @ 0:42](缩略图)](链接)` —— 笔记里直接显示预览,点击打开 */ +export function markdownThumbLinkForItem(fileName: string, url: string, thumbUrl: string, timeMs?: number | null): string { + return `[![${linkLabel(fileName, timeMs)}](${thumbUrl})](${url})` } diff --git a/src/shared/i18n/menu.ts b/src/shared/i18n/menu.ts index 299b131..47f0878 100644 --- a/src/shared/i18n/menu.ts +++ b/src/shared/i18n/menu.ts @@ -38,6 +38,8 @@ const zh = { copyPaths: (n: number) => `拷贝 ${n} 个路径`, copyLink: '拷贝链接', copyMarkdownLink: '拷贝 Markdown 链接', + copyWebLink: '拷贝网页链接', + copyWebMarkdown: '拷贝 Markdown(带缩略图)', openInPlayer: '在系统播放器中打开', } @@ -79,6 +81,8 @@ const en: typeof zh = { copyPaths: (n) => `Copy ${n} Paths`, copyLink: 'Copy Link', copyMarkdownLink: 'Copy Markdown Link', + copyWebLink: 'Copy Web Link', + copyWebMarkdown: 'Copy Markdown with Thumbnail', openInPlayer: 'Open in System Player', } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7e1cd35..69543b0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,7 +30,7 @@ export type MenuCommand = | 'source:cleanup' /** 网格右键菜单(原生 Menu.popup)里用户选中的动作 */ -export type ItemMenuAction = 'open' | 'quick-look' | 'reveal' | 'copy-path' | 'copy-link' | 'copy-md-link' | 'search-by-image' | 'open-external' | 'toggle-favorite' +export type ItemMenuAction = 'open' | 'quick-look' | 'reveal' | 'copy-path' | 'copy-link' | 'copy-md-link' | 'copy-web-link' | 'copy-web-md' | 'search-by-image' | 'open-external' | 'toggle-favorite' /** 画面方向:全景 = 长宽比 ≥ 2:1(横竖都算),方形 = 长短边相差 10% 以内 */ export type Orientation = 'landscape' | 'portrait' | 'square' | 'panorama' @@ -357,6 +357,7 @@ export const IPC_CHANNELS = { RETRY_FAILED_TASKS: 'retry-failed-tasks', CLEAR_FAILED_TASKS: 'clear-failed-tasks', GET_MEDIA_DETAIL: 'get-media-detail', + RESOLVE_LINK: 'resolve-link', // 电源策略(设置 › 通用:"电池供电时也索引") GET_INDEX_ON_BATTERY: 'get-index-on-battery', diff --git a/src/web/api.ts b/src/web/api.ts index 14ab3b3..4b4822f 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -217,6 +217,7 @@ export async function createWebApi(): Promise { retryFailedTasks: (ids?: number[]) => post('retryFailedTasks', argsOf(ids)), clearFailedTasks: () => post('clearFailedTasks'), getMediaDetail: (videoId: number) => post('getMediaDetail', [videoId]), + resolveLink: (target: import('../shared/deeplink').DeepLinkTarget) => post('resolveLink', [target]), // 系统 / 模型状态 getAppPath: () => post('getAppPath'),