前言
此前一篇博文中,博主采用了115+alist+infuse实现了简陋的家庭影院系统,但随着近期阿里云盘的崩溃,大量用户涌入115,官方为了降低服务器压力,开始封禁第三方挂载的刮削/扫库行为,目前infuse扫盘一次就会出现“429 too many request” 。
虽然Dps限制可以通过并发线程数和修改分页大小,但这种方法治标不治本。限制太极端,导致infuse启动后需要等待相当长的时间才能获取完整的媒体库,限制不足又将导致频繁429,甚至变成永久封禁。归根到底,还是得解决infuse的暴力扫描问题。
近期国产NAS系统Fnos爆火,飞牛影视作为该系统的扛把子功能,可以较为准确、快速地刮削元数据并对接infuse,最厉害的还是可以刮削更符合国人习惯的豆瓣元数据。博主在Unraid虚拟机上尝试了Fnos,虽然一定程度解决了刮削问题,但蓝光原盘ISO居然被直接无视了,咨询官方才知道不支持原盘,pass
下一位选手是老朋友JellyFin。据了解,JellyFin目前已支持蓝光原盘的扫描,并且还有一个很棒的刮削插件MetaShark(刮削豆瓣元数据),媒体服务器无法对AList提供的Webdav共享直接扫描,因此需要用Fuse将Webdav挂载为本地文件夹再导入媒体库。实测JellyFin导致115出现429封禁的概率是百分百,而且扫库速度奇慢,pass
折腾大半天后,博主甚至已经起了购入机械盘和正经NAS的想法,但本文的主角来的正是时候~
文章测试环境:
- 底层系统:Unraid
- Docker:Emby、Auto_symmlink
- 可执行文件:Clouddrive
- 可选:Alist+Fuse
- 客户端:AppleTv+infuse
- 网盘:115
配置Strm环境
什么是Strm文件
正文开始之前,介绍一下什么是Strm文件,简单来说Strm就是一个软链接,指向一条通往真正媒体文件等路径,这个路径可以是直链,也可以是本地路径。
创建一个普通文本文件并将.txt 扩展名重命名为.strm。然后使用文本编辑器(如 Microsoft Windows 中的记事本)打开它并输入流的直接 URL 链接。
这应该看起来像:
http://192.168.2.1:567/movie/spirited_away.iso
或者
mms://host/path/stream
或者
rtsp://host/path/livestream/cctv1.m3u8
或者
F:/Movies/Topgun (1986)/Topgun.mp4
Strm 文件可用于任何类型的视频,例如电影、剧集、音乐视频、家庭视频等,只需将 .strm 文件放在您想要的位置,就好像它是视频文件一样。
为什么要生成strm文件?
因为115的风控规则导致无法批量扫描和刮削视频文件,这时strm远程链接的特性就发挥了作用,刮削软件可以把strm视作视频文件,根据文件名获取信息,而无需去115读取文件,并根据需要改名而保持链接还是指向正确的远程地址。而获取115的文件列表只是通过读取目录信息而并不读取文件,所以更准确的说是扫描目录而不是扫描文件。
进一步来说,Strm避免了媒体服务器/infuse对网盘中媒体信息(例如分辨率、HDR、时长、音轨等)直接进行刮削,通常来说对于ISO原盘文件,刮削使用的ffprobe需要相当高的带宽资源才能提取出信息,这也是导致封控的罪魁祸首,如果媒体服务器(Emby)扫描的是Strm软链接,则不会使用ffprobe,只会简单从互联网刮削电影元数据(例如电影名称、海报等)。
这么做的最大好处自然是避免封控,所有刮削操作所需要的目录、电影名称都保存在本地,弊端自然就是无法提取媒体信息(Emby如果没有时长数据,将无法同步播放记录)。好消息是,无论Mkv还是ISO原盘,都存在曲线救国的解决方案,请看后文。
生成Strm文件
对媒体库手动添加Strm是不现实的,因此需要自动化工具实现。因为博主讲Emby部署在本地,不需要考虑流量消耗,这里选择较为方便的本地路径生成Strm。
注:互联网上其他教程也提到了通过Nginx转发实现302重定向,不消耗服务器流量,外网流畅观影。除非实在必要,否则博主不推荐这种做法,一方面部署302需要部署更多容器,更复杂的操作也加大了维护成本,另一方面115官方一直严格限制302挂载,因302被封禁账户下载与在线播放权限的例子并不少见(failed link: failed get link: {"state":false,"msg":"账号存在异常,此功能已被停用","errno":990020,"data":""}: unexpected error),猜测是检测多IP同时302下载,总而言之,最安全的做法还是放弃302。
既然需要指向本地路径,那就必须先把之前用的Alist Webdav挂载至本地,通常的方案是使用Fuse(Rclone):
curl https://rclone.org/install.sh | sudo bash
rclone config # 按照提示添加Webdav存储
rclone mount mywebdav:/ /mnt/webdav --daemon --vfs-cache-mode writes --allow-other # 挂载Webdav到本地
ShellScript挂载之后访问指定文件夹,应该就可以看到网盘中的文件了。注意,为避免风控,Alist应针对115存储进行相应的限制,推荐的配置是:1、更新Alist为最新版本。2、分页大小:9999,限制速率(限制所有 api 请求速率(1r/[limit_rate]s)) :1。
博主不喜欢装太多的轮子,因此选的是另一套方案:Clouddrive,虽然本质上也就是Alist+fuse的缝合,但整合在一起并且提供GUI界面还是不错的。值得一提的是,CD并未开源,并且收费,膈应的朋友可以选择前者,否则还是更推荐Clouddrive。
docker run -d \
--name clouddrive \
--restart unless-stopped \
--env CLOUDDRIVE_HOME=/Config \
-v <path to accept cloud mounts>:/CloudNAS:shared \
-v <path to app data>:/Config \
-v <other local shared path>:/media:shared \
--network host \
--pid host \
--privileged \
--device /dev/fuse:/dev/fuse \
cloudnas/clouddrive2
ShellScriptClouddrive通常使用两种安装方式:直接下载编译好的可执行文件/Docker容器,Docker的部署需要额外的步骤并且需要映射路径,博主选择的是前者。运行后访问http://localhost:19798,随后按照GUI界面将网盘文件挂载到本地。
为避免风控,CD同样需要进一步设置,推荐的配置如下:1、更新CD至0.8.6版本以及上。2、设置勾选目录缓存持久化,默认目录缓存时间:1800。
Auto_symlink:自动生成Strm文件
既然已经把网盘文件挂载到本地,那么下一步就是针对每一个视频文件生成对应的Strm文件,并且在网盘目录发生变动时,自动添加/删除对应的Strm文件。部署Auto_symlink项目来实现这一目标。
docker run -d \
--name auto_symlink \
-e TZ=Asia/Shanghai \
-v /volume1/CloudNAS:/volume1/CloudNAS:rslave \
-v /volume2/Media:/Media \
-v /volume1/docker/auto_symlink/config:/app/config \
-p 8095:8095 \
--user 0:0 \
--restart unless-stopped \
shenxianmq/auto_symlink:latest
# -v /your/cloud/path:/cloudpath:rslave: 将你的云盘路径(/your/cloud/path)映射到容器内的路径(/your/cloud/path)。rslave 表示使用相对于宿主机的从属挂载模式。请确保左右路径保持一致,否则生成的软链接不是指向真实路径,导入emby中的时候会导致无法观看。(简单的来说,这里需要填写你映射的云盘路径,且两边都填写一模一样的路径即可。)
# -v /your/media/path:/media: 将你即将创建软连接的位置映射到容器内的 /media 目录。
# -p 8095:8095: 映射8095端口,可方便的查看日志以及管理服务。
# -v /path/to/auto_symlink/config:/app/config: 将 auto_symlink 的配置目录映射到容器内的 /app/config。这样可以使容器中的 auto_symlink 使用外部的配置文件。
# --restart unless-stopped: 设置容器在退出时自动重启。
ShellScript注意部署该项目时,需要特别注意映射路径,因为涉及Strm文件的路径构成,映射错误可能导致后续Emby无法找到文件。对于媒体文件目录,一般将宿主机路径和容器路径保持一致,对于Clouddrive根目录,也进行相同的映射,最后映射Appdata即可,博主的实例如下:
运行后访问http://localhost:8095 即可进入WebUI。首先进入全局设置,推荐开启挂载检测、打开同步状态与实时监控。实时监控功能可以与Clouddrive联动(需要会员),在CD的Webui上存入/删除文件后,本项目可以立即同步。
实时监控生效的条件如下:
- cd2会员
- 文件是通过cd2挂载文件夹/网页版cd2中操作的,在网盘app中操作无法触发实时监控
- 检查是否打开全局设置中的实时监控,开启后重启AS
- 查看日志,看看是否有"开始监控xxx文件夹的作用",如果只出现"开始索引xxx文件夹",则说明该文件夹文件太多,索引时间很长,建议开启永久缓存
- 如果实时监控一直处于索引文件夹的状态,可能是因为系统监控文件数受到限制,可以依次运行下面三行脚本后,重启AS即可:
sudo echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
ShellScript随后在主页添加同步,媒体目录为网盘挂载在本地的目录,本地目录为保存Strm文件的目录(Emby媒体库目录),建议勾选更新软链接/删除软链接,确保CD2的操作同步,元数据相关的选项全部跳过,为防止风控,全部元数据以Info、json、jpg等格式保存在本地,不涉及云端,因此可以跳过。全部设置如下:
随后前往-常用工具-手动同步,开始第一步的数据同步(如果读取目录为空,应第一时间采用重启大法)。同步成功后前往strm保存的目录,下载任意strm文件并用记事本打开,核对路径是否与网盘媒体文件路径一致。不一致就是路径映射错误,重新配置并同步即可。
推荐配置:常用工具-Emby通知-填写Emby地址与APIkey,勾选启用通知。启用此工具后,每当Auto_symlink生成/删除strm文件时,将自动通知emby重新扫描媒体库(Emby可以直接关闭定期扫库)。最终实现的效果是Clouddrive转存视频文件,Auto_symlink自动生成对应的strm,emby自动刷新媒体库,非常方便快速。
常见问题:
Q: EMBY显示当前没有兼容的流
答: 请确保你EMBY映射的也是绝对路径,需要与 auto_symlink
设置的路径保持一致。也可能是Emby转码问题,尝试使用infuse播放。
Q: 虽然我有元数据,但EMBY扫库还是很慢?
答: 因为我们映射了所有影片的软连接,所以可以尝试先禁用EMBY的FFmpeg进程,CloudDrive2可以在设置黑名单添加/bin/ffprobe
,扫库完成后,再删除该黑名单即可
正确打开Emby
配置代理
第一次体验Emby时,感受到最大的就是奇慢无比的刮削速度,给博主的印象是一款没有啥优势的媒体服务器。与Plex竞争,缺失了一键内网穿透、音乐库等功能,而对比开源的JellyFin,插件生态薄弱,硬件加速价格昂贵,Strm+Emby组合似乎打开了新世界的大门(JellyFin不支持Strm,草率了,貌似是支持的)。
如何解决刮削缓慢的问题呢,emby元数据来自themoviedb,而tmdb在国内环境受到严重DNS污染,早些时间互联网其他教程推荐修改本地Hosts,手动解析至对应IP,目前这种方案已经几乎不可用。因此想达到一个正常的刮削速度,就必须为Emby配置代理
博主在Unraid上部署了Openwrt虚拟机,使用Passwall2配置了Http代理,想让Emby走Openwrt代理,需要在Docker容器启动时添加对应的变量,如上图所示,填写Openwrt地址以及端口。
Emby的媒体路径映射同样需要注意,媒体文件路径最好保持宿主机路径与容器路径一致,因为Emby打开strm链接时,是以容器的角度搜索的,如果不映射完整的路径,依旧无法找到媒体文件。
最后访问http://localhost:8096即可进入Emby的web界面,添加媒体库,选择Strm对应的目录,扫墓媒体库文件,精美的海报墙就出现了。
刮削Mediainfo
但是到此还远没有结束,即使有了电影元数据,媒体信息也并未提取,无论emby网页还是Infuse,都无法预览影片的相关参数,这导致的问题如下:1、不美观。2、缺失时长信息,infuse重启后播放进度丢失。3、载入影片的时间变长。
为了刮削mediainfo,博主采用了两种方案,对于MKV文件,只需要安装Emby插件:StrmAssistant,传送门。注:Infuse官方推荐的Infuse sync插件不建议安装,一方面会与StrmAssistant产生冲突,另一方面由于影片元数据全部保存在本地,并不需要优化同步速度,况且目前基本都已经是直连模式。
StrmAssistant最大的作用就是替代Emby对MKV影片进行Mediainfo的提取,“独占媒体信息提取”可以禁用Emby自带的ffprobe,并采用更低频率的ffprobe提取Strm对应的影片信息,既防止风控又可生成媒体信息,对于剧集还可以探测片头长度提供跳过,优化载入速度。安装插件-重启Emby-计划任务-执行神医助手任务,即可生效,此时对于MKV文件已经可以正常记录播放进度。
遗憾的是,StrmAssistant并不支持ISO文件,因此提取ISO原盘的Mediainfo就成了最困难的一步,只能自己动手,用脚本实现。第一个思路是直接用Mediainfo项目,无奈也不支持ISO,那么还是得安装FFmpeg。
Unraid这羸弱的性能就不指望从源码编译了,从https://github.com/BtbN/FFmpeg-Builds/releases下载对应的Build,解压进入bin目录就可以调用ffprobe进行手动提取了。注意提取蓝光ISO信息的指令与常规视频文件略有区别,ISO路径前必须加上bluray: 。例如想提取[岁月的童话 1991][台版原盘 国粤双语 DIY简繁 双语字幕].iso,可以执行:
./ffprobe -v error \
-print_format json \
-show_format \
-show_streams \
-show_chapters \
-show_programs \
bluray:"path/to/[岁月的童话 1991][台版原盘 国粤双语 DIY简繁 双语字幕].iso" > "[岁月的童话 1991][台版原盘 国粤双语 DIY简繁 双语字幕]-mediainfo.json"
ShellScript在UNraid系统下直接调用ffprobe会出现警告:bdj.c:795: BD-J check: Failed to load JVM library
bdj.c:795: BD-J check: Failed to load JVM library。这是缺失JAVA运行环境导致的,想消除警告,前往https://jdk.java.net/23/下载对应的OPENJDK,将JAVA导入系统路径并重新执行ffprobe,会惊奇地发现,居然生成了更多的警告:
ffprobe bdj.c:614: libbluray-j2se-1.3.4.jar not found.bdj.c:801: BD-J check: Failed to load libbluray.jarbdj.c:614
bdj.c:632: Cant access AWT jar file /usr/share/libbluray/libbluray-awt-j2se-1.3.2.jarbdj.c:801: BD-J check: Failed to load libbluray.jarbdj.c:632: Cant access AWT jar file /usr/share/libbluray/libbluray-awt-j2se-1.3.2.jarbdj.c:801
...
ShellScript这是因为安装JAVA环境后,又缺失了相应的依赖,博主在这边直接提供编译完成的JAR文件。
https://772123.xyz/cdn/libbluray-awt-j2se-1.3.2.jar
https://772123.xyz/cdn/libbluray-j2se-1.3.2.jar
将jar文件移动到/usr/share/libbluray/,随后添加到系统路径。最后执行ffprobe即可消除警告。
echo 'export LIBBLURAY_CP=/usr/share/libbluray/libbluray-j2se-1.3.2.jar' >> ~/.bashrc
source ~/.bashrc
ShellScript观察发现,ffprobe直接输出的json信息结构如下,并非Emby可以直接识别的格式。
{
"programs": [
{
"program_id": 1,
"program_num": 1,
"nb_streams": 35,
"pmt_pid": 256,
"pcr_pid": 4097,
"streams": [
{
"index": 0,
"codec_name": "hevc",
"codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)",
"profile": "Main 10",
"codec_type": "video",
"codec_tag_string": "HDMV",
"codec_tag": "0x564d4448",
"width": 3840,
"height": 2160,
"coded_width": 3840,
"coded_height": 2160,
"has_b_frames": 1,
"sample_aspect_ratio": "1:1",
"display_aspect_ratio": "16:9",
"pix_fmt": "yuv420p10le",
"level": 153,
"color_range": "tv",
"color_space": "bt2020nc",
"color_transfer": "smpte2084",
"color_primaries": "bt2020",
"chroma_location": "topleft",
"refs": 1,
"view_ids_available": "",
"view_pos_available": "",
"ts_id": "0",
"ts_packetsize": "192",
"id": "0x1011",
"r_frame_rate": "24000/1001",
"avg_frame_rate": "24000/1001",
"time_base": "1/90000",
"start_pts": 1048560,
"start_time": "11.650667",
"duration_ts": 1051616815,
"duration": "11684.631278",
"extradata_size": 726,
"disposition": {
"default": 0,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0,
"non_diegetic": 0,
"captions": 0,
"descriptions": 0,
"metadata": 0,
"dependent": 0,
"still_image": 0,
"multilayer": 0
}
},
{
"index": 1,
"codec_name": "hevc",
"codec_long_name": "H.265 / HEVC (High Efficiency Video Coding)",
"profile": "Main 10",
"codec_type": "video",
"codec_tag_string": "HDMV",
"codec_tag": "0x564d4448",
"width": 1920
......
JSON而Emby接受的JSON格式如下:
[{"MediaSourceInfo":{"Protocol":"File","Id":"5dfe5ad4-33dc-4d4d-8d31-de381f0ea0f9","Path":"/mnt/user/embydata/local/movie/云下载/[SGNB-296 V2][龙与地下城:侠盗荣耀 Dungeons & Dragons Honor Among Thieves 2023].iso","Type":"Default","Container":"MPEGTS","Size":93251432448,"Name":"[SGNB-296 V2][龙与地下城:侠盗荣耀 Dungeons & Dragons Honor Among Thieves 2023]","IsRemote":false,"HasMixedProtocols":false,"RunTimeTicks":80475067780,"SupportsTranscoding":true,"SupportsDirectStream":true,"SupportsDirectPlay":true,"IsInfiniteStream":false,"RequiresOpening":false,"RequiresClosing":false,"RequiresLooping":false,"SupportsProbing":false,"MediaStreams":[{"Codec":"hevc","Language":"und","TimeBase":"1/90000","DisplayTitle":"2160p HEVC","DisplayLanguage":"English","IsInterlaced":false,"IsDefault":false,"IsForced":false,"IsHearingImpaired":false,"Type":"Video","Index":0,"IsExternal":false,"IsTextSubtitleStream":false,"SupportsExternalStream":false,"Protocol":"File","PixelFormat":"yuv420p10le","Level":153,"BitRate":0,"RunTimeTicks":80474977780,"Profile":"Main 10","AspectRatio":"16:9","Width":3840,"Height":2160,"AverageFrameRate":23.976023976023978,"RealFrameRate":23.976023976023978,"BitDepth":0,"ChannelLayout":"","Channels":0,"SampleRate":0,"SubtitleLocationType":""},{"Codec":"hevc","Language":"und","TimeBase":"1/90000","DisplayTitle":"1080p HEVC","DisplayLanguage":"English","IsInterlaced":false,"IsDefault":false,"IsForced":false,"IsHearingImpaired":false,"Type":"Video","Index":1,"IsExternal":false,"IsTextSubtitleStream":false,"SupportsExternalStream":false,"Protocol":"File","PixelFormat":"yuv420p10le","Level":153,"BitRate":0,"RunTimeTicks":80474977780,"Profile":"Main 10","AspectRatio":"16:9","Width":1920,"Height":1080,"AverageFrameRate":23.976023976023978,"RealFrameRate":23.976023976023978,"BitDepth":0,"ChannelLayout":"","Channels":0,"SampleRate":0,"SubtitleLocationType":""},
....
JSON那么我们的首要目的就是找出两者重合的部分,进行相应的格式转化,因为Unraid系统中缺失高级语言的运行环境,在AI帮助下,博主使用VPS部署了一个Nodejs api进行格式转化,代码如下:
// index.js
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json({ limit: '50mb' })); // 增加限制以处理大型 JSON
app.use(cors());
// Helper function for safe division
const safeDivision = (numerator, denominator) => {
if (denominator === 0) return 0.0;
return numerator / denominator;
};
// Helper function to parse frame rate strings like "24000/1001"
const parseFrameRate = (frameRateStr) => {
const [numerator, denominator] = frameRateStr.split('/').map(Number);
return safeDivision(numerator, denominator);
};
// Main conversion function
const convertFfprobeToCustomFormat = (ffprobeData) => {
const formatInfo = ffprobeData.format || {};
const streams = ffprobeData.streams || [];
const programs = ffprobeData.programs || [];
const chapters = ffprobeData.chapters || [];
// Select all video streams
const videoStreams = streams.filter(stream => stream.codec_type === 'video');
if (videoStreams.length === 0) {
throw new Error("没有找到视频流。");
}
// Select the longest video stream based on duration
const mainVideoStream = videoStreams.reduce((prev, current) => {
const prevDuration = parseFloat(prev.duration) || 0;
const currentDuration = parseFloat(current.duration) || 0;
return (currentDuration > prevDuration) ? current : prev;
}, videoStreams[0]);
// Generate unique ID
const uniqueId = uuidv4();
// Map streams to MediaStreams
const mediaStreams = streams.map(stream => {
// 检查 codec_type 是否存在
if (!stream.codec_type) {
console.warn(`警告:stream 中缺少 codec_type,跳过此流。流信息: ${JSON.stringify(stream)}`);
return null; // 返回 null 表示跳过此流
}
const codecType = stream.codec_type.toLowerCase();
const language = stream.tags && stream.tags.language ? stream.tags.language : "und"; // und = undefined
// Calculate frame rates only for video streams
let avgFrameRate = 0.0;
let realFrameRate = 0.0;
if (codecType === "video") {
const avgFrameRateStr = stream.avg_frame_rate || "0/1";
const rFrameRateStr = stream.r_frame_rate || "0/1";
avgFrameRate = parseFrameRate(avgFrameRateStr);
realFrameRate = parseFrameRate(rFrameRateStr);
}
return {
"Codec": stream.codec_name || "",
"Language": language,
"TimeBase": stream.time_base || "",
"DisplayTitle": "",
"DisplayLanguage": "",
"IsInterlaced": false, // 可根据需要进一步设置
"IsDefault": Boolean(stream.disposition && stream.disposition.default),
"IsForced": Boolean(stream.disposition && stream.disposition.forced),
"IsHearingImpaired": Boolean(stream.disposition && stream.disposition.hearing_impaired),
"Type": codecType.charAt(0).toUpperCase() + codecType.slice(1),
"Index": stream.index !== undefined ? stream.index : -1,
"IsExternal": false, // 根据实际情况调整
"IsTextSubtitleStream": codecType === "subtitle",
"SupportsExternalStream": false, // 根据实际情况调整
"Protocol": "File",
"PixelFormat": codecType === "video" ? (stream.pix_fmt || "") : "",
"Level": codecType === "video" ? (stream.level || 0) : "",
"BitRate": stream.bit_rate ? parseInt(stream.bit_rate) : 0,
"RunTimeTicks": stream.duration ? Math.round(parseFloat(stream.duration) * 1e7) : 0, // 1 tick = 100纳秒
"Profile": stream.profile || "",
"AspectRatio": stream.display_aspect_ratio || "",
"Width": stream.width || 0,
"Height": stream.height || 0,
"AverageFrameRate": avgFrameRate,
"RealFrameRate": realFrameRate,
"BitDepth": stream.bits_per_raw_sample ? parseInt(stream.bits_per_raw_sample) : 0,
"ChannelLayout": codecType === "audio" ? (stream.channel_layout || "") : "",
"Channels": codecType === "audio" ? (stream.channels || 0) : 0,
"SampleRate": codecType === "audio" ? (stream.sample_rate ? parseInt(stream.sample_rate) : 0) : 0,
"SubtitleLocationType": codecType === "subtitle" ? "InternalStream" : ""
// 可以根据需要添加更多字段
};
}).filter(stream => stream !== null) // 过滤掉返回为 null 的流
.map((stream, idx) => {
// 设置 DisplayTitle 和 DisplayLanguage
if (stream.Type === "Video") {
stream.DisplayTitle = `${stream.Height}p ${stream.Codec.toUpperCase()}`;
stream.DisplayLanguage = "English"; // 可以根据实际情况调整
} else if (stream.Type === "Audio") {
stream.DisplayTitle = stream.ChannelLayout || `${stream.Channels} Channels`;
stream.DisplayLanguage = "English"; // 可以根据实际情况调整
} else if (stream.Type === "Subtitle") {
stream.DisplayTitle = `${stream.Codec.toUpperCase()} Subtitle`;
stream.DisplayLanguage = "English"; // 可以根据实际情况调整
}
return stream;
});
// Map chapters
const customChapters = chapters.map(chapter => {
const startTime = parseFloat(chapter.start_time) || 0;
return {
"StartPositionTicks": Math.round(startTime * 1e7),
"Name": (chapter.tags && chapter.tags.title) ? chapter.tags.title : `Chapter ${chapter.id || ""}`,
"MarkerType": "Chapter",
"ChapterIndex": chapter.id !== undefined ? chapter.id : 0
};
});
// Extract file path
let filePath = formatInfo.filename || "unknown";
if (filePath.startsWith("bluray:")) {
filePath = filePath.slice("bluray:".length);
}
// Construct MediaSourceInfo
const mediaSourceInfo = {
"MediaSourceInfo": {
"Protocol": "File",
"Id": uniqueId,
"Path": filePath,
"Type": "Default",
"Container": formatInfo.format_name ? formatInfo.format_name.split(',')[0].toUpperCase() : "",
"Size": formatInfo.size ? parseInt(formatInfo.size) : 0,
"Name": filePath.split('/').pop().split('.')[0] || "Unknown",
"IsRemote": false, // 根据实际情况调整
"HasMixedProtocols": false, // 根据实际情况调整
"RunTimeTicks": formatInfo.duration ? Math.round(parseFloat(formatInfo.duration) * 1e7) : 0,
"SupportsTranscoding": true, // 根据实际需求调整
"SupportsDirectStream": true, // 根据实际需求调整
"SupportsDirectPlay": true, // 根据实际需求调整
"IsInfiniteStream": false, // 根据实际需求调整
"RequiresOpening": false, // 根据实际需求调整
"RequiresClosing": false, // 根据实际需求调整
"RequiresLooping": false, // 根据实际需求调整
"SupportsProbing": false, // 根据实际需求调整
"MediaStreams": mediaStreams,
"Formats": [], // 根据需要填充
"Bitrate": formatInfo.bit_rate ? parseInt(formatInfo.bit_rate) : 0,
"RequiredHttpHeaders": {},
"AddApiKeyToDirectStreamUrl": false, // 根据实际需求调整
"ReadAtNativeFramerate": false, // 根据实际需求调整
"ItemId": "" // 可根据需要生成或填充
},
"Chapters": customChapters
};
return mediaSourceInfo;
};
// Define the /format endpoint
app.post('/format', (req, res) => {
const ffprobeData = req.body;
if (!ffprobeData || typeof ffprobeData !== 'object') {
return res.status(400).json({ error: "Invalid JSON payload." });
}
try {
const customFormat = convertFfprobeToCustomFormat(ffprobeData);
// Wrap the result in an array as per the example
return res.json([customFormat]);
} catch (error) {
return res.status(500).json({ error: error.message });
}
});
// Health check endpoint
app.get('/', (req, res) => {
res.send("FFprobe Formatter API is running.");
});
// Start the server
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
JavaScript部署完毕后进行测试:
curl -X POST http://IP:3000/format \
-H "Content-Type: application/json" \
-d @/path/to/test-mediainfo.json
ShellScript将返回的JSON文件放入Strm所在目录,进入Emby刷新对应影片元数据,此时就可以看到相应信息。使用Infuse播放也可以正常同步进度。注:ffprobe提取的mediainfo包含信息似乎并不全面,例如音频语言、字幕语言、码率、其他视频流等信息缺失,恳请了解ffmpeg的大佬给予指导。
自动化
最后的最后,就是要让Mediainfo的提取实现自动化,使用SHELL脚本实现,此脚本自动扫描Strm目录,寻找缺失mediainfo的影片,定位至媒体文件路径,使用ffprobe提取该媒体文件信息,将提取出的JSON发送给API进行格式转化,最后将其移动回Strm目录。
#!/bin/bash
# 设置脚本在遇到错误时退出
set -euo pipefail
# 定义目录路径
FFPROBE_DIR="/path/to/ffmpeg-master-latest-linux64-gpl/bin" #ffprobe所在路径
LINKS_DIR="/path/to/links" # strm所在路径
TEMP_DIR="/path/to/temp" # 用于格式转化的临时文件
MOVIES_DIR="/path/to/movie/" # 媒体文件所在目录
API_URL="http://YourIP:3000/format" # 格式转化API地址,假设 API 端点为 /format
# 定义锁文件路径,使用 /tmp 目录避免权限问题
LOCK_FILE="/tmp/process_strm.lock"
# 函数:释放锁文件
cleanup() {
rm -f "$LOCK_FILE"
exit
}
# 检查是否已有实例在运行
if [ -e "$LOCK_FILE" ]; then
echo "锁文件已存在,脚本可能正在运行。退出。"
exit 1
fi
# 创建锁文件
touch "$LOCK_FILE"
# 确保在脚本退出时删除锁文件
trap cleanup EXIT
# 进入 ffprobe 所在目录
echo "进入目录: $FFPROBE_DIR"
cd "$FFPROBE_DIR" || { echo "无法进入目录 $FFPROBE_DIR"; exit 1; }
if [ ! -d "$TEMP_DIR" ]; then
echo "创建 temp 目录: $TEMP_DIR"
mkdir -p "$TEMP_DIR"
fi
# 遍历所有 .strm 文件
echo "扫描目录: $LINKS_DIR 以查找 .strm 文件"
find "$LINKS_DIR" -maxdepth 1 -type f -name "*.strm" | while read -r strm_file; do
# 提取电影名(不带 .strm 扩展名)
filename=$(basename "$strm_file" .strm)
echo "处理电影: $filename"
# 定义路径
mediainfo_json_link="${LINKS_DIR}/${filename}-mediainfo.json"
mediainfo_json_temp="${TEMP_DIR}/${filename}-mediainfo.json"
iso_file="${MOVIES_DIR}/${filename}.iso"
# 检查是否已经存在 mediainfo.json
if [ -f "$mediainfo_json_link" ]; then
echo "已存在 mediainfo.json 文件,跳过: $mediainfo_json_link"
continue
fi
# 检查 ISO 文件是否存在
if [ ! -f "$iso_file" ]; then
echo "ISO 文件不存在,跳过: $iso_file"
continue
fi
# 执行 ffprobe 命令,生成 mediainfo.json
echo "运行 ffprobe 生成 mediainfo.json 文件"
set +e # 临时禁用错误退出,以处理可能的错误
./ffprobe -v error \
-print_format json \
-show_format \
-show_streams \
-show_chapters \
-show_programs \
bluray:"$iso_file" > "$mediainfo_json_temp"
ffprobe_status=$?
set -e # 重新启用错误退出
# 检查 ffprobe 是否成功以及文件是否创建
if [ $ffprobe_status -ne 0 ] || [ ! -s "$mediainfo_json_temp" ]; then
echo "ffprobe 执行失败或文件创建失败,跳过电影: $filename"
continue
fi
# 调用 API 格式化 JSON
echo "调用 API 进行格式化"
response=$(curl -s -X POST "$API_URL" \
-H "Content-Type: application/json" \
-d @"$mediainfo_json_temp")
# 检查 API 调用是否成功
if [ $? -ne 0 ] || [ -z "$response" ]; then
echo "API 调用失败或无响应,跳过电影: $filename"
continue
fi
# 保存 API 返回的格式化 JSON 到 LINKS_DIR
echo "保存格式化后的 JSON 到 $mediainfo_json_link"
echo "$response" > "$mediainfo_json_link"
# 等待 3 秒
echo "等待 5 秒后处理下一个文件..."
sleep 5
echo "完成处理电影: $filename"
echo "----------------------------------------"
done
echo "所有 .strm 文件处理完成。"
ShellScript接下来定期运行上述脚本即可实现自动化提取,如果是Unraid系统,前往设置-User script添加此脚本,推荐设置一天一次的频率。
使用定时任务的方式,缺点是不能即时同步电影信息,如果和Auto_symlink一样,实时监控目标文件夹,发生变动则立即执行,效率会提高不少,当然这个功能需要Clouddrive会员。
#!/bin/bash
# clouddrive_monitor.sh
# 监控 /mnt/user/myCloudDrive 目录,一旦有文件变动,就执行 111.sh
WATCH_DIR="/mnt/user/myCloudDrive" # 该目录为媒体文件路径,注意不要写成Strm目录,如果监控Strm路径会陷入死循环。
ACTION_SCRIPT="/mnt/user/scripts/111.sh" #这里填写上一步脚本的路径
echo "[INFO] 开始监控目录: $WATCH_DIR"
echo "[INFO] 检测到变动时,会执行: $ACTION_SCRIPT"
echo
# 持续监听 -m、递归监听 -r,并监控 create/delete/modify/move 四种事件
inotifywait -m -r -e create,delete,modify,move "$WATCH_DIR" \
| while read path action file; do
echo "[`date +'%Y-%m-%d %H:%M:%S'`] $action => $path$file"
# 在这里执行脚本
bash "$ACTION_SCRIPT"
done
ShellScript如果部署了实时监控脚本,前一步的计划任务就可以删去,Unraid用户同样在设置-user script中添加次监控脚本,设置开机自动运行即可。最后进行测试,前往CLouddrive Web界面添加任意ISO格式电影,前往Emby,一切顺利的话稍后就刷新出新增电影,并且元数据一应俱全。
小结
使用Emby + strm方案替换原Alist后,infuse体验顿时丝滑无比,再也没有出现被115封禁的情况,如果Infuse没有显示媒体信息,可以大胆地删除元数据重新加载。但本方案仍然存在些许遗憾,ISO的媒体信息提取并不完善,对于TV剧集,目前也无法区分每一集的视频流,只能存储MKV格式的蓝光Remux。
如果各位发烧友有更好的方案,欢迎评论留言~
感谢分享,跟着教程来终于看到效果了,里面有不少关键的小细节也说出来了,好文!