全部 / 兴趣交流 / 技术交流 / 软件交流 · 2024年12月29日 1

Strm+Emby=无痛刮削,应对网盘风控新策略

前言

NAS折腾记1️⃣:从OpenWrt到Unraid - hiRipple
All in One Or All in Boom?
hiripple.com

此前一篇博文中,博主采用了115+alist+infuse实现了简陋的家庭影院系统,但随着近期阿里云盘的崩溃,大量用户涌入115,官方为了降低服务器压力,开始封禁第三方挂载的刮削/扫库行为,目前infuse扫盘一次就会出现“429 too many request” 。

封禁扫库行为

虽然Dps限制可以通过并发线程数和修改分页大小,但这种方法治标不治本。限制太极端,导致infuse启动后需要等待相当长的时间才能获取完整的媒体库,限制不足又将导致频繁429,甚至变成永久封禁。归根到底,还是得解决infuse的暴力扫描问题。

Fnos

近期国产NAS系统Fnos爆火,飞牛影视作为该系统的扛把子功能,可以较为准确、快速地刮削元数据并对接infuse,最厉害的还是可以刮削更符合国人习惯的豆瓣元数据。博主在Unraid虚拟机上尝试了Fnos,虽然一定程度解决了刮削问题,但蓝光原盘ISO居然被直接无视了,咨询官方才知道不支持原盘,pass

MetaShark

下一位选手是老朋友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
ShellScript

Clouddrive通常使用两种安装方式:直接下载编译好的可执行文件/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

缺失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,一切顺利的话稍后就刷新出新增电影,并且元数据一应俱全。

小结

infuse

使用Emby + strm方案替换原Alist后,infuse体验顿时丝滑无比,再也没有出现被115封禁的情况,如果Infuse没有显示媒体信息,可以大胆地删除元数据重新加载。但本方案仍然存在些许遗憾,ISO的媒体信息提取并不完善,对于TV剧集,目前也无法区分每一集的视频流,只能存储MKV格式的蓝光Remux。

如果各位发烧友有更好的方案,欢迎评论留言~