把飞牛影视(Trim / fnOS)里的观看记录,整理成一条「谁在什么时候看了哪部剧的第几集」的时间线,外加一张「剧集 × 用户」矩阵,以及一页「谁现在正在看」的实时状态。
源数据库一律以 ?mode=ro 打开,不会写回飞牛影视,所有数据都落在应用自己的 app.db 里。白天 / 黑夜 / 跟随系统三种主题。
一套 CSS 设计系统(token 化的色板 / 圆角 / 阴影 / 动效),卡片化布局,桌面与手机两套形态:
- 桌面:左侧固定导航 + 主区 + 右侧栏,卡片带层级与微交互(悬停浮起、渐变数字、顶部高光条)。
- 手机:侧栏收进抽屉,顶部一条毛玻璃工具栏,底部五个大按钮的导航条;时间线每一行变成一张卡片(海报 + 剧名 + 集号 + 进度分三层),数据表格全部改成「字段名 — 值」的卡片列表,不用左右滑;矩阵页保留横向滚动,但剧名列钉在左边。
- 触控目标按手指尺寸放大,正文 15px,深浅两套主题都做了对比度校正。
在 NAS 上准备一个目录(例如 /vol1/1000/docker/fn-media-ext),把仓库里的 Dockerfile、docker-compose.yml、app/、templates/、static/、requirements.txt 放进去,然后:
docker compose up -d --build打开 http://<NAS-IP>:8787,第一次访问会要求创建管理员账号(只有一个账号,仅自己用)。
不想在 NAS 上编译,可以直接用 GitHub Actions 发布到 GHCR 的镜像:
docker pull ghcr.io/jimboo7339/fn-media-ext:latest把 docker-compose.yml 里的 build: . 换成这个 image:,然后 docker compose up -d(不用 --build)。包默认是私有的,首次拉取要先 docker login ghcr.io;在 GitHub 的包设置里改成 public 就能匿名拉取。
main 分支推一次出一组标签:latest / main / sha-<短提交>;打 vX.Y.Z 标签时额外产出 X.Y.Z 和 X.Y。流水线在发布前会先真起一个容器打 /health,不通就不推。
docker-compose.yml 里已经按飞牛影视的默认安装位置写好,装在不同存储空间时按需改:
| 容器内 | 宿主机 | 用途 |
|---|---|---|
/data/trim |
/usr/local/apps/@appdata/trim.media/database |
三个源数据库。挂载时不要加 :ro——WAL 库要建 -shm,只读挂载会让实时在线页退回快照;只读由应用层的 ?mode=ro 保证 |
/data/meta |
/vol1/@appmeta/trim.media |
海报与缓存,只读,可不挂 |
/data/app |
./appdata |
快照与 app.db,可读写 |
确认数据库路径:
ls /usr/local/apps/@appdata/trim.media/database
# trimmedia.db trimmedia_ext.db trimactivity.db确认海报路径:
ls /vol1/@appmeta/trim.media
# cache img index progress_thumb subtitle海报在 img/ 下,应用通过 /data/meta/img 访问。目录不存在也能跑,只是所有海报会退化成带首字的色块。
| 变量 | 默认值 | 说明 |
|---|---|---|
TRIM_DB_DIR |
/data/trim |
源数据库目录 |
TRIM_IMG_ROOT |
/data/meta/img |
海报根目录 |
DATA_DIR |
/data/app |
快照与 app.db 存放处 |
SNAPSHOT_MINUTES |
10 |
后台同步间隔(分钟),最小 1 |
TZ |
Asia/Shanghai |
展示用时区 |
CARD_FONT |
自动探测 | 日报卡片用的中文字体路径。容器里默认是 /usr/share/fonts/truetype/wqy/wqy-microhei.ttc |
EXCLUDE_DEFAULT_LIBRARY |
空 | 首次启动时预置排除的媒体库,逗号分隔 |
EXCLUDE_DEFAULT_ACCOUNT |
空 | 首次启动时预置排除的账号,逗号分隔 |
时间线:按天分组的观看流水,每行是「海报 + 剧名 + SxxExx + 单集标题 + 谁 + 几点 + 进度」。顶部四条 KPI、90 天活跃度热力条、右侧「正在追 / 多人共看 / 被排除的内容」。侧栏的成员列表点名字进这个人的详情页,点右侧漏斗图标才是「从时间线里隐藏 ta」(被隐藏的成员会划线显示,再点一次恢复);媒体库列表点一下即筛选。时间范围 7/30/90 天或全部切换。
关于日志里的「非 HTTP 请求」:NAS 上可能有网关或监控组件在周期性探测这个端口(抓包确认:TCP 握手成功后发来非 HTTP 字节,被拒掉并在日志里留一条 WARNING)。它不影响应用,但会刷屏,所以默认把这条特定告警吞掉,改成每 5 分钟汇总一条(带来源地址);计数也能在 /health 看到。连这条汇总也不想要就把 QUIET_SUMMARY 设成 0(docker-compose.yml 里已这么配),日志里彻底不再出现相关内容,计数仍在 /health 的 bad_requests / last_bad_peer 里。想看原始告警就设 QUIET_BAD_HTTP=0。排查过程见 docs/troubleshooting.md。
实时在线:每 10 秒刷新一次。正在观看的节目(海报、集数、单集标题、分辨率、码率、进度、多久前上报)、最近 12 条播放、12 小时内的登录设备与客户端。飞牛影视没有「在线用户」接口,这里的信号来自播放进度心跳,5 分钟内还在上报就算「正在看」,30 分钟内有过访问就算「活跃」。没人看的时候会显示「最近一次观看」,不会看起来像坏了。
剧集矩阵:防「谁看过哪部剧」的横向对比表。单元格是观看集数和最后一集的进度。
搜索:侧栏常驻搜索框(按 / 直接跳到它),支持剧名、单集标题、季集编号(S01E14)和成员名,结果分成「剧集 / 成员 / 观看记录」三块。搜索是服务端做的,链接可以分享。
单剧详情页:时间线、矩阵、实时在线里的剧名点进去就是这一页。「谁在看」按人列出看到的集数、已看集数、最近一次时间与进度条;下面是分集明细(每集有谁看过、进度、最近时间)和最近观看流水。全剧总集数从快照库里读,所以能显示「已看 2 / 15」而不是只知道自己看过几集。
用户画像:点任意用户名进入。「观看记录 / 活跃天数 / 在追剧集 / 观看时长」四条 KPI,加上媒体库口味分布、24 小时观看时段直方图、星期分布、在追清单、整部看完的剧,以及「和谁看得最像」(共同剧集数)。观看时长按每集的最大播放进度估算,页面底部标了这一点,不假装是实际播放耗时。
批量标记折叠:同一个人 60 秒内连续标记 ≥5 集,会折叠成一张卡片,不再刷屏(飞牛影视里一次性「标记已看」整季时会出现这种情况)。
排除:设置页可以把某个媒体库或某个账号整体排除,时间线和矩阵里都不会出现,源数据不动。首次启动时的预置排除项由 EXCLUDE_DEFAULT_LIBRARY / EXCLUDE_DEFAULT_ACCOUNT 两个环境变量指定,留空就不排除任何东西。已删除的账号不用手动排除:导入阶段就已经被过滤掉,也不会出现在设置页的账号列表里。
月度 / 年度回顾:/recap。「这个月你们一共看了 40 条、16 小时,最上头的是《某部剧》」,带每日/每月柱状图、成员排行、新开坑,以及与上一区间的环比。环比只在上一区间基数够大时显示——拿 2 条做基数会算出「+17700%」这种没意义的数字。
弃剧分析:/dropouts。最近一次观看停在半途、且超过 7/30/90/180 天没有动静的剧。分「看过几集」和「已看几集」两列——「看过 24 集、已看 0 集」和「看过 1 集、已看 0 集」是两种完全不同的弃剧,混在一列里就看不出来了。另有弃得最多的人、被弃最多的剧,以及按媒体库的分布。
数据排行:/stats。按时长和按记录数的成员排行、哪部剧最耗时、剧集热度表。时长是估算(见下),所以记录数和时长两个排名不一定一致,页面底部说明了原因。
每日日报推送:/digest。把某一天的观看情况汇成一段可以直接发出去的话:几人看了多久、成员时长排名、哪几部剧有动静、当天新入库了什么。右栏就是「发出去的那段原文」,下面是推送设置和最近的发送记录,改完可以先「保存并发送测试」。
接 Server 酱 / Telegram / 企业微信群机器人 / 钉钉 / 飞书,也可以选「通用 JSON」POST 到自己的地址(body 里带 report 结构化数据,方便二次加工)。默认每天 09:00 发前一天;想晚上发当天,把「统计哪一天」切成当天。投递失败隔 15 分钟重试,容器迟到超过 12 小时才启动就直接跳过,不在半夜补发早上的日报;没有观看记录的日子默认不打扰。除了你填的这个地址,应用不会向任何第三方发请求。
同一份数据还会画成一张 1080 宽的卡片(暗色底、三张海报、时长排名、新增入库),推送形式可选「文字 + 图片 / 只发文字 / 只发图片」:
- 企业微信:文字一条 + 图片一条,base64 直传,不依赖外网;
- 钉钉 / Server 酱:消息末尾追加图片链接,需要「看板地址」能从手机访问(同一局域网或反向代理);
- 飞书:自定义机器人不支持内嵌图片,只能给一个「图片:链接」;
- 通用 JSON:body 里直接带
image_base64和card_url。
剧集那一行会同时给「看到第几集」和「库里更新到第几集」:前者是这家人的全时段进度,后者是媒体库里真实存在的最新一集,二者都不是「当天最后一条记录看的是哪一集」。「看到」只统计库里 Season 树中真实存在的集——挂不上树的孤儿观看记录会出现在时间线里,但不会把进度顶到一集库里没有的位置。
图片链接是 HMAC 签名地址,拿到链接不登录也能看——不想公开就选「只发文字」。画图用 Pillow + 容器里的 fonts-wqy-microhei;找不到中文字体时自动退回纯文字,不会发一张方块图。
一起看 / 撞车:并进剧集矩阵。同一天、同一集、两个以上的人:3 小时以内算「几乎同时」,其余只算「同一天」,跨零点的组合直接不计。
媒体库体检:/libraries。每个媒体库的记录/剧集/人数/时长/海报路径/搁置天数,外加一批数据质量检查:海报文件是否真的存在、哪些剧缺时长导致算不出进度、同一部剧是否散落在多个媒体库(按 trim_id 合并)、从未被看的媒体库、90 天以上没动的剧。
主题切换:左下角三个按钮(跟随系统 / 浅色 / 深色),选择存在浏览器里,刷新不闪白,也会记住。分享链接可以带 ?theme=light 或 ?theme=dark 直接指定。
- 把
trimmedia.db/trimmedia_ext.db/trimactivity.db用 SQLite 在线备份复制到appdata/snap/(只读挂载下备份失败时退回「复制 db + wal + shm」); - 只查询快照;源库里已删除的账号(
user.status=0或用户名带_deleted_)在导入阶段直接丢弃,它们的观看记录、登录设备与活动日志都不进入任何统计(源库保持只读,不做任何修改); - 把
item_user_play的每一行解析成「剧集 + 单集」:Episode → Season → TV,再用trim_id(IMDb/TMDB id)兜底合并同一部剧的不同季、不同媒体库副本。只有Episode和Movie的记录会变成事件:Season/TV行上的播放记录是「整季/整剧标记」,不指向具体哪一集(飞牛甚至会在 Season 行的episode_number里写一个比总集数还大的数),照单全收会造出并不存在的 S01E30,所以直接忽略; - 剧名、类型、海报优先取
item_archive里的 TMDB 元数据,缺失时退回item表; - 进度 =
item_user_play.ts/ 时长,时长依次取media_stream.duration→item.runtime × 60; - 结果写进
app.db的watch_event,页面只读app.db。
数据新鲜度分两条路径:
- 实时在线不等后台同步,每次打开都直接开一个只读连接查源库(结果缓存 3 秒);只读连接打不开时自动退回快照,并在页面上标出「读的是快照,最多滞后 N 分钟」。源库是 WAL 模式,这个只读连接要求
/data/trim可写挂载,否则它一定打不开(见「常见问题」)。 - 其余页面读的是派生表
app.db(源库 42 MB,派生表 204 KB)。除了每 10 分钟的定时同步,每次打开页面时如果数据超过 60 秒,会在后台补一次同步:页面立即返回现有数据,下一次翻页就是最新的。实测页面响应 55–75 ms,补同步在后台线程里跑(约 0.25 秒;源库有变化时约 1.7 秒)。
侧栏底部和设置页都会显示「数据更新至 …」,设置页还有「立即同步」按钮。想关掉按需补同步就把 SYNC_ON_VIEW 设为 0。
原始库里的用户 token、IP、设备信息不会被复制进 app.db,也不会出现在页面上。
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt
TRIM_DB_DIR=tmp DATA_DIR=data TRIM_IMG_ROOT=tmp/img \
.venv/Scripts/python -m uvicorn app.main:app --port 8787 --reload本地没有 app.db 时会走 /setup 创建账号。调试时想跳过登录,可以加 PREVIEW_AUTOLOGIN=1(会使用第一个账号;生产环境不要开)。
app/
config.py 路径、时区、常量
db.py app.db 建表与读写
ingest.py 快照 + watch_event 生成
queries.py 时间线 / 矩阵 / 详情页 / 搜索 / 回顾 / 体检 / 筛选
digest.py 每日日报:取数、排版、webhook 投递与定时
card.py 日报卡片图(Pillow,需要中文字体)
diagnose.py 排查单部剧的集号归属(python -m app.diagnose 剧名)
additions.py 当天新入库的剧集与电影(只查快照)
auth.py 账号与 cookie 会话
posters.py 海报定位(按文件名建索引兜底)
main.py 路由
templates/ Jinja2 模板
static/ app.css / app.js
docs/ 分析报告、设计文档与路线图
scripts/ 导出预览数据与运维排查的小工具
.github/ GitHub Actions 流水线(构建并发布 Docker 镜像)
第一梯队(搜索、月度/年度回顾)与第二梯队(弃剧分析、观看时长排行、同时观看、媒体库体检、每日日报推送)都做完了,见上面的功能一节。docs/roadmap.md 里还剩:阈值提醒、TMDB 补全,以及一批工程向的改进和多账号/PWA/单剧分享卡片。
海报全是色块:TRIM_IMG_ROOT 没指对,或者容器里没挂 /vol1/@appmeta/trim.media。设置页会显示「海报可解析 x / y」,可以直接对照。应用会按 item.posters 的完整相对路径找,找不到再按文件名在图片目录里建一次索引兜底。
时间/天数不对:容器默认 TZ=Asia/Shanghai,如果 NAS 在别的时区就改这个变量。
同步失败:设置页会显示最近一次错误。最常见是数据库目录挂错或权限不足(容器默认以 root 运行)。另一个坑是 /data/trim 加了 :ro:源库打不开,同步会静默退回「复制 db + wal + shm」,于是同步看起来是成功的(error 为空),但快照的 mtime 被 copy2 保留成源库的 mtime,_stale() 每次都判为需要刷新 → 每次同步都整份重拷一遍,实时在线页也一直退回快照。
端口冲突:改 docker-compose.yml 的 ports 左侧即可,例如 18787:8787。
实时在线一直显示「读取的是快照数据」:说明容器打不开源库的只读连接。源库是 WAL 模式,SQLite 只读打开需要在同目录创建/映射 -shm,所以 /data/trim 不能加 :ro(应用层仍然用 ?mode=ro,不会写坏飞牛)。在容器里确认:
docker exec -i fn-media-ext python - <<'PY'
import sqlite3
from app import config
p = config.DB_DIR / "trimmedia.db"
print("存在:", p.exists())
c = sqlite3.connect(f"file:{p.as_posix()}?mode=ro", uri=True, timeout=2)
print("探针:", c.execute("SELECT 1 FROM item_user_play LIMIT 1").fetchone())
PY报 unable to open database file 就是它:去掉 :ro 后 docker compose up -d(不用重建镜像)即可。另外飞牛只在真正播放和拖动进度时更新 item_user_play,暂停超过 5 分钟就会被判定为「不在看」,所以没人看时这一页本来就该是 0 路。
主题怎么改:左下角三个按钮,选完立刻生效并记住;选「跟随系统」就随 NAS 或手机的深色模式走。
点剧名/用户名 404:详情页只展示有观看记录、且没被排除的内容。被排除的媒体库或账号点进去会显示「没有…的观看记录」,这是有意为之,不是缺页面。
日报图片是方块 / 没有图:镜像里没装中文字体。官方 Dockerfile 已经装 fonts-wqy-microhei;自己改镜像的可以用 CARD_FONT=/path/to/font.ttc 指定。一个中文字体都找不到时,推送会自动退回纯文字,页面右栏也会提示。
日报里的集号对不上 / 出现库里没有的一集:先在容器里跑一次诊断:
docker exec -it fn-media-ext python -m app.diagnose 剧名它会列出库里每个 Season 的集数、挂不上 Season 树的 Episode、最近的观看记录,以及「库里最大集 / 观看记录能对上的最大集 / 不过滤的最大集」。常见原因是条目的父节点丢失,或别的剧借用了同一个 trim_id,导致一条记录挂到了这部剧上;在飞牛里重新刮削/重新匹配后通常就归位了。日报本身只把 Season 树里真实存在的集算进「看到」,所以不会再出现「看到 E30 / 更新至 E22」这种自相矛盾的卡片。
钉钉/飞书的图片打不开:图片是一个签名链接,手机必须能访问「看板地址」(在家用局域网地址,在外面要反向代理或 VPN)。飞书自定义机器人本来就不能内嵌图片,只能给链接。
卡片链接会不会泄露:链接带 HMAC 签名,知道链接就能看,猜不到别的日期。介意的话选「只发文字」,或者不要把看板地址指到公网。