From ae15e5bba443f3b40820bf66ad76ab54476d3f6f Mon Sep 17 00:00:00 2001 From: SuperMonster003 <30370009+SuperMonster003@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:10:07 +0800 Subject: [PATCH 1/3] feat: add opt-in snapshot v2 contract --- MEDIAINFO_NATIVE_JSON.md | 18 +-- MEDIAINFO_SNAPSHOT_V2.md | 124 +++++++++++++++ ROADMAP.md | 10 +- .../MediaInfoSnapshotV2ContractTest.kt | 144 ++++++++++++++++++ .../mediainfo/MediainfoPluginServiceTest.kt | 81 ++++++++++ .../mediainfo/MediainfoReleaseSmokeTest.kt | 20 +++ .../plugin/mediainfo/MediaInfoResultCache.kt | 3 +- .../mediainfo/MediainfoPluginService.kt | 92 ++++++++--- .../plugin/mediainfo/MediainfoSnapshot.kt | 136 +++++++++++++++++ .../plugin/mediainfo/PluginRuntimeInfo.kt | 13 +- .../mediainfo/MediaInfoReportParserTest.kt | 41 +++++ .../mediainfo/MediaInfoResultCacheTest.kt | 19 +++ 12 files changed, 667 insertions(+), 34 deletions(-) create mode 100644 MEDIAINFO_SNAPSHOT_V2.md create mode 100644 app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt diff --git a/MEDIAINFO_NATIVE_JSON.md b/MEDIAINFO_NATIVE_JSON.md index fd857ba..348b62d 100644 --- a/MEDIAINFO_NATIVE_JSON.md +++ b/MEDIAINFO_NATIVE_JSON.md @@ -4,12 +4,12 @@ MediaInfoLib 26.05 的 `Output=JSON` 在当前四 ABI 构建配置中可用, JSON 语法和外层结构稳定, 适合作为未来结构化接口的数据源. 它不能在不改变语义的情况下直接替换 `autojs6-plugin-mediainfo-snapshot-v1`: 原生 JSON 使用机器字段名和原始数值, 可包含嵌套对象及上游诊断字段, 而 v1 使用文本报告中的显示标签, 格式化值和插件生成的 `file` 小节. -因此 v2.1.x 的决定是: +因此当前演进决定是: 1. 保留内部 `MediaInfo.getMIJson()` 与 JNI 安全切换能力, 作为测试和后续 schema 设计的基础. -2. `inform`, `get`, `snapshot` 三个公开行为和 AIDL 均不变; `snapshot-v1` 继续以文本报告解析结果为唯一数据源. -3. 不对原生 JSON 做静默透传, 也不在上游更新时自动改变公开快照. -4. 如后续引入 `snapshot-v2`, 必须采用新的 schema 标识和显式宿主协商, 并由插件维护稳定字段映射, 单位与类型规则. +2. `inform`, `get` 与缺省 `snapshot` 行为及 AIDL 方法签名不变; `snapshot-v1` 继续以文本报告解析结果为唯一数据源. +3. 插件侧提供显式 opt-in 的 `snapshot-v2` 稳定 envelope, 但不对原生根对象做透传, 也不在上游更新时自动替换 v1. +4. 宿主与共享 API 接入前, v2 只作为兼容基础和测试面存在; 完整契约与协同边界见 [`MEDIAINFO_SNAPSHOT_V2.md`](MEDIAINFO_SNAPSHOT_V2.md). ## 官方源码审查 @@ -72,13 +72,13 @@ MediaInfoLib 26.05 的 `Output=JSON` 在当前四 ABI 构建配置中可用, JSO | 结构 | section -> array -> string map | 字符串, 属性对象与嵌套 `extra` 可混合 | v1 的 `Map` 模型不能无损承载 | | 诊断字段 | 当前损坏样本只保留四个 General 显示字段 | 另含 `extra.IsTruncated=Yes` | 非稳定诊断信息会重新进入公开契约 | -## 后续 snapshot-v2 门槛 +## snapshot-v2 门槛与当前进度 -未来如推进 `snapshot-v2`, 至少应满足: +插件侧第一阶段已经满足显式 schema 标识, v1 缺省兼容, 原生 envelope 校验, 多流分组, `@` 属性 / `extra` 分区, schema 缓存隔离和当前 26.05 实机往返测试. 仍需满足的协同与长期门槛包括: -- 使用新的 AIDL/API 能力协商与 `schema` 值, 不复用 v1 名称. -- 明确区分稳定规范字段, 上游扩展字段和显示文本; 不把整个 track 对象无版本透传. +- 在共享 API 中固化 capability / option 常量, 并由宿主显式协商 `schema`; 不复用 v1 名称. +- 继续区分稳定 envelope, 上游扩展字段和显示文本; 不把整个 track 对象无版本透传. - 为时长, 大小, 码率, 采样率等定义类型与单位, 同时决定是否保留显示值. -- 统一重复流, `@typeorder`, nested `extra` 和二进制 / cover data 的表示及大小上限. +- 评估 `fields` 中常用字段的可选强类型视图, 并定义二进制 / cover data 的表示及大小上限. - 固定至少前一稳定版与当前稳定版的 JSON fixtures, 并在每次上游更新 Draft PR 中审阅 schema diff. - 使用现有 MP4, WebM, FLAC, 损坏文件, 多流文件与大文件矩阵回归; 上游升级不得自动合并或发布结构变化. diff --git a/MEDIAINFO_SNAPSHOT_V2.md b/MEDIAINFO_SNAPSHOT_V2.md new file mode 100644 index 0000000..8c20510 --- /dev/null +++ b/MEDIAINFO_SNAPSHOT_V2.md @@ -0,0 +1,124 @@ +# MediaInfo snapshot-v2 契约 + +## 状态与边界 + +本文档定义 `autojs6-plugin-mediainfo-snapshot-v2` 的插件侧契约. 当前实现是 M2 的兼容基础, 尚未替换 v1, 也尚未接入 AutoJs6 的 Node / Rhino 公共 API. 在宿主和共享 `mediainfo-api` 完成协同改造前, 普通脚本仍只会得到 v1. + +设计遵循三个边界: + +1. v1 是缺省协议, 现有调用无需修改且输出语义不变. +2. v2 必须由调用方显式选择, 不根据插件版本或 MediaInfoLib 版本自动切换. +3. MediaInfoLib 原生 JSON 只是数据源. 插件拥有 v2 外层契约, 不把上游根对象直接作为公共快照返回. + +## Schema 协商 + +现有 AIDL 已允许 `snapshot(fd, displayName, Bundle options)`, 因此插件侧无需修改 Binder 方法签名. `options` 使用以下值: + +| Bundle 键 | 类型 | 值 | 行为 | +| --- | --- | --- | --- | +| `schema` | `String` | 缺失, `null`, 空白或 `autojs6-plugin-mediainfo-snapshot-v1` | 返回 v1 | +| `schema` | `String` | `autojs6-plugin-mediainfo-snapshot-v2` | 返回 v2 | +| `schema` | `String` | 其他值 (包括在标识两侧增加空白) | 以 `IllegalArgumentException` 拒绝, 不猜测或修正版本 | +| `includeInform` | `Boolean` | 缺省 `true` | 决定 `inform` 是否携带文本报告 | +| `includeSections` | `Boolean` | 缺省 `true` | v1 决定 `sections`; v2 为保持现有选项 ABI, 决定 `tracks` | + +插件通过 `PluginInfo.capabilities` 广告能力: + +| capability 键 | 类型 | 当前值 | +| --- | --- | --- | +| `snapshotSchemas` | `String[]` | v1, v2, 按优先兼容顺序排列 | +| `defaultSnapshotSchema` | `String` | `autojs6-plugin-mediainfo-snapshot-v1` | +| `engineVersion` | `String` | MediaInfoLib `Info_Version` 去除首尾空白后的非空结果; 查询失败时省略 | + +共享 API 后续应为这些字符串增加常量. 在此之前, 插件用内部常量实现并测试协议. 这些附加 capability 不改变现有 `REQUIRES_HOST_VERSION = 3923`, 因为旧宿主会忽略未知 Bundle 键, 且缺省 snapshot 仍是 v1. + +`Info_Parameters` 不放入发现阶段的 capability Bundle. 该值体积大, 应在后续通过专用 AIDL 查询按需取得, 避免每次插件发现都跨 Binder 传输完整参数表. + +## v2 Envelope + +示例: + +```json +{ + "schema": "autojs6-plugin-mediainfo-snapshot-v2", + "file": { + "name": "movie.mkv", + "sizeBytes": 4096 + }, + "engine": { + "name": "MediaInfoLib", + "version": "26.05", + "url": "https://mediaarea.net/MediaInfo" + }, + "inform": "", + "tracks": { + "general": [ + { + "fields": { + "Format": "Matroska", + "FileSize": 4096 + } + } + ], + "audio": [ + { + "fields": { + "Format": "PCM", + "SamplingRate": 48000 + }, + "attributes": { + "@typeorder": 1 + }, + "extra": { + "IsTruncated": "Yes" + } + } + ] + } +} +``` + +外层规则: + +| 路径 | 契约 | +| --- | --- | +| `schema` | 始终为完整 v2 标识 | +| `file.name` | 调用方传入的显示名; 无值时为空字符串 | +| `file.sizeBytes` | 插件实际解析的数据源大小, JSON 整数 | +| `engine.name` | 原生 `creatingLibrary.name`, 必须为非空字符串 | +| `engine.version` | 原生 `creatingLibrary.version`, 必须为非空字符串 | +| `engine.url` | 原生值为非空字符串时才出现; 其他 `creatingLibrary` 字段不透传 | +| `inform` | `includeInform=true` 时为文本报告, 否则为空字符串; 键始终存在 | +| `tracks` | `includeSections=true` 时按流类型分组, 否则为空对象; 键始终存在 | + +Track 规则: + +- 原生 `media.track[]` 的 `@type` 使用 `Locale.US` 转为小写并作为 `tracks` 的分组键. 分组键必须匹配 `[a-z][a-z0-9_-]*`. +- 同类流保持原生数组顺序. 数组下标就是从 0 开始的 `streamNumber`; v1 的 `audio #1` / `audio #2` 显示标题不再承担寻址语义. +- `@type` 只用于分组, 不进入 track 对象. +- 其他以 `@` 开头的原生键原名放入 `attributes`; 没有属性时省略该对象. +- 原生 `extra` 必须是对象并独立放入 `extra`; 没有 `extra` 时省略. 诊断字段由上游定义, 消费方不得假定固定集合. +- 其余原生 track 成员原名放入 `fields`. 字符串, 数字, 布尔值, `null`, 数组和对象保留 JSON 类型, 不转为 v1 的显示文本或 camelCase. + +只有 v2 的 envelope, 分组方式与分区规则属于插件稳定契约. `fields`, `attributes` 和 `extra` 内的具体键、值与可用性仍是 MediaInfoLib 上游扩展面; 升级原生库时必须审阅差异, 但不应把新增字段误判为 v2 envelope 破坏. + +## 兼容与错误行为 + +- 未指定 `schema` 的旧调用继续走原有文本 Inform 解析路径, 包括 v1 的 `fileName`, `sizeBytes`, `inform` 与 `sections`. +- v1 与 v2 使用包含 schema 标识的不同缓存键, 相同文件和选项不会串用结果. +- v2 原生 JSON 为空时, regular FD 直读仍按现有策略回退到插件私有副本; 私有副本仍无法解析时返回空结果. +- 原生 JSON envelope 缺少 `creatingLibrary`, `media.track`, 必需字符串或合法 track 类型时显式失败, 不生成看似成功但结构不完整的 v2. +- 解析, FD 回退, 30 秒取消和超时边界沿用现有服务机制. + +## 协同接入步骤 + +插件仓库完成本契约并不代表 M2 协同项已经完成. 后续需要在 AutoJs6 与共享 API 中依次完成: + +1. 在 `MediainfoOptionKeys` / capability keys 中加入本页常量, 发布新的 `mediainfo-api.aar`. +2. 宿主发现插件时读取 `snapshotSchemas`; 只有明确包含 v2 才发送 v2 schema. +3. Node / Rhino API 提供显式 schema 选择, 保持默认 v1, 并对不支持 v2 的旧插件给出可识别错误或按调用方策略回退. +4. 更新双引擎类型定义, 示例与 10 语言使用说明, 不把 v2 的 `fields` 动态集合声明为固定类型. +5. 使用多音轨, 多字幕, MP4, WebM, FLAC + cover, 损坏文件及大文件矩阵做跨仓库端到端验证. +6. 如需公开 `Info_Parameters`, 新增按需查询方法与响应大小测试, 不扩张发现 Bundle. + +宿主/API 接入完成并满足上述验证前, Roadmap 的 “快照 schema v2 (协同项)” 与 “引擎信息透出” 保持未勾选状态. diff --git a/ROADMAP.md b/ROADMAP.md index 92f2a1b..b1a729a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ |---|---|---| | M0 | 基线能力 (v1.0.0) | 已完成 | | M1 | 文档与工程化 | 进行中 | -| M2 | 读取能力增强 | 规划中 | +| M2 | 读取能力增强 | 进行中 | | M3 | 性能与大文件 | 已完成 | | M4 | 健壮性与诊断 | 进行中 | | M5 | 原生库演进 (v2.0.0+) | 进行中 | @@ -67,17 +67,19 @@ ****** -## M2 读取能力增强 - 规划中 +## M2 读取能力增强 - 进行中 ****** 说明: JNI 封装 `MediaInfo.kt` 已具备 `streamNum`, `InfoKind`, `countGet`, `getMIOption` 等完整能力; AIDL 与插件服务已可接收 `streamNumber`, 但当前宿主 Node / Rhino API 仍固定查询同类流第 1 条, InfoKind 也固定为参数值文本. 本里程碑以 "接线已有能力" 为主, 原生层无需改动. +当前进度: 插件侧已建立显式 opt-in 的 snapshot-v2 契约与 capability 广告, 缺省及空白 schema 仍返回 v1, 未知 schema 显式拒绝; v2 以 MediaInfoLib 原生 JSON 为数据源, 将同类流按数组分组并隔离动态字段, 属性与诊断扩展. 宿主与共享 API 尚未接入, 因此对应协同项保持未勾选. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `PluginRuntimeInfo.kt`, `MediainfoPluginService.kt`) + - [ ] `get` 支持流序号 (协同项): AIDL 层以选项或新方法携带 `streamNumber`, Node / Rhino API 同步透出, 使脚本可查询第 2 条及之后的音轨 / 字幕. (落点: `MediaInfo.kt` 的 `get(filename, streamKind, streamNum, parameter)`, `MediainfoPluginService.kt`) - [ ] 流计数查询 (协同项): 透出 `countGet`, 返回某流类型的流数量, 配合流序号实现多流遍历. (落点: `MediaInfo.kt` 的 `countGet`) - [ ] InfoKind 扩展查询 (协同项): 支持 `MEASURE` / `INFO` / `NAME_TEXT` 等信息种类, 获取参数单位, 说明与本地化名称. (落点: `MediaInfo.kt` 的 `InfoKind` 枚举) -- [ ] 引擎信息透出: 经 `getMIOption` 提供 `Info_Version` / `Info_Parameters` 等库级信息, 便于脚本诊断与参数发现. (落点: `MediaInfo.kt` 的 `getMIOption`) -- [ ] 快照 schema v2 (协同项): 规范化多流 sections 表示 (以数组序号取代 `audio #1` 式小节名), 明确字段命名规则与 schema 版本协商方式, 保持对 v1 消费方的兼容期. +- [ ] 引擎信息透出: 经 `getMIOption` 提供 `Info_Version` / `Info_Parameters` 等库级信息, 便于脚本诊断与参数发现. 插件发现信息已通过 capability 提供小体积的 `Info_Version`; 大体积 `Info_Parameters` 与宿主脚本入口仍待专用按需 API. (落点: `MediaInfo.kt` 的 `getMIOption`, `PluginRuntimeInfo.kt`, `MEDIAINFO_SNAPSHOT_V2.md`) +- [ ] 快照 schema v2 (协同项): 规范化多流 sections 表示 (以数组序号取代 `audio #1` 式小节名), 明确字段命名规则与 schema 版本协商方式, 保持对 v1 消费方的兼容期. 插件侧契约, opt-in 实现, 缓存隔离与测试已落地; 共享 API 常量, 宿主双引擎入口及跨仓库验收待完成. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `MediainfoPluginService.kt`, `MediaInfoSnapshotV2ContractTest.kt`) 验收条件: 双引擎示例脚本可读取多音轨样本的第 2 条流及其单位信息; `.readme` / `plugin_instruction` 文档同步更新; 涉及 AIDL 变更的条目与宿主版本要求 (`REQUIRES_HOST_VERSION`) 联动更新. diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt new file mode 100644 index 0000000..b29aab2 --- /dev/null +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt @@ -0,0 +1,144 @@ +package io.github.supermonster003.autojs6.plugin.mediainfo + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MediaInfoSnapshotV2ContractTest { + + @Test + fun groupsRepeatedTracksAndPreservesUpstreamValueTypes() { + val snapshot = JSONObject( + MediaInfoSnapshotV2.build( + fileName = "two-tracks.mka", + sizeBytes = 4_096, + inform = "General\nFormat : Matroska", + includeInform = true, + includeTracks = true, + nativeJson = nativeFixture, + ), + ) + + assertEquals(MediainfoSnapshotContract.SCHEMA_V2, snapshot.getString("schema")) + assertEquals("two-tracks.mka", snapshot.getJSONObject("file").getString("name")) + assertEquals(4_096, snapshot.getJSONObject("file").getLong("sizeBytes")) + assertEquals("General\nFormat : Matroska", snapshot.getString("inform")) + + val engine = snapshot.getJSONObject("engine") + assertEquals("MediaInfoLib", engine.getString("name")) + assertEquals("26.05", engine.getString("version")) + assertEquals("https://mediaarea.net/MediaInfo", engine.getString("url")) + assertFalse("Unspecified native library keys leaked into the stable engine", engine.has("build")) + + val tracks = snapshot.getJSONObject("tracks") + assertEquals(1, tracks.getJSONArray("general").length()) + val audio = tracks.getJSONArray("audio") + assertEquals(2, audio.length()) + + val firstAudio = audio.getJSONObject(0) + val fields = firstAudio.getJSONObject("fields") + assertEquals("PCM", fields.getString("Format")) + assertEquals(8_000, fields.getInt("SamplingRate")) + assertTrue(fields.getBoolean("Default")) + assertFalse("The grouping discriminator leaked into fields", fields.has("@type")) + assertEquals(1, firstAudio.getJSONObject("attributes").getInt("@typeorder")) + assertEquals("Yes", firstAudio.getJSONObject("extra").getString("IsTruncated")) + + assertEquals("AAC", audio.getJSONObject(1).getJSONObject("fields").getString("Format")) + } + + @Test + fun payloadFlagsKeepTheEnvelopeAndOmitOptionalContent() { + val snapshot = JSONObject( + MediaInfoSnapshotV2.build( + fileName = "minimal.mka", + sizeBytes = 10, + inform = "must not leak", + includeInform = false, + includeTracks = false, + nativeJson = nativeFixture, + ), + ) + + assertEquals("", snapshot.getString("inform")) + assertEquals(0, snapshot.getJSONObject("tracks").length()) + assertEquals("MediaInfoLib", snapshot.getJSONObject("engine").getString("name")) + } + + @Test + fun rejectsNativeShapesThatCannotSatisfyTheContract() { + val malformedFixtures = listOf( + """ + { + "creatingLibrary": { "name": "MediaInfoLib", "version": "26.05" }, + "media": {} + } + """.trimIndent(), + """ + { + "creatingLibrary": { "name": "MediaInfoLib", "version": "26.05" }, + "media": { + "track": [ + { "@type": "Audio Track", "Format": "PCM" } + ] + } + } + """.trimIndent(), + ) + + malformedFixtures.forEach { nativeJson -> + val failure = runCatching { + MediaInfoSnapshotV2.build( + fileName = "invalid.media", + sizeBytes = 0, + inform = "", + includeInform = false, + includeTracks = false, + nativeJson = nativeJson, + ) + }.exceptionOrNull() + assertTrue("Malformed native JSON was accepted: $nativeJson", failure != null) + } + } + + private val nativeFixture = """ + { + "creatingLibrary": { + "name": "MediaInfoLib", + "version": "26.05", + "url": "https://mediaarea.net/MediaInfo", + "build": "fixture-only" + }, + "media": { + "@ref": "two-tracks.mka", + "track": [ + { + "@type": "General", + "Format": "Matroska", + "FileSize": 4096 + }, + { + "@type": "Audio", + "@typeorder": 1, + "Format": "PCM", + "SamplingRate": 8000, + "Default": true, + "extra": { + "IsTruncated": "Yes" + } + }, + { + "@type": "Audio", + "@typeorder": 2, + "Format": "AAC" + } + ] + } + } + """.trimIndent() +} diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt index 235febd..a7a610a 100644 --- a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt @@ -16,6 +16,7 @@ import org.autojs.plugin.mediainfo.api.MediainfoOptionKeys import org.autojs.plugin.mediainfo.api.MediainfoPluginIds import org.json.JSONObject import org.mediainfo.android.MediaInfo +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -195,6 +196,40 @@ class MediainfoPluginServiceTest { .getString("format") .contains("PCM", ignoreCase = true), ) + + val v2Options = Bundle(options).apply { + putString( + MediainfoSnapshotContract.OPTION_SCHEMA, + MediainfoSnapshotContract.SCHEMA_V2, + ) + } + val v2SnapshotText = withMediaDescriptor(mediaFile) { descriptor -> + plugin.snapshot(descriptor, mediaFile.name, v2Options) + } + val v2Snapshot = JSONObject(v2SnapshotText) + assertEquals(MediainfoSnapshotContract.SCHEMA_V2, v2Snapshot.getString("schema")) + assertFalse("snapshot-v2 leaked the v1 fileName field", v2Snapshot.has("fileName")) + assertFalse("snapshot-v2 leaked the v1 sections field", v2Snapshot.has("sections")) + assertEquals(mediaFile.name, v2Snapshot.getJSONObject("file").getString("name")) + assertEquals(mediaFile.length(), v2Snapshot.getJSONObject("file").getLong("sizeBytes")) + assertEquals("", v2Snapshot.getString("inform")) + assertEquals("MediaInfoLib", v2Snapshot.getJSONObject("engine").getString("name")) + assertTrue(v2Snapshot.getJSONObject("engine").getString("version").isNotBlank()) + val tracks = v2Snapshot.getJSONObject("tracks") + assertTrue("snapshot-v2 has no General track", tracks.getJSONArray("general").length() > 0) + val audioTracks = tracks.getJSONArray("audio") + assertTrue("snapshot-v2 has no Audio track", audioTracks.length() > 0) + assertTrue( + "snapshot-v2 audio format is not PCM", + audioTracks.getJSONObject(0) + .getJSONObject("fields") + .getString("Format") + .contains("PCM", ignoreCase = true), + ) + val cachedV2Snapshot = withMediaDescriptor(mediaFile) { descriptor -> + plugin.snapshot(descriptor, mediaFile.name, v2Options) + } + assertEquals(v2SnapshotText, cachedV2Snapshot) } finally { mediaFile.delete() } @@ -264,6 +299,40 @@ class MediainfoPluginServiceTest { } } + @Test + fun unsupportedSnapshotSchemaClosesTheServiceOwnedDescriptor() { + withBoundPlugin { plugin -> + val mediaFile = createWaveFile() + try { + val descriptor = ParcelFileDescriptor.open(mediaFile, ParcelFileDescriptor.MODE_READ_ONLY) + try { + val failure = runCatching { + plugin.snapshot( + descriptor, + mediaFile.name, + Bundle().apply { + putString(MediainfoSnapshotContract.OPTION_SCHEMA, "snapshot-latest") + }, + ) + }.exceptionOrNull() + assertTrue( + "Unsupported snapshot schema did not produce the expected error: $failure", + failure is IllegalArgumentException && + failure.message.orEmpty().contains("snapshot-latest"), + ) + assertFalse( + "The service retained its descriptor after snapshot validation failed", + descriptor.fileDescriptor.valid(), + ) + } finally { + runCatching { descriptor.close() } + } + } finally { + mediaFile.delete() + } + } + } + private fun assertRuntimeInfo(plugin: IMediainfoPlugin) { val info = plugin.info assertEquals(MediainfoPluginIds.ID, info.id) @@ -292,6 +361,18 @@ class MediainfoPluginServiceTest { val capabilities = requireNotNull(info.capabilities) { "Plugin capabilities are missing" } assertEquals(3923, capabilities.getInt(PluginCapabilityKeys.REQUIRES_HOST_VERSION)) + assertArrayEquals( + MediainfoSnapshotContract.SUPPORTED_SCHEMAS, + capabilities.getStringArray(MediainfoSnapshotContract.CAPABILITY_SUPPORTED_SCHEMAS), + ) + assertEquals( + MediainfoSnapshotContract.SCHEMA_V1, + capabilities.getString(MediainfoSnapshotContract.CAPABILITY_DEFAULT_SCHEMA), + ) + assertEquals( + MediaInfo().getMIOption("Info_Version").trim(), + capabilities.getString(MediainfoSnapshotContract.CAPABILITY_ENGINE_VERSION), + ) } private fun withBoundPlugin(block: (IMediainfoPlugin) -> Unit) { diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt index 7eb4739..3130dfc 100644 --- a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt @@ -66,6 +66,26 @@ class MediainfoReleaseSmokeTest { assertEquals("autojs6-plugin-mediainfo-snapshot-v1", snapshot.getString("schema")) assertEquals("", snapshot.getString("inform")) assertTrue(snapshot.getJSONObject("sections").getJSONArray("audio").length() > 0) + + val v2Options = Bundle(options).apply { + putString( + MediainfoSnapshotContract.OPTION_SCHEMA, + MediainfoSnapshotContract.SCHEMA_V2, + ) + } + val v2Snapshot = withDescriptor(mediaFile) { descriptor -> + JSONObject(plugin.snapshot(descriptor, mediaFile.name, v2Options)) + } + assertEquals(MediainfoSnapshotContract.SCHEMA_V2, v2Snapshot.getString("schema")) + assertEquals("MediaInfoLib", v2Snapshot.getJSONObject("engine").getString("name")) + assertTrue( + v2Snapshot.getJSONObject("tracks") + .getJSONArray("audio") + .getJSONObject(0) + .getJSONObject("fields") + .getString("Format") + .contains("PCM", ignoreCase = true), + ) } } finally { mediaFile.delete() diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt index cc8f278..9ac9500 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt @@ -22,6 +22,7 @@ internal data class MediaGetRequest( internal data class MediaSnapshotRequest( val includeInform: Boolean, val includeSections: Boolean, + val schema: String = MediainfoSnapshotContract.SCHEMA_V1, ) internal data class MediaCacheHit(val value: T) @@ -170,7 +171,7 @@ internal class MediaInfoResultCache( const val DEFAULT_MAX_CHARACTERS = 2 * 1_024 * 1_024 private const val NANOS_PER_MILLISECOND = 1_000_000L - private const val SNAPSHOT_KEY_ESTIMATE = 2 + private const val SNAPSHOT_KEY_ESTIMATE = 64 private const val QUERY_KEY_ESTIMATE = 8 } } diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt index d2c1fd0..f6959d3 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt @@ -26,6 +26,11 @@ import java.util.concurrent.TimeUnit class MediainfoPluginService : Service() { private val resultCache = MediaInfoResultCache() + private val engineVersion: String? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + runCatching { MediaInfo().getMIOption("Info_Version").trim() } + .getOrNull() + ?.takeIf { it.isNotBlank() } + } private val activeCalls = ConcurrentHashMap.newKeySet() private val timeoutScheduler = Executors.newSingleThreadScheduledExecutor { runnable -> Thread(runnable, "mediainfo-call-timeout").apply { isDaemon = true } @@ -50,6 +55,7 @@ class MediainfoPluginService : Service() { return pluginInfo( name = getString(R.string.app_name), description = getString(R.string.plugin_description), + engineVersion = engineVersion, ) } @@ -126,33 +132,30 @@ class MediainfoPluginService : Service() { override fun snapshot(mediaFd: ParcelFileDescriptor?, displayName: String?, options: Bundle?): String { return withCallTimeout { call -> + val snapshotOptions = try { + parseSnapshotOptions( + readBoolean = { key, defaultValue -> + options?.getBoolean(key, defaultValue) ?: defaultValue + }, + readString = { key -> options?.getString(key) }, + ) + } catch (error: Throwable) { + runCatching { mediaFd?.close() } + throw error + } withMediaSource(mediaFd, displayName, call) { source -> - val snapshotOptions = parseSnapshotOptions { key, defaultValue -> - options?.getBoolean(key, defaultValue) ?: defaultValue - } val request = MediaSnapshotRequest( includeInform = snapshotOptions.includeInform, includeSections = snapshotOptions.includeSections, + schema = snapshotOptions.schema.id, ) resultCache.getSnapshot(source.cacheIdentity, request)?.let { cached -> return@withMediaSource MediaParseAttempt(cached, true) } - val inform = resultCache.getInform(source.cacheIdentity) - ?: call.withMediaInfo { it.getMI(source.path).orEmpty() }.also { report -> - if (report.isNotBlank()) resultCache.putInform(source.cacheIdentity, report) - } - val snapshot = JSONObject() - .put("schema", "autojs6-plugin-mediainfo-snapshot-v1") - .put("fileName", displayName.orEmpty()) - .put("sizeBytes", source.sizeBytes) - .put("inform", if (snapshotOptions.includeInform) inform else "") - .put( - "sections", - if (snapshotOptions.includeSections) mediaInfoSections(inform) else JSONObject(), - ) - .toString() - if (inform.isNotBlank()) resultCache.putSnapshot(source.cacheIdentity, request, snapshot) - MediaParseAttempt(snapshot, inform.isNotBlank()) + when (snapshotOptions.schema) { + SnapshotSchema.V1 -> createV1Snapshot(source, displayName, snapshotOptions, request, call) + SnapshotSchema.V2 -> createV2Snapshot(source, displayName, snapshotOptions, request, call) + } } } } @@ -168,6 +171,57 @@ class MediainfoPluginService : Service() { val validationReport: String?, ) + private fun createV1Snapshot( + source: MediaInputSource, + displayName: String?, + options: SnapshotOptions, + request: MediaSnapshotRequest, + call: MediaInfoCallGuard, + ): MediaParseAttempt { + val inform = getOrParseInform(source, call) + val snapshot = JSONObject() + .put("schema", MediainfoSnapshotContract.SCHEMA_V1) + .put("fileName", displayName.orEmpty()) + .put("sizeBytes", source.sizeBytes) + .put("inform", if (options.includeInform) inform else "") + .put( + "sections", + if (options.includeSections) mediaInfoSections(inform) else JSONObject(), + ) + .toString() + if (inform.isNotBlank()) resultCache.putSnapshot(source.cacheIdentity, request, snapshot) + return MediaParseAttempt(snapshot, inform.isNotBlank()) + } + + private fun createV2Snapshot( + source: MediaInputSource, + displayName: String?, + options: SnapshotOptions, + request: MediaSnapshotRequest, + call: MediaInfoCallGuard, + ): MediaParseAttempt { + val nativeJson = call.withMediaInfo { it.getMIJson(source.path).orEmpty() } + if (nativeJson.isBlank()) return MediaParseAttempt("", false) + + val inform = if (options.includeInform) getOrParseInform(source, call) else "" + val snapshot = MediaInfoSnapshotV2.build( + fileName = displayName.orEmpty(), + sizeBytes = source.sizeBytes, + inform = inform, + includeInform = options.includeInform, + includeTracks = options.includeSections, + nativeJson = nativeJson, + ) + resultCache.putSnapshot(source.cacheIdentity, request, snapshot) + return MediaParseAttempt(snapshot, true) + } + + private fun getOrParseInform(source: MediaInputSource, call: MediaInfoCallGuard): String = + resultCache.getInform(source.cacheIdentity) + ?: call.withMediaInfo { it.getMI(source.path).orEmpty() }.also { report -> + if (report.isNotBlank()) resultCache.putInform(source.cacheIdentity, report) + } + private fun withCallTimeout(block: (MediaInfoCallGuard) -> T): T { val call = MediaInfoCallGuard() activeCalls += call diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt index 389a43e..d1b3352 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt @@ -1,19 +1,155 @@ package io.github.supermonster003.autojs6.plugin.mediainfo import org.autojs.plugin.mediainfo.api.MediainfoOptionKeys +import org.json.JSONArray +import org.json.JSONObject import java.util.Locale +internal object MediainfoSnapshotContract { + const val OPTION_SCHEMA = "schema" + + const val SCHEMA_V1 = "autojs6-plugin-mediainfo-snapshot-v1" + const val SCHEMA_V2 = "autojs6-plugin-mediainfo-snapshot-v2" + + const val CAPABILITY_SUPPORTED_SCHEMAS = "snapshotSchemas" + const val CAPABILITY_DEFAULT_SCHEMA = "defaultSnapshotSchema" + const val CAPABILITY_ENGINE_VERSION = "engineVersion" + + val SUPPORTED_SCHEMAS = arrayOf(SCHEMA_V1, SCHEMA_V2) +} + +internal enum class SnapshotSchema(val id: String) { + V1(MediainfoSnapshotContract.SCHEMA_V1), + V2(MediainfoSnapshotContract.SCHEMA_V2), + ; + + companion object { + fun resolve(requestedSchema: String?): SnapshotSchema { + if (requestedSchema.isNullOrBlank()) return V1 + return entries.firstOrNull { it.id == requestedSchema } + ?: throw IllegalArgumentException("Unsupported MediaInfo snapshot schema: $requestedSchema") + } + } +} + internal data class SnapshotOptions( val includeInform: Boolean = true, val includeSections: Boolean = true, + val schema: SnapshotSchema = SnapshotSchema.V1, ) internal fun parseSnapshotOptions(readBoolean: (key: String, defaultValue: Boolean) -> Boolean): SnapshotOptions = + parseSnapshotOptions(readBoolean = readBoolean, readString = { null }) + +internal fun parseSnapshotOptions( + readBoolean: (key: String, defaultValue: Boolean) -> Boolean, + readString: (key: String) -> String?, +): SnapshotOptions = SnapshotOptions( includeInform = readBoolean(MediainfoOptionKeys.INCLUDE_INFORM, true), includeSections = readBoolean(MediainfoOptionKeys.INCLUDE_SECTIONS, true), + schema = SnapshotSchema.resolve(readString(MediainfoSnapshotContract.OPTION_SCHEMA)), ) +/** + * Converts MediaInfoLib's versioned native JSON envelope into the plugin-owned + * snapshot-v2 envelope. Track field names and values intentionally remain + * upstream extensions; only the surrounding grouping and metadata are stable. + */ +internal object MediaInfoSnapshotV2 { + + fun build( + fileName: String, + sizeBytes: Long, + inform: String, + includeInform: Boolean, + includeTracks: Boolean, + nativeJson: String, + ): String { + val nativeRoot = JSONObject(nativeJson) + val nativeLibrary = nativeRoot.requiredObject("creatingLibrary") + val nativeTracks = nativeRoot + .requiredObject("media") + .requiredArray("track") + require(nativeTracks.length() > 0) { "MediaInfo native JSON contains no tracks" } + + val engine = JSONObject() + .put("name", nativeLibrary.requiredString("name")) + .put("version", nativeLibrary.requiredString("version")) + nativeLibrary.optionalString("url")?.let { engine.put("url", it) } + + val adaptedTracks = adaptTracks(nativeTracks) + val tracks = if (includeTracks) adaptedTracks else JSONObject() + return JSONObject() + .put("schema", MediainfoSnapshotContract.SCHEMA_V2) + .put( + "file", + JSONObject() + .put("name", fileName) + .put("sizeBytes", sizeBytes), + ) + .put("engine", engine) + .put("inform", if (includeInform) inform else "") + .put("tracks", tracks) + .toString() + } + + private fun adaptTracks(nativeTracks: JSONArray): JSONObject { + val groupedTracks = linkedMapOf() + for (index in 0 until nativeTracks.length()) { + val nativeTrack = nativeTracks.optJSONObject(index) + ?: throw IllegalArgumentException("MediaInfo native track $index is not an object") + val type = nativeTrack.requiredString("@type").lowercase(Locale.US) + require(TRACK_TYPE.matches(type)) { "Unsupported MediaInfo native track type: $type" } + groupedTracks.getOrPut(type) { JSONArray() }.put(adaptTrack(nativeTrack)) + } + + return JSONObject().also { result -> + groupedTracks.forEach { (type, values) -> result.put(type, values) } + } + } + + private fun adaptTrack(nativeTrack: JSONObject): JSONObject { + val fields = JSONObject() + val attributes = JSONObject() + var extra: JSONObject? = null + + nativeTrack.keys().forEach { key -> + when { + key == "@type" -> Unit + key == "extra" -> { + extra = nativeTrack.optJSONObject(key) + ?: throw IllegalArgumentException("MediaInfo native track extra is not an object") + } + key.startsWith("@") -> attributes.put(key, nativeTrack.get(key)) + else -> fields.put(key, nativeTrack.get(key)) + } + } + + return JSONObject() + .put("fields", fields) + .also { result -> + if (attributes.length() > 0) result.put("attributes", attributes) + extra?.let { result.put("extra", it) } + } + } + + private fun JSONObject.requiredObject(key: String): JSONObject = + optJSONObject(key) ?: throw IllegalArgumentException("MediaInfo native JSON is missing object: $key") + + private fun JSONObject.requiredArray(key: String): JSONArray = + optJSONArray(key) ?: throw IllegalArgumentException("MediaInfo native JSON is missing array: $key") + + private fun JSONObject.requiredString(key: String): String = + optionalString(key) + ?: throw IllegalArgumentException("MediaInfo native JSON is missing string: $key") + + private fun JSONObject.optionalString(key: String): String? = + (opt(key) as? String)?.takeIf { it.isNotBlank() } + + private val TRACK_TYPE = Regex("[a-z][a-z0-9_-]*") +} + internal object MediaInfoReportParser { fun parseSections(inform: String): Map>> { diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt index 4eb3581..e11bcdd 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt @@ -6,7 +6,7 @@ import org.autojs.plugin.common.api.PluginCapabilityKeys import org.autojs.plugin.common.api.PluginInfo import org.autojs.plugin.mediainfo.api.MediainfoPluginIds -internal fun Context.pluginInfo(name: String, description: String): PluginInfo { +internal fun Context.pluginInfo(name: String, description: String, engineVersion: String?): PluginInfo { val appContext = applicationContext val packageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0) return PluginInfo().apply { @@ -22,6 +22,17 @@ internal fun Context.pluginInfo(name: String, description: String): PluginInfo { supportedAbis = NativeLibraryInventory.supportedAbis(appContext) capabilities = android.os.Bundle().apply { putInt(PluginCapabilityKeys.REQUIRES_HOST_VERSION, 3923) + putStringArray( + MediainfoSnapshotContract.CAPABILITY_SUPPORTED_SCHEMAS, + MediainfoSnapshotContract.SUPPORTED_SCHEMAS.copyOf(), + ) + putString( + MediainfoSnapshotContract.CAPABILITY_DEFAULT_SCHEMA, + MediainfoSnapshotContract.SCHEMA_V1, + ) + engineVersion?.takeIf { it.isNotBlank() }?.let { version -> + putString(MediainfoSnapshotContract.CAPABILITY_ENGINE_VERSION, version) + } } } } diff --git a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt index dcc7bbb..22e063c 100644 --- a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt +++ b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt @@ -92,6 +92,7 @@ class MediaInfoReportParserTest { assertTrue(options.includeInform) assertTrue(options.includeSections) + assertEquals(SnapshotSchema.V1, options.schema) assertEquals(2, requestedDefaults.size) assertTrue(requestedDefaults.all { it.second }) } @@ -102,5 +103,45 @@ class MediaInfoReportParserTest { assertFalse(options.includeInform) assertTrue(options.includeSections) + assertEquals(SnapshotSchema.V1, options.schema) + } + + @Test + fun `snapshot schema requires an explicit supported identifier`() { + val v1 = parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { MediainfoSnapshotContract.SCHEMA_V1 }, + ) + val v2 = parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { MediainfoSnapshotContract.SCHEMA_V2 }, + ) + val blank = parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { " " }, + ) + + assertEquals(SnapshotSchema.V1, v1.schema) + assertEquals(SnapshotSchema.V2, v2.schema) + assertEquals(SnapshotSchema.V1, blank.schema) + + val failure = runCatching { + parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { "snapshot-latest" }, + ) + }.exceptionOrNull() + assertTrue( + "Unknown schema did not produce a useful validation error: $failure", + failure is IllegalArgumentException && failure.message.orEmpty().contains("snapshot-latest"), + ) + + val paddedFailure = runCatching { + parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { " ${MediainfoSnapshotContract.SCHEMA_V2} " }, + ) + }.exceptionOrNull() + assertTrue("A padded schema identifier must not be accepted", paddedFailure is IllegalArgumentException) } } diff --git a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt index cd67739..09a4b87 100644 --- a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt +++ b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt @@ -23,6 +23,25 @@ class MediaInfoResultCacheTest { assertEquals(MediaCacheStats(1, 1, 1, cache.stats().estimatedCharacters), cache.stats()) } + @Test + fun snapshotSchemasHaveIndependentCacheEntries() { + val cache = cache() + val identity = identity(1) + val v1 = MediaSnapshotRequest( + includeInform = false, + includeSections = true, + schema = MediainfoSnapshotContract.SCHEMA_V1, + ) + val v2 = v1.copy(schema = MediainfoSnapshotContract.SCHEMA_V2) + + cache.putSnapshot(identity, v1, "v1") + cache.putSnapshot(identity, v2, "v2") + + assertEquals("v1", cache.getSnapshot(identity, v1)) + assertEquals("v2", cache.getSnapshot(identity, v2)) + assertEquals(2, cache.stats().snapshotCount) + } + @Test fun fileLruEvictsTheLeastRecentlyUsedIdentity() { val cache = cache(maxFiles = 2) From eb889c79016dce98f4fec5129feb24569bae4ddf Mon Sep 17 00:00:00 2001 From: SuperMonster003 <30370009+SuperMonster003@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:27:46 +0800 Subject: [PATCH 2/3] refactor: share snapshot schema contract with host --- .../MediaInfoSnapshotV2ContractTest.kt | 3 +- .../mediainfo/MediainfoPluginServiceTest.kt | 20 +++++++------ .../mediainfo/MediainfoReleaseSmokeTest.kt | 7 +++-- .../plugin/mediainfo/MediaInfoResultCache.kt | 3 +- .../mediainfo/MediainfoPluginService.kt | 3 +- .../plugin/mediainfo/MediainfoSnapshot.kt | 27 +++++------------- .../plugin/mediainfo/PluginRuntimeInfo.kt | 12 ++++---- .../mediainfo/MediaInfoReportParserTest.kt | 7 +++-- .../mediainfo/MediaInfoResultCacheTest.kt | 5 ++-- libs/mediainfo-api.aar | Bin 8649 -> 11632 bytes 10 files changed, 42 insertions(+), 45 deletions(-) diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt index b29aab2..e159c8f 100644 --- a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt @@ -1,6 +1,7 @@ package io.github.supermonster003.autojs6.plugin.mediainfo import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,7 +25,7 @@ class MediaInfoSnapshotV2ContractTest { ), ) - assertEquals(MediainfoSnapshotContract.SCHEMA_V2, snapshot.getString("schema")) + assertEquals(MediainfoSnapshotSchemas.V2, snapshot.getString("schema")) assertEquals("two-tracks.mka", snapshot.getJSONObject("file").getString("name")) assertEquals(4_096, snapshot.getJSONObject("file").getLong("sizeBytes")) assertEquals("General\nFormat : Matroska", snapshot.getString("inform")) diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt index a7a610a..9797a0c 100644 --- a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginServiceTest.kt @@ -13,7 +13,9 @@ import androidx.test.platform.app.InstrumentationRegistry import org.autojs.plugin.common.api.PluginCapabilityKeys import org.autojs.plugin.mediainfo.api.IMediainfoPlugin import org.autojs.plugin.mediainfo.api.MediainfoOptionKeys +import org.autojs.plugin.mediainfo.api.MediainfoPluginCapabilityKeys import org.autojs.plugin.mediainfo.api.MediainfoPluginIds +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.json.JSONObject import org.mediainfo.android.MediaInfo import org.junit.Assert.assertArrayEquals @@ -199,15 +201,15 @@ class MediainfoPluginServiceTest { val v2Options = Bundle(options).apply { putString( - MediainfoSnapshotContract.OPTION_SCHEMA, - MediainfoSnapshotContract.SCHEMA_V2, + MediainfoOptionKeys.SCHEMA, + MediainfoSnapshotSchemas.V2, ) } val v2SnapshotText = withMediaDescriptor(mediaFile) { descriptor -> plugin.snapshot(descriptor, mediaFile.name, v2Options) } val v2Snapshot = JSONObject(v2SnapshotText) - assertEquals(MediainfoSnapshotContract.SCHEMA_V2, v2Snapshot.getString("schema")) + assertEquals(MediainfoSnapshotSchemas.V2, v2Snapshot.getString("schema")) assertFalse("snapshot-v2 leaked the v1 fileName field", v2Snapshot.has("fileName")) assertFalse("snapshot-v2 leaked the v1 sections field", v2Snapshot.has("sections")) assertEquals(mediaFile.name, v2Snapshot.getJSONObject("file").getString("name")) @@ -311,7 +313,7 @@ class MediainfoPluginServiceTest { descriptor, mediaFile.name, Bundle().apply { - putString(MediainfoSnapshotContract.OPTION_SCHEMA, "snapshot-latest") + putString(MediainfoOptionKeys.SCHEMA, "snapshot-latest") }, ) }.exceptionOrNull() @@ -362,16 +364,16 @@ class MediainfoPluginServiceTest { val capabilities = requireNotNull(info.capabilities) { "Plugin capabilities are missing" } assertEquals(3923, capabilities.getInt(PluginCapabilityKeys.REQUIRES_HOST_VERSION)) assertArrayEquals( - MediainfoSnapshotContract.SUPPORTED_SCHEMAS, - capabilities.getStringArray(MediainfoSnapshotContract.CAPABILITY_SUPPORTED_SCHEMAS), + MediainfoSnapshotSchemas.VALUES.toTypedArray(), + capabilities.getStringArray(MediainfoPluginCapabilityKeys.SNAPSHOT_SCHEMAS), ) assertEquals( - MediainfoSnapshotContract.SCHEMA_V1, - capabilities.getString(MediainfoSnapshotContract.CAPABILITY_DEFAULT_SCHEMA), + MediainfoSnapshotSchemas.V1, + capabilities.getString(MediainfoPluginCapabilityKeys.DEFAULT_SNAPSHOT_SCHEMA), ) assertEquals( MediaInfo().getMIOption("Info_Version").trim(), - capabilities.getString(MediainfoSnapshotContract.CAPABILITY_ENGINE_VERSION), + capabilities.getString(MediainfoPluginCapabilityKeys.ENGINE_VERSION), ) } diff --git a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt index 3130dfc..67b87eb 100644 --- a/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoReleaseSmokeTest.kt @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.autojs.plugin.mediainfo.api.IMediainfoPlugin import org.autojs.plugin.mediainfo.api.MediainfoOptionKeys +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -69,14 +70,14 @@ class MediainfoReleaseSmokeTest { val v2Options = Bundle(options).apply { putString( - MediainfoSnapshotContract.OPTION_SCHEMA, - MediainfoSnapshotContract.SCHEMA_V2, + MediainfoOptionKeys.SCHEMA, + MediainfoSnapshotSchemas.V2, ) } val v2Snapshot = withDescriptor(mediaFile) { descriptor -> JSONObject(plugin.snapshot(descriptor, mediaFile.name, v2Options)) } - assertEquals(MediainfoSnapshotContract.SCHEMA_V2, v2Snapshot.getString("schema")) + assertEquals(MediainfoSnapshotSchemas.V2, v2Snapshot.getString("schema")) assertEquals("MediaInfoLib", v2Snapshot.getJSONObject("engine").getString("name")) assertTrue( v2Snapshot.getJSONObject("tracks") diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt index 9ac9500..34712bb 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCache.kt @@ -1,5 +1,6 @@ package io.github.supermonster003.autojs6.plugin.mediainfo +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import java.util.LinkedHashMap internal data class MediaFileIdentity( @@ -22,7 +23,7 @@ internal data class MediaGetRequest( internal data class MediaSnapshotRequest( val includeInform: Boolean, val includeSections: Boolean, - val schema: String = MediainfoSnapshotContract.SCHEMA_V1, + val schema: String = MediainfoSnapshotSchemas.V1, ) internal data class MediaCacheHit(val value: T) diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt index f6959d3..69b6e2a 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoPluginService.kt @@ -10,6 +10,7 @@ import android.system.OsConstants import android.system.StructPollfd import org.autojs.plugin.common.api.PluginInfo import org.autojs.plugin.mediainfo.api.IMediainfoPlugin +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.json.JSONArray import org.json.JSONObject import org.mediainfo.android.MediaInfo @@ -180,7 +181,7 @@ class MediainfoPluginService : Service() { ): MediaParseAttempt { val inform = getOrParseInform(source, call) val snapshot = JSONObject() - .put("schema", MediainfoSnapshotContract.SCHEMA_V1) + .put("schema", MediainfoSnapshotSchemas.V1) .put("fileName", displayName.orEmpty()) .put("sizeBytes", source.sizeBytes) .put("inform", if (options.includeInform) inform else "") diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt index d1b3352..0337f90 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediainfoSnapshot.kt @@ -1,33 +1,20 @@ package io.github.supermonster003.autojs6.plugin.mediainfo import org.autojs.plugin.mediainfo.api.MediainfoOptionKeys +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.json.JSONArray import org.json.JSONObject import java.util.Locale -internal object MediainfoSnapshotContract { - const val OPTION_SCHEMA = "schema" - - const val SCHEMA_V1 = "autojs6-plugin-mediainfo-snapshot-v1" - const val SCHEMA_V2 = "autojs6-plugin-mediainfo-snapshot-v2" - - const val CAPABILITY_SUPPORTED_SCHEMAS = "snapshotSchemas" - const val CAPABILITY_DEFAULT_SCHEMA = "defaultSnapshotSchema" - const val CAPABILITY_ENGINE_VERSION = "engineVersion" - - val SUPPORTED_SCHEMAS = arrayOf(SCHEMA_V1, SCHEMA_V2) -} - internal enum class SnapshotSchema(val id: String) { - V1(MediainfoSnapshotContract.SCHEMA_V1), - V2(MediainfoSnapshotContract.SCHEMA_V2), + V1(MediainfoSnapshotSchemas.V1), + V2(MediainfoSnapshotSchemas.V2), ; companion object { fun resolve(requestedSchema: String?): SnapshotSchema { - if (requestedSchema.isNullOrBlank()) return V1 - return entries.firstOrNull { it.id == requestedSchema } - ?: throw IllegalArgumentException("Unsupported MediaInfo snapshot schema: $requestedSchema") + val resolvedSchema = MediainfoSnapshotSchemas.resolve(requestedSchema) + return entries.first { it.id == resolvedSchema } } } } @@ -48,7 +35,7 @@ internal fun parseSnapshotOptions( SnapshotOptions( includeInform = readBoolean(MediainfoOptionKeys.INCLUDE_INFORM, true), includeSections = readBoolean(MediainfoOptionKeys.INCLUDE_SECTIONS, true), - schema = SnapshotSchema.resolve(readString(MediainfoSnapshotContract.OPTION_SCHEMA)), + schema = SnapshotSchema.resolve(readString(MediainfoOptionKeys.SCHEMA)), ) /** @@ -81,7 +68,7 @@ internal object MediaInfoSnapshotV2 { val adaptedTracks = adaptTracks(nativeTracks) val tracks = if (includeTracks) adaptedTracks else JSONObject() return JSONObject() - .put("schema", MediainfoSnapshotContract.SCHEMA_V2) + .put("schema", MediainfoSnapshotSchemas.V2) .put( "file", JSONObject() diff --git a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt index e11bcdd..0de07a7 100644 --- a/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt +++ b/app/src/main/java/io/github/supermonster003/autojs6/plugin/mediainfo/PluginRuntimeInfo.kt @@ -4,7 +4,9 @@ import android.content.Context import android.os.Build import org.autojs.plugin.common.api.PluginCapabilityKeys import org.autojs.plugin.common.api.PluginInfo +import org.autojs.plugin.mediainfo.api.MediainfoPluginCapabilityKeys import org.autojs.plugin.mediainfo.api.MediainfoPluginIds +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas internal fun Context.pluginInfo(name: String, description: String, engineVersion: String?): PluginInfo { val appContext = applicationContext @@ -23,15 +25,15 @@ internal fun Context.pluginInfo(name: String, description: String, engineVersion capabilities = android.os.Bundle().apply { putInt(PluginCapabilityKeys.REQUIRES_HOST_VERSION, 3923) putStringArray( - MediainfoSnapshotContract.CAPABILITY_SUPPORTED_SCHEMAS, - MediainfoSnapshotContract.SUPPORTED_SCHEMAS.copyOf(), + MediainfoPluginCapabilityKeys.SNAPSHOT_SCHEMAS, + MediainfoSnapshotSchemas.VALUES.toTypedArray(), ) putString( - MediainfoSnapshotContract.CAPABILITY_DEFAULT_SCHEMA, - MediainfoSnapshotContract.SCHEMA_V1, + MediainfoPluginCapabilityKeys.DEFAULT_SNAPSHOT_SCHEMA, + MediainfoSnapshotSchemas.DEFAULT, ) engineVersion?.takeIf { it.isNotBlank() }?.let { version -> - putString(MediainfoSnapshotContract.CAPABILITY_ENGINE_VERSION, version) + putString(MediainfoPluginCapabilityKeys.ENGINE_VERSION, version) } } } diff --git a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt index 22e063c..d49bb0d 100644 --- a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt +++ b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoReportParserTest.kt @@ -1,5 +1,6 @@ package io.github.supermonster003.autojs6.plugin.mediainfo +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -110,11 +111,11 @@ class MediaInfoReportParserTest { fun `snapshot schema requires an explicit supported identifier`() { val v1 = parseSnapshotOptions( readBoolean = { _, defaultValue -> defaultValue }, - readString = { MediainfoSnapshotContract.SCHEMA_V1 }, + readString = { MediainfoSnapshotSchemas.V1 }, ) val v2 = parseSnapshotOptions( readBoolean = { _, defaultValue -> defaultValue }, - readString = { MediainfoSnapshotContract.SCHEMA_V2 }, + readString = { MediainfoSnapshotSchemas.V2 }, ) val blank = parseSnapshotOptions( readBoolean = { _, defaultValue -> defaultValue }, @@ -139,7 +140,7 @@ class MediaInfoReportParserTest { val paddedFailure = runCatching { parseSnapshotOptions( readBoolean = { _, defaultValue -> defaultValue }, - readString = { " ${MediainfoSnapshotContract.SCHEMA_V2} " }, + readString = { " ${MediainfoSnapshotSchemas.V2} " }, ) }.exceptionOrNull() assertTrue("A padded schema identifier must not be accepted", paddedFailure is IllegalArgumentException) diff --git a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt index 09a4b87..d14e9be 100644 --- a/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt +++ b/app/src/test/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoResultCacheTest.kt @@ -1,5 +1,6 @@ package io.github.supermonster003.autojs6.plugin.mediainfo +import org.autojs.plugin.mediainfo.api.MediainfoSnapshotSchemas import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -30,9 +31,9 @@ class MediaInfoResultCacheTest { val v1 = MediaSnapshotRequest( includeInform = false, includeSections = true, - schema = MediainfoSnapshotContract.SCHEMA_V1, + schema = MediainfoSnapshotSchemas.V1, ) - val v2 = v1.copy(schema = MediainfoSnapshotContract.SCHEMA_V2) + val v2 = v1.copy(schema = MediainfoSnapshotSchemas.V2) cache.putSnapshot(identity, v1, "v1") cache.putSnapshot(identity, v2, "v2") diff --git a/libs/mediainfo-api.aar b/libs/mediainfo-api.aar index 1926b73b1616a166e1a4050d49431204ee3762e0..3280b4ca82c404eaa29659ae4aa11c00ea2ad468 100644 GIT binary patch delta 11062 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 delta 8052 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 C1-l>s From 9bc4ca9894a51ca45350c9b3992e921139e1ddb4 Mon Sep 17 00:00:00 2001 From: SuperMonster003 <30370009+SuperMonster003@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:27:51 +0800 Subject: [PATCH 3/3] docs: complete snapshot v2 roadmap milestone --- MEDIAINFO_SNAPSHOT_V2.md | 22 ++++++++++------------ ROADMAP.md | 9 +++++---- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/MEDIAINFO_SNAPSHOT_V2.md b/MEDIAINFO_SNAPSHOT_V2.md index 8c20510..00484f6 100644 --- a/MEDIAINFO_SNAPSHOT_V2.md +++ b/MEDIAINFO_SNAPSHOT_V2.md @@ -2,7 +2,7 @@ ## 状态与边界 -本文档定义 `autojs6-plugin-mediainfo-snapshot-v2` 的插件侧契约. 当前实现是 M2 的兼容基础, 尚未替换 v1, 也尚未接入 AutoJs6 的 Node / Rhino 公共 API. 在宿主和共享 `mediainfo-api` 完成协同改造前, 普通脚本仍只会得到 v1. +本文档定义 `autojs6-plugin-mediainfo-snapshot-v2` 的稳定插件契约. M2 协同实现已接入共享 `mediainfo-api`, AutoJs6 宿主及 Node / Rhino 公共 API, 但不会替换 v1: 普通脚本仍默认得到 v1, 只有显式选择并通过能力协商后才得到 v2. 设计遵循三个边界: @@ -30,7 +30,7 @@ | `defaultSnapshotSchema` | `String` | `autojs6-plugin-mediainfo-snapshot-v1` | | `engineVersion` | `String` | MediaInfoLib `Info_Version` 去除首尾空白后的非空结果; 查询失败时省略 | -共享 API 后续应为这些字符串增加常量. 在此之前, 插件用内部常量实现并测试协议. 这些附加 capability 不改变现有 `REQUIRES_HOST_VERSION = 3923`, 因为旧宿主会忽略未知 Bundle 键, 且缺省 snapshot 仍是 v1. +共享 API 已通过 `MediainfoOptionKeys`, `MediainfoPluginCapabilityKeys` 与 `MediainfoSnapshotSchemas` 定义这些键名, schema 标识, 缺省策略及稳定的不支持错误前缀; 插件随仓库内 `libs/mediainfo-api.aar` 使用同一份定义. 这些附加 capability 不改变现有 `REQUIRES_HOST_VERSION = 3923`, 因为旧宿主会忽略未知 Bundle 键, 且缺省 snapshot 仍是 v1. `Info_Parameters` 不放入发现阶段的 capability Bundle. 该值体积大, 应在后续通过专用 AIDL 查询按需取得, 避免每次插件发现都跨 Binder 传输完整参数表. @@ -110,15 +110,13 @@ Track 规则: - 原生 JSON envelope 缺少 `creatingLibrary`, `media.track`, 必需字符串或合法 track 类型时显式失败, 不生成看似成功但结构不完整的 v2. - 解析, FD 回退, 30 秒取消和超时边界沿用现有服务机制. -## 协同接入步骤 +## 协同接入状态 -插件仓库完成本契约并不代表 M2 协同项已经完成. 后续需要在 AutoJs6 与共享 API 中依次完成: +- [x] 共享 API 通过 `MediainfoOptionKeys` / capability keys / `MediainfoSnapshotSchemas` 统一本页常量, 并将新 AAR 同步回插件. +- [x] 宿主发现插件时读取 `snapshotSchemas`; 只有 capability 明确包含 v2 才发送 v2 schema, 对旧插件保留缺省 v1 路径. +- [x] Node / Rhino API 提供显式 schema 选择与 `capabilities()`, 默认保持 v1, 不支持显式 v2 时返回稳定错误. +- [x] 双引擎类型定义, API 文档与示例已同步; v2 的 `fields`, `attributes` 与 `extra` 均保留为动态 JSON 扩展面. +- [x] 插件侧多轨, 多字幕, MP4, WebM, FLAC + cover, 损坏文件及大文件矩阵保持通过; QV710AF65F 进一步通过真实 AIDL, Rhino 生产引擎, Node 直连 / compat 门面及真实 Android Provider 的跨仓库端到端验证. +- [ ] `Info_Parameters` 继续延后. 如后续决定公开, 新增按需查询方法与响应大小测试, 不扩张发现 Bundle. -1. 在 `MediainfoOptionKeys` / capability keys 中加入本页常量, 发布新的 `mediainfo-api.aar`. -2. 宿主发现插件时读取 `snapshotSchemas`; 只有明确包含 v2 才发送 v2 schema. -3. Node / Rhino API 提供显式 schema 选择, 保持默认 v1, 并对不支持 v2 的旧插件给出可识别错误或按调用方策略回退. -4. 更新双引擎类型定义, 示例与 10 语言使用说明, 不把 v2 的 `fields` 动态集合声明为固定类型. -5. 使用多音轨, 多字幕, MP4, WebM, FLAC + cover, 损坏文件及大文件矩阵做跨仓库端到端验证. -6. 如需公开 `Info_Parameters`, 新增按需查询方法与响应大小测试, 不扩张发现 Bundle. - -宿主/API 接入完成并满足上述验证前, Roadmap 的 “快照 schema v2 (协同项)” 与 “引擎信息透出” 保持未勾选状态. +因此 Roadmap 已勾选 “快照 schema v2 (协同项)” 与小体积的 “引擎版本透出”; `Info_Parameters`, 直接 `get` 流序号, 流计数与 InfoKind 仍保持独立未完成条目. diff --git a/ROADMAP.md b/ROADMAP.md index b1a729a..c37f438 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,15 +73,16 @@ 说明: JNI 封装 `MediaInfo.kt` 已具备 `streamNum`, `InfoKind`, `countGet`, `getMIOption` 等完整能力; AIDL 与插件服务已可接收 `streamNumber`, 但当前宿主 Node / Rhino API 仍固定查询同类流第 1 条, InfoKind 也固定为参数值文本. 本里程碑以 "接线已有能力" 为主, 原生层无需改动. -当前进度: 插件侧已建立显式 opt-in 的 snapshot-v2 契约与 capability 广告, 缺省及空白 schema 仍返回 v1, 未知 schema 显式拒绝; v2 以 MediaInfoLib 原生 JSON 为数据源, 将同类流按数组分组并隔离动态字段, 属性与诊断扩展. 宿主与共享 API 尚未接入, 因此对应协同项保持未勾选. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `PluginRuntimeInfo.kt`, `MediainfoPluginService.kt`) +当前进度: snapshot-v2 已完成插件, 共享 API, AutoJs6 宿主, Rhino / Node 公共入口, 类型声明与文档的协同接入. 缺省及空白 schema 仍返回 v1, 只有精确请求且插件明确广告 v2 时才启用 v2, 未知或两侧带空白的 schema 显式拒绝. v2 以 MediaInfoLib 原生 JSON 为数据源, 将同类流按数组分组并隔离动态字段, 属性与诊断扩展; `Info_Version` 已通过 capability 按需暴露, `Info_Parameters` 继续延后. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `PluginRuntimeInfo.kt`, `libs/mediainfo-api.aar`, AutoJs6 `MediainfoPluginHost.kt` / `Mediainfo.kt` / `NodeBridgeProtocol.kt`) - [ ] `get` 支持流序号 (协同项): AIDL 层以选项或新方法携带 `streamNumber`, Node / Rhino API 同步透出, 使脚本可查询第 2 条及之后的音轨 / 字幕. (落点: `MediaInfo.kt` 的 `get(filename, streamKind, streamNum, parameter)`, `MediainfoPluginService.kt`) - [ ] 流计数查询 (协同项): 透出 `countGet`, 返回某流类型的流数量, 配合流序号实现多流遍历. (落点: `MediaInfo.kt` 的 `countGet`) - [ ] InfoKind 扩展查询 (协同项): 支持 `MEASURE` / `INFO` / `NAME_TEXT` 等信息种类, 获取参数单位, 说明与本地化名称. (落点: `MediaInfo.kt` 的 `InfoKind` 枚举) -- [ ] 引擎信息透出: 经 `getMIOption` 提供 `Info_Version` / `Info_Parameters` 等库级信息, 便于脚本诊断与参数发现. 插件发现信息已通过 capability 提供小体积的 `Info_Version`; 大体积 `Info_Parameters` 与宿主脚本入口仍待专用按需 API. (落点: `MediaInfo.kt` 的 `getMIOption`, `PluginRuntimeInfo.kt`, `MEDIAINFO_SNAPSHOT_V2.md`) -- [ ] 快照 schema v2 (协同项): 规范化多流 sections 表示 (以数组序号取代 `audio #1` 式小节名), 明确字段命名规则与 schema 版本协商方式, 保持对 v1 消费方的兼容期. 插件侧契约, opt-in 实现, 缓存隔离与测试已落地; 共享 API 常量, 宿主双引擎入口及跨仓库验收待完成. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `MediainfoPluginService.kt`, `MediaInfoSnapshotV2ContractTest.kt`) +- [x] 引擎版本透出 (协同项): 插件发现 capability 以小体积 `Info_Version` 提供 `engineVersion`, 共享 API 定义稳定键名, Rhino `mediainfo.capabilities()` 与 Node `require("mediainfo").capabilities()` 均可查询; 查询失败时安全省略版本字段. (落点: `MediaInfo.kt` 的 `getMIOption`, `PluginRuntimeInfo.kt`, `MEDIAINFO_SNAPSHOT_V2.md`, `libs/mediainfo-api.aar`, AutoJs6 `MediainfoPluginHost.kt` / `Mediainfo.kt` / `NodeBridgeProtocol.kt`) +- [ ] `Info_Parameters` 按需查询 (协同项, 继续延后): 大体积参数表不进入发现阶段的 capability Bundle; 如后续需要公开, 使用专用 AIDL / 宿主脚本入口并补充响应大小与 Binder 边界测试. (落点: `MediaInfo.kt` 的 `getMIOption`, `MEDIAINFO_SNAPSHOT_V2.md`) +- [x] 快照 schema v2 (协同项): 规范化多流表示 (以数组下标取代 `audio #1` 式小节名), 明确字段分区, 命名边界与 schema 版本协商方式, 保持 v1 为默认协议. 插件契约, opt-in 实现, 缓存隔离, 共享 API 常量, 宿主能力协商, Rhino / Node 公共入口, 类型声明, 文档与 ARM64 实体机端到端验收均已完成. (落点: `MEDIAINFO_SNAPSHOT_V2.md`, `MediainfoSnapshot.kt`, `MediainfoPluginService.kt`, `MediaInfoSnapshotV2ContractTest.kt`, `libs/mediainfo-api.aar`, AutoJs6 `MediainfoSnapshotSchemas.kt` / `MediainfoPluginHost.kt` / `MediainfoSnapshotRhinoInstrumentationTest.kt` / `NodeMediaBridgeInstrumentationTest.kt`) -验收条件: 双引擎示例脚本可读取多音轨样本的第 2 条流及其单位信息; `.readme` / `plugin_instruction` 文档同步更新; 涉及 AIDL 变更的条目与宿主版本要求 (`REQUIRES_HOST_VERSION`) 联动更新. +验收进度: snapshot-v2 子项已在 QV710AF65F 上通过插件真实 AIDL, Rhino 生产引擎, Node 直连 / compat 门面与真实 Android Provider 验证, 双引擎类型声明及文档已同步; 本里程碑整体仍等待直接 `get` 流序号, 流计数与 InfoKind 单位信息. 涉及未来 AIDL 变更的条目仍须与宿主版本要求 (`REQUIRES_HOST_VERSION`) 联动更新. ******