
背景多媒体收录要同时接住多路音视频。频道、摄像机、会议室各写各的文件写完还要能按来源和日期找回来并在网页上从上次的位置接着播。架构如果停在“流已经落成文件”分类、检索、点播和播放历史就会各自长出一套路径。本文按这个目标说明收录子系统的架构。一条收录在封闭时成为一条资产视频轨和音频轨在同一个 asset_id 里目录、索引和播放地址都按资产走。应用层 Node 负责栏目树、搜索入口、签名地址和播放历史。点播网关按签名路径把已经封闭的历史流交给网页。浏览器不扫盘收录库里也不出现用户编号后面的存放、检索和回放都从三条架构约束长出来(1). 资产是音视频的最小单位。 时长、起止时间、海报和播放列表挂在同一个 asset_id 上。音频不另开目录也不另做一棵树。(2). 收录进程只保存事实。 来源、时间、形态、状态和文件位置由收录写入。栏目、权限、谁看到哪一秒由 Node 保存。(3). 媒体字节不穿过 Node。 检索和播放会话走 Node分片和 Range 走点播网关。一、总体架构使用方只看见 Node。Node 向收录要资产向播放历史要进度把签名路径交给网页。网页再向点播网关拉流。图 1总体架构。检索走资产索引媒体字节走点播网关播放历史留在应用层 Node管理台和网页都只访问 Node。Node 再调用收录的内部接口。媒体字节从点播网关直接进播放器不穿过 Node也不回到收录进程。二、写入路径任务、封闭与分类写入路径是架构里唯一改磁盘的路径。分类放在这条路径的封闭点上不放在每一帧的写入里。文件还在增长时最终大小、结束时间和时长都还没有索引行也不应该被搜索命中。一条收录会因为三种原因封闭1). 滚动切片。任务带 slice_ms例如一小时。到点后封闭当前条立刻打开下一条。2). 节目单。Node 创建任务时带上 program_id 和出点到点调用停止。3). 人工停止。管理台结束这次收录。封闭时收录子系统按固定顺序做完这些事4). 把写盘句柄刷出并关掉。临时文件名是 {asset_id}.part此刻大小稳定。5). 用收录器自己累计的时间轴算出 duration_ms。时间轴对不上时再用 ffprobe 读一次时长。6). 用开始时间和业务时区生成 day_key例如 2026-10-17。跨过零点的一条仍然挂在开始那天。7). 在同一块磁盘上把临时文件 rename 到最终目录。临时目录和正式目录必须在同一卷跨卷的 rename 会退化成拷贝。8). 写 manifest.json插入索引行状态为 sealed。9). 后台补海报并把播放列表收成可点播形态。完成后状态改为 ready。10). 回调 Nodeasset-ready。界面上的播放按钮靠这次回调点亮靠扫目录点不亮。分类字段就这几列。改栏目名称、改展示顺序都只改 Node 侧的配置不搬文件。source_id 和 asset_id 只允许字母、数字、下划线和短横线。后面的树节点 id 用冒号拼接这两个字段里不能再出现冒号。状态只向前走。recording 的行可以出现在管理台用来表示“正在收”但不能创建播放会话。sealed 表示文件已经封闭海报或播放列表还在补。ready 才能点播。failed 保留索引行和日志方便按来源追查不把半截文件暴露给网页。三、存储按来源和日期落盘存储键在封闭时生成此后不随栏目改名而变化。目录按来源和开始日期分层资产 id 放在最后一级避免同一天里文件名互相覆盖。视频、音频、海报和播放列表都在这个目录里。/data/archive/{source_id}/{yyyy}/{mm}/{dd}/{asset_id}/ media.m3u8 seg-00000.ts seg-00001.ts poster.jpg manifest.json只存单文件时目录里是做好 faststart 的media.mp4其余字段不变。storage_key形如ch_news_01/2026/10/17/a_7f3cday_key是2026-10-17路径里的年、月、日从同一个字符串切出来避免索引和目录差一天。std::string StorageKey(std::string_view source_id, std::string_view day_key, std::string_view asset_id) { // day_key 已是 2026-10-17 return std::string(source_id) / std::string(day_key.substr(0, 4)) / std::string(day_key.substr(5, 2)) / std::string(day_key.substr(8, 2)) / std::string(asset_id); }manifest.json跟文件放在一起索引丢失时可以按目录重建{ asset_id: a_7f3c, source_id: ch_news_01, program_id: , form: rolling, day_key: 2026-10-17, start_ms: 1792216800000, end_ms: 1792220400000, duration_ms: 3600000, status: ready, storage_key: ch_news_01/2026/10/17/a_7f3c, video: {codec: h264, width: 1920, height: 1080}, audio: {codec: aac, sample_rate: 48000, channels: 2}, playback: media.m3u8 }video 和 audio 记在同一条 manifest 里点播时一起交给播放器。缺音频轨时 audio 为空对象资产 id 仍然只有一个。start_ms 的 1792216800000 是东八区 2026-10-17 14:00end_ms 比它多一小时。业务分类新闻、监控、会议放在 Node 的栏目配置里用 source_id 或 program_id 去对应。收录目录不因为“这条改判成会议”而搬家。四、检索查询打在资产索引上检索是架构里的读路径。界面上的每一次点树、每一次按时间查找都是资产表上的一次查询。读目录只用于索引丢失后的重建。CREATE TABLE assets ( asset_id VARCHAR(64) PRIMARY KEY, source_id VARCHAR(64) NOT NULL, program_id VARCHAR(64) NOT NULL DEFAULT , form VARCHAR(16) NOT NULL, day_key CHAR(10) NOT NULL, start_ms BIGINT NOT NULL, end_ms BIGINT NOT NULL, duration_ms BIGINT NOT NULL, status VARCHAR(16) NOT NULL, storage_key VARCHAR(255) NOT NULL, playback VARCHAR(64) NOT NULL, KEY idx_source_day (source_id, day_key, start_ms), KEY idx_source_time (source_id, start_ms, end_ms) );按某一天浏览用day_key。日期在封闭时已经按业务时区写成字符串查询侧不再做时区换算。SELECT asset_id, start_ms, end_ms, duration_ms, form, storage_key, playback FROM assets WHERE source_id ? AND day_key ? AND status ready ORDER BY start_ms, asset_id LIMIT ?;按时间范围搜用区间重叠两条边界都要写上。只判断start_ms落在窗口里会漏掉“14:00 开始、窗口从 14:30 起”的那一条。SELECT asset_id, start_ms, end_ms, duration_ms, form, storage_key FROM assets WHERE source_id ? AND start_ms ? -- 查询窗口的结束 AND end_ms ? -- 查询窗口的开始 AND status ready ORDER BY start_ms, asset_id LIMIT ?;翻页用(start_ms, asset_id)做游标不用OFFSET。收录一天可能切出几十条频道多了以后偏移量会把前面的行重复扫描一遍。内部接口只给 Node 调用不对浏览器开放请求体{ source_id: ch_news_01, day_key: 2026-10-17, status: ready, limit: 50 }时间范围查询把day_key换成from_ms和to_ms。两个条件同时空着时接口直接返回 400避免一次把全库拉走。五、展示把索引装配成分类树分类树属于应用层不属于磁盘。每点开一层Node 就把节点 id 翻译成一次 QueryAssets。图 3日期节点对应一次按 source_id 和 day_key 的查询叶子才是资产日期层用聚合不把一年 365 天都列出来SELECT day_key, COUNT(*) AS n FROM assets WHERE source_id ? AND status IN (ready, recording) GROUP BY day_key ORDER BY day_key DESC LIMIT 60;形态筛选是同一接口上的form条件。搜索框走时间范围结果用平铺列表不套进树里。两套界面共用一个查询避免树和搜索各维护一份路径。Node 在返回树之前做权限过滤。收录子系统信任内网调用不认识登录用户。function parseNode(parent) { if (!parent) return { kind: root }; const parts parent.split(:); if (parts[0] src parts.length 2) { return { kind: source, sourceId: parts[1] }; } if (parts[0] day parts.length 3 /^\d{4}-\d{2}-\d{2}$/.test(parts[2])) { return { kind: day, sourceId: parts[1], dayKey: parts[2] }; } const err new Error(bad node); err.status 400; throw err; } async function listDay(sourceId, dayKey) { const res await fetch(http://recording.internal/internal/assets/query, { method: POST, headers: { content-type: application/json }, body: JSON.stringify({ source_id: sourceId, day_key: dayKey, status: ready, limit: 50, }), }); if (!res.ok) throw new Error(query ${res.status}); return res.json(); }对外的接口是 Node 自己的六、调用边界应用层 Node 怎么接收录调用关系把三条架构约束落成接口。一次“点开昨天 14 点并播放”拆成三段Node 向收录要资产列表Node 签发播放地址网页向点播网关拉字节。图 4树和搜索停在收录的查询接口拉流停在点播网关心跳停在 Node创建任务同样从 Node 发出。滚动收录{ source_id: ch_news_01, url: rtmp://origin/live/news01, form: rolling, slice_ms: 3600000, program_id: }节目收录由 Node 按节目单调用开始和停止。收录进程不自己读节目单数据库。资产变为ready之后收录向 Node 发一次回调{ asset_id: a_7f3c, source_id: ch_news_01, day_key: 2026-10-17, start_ms: 1792216800000, duration_ms: 3600000, status: ready }回调失败时收录重试。Node 也可以在用户点开那一天时重新查询列表以查询结果为准回调只负责让树上的状态早点变。调用方向固定成下面这几条(1). 浏览器只调用 Node 的 /api/*。(2). Node 调用收录的 /internal/*创建任务、停止任务、查询资产。(3). 收录在状态变成 ready 时回调 Node。(4). 网页拿着 Node 签发的路径访问点播网关。(5). 播放心跳只写 Node 的播放历史表。收录的查询接口返回 storage_key 给 NodeNode 用它拼签名路径。这个字段不到浏览器的分类接口里。分类接口给前端的是节点id、标题、起止时间和时长。七、点播ready 之后才签发地址点播网关是架构里的读文件进程。它从 statusready 的资产开始服务。Node 创建会话时再向收录读一次资产状态不对、资产不存在都返回 404不签发地址。会话响应{ asset_id: a_7f3c, url: https://vod.example/play/1792224000.SIG/ch_news_01/2026/10/17/a_7f3c/media.m3u8, start_ms: 1792216800000, duration_ms: 3600000, resume_ms: 720000 }令牌签的是路径前缀不是单独一个 m3u8 地址。播放列表里的分片是相对路径浏览器会把它们解析到同一层目录下前缀里的令牌还在/play/{exp}.{sig}/{storage_key}/media.m3u8 /play/{exp}.{sig}/{storage_key}/seg-00000.tsimport crypto from node:crypto; function signPrefix(storageKey, ttlSec) { const exp Math.floor(Date.now() / 1000) ttlSec; const sig crypto .createHmac(sha256, process.env.PLAY_SECRET) .update(${storageKey}|${exp}) .digest(base64url); return { exp, token: ${exp}.${sig} }; }根目录不要用符号链接指到归档区外面。这个函数只做词法判断不解析链接。两种可播形态资产记录里的playback字段决定会话返回哪一种收录器自己切分片时直接写播放列表分片时长 6 秒hls_list_size对应的行为是全部保留。收录过程中如果允许从头回看类型用EVENT。封闭时把类型改成VOD并写上EXT-X-ENDLIST。分片文件不动。收录器只产出一个大文件时封闭后再封装一次。点播列表必须保留全部分片hls_list_size用 0ffmpeg -i media.mp4 -c copy \ -f hls -hls_time 6 -hls_playlist_type vod -hls_list_size 0 \ -hls_segment_filename seg-%05d.ts \ media.m3u8-c copy 在关键帧附近切开。收录侧的 GOP 设成 2 秒拖拽和快进的落点误差就在一个 GOP 以内。封装完成、manifest.json 写好之后才把状态改成 ready。八、播放历史用户进度单独成库播放历史是用户数据放在 Node 的库里和收录的资产表分开。打开会话时Node 把展示要用的字段抄一份进来。之后的心跳只更新这张表不再回收录查询。CREATE TABLE playback_history ( user_id VARCHAR(64) NOT NULL, asset_id VARCHAR(64) NOT NULL, source_id VARCHAR(64) NOT NULL, day_key CHAR(10) NOT NULL, start_ms BIGINT NOT NULL, duration_ms BIGINT NOT NULL, position_ms BIGINT NOT NULL, updated_at TIMESTAMP NOT NULL, PRIMARY KEY (user_id, asset_id), KEY idx_user_recent (user_id, updated_at) );最近播放直接查这张表按 updated_at 倒序取 20 条。资产后来被删掉时这一行还在再次打开会话会在收录侧得到 404界面把该条标成不可播。续播位置有两条空白开头 5 秒以内从 0 开始离结尾不足 10 秒也从 0 开始。中间的位置原样返回给播放器。function resumeMs(row, durationMs) { if (!row) return 0; if (row.position_ms 5000) return 0; if (row.position_ms durationMs - 10000) return 0; return row.position_ms; }心跳 10 秒一次暂停、离开页面时再用 sendBeacon 送一次。请求带 kindprogress 或 seek。晚到的进度包如果比已保存的位置倒退超过 2 秒丢掉用户主动拖动进度条时 kindseek接受新位置。function shouldApply(prev, next) { if (next.kind seek) return true; if (!prev) return true; return next.position_ms 2000 prev.position_ms; }主键是 (user_id, asset_id)。同一个人反复看同一条更新这一行最近播放列表里也只出现一次。九、网页回放历史流进播放器网页回放消费的是已经封闭的资产播放列表在封闭时已经收尾。播放器可以拖到任意分片也可以从 resume_ms 起播。音视频仍走同一条播放地址。#EXTM3U #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:6 #EXTINF:6.000, seg-00000.ts #EXTINF:6.000, seg-00001.ts #EXT-X-ENDLIST网页侧用 hls.js 的startPosition承接续播单位是秒const hls new Hls({ startPosition: session.resume_ms 0 ? session.resume_ms / 1000 : -1, }); hls.loadSource(session.url); hls.attachMedia(video); video.addEventListener(timeupdate, () { const abs session.start_ms Math.round(video.currentTime * 1000); clock.textContent formatWall(abs); });墙上时钟用资产的start_ms加上播放器里的相对进度。14:00 开始的一条播到第 12 分钟界面显示 14:12。进度条上的刻度按这个公式换算拖拽时再换回相对秒数交给播放器。倍速和跳转是两件事。连续倍速设置video.playbackRate2 倍和 4 倍由浏览器加快解码。8 倍时 1080p 可能来不及解画面会顺滑下降成跳帧。快进跳转沿用分片每个分片以 IDR 开头拖到某个时间点就会落到包含该时间的分片。GOP 2 秒、分片 6 秒时落点误差不超过一个 GOP。MP4 点播不经过 hls.js。会话里的url指向media.mp4播放器发Range网关回206。moov在文件头时首帧不用把整个文件下完。续播同样是video.currentTime resume_ms / 1000。图 5会话带上续播位置心跳写回播放历史墙上时钟由资产起点加播放进度得到进度上报独立于timeupdate。timeupdate只改界面上的时钟心跳用 10 秒定时器离开页面时补一次sendBeacon。十、一次点击如何穿过整条架构把前面的接口按一次点击串起来看清一次操作分别碰到收录、Node 和点播网关的哪一段。用户在树上展开「新闻频道 / 2026-10-17」再打开 14:00 那一条。(1). 网页请求 GET /api/catalog/nodes?parentsrc:ch_news_01。Node 向收录要日期聚合返回 day:ch_news_01:2026-10-17。(2). 网页请求 parentday:ch_news_01:2026-10-17。Node 调用 QueryAssets得到 14:00 和 15:00 两条 ready 资产。(3). 用户点击 14:00。网页 POST /api/playback/sessions正文是 asset_id。(4). Node 再读一次资产确认 ready。播放历史里已有 12 分钟离开头和结尾都够远resume_ms720000。(5). Node 用 storage_key 签发两小时有效的路径前缀返回 m3u8 地址、start_ms 和 resume_ms。(6). hls.js 从 720 秒起播。界面时钟显示 14:12。(7). 之后每 10 秒网页把当前位置发给 /api/playback/progress。Node 更新 playback_history 这一行。(8). 下次再打开同一条会话里的 resume_ms 就是这次停下的位置。第 4 步和第 7 步都不写收录库。第 6 步的字节不经过 Node。十一、架构落地时的检查小结多媒体收录子系统把多路音视频收成可检索、可点播的资产。收录在封闭时写好分类字段、目录和索引视频轨和音频轨共用同一个asset_id状态推进到ready。应用层 Node 把索引查询装配成树和搜索结果签发带前缀令牌的播放地址同时单独保存每个人的播放历史。点播网关只按签名路径读已经收尾的历史流HLS 列表带EXT-X-ENDLIST或 MP4 支持 Range。进度条上的墙上时钟是这条资产的开始时间加上播放器里的相对进度。频道、摄像机和天数增加之后要加的是收录任务、索引和磁盘。分类树、续播和网页回放沿用同一套资产和同一套调用边界。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。