Skip to content

About

把飞牛影视(fnOS)的观看记录整理成「谁在什么时候看了哪一集」的时间线,外加剧集矩阵、实时在线、弃剧分析、月度回顾与每日日报卡片。只读访问源库、不写回飞牛,单容器 Docker、单管理员,约 70 MB 常驻内存。

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

观影时间线

把飞牛影视(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 直接指定。

数据是怎么来的

  1. 把 trimmedia.db / trimmedia_ext.db / trimactivity.db 用 SQLite 在线备份复制到 appdata/snap/(只读挂载下备份失败时退回「复制 db + wal + shm」);
  2. 只查询快照;源库里已删除的账号(user.status=0 或用户名带 _deleted_)在导入阶段直接丢弃,它们的观看记录、登录设备与活动日志都不进入任何统计(源库保持只读,不做任何修改);
  3. 把 item_user_play 的每一行解析成「剧集 + 单集」:Episode → Season → TV,再用 trim_id(IMDb/TMDB id)兜底合并同一部剧的不同季、不同媒体库副本。只有 Episode 和 Movie 的记录会变成事件:Season / TV 行上的播放记录是「整季/整剧标记」,不指向具体哪一集(飞牛甚至会在 Season 行的 episode_number 里写一个比总集数还大的数),照单全收会造出并不存在的 S01E30,所以直接忽略;
  4. 剧名、类型、海报优先取 item_archive 里的 TMDB 元数据,缺失时退回 item 表;
  5. 进度 = item_user_play.ts / 时长,时长依次取 media_stream.duration → item.runtime × 60;
  6. 结果写进 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,也不会出现在页面上。

本地开发(不用 Docker)

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 签名,知道链接就能看,猜不到别的日期。介意的话选「只发文字」,或者不要把看板地址指到公网。

About

把飞牛影视(fnOS)的观看记录整理成「谁在什么时候看了哪一集」的时间线,外加剧集矩阵、实时在线、弃剧分析、月度回顾与每日日报卡片。只读访问源库、不写回飞牛,单容器 Docker、单管理员,约 70 MB 常驻内存。

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages