概述
使用网盘当媒体库存放媒体资源;使用 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 获取到的不同媒体库文件路径进行反代或者链接重写,以满足不同的需求。
至于满足什么样的需求,见仁见智,亦或多一份玩法、花样罢了。