概述

使用网盘当媒体库存放媒体资源;使用 alist 文件链接生成 strm 文件,以解决 emby 扫库触发网盘 api 限制问题;使用 openresty(nginx + lua)反代,通过 lua 判断媒体资源路径,进行代理视频链接或进行重写网盘直链。

以上服务都在同一服务器中搭建、配置。

alist 安装与配置

安装与配置

根据 官方文档 安装、添加存储即可。

需要注意的是,alist 需要设置为可以直接通过直链下载文件,如:

https://alist.example.com/d/tv/abc.mp4

或者像我一样不需要对外开放 alist,直接本机使用的:

http://127.0.0.1:5244/d/tv/abc.mp4

生成 strm 文件

因为我的媒体资源都是提前整理好上传网盘的,于是使用 alist api 写了个 python3 脚本(AI 写的),按照网盘目录在服务器 1:1 生成 strm 文件:

import http.client
import json
import os
from urllib.parse import quote
from functools import wraps
from datetime import datetime
import time
import socket

class NetworkError(Exception):
    pass

def debug_log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not wrapper.debug_enabled:
            return func(*args, **kwargs)
        
        # 获取当前时间
        current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
        
        # 打印函数调用信息
        print(f"\n[DEBUG] {current_time} - Calling: {func.__name__}")
        print(f"[DEBUG] Arguments: args={args}, kwargs={kwargs}")
        
        try:
            result = func(*args, **kwargs)
            print(f"[DEBUG] {func.__name__} completed successfully")
            print(f"[DEBUG] Return value: {result}")
            return result
        except Exception as e:
            print(f"[DEBUG] Error in {func.__name__}: {str(e)}")
            raise
    
    wrapper.debug_enabled = False
    return wrapper

def retry_on_network_error(max_retries=3, delay=5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            retries = 0
            while retries < max_retries:
                try:
                    return func(*args, **kwargs)
                except (socket.timeout, TimeoutError, ConnectionError) as e:
                    retries += 1
                    if retries == max_retries:
                        raise NetworkError(f"Failed after {max_retries} retries: {str(e)}")
                    print(f"[WARNING] Network error: {str(e)}. Retry {retries}/{max_retries} after {delay} seconds...")
                    time.sleep(delay)
            return None
        return wrapper
    return decorator

class AListClient:
    def __init__(self, host, username, password, base_url, debug=False, 
                 timeout=30, max_retries=3, retry_delay=5):
        self.host = host
        self.username = username
        self.password = password
        self.base_url = base_url
        self.token = None
        self.video_extensions = ('.mp4', '.ts', '.mkv')
        self.debug = debug
        self.timeout = timeout
        self.max_retries = max_retries
        self.retry_delay = retry_delay
        
        debug_log.debug_enabled = debug

    def create_connection(self):
        conn = http.client.HTTPConnection(self.host, timeout=self.timeout)
        return conn

    @debug_log
    @retry_on_network_error(max_retries=3, delay=5)
    def login(self):
        conn = self.create_connection()
        try:
            payload = json.dumps({
                "username": self.username,
                "password": self.password
            })
            headers = {
                'Content-Type': 'application/json'
            }
            
            if self.debug:
                print(f"[DEBUG] Login request - Host: {self.host}, Username: {self.username}")
            
            conn.request("POST", "/api/auth/login", payload, headers)
            res = conn.getresponse()
            data = json.loads(res.read().decode("utf-8"))
            
            if self.debug:
                print(f"[DEBUG] Login response: {data}")
            
            if data["code"] == 200:
                self.token = data["data"]["token"]
                return True
            return False
        finally:
            conn.close()

    @debug_log
    @retry_on_network_error(max_retries=3, delay=5)
    def list_files(self, path):
        conn = self.create_connection()
        try:
            payload = json.dumps({
                "path": path,
                "password": "",
                "page": 1,
                "per_page": 0,
                "refresh": False
            })
            headers = {
                'Authorization': self.token,
                'Content-Type': 'application/json'
            }
            
            if self.debug:
                print(f"[DEBUG] List files request - Path: {path}")
            
            conn.request("POST", "/api/fs/list", payload, headers)
            res = conn.getresponse()
            data = json.loads(res.read().decode("utf-8"))
            
            if self.debug:
                print(f"[DEBUG] List files response code: {data['code']}")
                print(f"[DEBUG] Number of items: {len(data['data']['content']) if data['code'] == 200 else 0}")
            
            if data["code"] == 200:
                return data["data"]["content"]
            return []
        finally:
            conn.close()

    @debug_log
    def process_directory(self, source_path, target_base_paths):
        try:
            if self.debug:
                print(f"[DEBUG] Processing directory: {source_path}")
                print(f"[DEBUG] Target base paths: {target_base_paths}")
            
            files = self.list_files(source_path)
            
            for item in files:
                try:
                    current_source_path = f"{source_path}/{item['name']}"
                    
                    # Create paths for all target directories
                    target_paths = [os.path.join(base_path, item['name']) for base_path in target_base_paths]

                    if self.debug:
                        print(f"[DEBUG] Processing item: {item['name']}")
                        print(f"[DEBUG] Is directory: {item['is_dir']}")
                        print(f"[DEBUG] Target paths: {target_paths}")

                    if item["is_dir"]:
                        for target_path in target_paths:
                            if self.debug:
                                print(f"[DEBUG] Creating directory: {target_path}")
                            os.makedirs(target_path, exist_ok=True)
                        self.process_directory(current_source_path, target_paths)
                    else:
                        if item["name"].lower().endswith(self.video_extensions):
                            url_path = quote(current_source_path)
                            strm_content = f"{self.base_url}{url_path}"
                            
                            # Create .strm files in all target directories
                            for target_path in target_paths:
                                strm_path = f"{os.path.splitext(target_path)[0]}.strm"
                                os.makedirs(os.path.dirname(strm_path), exist_ok=True)
                                
                                if self.debug:
                                    print(f"[DEBUG] Creating .strm file: {strm_path}")
                                    print(f"[DEBUG] STRM content: {strm_content}")
                                
                                with open(strm_path, 'w', encoding='utf-8') as f:
                                    f.write(strm_content)
                                print(f"Created: {strm_path}")
                except Exception as e:
                    print(f"[ERROR] Failed to process item {item['name']}: {str(e)}")
                    continue
        except Exception as e:
            print(f"[ERROR] Failed to process directory {source_path}: {str(e)}")
            raise

def main():
    # 配置信息
    host = "127.0.0.1:5244" # alist 地址
    username = "admin"      # alist 账号
    password = "password"   # alist 密码
    source_path = "/media"  # alist 挂载路径
    target_base_paths = ["/media/library", "/media/library_GO"]  # 两个本地目标目录,用于 emby 入库媒体
    debug_mode = True
    base_url = f"http://{host}/d"

    client = AListClient(
        host=host,
        username=username,
        password=password,
        base_url=base_url,
        debug=debug_mode,
        timeout=60,
        max_retries=3,
        retry_delay=5
    )
    
    try:
        if client.login():
            print("Login successful")
            client.process_directory(source_path, target_base_paths)
        else:
            print("Login failed")
    except NetworkError as e:
        print(f"Network error occurred: {str(e)}")
    except Exception as e:
        print(f"Unexpected error occurred: {str(e)}")

if __name__ == "__main__":
    main()

按自己的情况修改脚本中注释的地方,如果 alist 的链接是 https,则需要将 conn = http.client.HTTPConnection(self.host, timeout=self.timeout) 修改为 conn = http.client.HTTPSConnection(self.host, timeout=self.timeout)

上面脚本会生产两个目录 /media/library/media/library_GO,两个目录内容完全一样,路径可以自定义,后面的 openresty 配置会根据目录路径进行链接重写或代理。用其他工具生成也是可以。

emby 安装与配置

安装

直接使用 docker 安装,版本选自己需要就可以:

docker run -d \
  --name=emby \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Etc/UTC \
  --net=host \
  -v /path/to/emby/library:/config \
  -v /media:/media \
  --restart unless-stopped \
  lscr.io/linuxserver/emby

注意映射路径与生成 strm 文件的一致;

配置

新建媒体库时,注意 /media/library 与 /media/library_GO 要分开,如图:

openresty 安装与配置

安装

参考文档:OpenResty® Linux Packages

这里截取 x86_64 debian 12 的安装过程:

# apt-get -y install --no-install-recommends wget gnupg ca-certificates
# wget -O - https://openresty.org/package/pubkey.gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/openresty.gpg
# codename=`grep -Po 'VERSION="[0-9]+ \(\K[^)]+' /etc/os-release`
# echo "deb http://openresty.org/package/debian $codename openresty" | tee /etc/apt/sources.list.d/openresty.list
# apt-get update
# apt-get -y install openresty
# opm get ledgetech/lua-resty-http

配置参考

worker_processes auto;
worker_cpu_affinity auto;
worker_priority -5;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    keepalive_requests 100000;
    reset_timedout_connection on;
    client_body_timeout 10;
    send_timeout 20;
    server_tokens off;
    
    open_file_cache max=200000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
    
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    
    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_vary on;
    gzip_disable "MSIE [1-6]\.";
    
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    lua_code_cache on;
    
    limit_conn_zone $binary_remote_addr zone=addr:10m;
    limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s;

    upstream emby-backend {
        server 127.0.0.1:8096;
        keepalive 1024;
    }

    server {
        listen 80 default_server;
        server_name _;

        location ~ ^/emby/videos/([^/]+)/([^/]+) {
            access_by_lua_block {
                local emby_url = 'http://127.0.0.1:8096'
                local emby_path = '/media/library/'
                local emby_path_GO = '/media/library_GO'

                local args = ngx.req.get_uri_args()
                local media_source_id = args.MediaSourceId
                local api_key = args.api_key
                local item_id = string.match(ngx.var.uri, "/emby/videos/([^/]+)/")

                local function url_decode(str)
                    str = string.gsub(str, '+', ' ')
                    str = string.gsub(str, '%%(%x%x)', function(h)
                        return string.char(tonumber(h, 16))
                    end)
                    return str
                end

                if not (media_source_id and api_key and item_id) then
                    ngx.status = 400
                    ngx.say("Missing required parameters")
                    ngx.exit(400)
                end

                local http = require "resty.http"
                local httpc = http.new()
                
                local items_url = string.format(
                    "%s/emby/Items?Fields=Path&Ids=%s&api_key=%s",
                    emby_url, media_source_id, api_key
                )
                
                local res, err = httpc:request_uri(items_url)
                if not res then
                    ngx.log(ngx.ERR, "Failed to request Items: ", err)
                    ngx.exit(500)
                end

                local cjson = require "cjson"
                local items_data = cjson.decode(res.body)
                
                if not items_data.Items or #items_data.Items == 0 then
                    ngx.exit(404)
                end

                local path = items_data.Items[1].Path

                if string.match(path, "^" .. emby_path_GO) then
                    local playback_url = string.format(
                        "%s/Items/%s/PlaybackInfo?MediaSourceId=%s&api_key=%s",
                        emby_url, item_id, media_source_id, api_key
                    )
                    
                    local playback_res, err = httpc:request_uri(playback_url)
                    if not playback_res then
                        ngx.log(ngx.ERR, "Failed to request PlaybackInfo: ", err)
                        ngx.exit(500)
                    end

                    local playback_data = cjson.decode(playback_res.body)
                    local media_path = playback_data.MediaSources[1].Path

                    local head_res, err = httpc:request_uri(media_path, {
                        method = "HEAD"
                    })
                    
                    if not head_res then
                        ngx.log(ngx.ERR, "Failed to get redirect location: ", err)
                        ngx.exit(500)
                    end

                    if head_res.status == 302 then
                        ngx.redirect(head_res.headers.Location)
                    end

                elseif string.match(path, "^" .. emby_path) then
                    ngx.exec("@emby")
                else
                    ngx.exit(404)
                end
            }
        }

        location / {
            proxy_pass http://emby-backend;
        }

        location @emby {
            proxy_pass http://emby-backend;
        }

    }
}

总结

重点是同一份资源,用 strm 文件分两个路径、两个媒体库,然后通过 emby 的 api 获取到的不同媒体库文件路径进行反代或者链接重写,以满足不同的需求。

至于满足什么样的需求,见仁见智,亦或多一份玩法、花样罢了。