Emby + Alist + OneDrive 无转码直链方案

背景介绍

通过 Nginx 的 njs 模块实现 Emby 视频请求直接重定向到 OneDrive 直链,避免服务器转码和流量消耗。

环境要求

Nginx with njs moduleEmby ServerAlistOneDrive 存储

实现原理

Emby 发起视频请求Nginx 拦截请求并通过 njs 处理获取 Alist 直链302 重定向到 OneDrive 直链客户端直接从 OneDrive 获取视频数据

更多配置请参考大佬文章:https://syq.pub/archives/27/

配置步骤

1. Nginx 配置

在 /etc/nginx/conf.d/emby.conf 中添加:

js_path /etc/nginx/conf.d/;
js_import emby2Pan from emby.js;

proxy_cache_path /var/cache/nginx/emby levels=1:2 keys_zone=emby:100m max_size=1g inactive=30d use_temp_path=off;proxy_cache_path /var/cache/nginx/emby/subs levels=1:2 keys_zone=embysubs:10m max_size=1g inactive=30d use_temp_path=off;

server {
    listen 80;
    server_name 你的emby域名;
    
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy 'no-referrer';
    
    set $emby http://127.0.0.1:8096;

    # 修改视频匹配规则,同时匹配直接播放和 HLS 格式
    location ~* /emby/videos/(\d+)/(original\..*|hls1/.*\.ts|hls1/.*\.m3u8|hls1/.*\.mp4) {
        js_content emby2Pan.redirect2Pan;
    }

    location ~ /(socket|embywebsocket) {
        proxy_pass $emby;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_read_timeout 86400;
        proxy_send_timeout 86400;
        keepalive_timeout 86400;
    }

    location ~* /videos/(.*)/Subtitles {
        proxy_pass $emby;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;

        proxy_cache embysubs;
        proxy_cache_revalidate on;
        proxy_cache_lock_timeout 10s;
        proxy_cache_lock on;
        proxy_cache_valid 200 30d;
        proxy_cache_key $proxy_host$uri;
        proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
    }

    location ~ /Items/(.*)/Images {
        proxy_pass $emby;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;

        proxy_cache emby;
        proxy_cache_revalidate on;
        proxy_cache_lock_timeout 10s;
        proxy_cache_lock on;
        proxy_cache_valid 200 30d;
        proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
        proxy_cache_key $proxy_host$uri;
    }

    location / {
        proxy_pass $emby;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Protocol $scheme;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_buffering off;
    }
}

2. njs 脚本

async function redirect2Pan(r) {
    r.warn('===============开始处理视频请求===============');
    r.warn('请求URI: ' + r.uri);
    r.warn('请求参数: ' + JSON.stringify(r.args));

    const config = {
        embyHost: 'http://127.0.0.1:8096',
        embyMountPath: '/mnt/',
        alistHost: 'http://127.0.0.1:5244',
        alistUsername: 'admin',
        alistPassword: 'alist登录密码',
        defaultApiKey: 'emby密钥'
    };

    try {
        // 从 Emby 请求中提取视频 ID
        const videoId = r.uri.match(/\/emby\/videos\/(\d+)\//)[1];
        
        // 获取视频信息
        const videoInfoRes = await ngx.fetch(`${config.embyHost}/emby/Items/${videoId}/PlaybackInfo?api_key=${config.defaultApiKey}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({})
        });

        const videoInfo = await videoInfoRes.json();
        if (!videoInfo.MediaSources || !videoInfo.MediaSources[0] || !videoInfo.MediaSources[0].Path) {
            throw new Error('无法获取视频文件路径');
        }

        const embyPath = videoInfo.MediaSources[0].Path;
        r.warn('获取到 Emby 文件路径: ' + embyPath);

        // 处理路径中的特殊字符
        let alistPath = embyPath.replace(config.embyMountPath, '/');
        // 替换冒号为全角冒号
        alistPath = alistPath.replace(/:/g, ':');
        r.warn('Alist 文件路径: ' + alistPath);

        // 获取 Alist token
        const loginRes = await ngx.fetch(config.alistHost + '/api/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                username: config.alistUsername,
                password: config.alistPassword
            })
        });

        const loginData = await loginRes.json();
        if (loginData.code !== 200) {
            throw new Error('登录失败: ' + loginData.message);
        }

        // 获取文件直链
        const getRes = await ngx.fetch(config.alistHost + '/api/fs/get', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': loginData.data.token
            },
            body: JSON.stringify({
                path: alistPath,
                password: '',
                page: 1,
                per_page: 0,
                refresh: false
            })
        });

        const getData = await getRes.json();
        r.warn('获取文件信息响应: ' + JSON.stringify(getData));

        if (getData.code !== 200 || !getData.data.raw_url) {
            throw new Error('获取文件直链失败: ' + getData.message);
        }

        r.warn('获取到 Alist 直链: ' + getData.data.raw_url);
        
        // 添加必要的响应头
        r.headersOut['Access-Control-Allow-Origin'] = '*';
        r.headersOut['Access-Control-Allow-Methods'] = 'GET, OPTIONS';
        r.headersOut['Access-Control-Allow-Headers'] = 'Range';
        r.headersOut['Accept-Ranges'] = 'bytes';

        return r.return(302, getData.data.raw_url);

    } catch (error) {
        r.error('处理请求时发生错误: ' + error.toString());
        return r.return(500, 'Internal Server Error');
    }
}

export default { redirect2Pan };

cat /etc/nginx/nginx.conf 配置参考:

load_module modules/ngx_http_js_module.so;
load_module modules/ngx_stream_js_module.so;

user www-data;
worker_processes auto;
pid /run/nginx.pid;

# 确保设置了 debug 级别
error_log /var/log/nginx/error.log debug;

events {
    worker_connections 768;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

Emby 设置

1. 服务器端设置:管理 -> 媒体库 -> 播放启用直接播放禁用转码客户端设置:设置 -> 播放播放方式选择”直接播放”关闭”启用 HLS”最大流媒体比特率选择”原始”

验证方法

查看 Nginx 错误日志:

tail -f /var/log/nginx/error.log

正常日志示例:

2025/01/19 00:07:43 [warn] 6640#6640: *432 js: 获取到 Emby 文件路径: /mnt/odmovie1/电影/我是谁.mkv

2025/01/19 00:07:43 [warn] 6640#6640: *432 js: Alist 文件路径: /odmovie1/电影/我是谁.mkv

2025/01/19 00:07:43 [warn] 6640#6640: *432 js: 获取到 Alist 直链: https://xxx.sharepoint.com/…

优势

无需服务器转码不消耗服务器带宽播放速度取决于 OneDrive 速度服务器只处理轻量级请求支持特殊字符文件名

注意事项

确保 Nginx 已安装 njs 模块正确配置 Emby API Key配置正确的 Alist 账号密码路径映射要准确匹配处理文件名中的特殊字符

故障排查

检查 Nginx 错误日志确认 Emby API 可访问验证 Alist 登录状态检查文件路径映射确认客户端播放设置

参考资料

重启 nginx

nginx -t && nginx -s reload && systemctl restart nginx
tail -f /var/log/nginx/error.log

By 行政