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..00484f6 --- /dev/null +++ b/MEDIAINFO_SNAPSHOT_V2.md @@ -0,0 +1,122 @@ +# MediaInfo snapshot-v2 契约 + +## 状态与边界 + +本文档定义 `autojs6-plugin-mediainfo-snapshot-v2` 的稳定插件契约. M2 协同实现已接入共享 `mediainfo-api`, AutoJs6 宿主及 Node / Rhino 公共 API, 但不会替换 v1: 普通脚本仍默认得到 v1, 只有显式选择并通过能力协商后才得到 v2. + +设计遵循三个边界: + +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 已通过 `MediainfoOptionKeys`, `MediainfoPluginCapabilityKeys` 与 `MediainfoSnapshotSchemas` 定义这些键名, schema 标识, 缺省策略及稳定的不支持错误前缀; 插件随仓库内 `libs/mediainfo-api.aar` 使用同一份定义. 这些附加 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 秒取消和超时边界沿用现有服务机制. + +## 协同接入状态 + +- [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. + +因此 Roadmap 已勾选 “快照 schema v2 (协同项)” 与小体积的 “引擎版本透出”; `Info_Parameters`, 直接 `get` 流序号, 流计数与 InfoKind 仍保持独立未完成条目. diff --git a/ROADMAP.md b/ROADMAP.md index 92f2a1b..c37f438 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,19 +67,22 @@ ****** -## M2 读取能力增强 - 规划中 +## M2 读取能力增强 - 进行中 ****** 说明: JNI 封装 `MediaInfo.kt` 已具备 `streamNum`, `InfoKind`, `countGet`, `getMIOption` 等完整能力; AIDL 与插件服务已可接收 `streamNumber`, 但当前宿主 Node / Rhino API 仍固定查询同类流第 1 条, InfoKind 也固定为参数值文本. 本里程碑以 "接线已有能力" 为主, 原生层无需改动. +当前进度: 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` 等库级信息, 便于脚本诊断与参数发现. (落点: `MediaInfo.kt` 的 `getMIOption`) -- [ ] 快照 schema v2 (协同项): 规范化多流 sections 表示 (以数组序号取代 `audio #1` 式小节名), 明确字段命名规则与 schema 版本协商方式, 保持对 v1 消费方的兼容期. +- [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`) 联动更新. ****** 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..e159c8f --- /dev/null +++ b/app/src/androidTest/java/io/github/supermonster003/autojs6/plugin/mediainfo/MediaInfoSnapshotV2ContractTest.kt @@ -0,0 +1,145 @@ +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 +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(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")) + + 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..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,9 +13,12 @@ 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 import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -195,6 +198,40 @@ class MediainfoPluginServiceTest { .getString("format") .contains("PCM", ignoreCase = true), ) + + val v2Options = Bundle(options).apply { + putString( + MediainfoOptionKeys.SCHEMA, + MediainfoSnapshotSchemas.V2, + ) + } + val v2SnapshotText = withMediaDescriptor(mediaFile) { descriptor -> + plugin.snapshot(descriptor, mediaFile.name, v2Options) + } + val v2Snapshot = JSONObject(v2SnapshotText) + 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")) + 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 +301,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(MediainfoOptionKeys.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 +363,18 @@ class MediainfoPluginServiceTest { val capabilities = requireNotNull(info.capabilities) { "Plugin capabilities are missing" } assertEquals(3923, capabilities.getInt(PluginCapabilityKeys.REQUIRES_HOST_VERSION)) + assertArrayEquals( + MediainfoSnapshotSchemas.VALUES.toTypedArray(), + capabilities.getStringArray(MediainfoPluginCapabilityKeys.SNAPSHOT_SCHEMAS), + ) + assertEquals( + MediainfoSnapshotSchemas.V1, + capabilities.getString(MediainfoPluginCapabilityKeys.DEFAULT_SNAPSHOT_SCHEMA), + ) + assertEquals( + MediaInfo().getMIOption("Info_Version").trim(), + capabilities.getString(MediainfoPluginCapabilityKeys.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..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 @@ -66,6 +67,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( + MediainfoOptionKeys.SCHEMA, + MediainfoSnapshotSchemas.V2, + ) + } + val v2Snapshot = withDescriptor(mediaFile) { descriptor -> + JSONObject(plugin.snapshot(descriptor, mediaFile.name, v2Options)) + } + assertEquals(MediainfoSnapshotSchemas.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..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,6 +23,7 @@ internal data class MediaGetRequest( internal data class MediaSnapshotRequest( val includeInform: Boolean, val includeSections: Boolean, + val schema: String = MediainfoSnapshotSchemas.V1, ) internal data class MediaCacheHit(val value: T) @@ -170,7 +172,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..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 @@ -26,6 +27,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 +56,7 @@ class MediainfoPluginService : Service() { return pluginInfo( name = getString(R.string.app_name), description = getString(R.string.plugin_description), + engineVersion = engineVersion, ) } @@ -126,33 +133,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 +172,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", MediainfoSnapshotSchemas.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..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,19 +1,142 @@ 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 enum class SnapshotSchema(val id: String) { + V1(MediainfoSnapshotSchemas.V1), + V2(MediainfoSnapshotSchemas.V2), + ; + + companion object { + fun resolve(requestedSchema: String?): SnapshotSchema { + val resolvedSchema = MediainfoSnapshotSchemas.resolve(requestedSchema) + return entries.first { it.id == resolvedSchema } + } + } +} + 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(MediainfoOptionKeys.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", MediainfoSnapshotSchemas.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..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,9 +4,11 @@ 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): 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 +24,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( + MediainfoPluginCapabilityKeys.SNAPSHOT_SCHEMAS, + MediainfoSnapshotSchemas.VALUES.toTypedArray(), + ) + putString( + MediainfoPluginCapabilityKeys.DEFAULT_SNAPSHOT_SCHEMA, + MediainfoSnapshotSchemas.DEFAULT, + ) + engineVersion?.takeIf { it.isNotBlank() }?.let { 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 dcc7bbb..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 @@ -92,6 +93,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 +104,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 = { MediainfoSnapshotSchemas.V1 }, + ) + val v2 = parseSnapshotOptions( + readBoolean = { _, defaultValue -> defaultValue }, + readString = { MediainfoSnapshotSchemas.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 = { " ${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 cd67739..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 @@ -23,6 +24,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 = MediainfoSnapshotSchemas.V1, + ) + val v2 = v1.copy(schema = MediainfoSnapshotSchemas.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) diff --git a/libs/mediainfo-api.aar b/libs/mediainfo-api.aar index 1926b73..3280b4c 100644 Binary files a/libs/mediainfo-api.aar and b/libs/mediainfo-api.aar differ