Skip to content

Latest commit

 

History

History
1068 lines (886 loc) · 54.9 KB

File metadata and controls

1068 lines (886 loc) · 54.9 KB

接口契约:轻量级单用户视频媒体服务器

对外接口的单一真源。始终原地更新到当前契约。

1. 通用约定

  • 协议:HTTP/HTTPS RESTful API
  • 认证:基于 Cookie 的会话认证,登录后返回 Set-Cookie 头部(HttpOnly auth_token)。除 /api/auth/login/api/auth/logout/api/auth/setup-status/api/auth/setup/health 及前端静态资源外,所有 /api/* 端点均强制校验 JWT(Cookie auth_tokenAuthorization: Bearer <token> 任一有效),未携带或无效凭据返回 401
  • 编码:请求/响应体使用 JSON(Content-Type: application/json),视频流使用 video/mp2t
  • 分页:列表接口支持 page(从 1 开始)和 page_size(默认 20,最大 100)参数
  • 时间格式:ISO 8601(YYYY-MM-DDTHH:MM:SSZ
  • 静态资源:前端文件通过 go:embed 内嵌,由 / 路径提供服务

2. 错误约定

统一错误返回结构:

{
  "code": "ERROR_CODE",
  "message": "人类可读的错误描述"
}
HTTP 状态码 含义
400 请求参数错误
401 未认证
403 禁止访问
404 资源不存在
500 服务器内部错误

3. 端点 / 方法

登录

  • 方法 / 路径POST /api/auth/login
  • 请求
    {
      "username": "string",
      "password": "string"
    }
  • 响应(200):
    {
      "username": "string"
    }
  • 错误401 用户名或密码错误

登出

  • 方法 / 路径POST /api/auth/logout
  • 响应(200):空

首次初始化状态(FR-109)

  • 方法 / 路径GET /api/auth/setup-status(免登)
  • 响应(200):needs_setuptrue 表示系统尚无任何用户、需首次初始化
    { "needs_setup": true }

首次初始化(FR-109)

  • 方法 / 路径POST /api/auth/setup(免登;仅在系统无用户时可用)
  • 请求
    {
      "username": "string",
      "password": "string"
    }
  • 响应(200):创建首个账户并自动登录(下发 Set-Cookie
    { "username": "string" }
  • 错误400 参数错误;409 系统已初始化(已存在用户)

修改密码(FR-108)

  • 方法 / 路径POST /api/me/password(需登录;用户取自认证上下文)
  • 请求
    {
      "old_password": "string",
      "new_password": "string"
    }
  • 响应(204):密码已更新(立即生效)
  • 错误400 参数错误;401 未登录或当前密码错误

获取媒体库目录列表

  • 方法 / 路径GET /api/library/paths
  • 响应(200):每项含 media_count,即该库已索引(未软删)的媒体文件数量,供存储库卡片展示。
    {
      "items": [
        {
          "id": 1,
          "path": "/media/movies",
          "type": "local",
          "label": "电影",
          "enabled": true,
          "media_count": 42
        }
      ]
    }

添加媒体库目录

  • 方法 / 路径POST /api/library/paths
  • 请求
    {
      "path": "\\\\192.168.1.100\\Share\\Movies",
      "type": "smb",
      "label": "NAS 电影",
      "smb_username": "optional",
      "smb_password": "optional"
    }
  • 响应(201):目录记录对象
  • 错误400 本地路径不可访问、不是目录或请求参数错误;500 保存失败
  • 说明local 路径必须在服务器本机存在且为目录;smb 路径支持 UNC 或 smb://host/share/path 输入,服务端统一存储为 host/share/path,凭据通过 /api/smb/credentials 保存。

删除媒体库目录

  • 方法 / 路径DELETE /api/library/paths/:id
  • 响应(204):空

浏览目录

  • 方法 / 路径GET /api/library/browse
  • 查询参数
    • parent_path:父目录的真实磁盘路径(必填)。取哨兵值 __root__ 时返回各盘符 / 共享根(FR-121)作为顶层目录项
    • sort:文件排序(可选):name / size / type / time(修改时间),缺省 name;目录恒在文件前、按名排序
    • library_id:可选、已弃用——目录导航按真实路径跨库聚合,不再按库收窄(仍接受以向后兼容,被忽略)
  • 真实路径树聚合(FR-121,ADR-0046 取代 ADR-0037):按真实磁盘路径跨所有启用库合并成单一目录树。浏览路径 P 时,子目录 = media_filesfile_path LIKE 'P/%'(跨库、未软删)的下一级目录去重,文件 = 目录恰为 P 的项;有路径包含关系的库在公共上级自然合并(加 D:\1D:\1\2D: → 1 → 2;加 D:\ 库则整盘可浏览)。
  • 根响应(200,parent_path=__root__):directories 为各盘符 / 共享根,面包屑单段 {"name":"全部","path":"__root__"}files 为空:
    {
      "breadcrumbs": [{"name": "全部", "path": "__root__"}],
      "directories": [
        {"name": "D:", "path": "D:"},
        {"name": "E:", "path": "E:"}
      ],
      "files": []
    }
  • 路径响应(200,如 parent_path=D:/1):子目录跨库合并、不带 library_id;文件各带自身 library_id,按 sort 排序:
    {
      "breadcrumbs": [
        {"name": "D:", "path": "D:"},
        {"name": "1", "path": "D:/1"}
      ],
      "directories": [
        {"name": "2", "path": "D:/1/2"}
      ],
      "files": [
        {"id": 1, "library_id": 1, "file_name": "a.png", "file_path": "D:/1/a.png", "file_size": 2400000, "format": "png", "modified_at": "2026-06-20T10:00:00Z"}
      ]
    }
  • 说明file_path 前缀匹配一次查询(大小写不敏感,复用 file_path 索引满足 NFR-08),Go 层按下一级目录去重分组。Windows 盘符路径正斜杠规范化、面包屑不加前导 /(如 D:/1)。顶层盘符 / 共享根由各启用库 path 推导(本地 D:/...D:、UNC //host/share/...//host/share)去重。

获取媒体文件列表

  • 方法 / 路径GET /api/library/media
  • 查询参数
    • library_id:按媒体库过滤(可选)
    • sort:排序方式,time_desc(默认,按入库时间降序)/ time_asc / name / media_time(按媒体时间降序,缺失回退入库时间,FR-31)/ media_time_asc(按媒体时间升序)
    • page:页码
    • page_size:每页条数
    • search:搜索(可选)。走 everything 式表达式解析(FR-35):裸词→文件名包含(多词 AND);ext:jpgext:jpg,png→按扩展名;type:image/type:video→按类型;size:>10mb/size:<=2gb/size:>=500kb(单位 b/kb/mb/gb/tb)→按大小。无法识别的 key:val 退化为文件名关键词。纯文本与旧行为一致(向后兼容)。
    • favorite:传 true/1 时仅返回已收藏媒体(可选,FR-41)
    • tag_id:传标签 ID 时仅返回打了该标签的媒体(可选,FR-41)
    • 结构化筛选(可选,FR-35,显式参数优先于 search 表达式同名约束):typeimage/video)、size_min/size_max(字节)、time_from/time_to(媒体时间范围,RFC3339YYYY-MM-DD,按 COALESCE(media_time, added_at) 比较)、path(目录前缀)。以上全部走参数化查询,无 SQL 注入面。
    • has_gps:传 true 时仅返回带 GPS 坐标(gps_lat != 0 OR gps_lon != 0)的媒体(可选,FR-39 照片地图)。
  • 响应(200):
    {
      "items": [
        {
          "id": 1,
          "file_name": "电影名.mkv",
          "file_path": "/media/movies/电影名.mkv",
          "file_size": 10737418240,
          "format": "mkv",
          "video_codec": "hevc",
          "audio_codec": "aac",
          "duration": 7200.0,
          "width": 1920,
          "height": 1080,
          "bitrate": 12000000,
          "added_at": "2025-01-01T12:00:00Z",
          "media_time": "2024-12-25T08:30:00Z",
          "media_time_source": "exif",
          "camera": "Canon EOS R5",
          "lens": "RF24-70mm F2.8 L IS USM",
          "aperture": "f/2.8",
          "shutter": "1/200",
          "iso": 400,
          "gps_lat": 31.23,
          "gps_lon": 121.47
        }
      ],
      "total": 100,
      "page": 1,
      "page_size": 20
    }
  • 说明:仅返回未软删的媒体(deleted_at IS NULL);已软删项见回收站接口(FR-25)。

获取媒体文件详情

  • 方法 / 路径GET /api/library/media/:id
  • 响应(200):媒体文件详情对象(含字幕轨道信息,以及 FR-44 的 last_positionwatchedlast_watched_at

继续观看列表(FR-44)

  • 方法 / 路径GET /api/library/continue-watching
  • 查询参数
    • limit:返回条数上限,默认 12,超过 50 时收敛到 50
  • 响应(200):
    {
      "items": [
        {"id": 1, "file_name": "电影名.mkv", "duration": 7200.0, "last_position": 1234.5, "watched": false, "last_watched_at": "2025-01-01T12:00:00Z"}
      ]
    }
  • 说明:返回「有进度(last_position>0)且未看完(watched=false)」的媒体,按 last_watched_at 倒序,排除已删除记录,供首页「继续观看」区块展示。

那年今日列表(FR-72)

  • 方法 / 路径GET /api/library/on-this-day
  • 查询参数
    • limit:返回条数上限,默认 12,超过 50 时收敛到 50
  • 响应(200):
    {
      "items": [
        {"id": 1, "file_name": "海边日落.jpg", "media_time": "2022-06-23T12:00:00Z"}
      ]
    }
  • 说明:返回「往年同一天」拍摄的媒体——media_time 非空、未软删,且其月-日等于服务器本地「今天」的月-日、年份不等于今年,按 media_time 倒序,供首页「那年今日」回忆区块展示。

记录媒体查看(FR-120)

  • 方法 / 路径PUT /api/library/media/:id/viewed
  • 响应(200):{"ok": true}(把该媒体 last_viewed_at 置为当前时间)
  • 说明:媒体在查看器 / 播放页被打开时由前端调用,记录最近查看时间。非法 id → 400,记录不存在 → 404。与 FR-44 的 last_watched_at(仅视频播放进度)不同,覆盖图片 + 视频的「打开」。

最近查看列表(FR-120)

  • 方法 / 路径GET /api/library/recently-viewed
  • 查询参数limit(缺省 12,上限 50)
  • 响应(200):{"items": [ MediaFile, ... ]}
  • 说明:返回 last_viewed_at 非空、未软删的媒体,按 last_viewed_at 倒序,供时间轴页「最近查看」回忆区块展示。

观看统计(FR-75)

  • 方法 / 路径GET /api/library/stats
  • 查询参数:无
  • 响应(200):
    {
      "total": 42,
      "watched": 18,
      "unwatched": 24,
      "recent_timeline": [{"date": "2026-06-24", "count": 5}],
      "position_heatmap": [3, 1, 0, 2, 1, 4, 0, 1, 2, 6],
      "by_library": [{"library_id": 1, "label": "电影", "watched": 12}],
      "by_format": [{"format": "mp4", "watched": 11}],
      "top_viewed": [{"id": 11, "file_name": "热门片.mp4", "view_count": 5}]
    }
  • 说明:聚合观看统计,各维度均仅统计未软删媒体。watched/unwatched 为看完/未看完计数;recent_timelinelast_watched_at 本地时区天分桶(倒序、最近 30 天有观看的天);position_heatmap 为续播进度(last_position/durationduration>0)落入 10 档(下标 0=0-10%…9=90-100%)的媒体数;by_library/by_format 为各库/各格式已看媒体数;top_viewed 为观看次数(view_count,看完一次 +1)Top 10。供观看统计页(/stats)展示。

媒体库概览汇总(FR-117)

  • 方法 / 路径GET /api/library/summary
  • 查询参数:无
  • 响应(200):
    {
      "total": 12480,
      "video_count": 3210,
      "image_count": 9270,
      "total_size": 1979900000000,
      "total_duration": 2311200.0,
      "library_count": 5,
      "by_library": [
        {"library_id": 1, "label": "电影", "media_count": 3460, "video_count": 3460, "image_count": 0, "total_size": 580000000000, "total_duration": 1980000.0}
      ]
    }
  • 说明:媒体库总量聚合,供首页概览看板(/)展示。全部维度 WHERE deleted_at IS NULL;视频/图片按内置图片扩展名集合区分(LOWER(format) IN 内置图片集 为图片,否则视频,与媒体筛选口径一致);total_size=SUM(file_size)(字节)、total_duration=SUM(duration)(秒);library_count 取启用库数;by_librarylibrary_id 分组(label 取自 library_paths)。空库返回各项为零、by_library 为空数组,HTTP 200。一次聚合查询完成,避免 N+1。

媒体增长趋势(FR-118)

  • 方法 / 路径GET /api/library/trends
  • 查询参数:无
  • 响应(200):
    {
      "media_added": [
        {"date": "2026-05-01", "count": 10, "size": 1000000000, "duration": 3000.0},
        {"date": "2026-05-03", "count": 5, "size": 500000000, "duration": 1500.0}
      ]
    }
  • 说明:按 added_at 本地时区天分桶的「按天新增媒体」全时段序列,供统计页「媒体」tab 算累计增长曲线(媒体数 / 容量 / 时长)。仅含有新增的天、升序;count=当天新增数、size=SUM(file_size)duration=SUM(duration);全程 deleted_at IS NULL。空库返回 {"media_added": []}。观看活跃趋势复用 GET /api/library/statsrecent_timeline(不另设端点)。

重命名媒体文件

  • 方法 / 路径PUT /api/library/media/:id/rename
  • 请求
    {"new_name": "新文件名.mp4"}
  • 响应(200):更新后的媒体文件对象
  • 说明new_name 仅允许单层文件名(不含 /\./..)。后端先对磁盘文件原子改名,再更新 media_files.file_path/file_name/format,数据库更新失败时尽力回滚磁盘改名;旧缩略图按旧路径 hash 命名,重命名后失效并异步为新文件重新生成。SMB 远程文件暂不支持。
  • 错误400 新名不合法或不支持的文件类型,404 媒体记录不存在,409 目标文件名已存在,500 重命名失败

修改显示名(FR-30)

  • 方法 / 路径PUT /api/library/media/:id/display-name
  • 请求
    {"display_name": "我的影片"}
  • 响应(200):更新后的媒体文件对象(含 display_name
  • 说明:仅更新库内显示名(media_files.display_name),不改动磁盘真实文件名与路径;服务端对 display_name 去首尾空白后落库,空串表示清除显示名。列表/卡片/详情展示名优先用 display_name,为空时回退 file_name。需改磁盘真实文件名请改用「重命名媒体文件」端点。
  • 错误400 请求体无效,404 媒体记录不存在,500 更新失败

软删除媒体文件(FR-25)

  • 方法 / 路径DELETE /api/library/media/:id
  • 响应(204):无响应体
  • 说明:软删除——仅置 media_files.deleted_at = now,不物理删除数据库记录、不删除磁盘源文件。软删后该媒体从常规列表(GET /api/library/media)与各库已索引计数中排除,进入回收站。重复删除已软删项返回 500(视为不存在)。
  • 错误400 ID 无效,500 删除失败(含记录不存在)

批量软删媒体文件(FR-69)

  • 方法 / 路径POST /api/library/media/batch-delete
  • 请求体{"ids": [101, 102, ...]},待软删的媒体 ID 数组。
  • 响应(200):{"deleted": N},N 为实际软删条数。
  • 说明:批量软删——在单事务内对所有 deleted_at IS NULL 的有效 ID 一次 UPDATEdeleted_at = now,复用单条软删语义(不动磁盘源文件),软删项一并进回收站。跳过不存在 / 已软删的 ID(不计入 deleted、不报错);空 ids 为 no-op 返回 {"deleted": 0}
  • 错误400 请求体无效,500 批量删除失败

批量打包下载(FR-91)

  • 方法 / 路径GET /api/library/media/batch-download?ids=101,102,...
  • 查询参数ids 逗号分隔的媒体 ID 列表(忽略空白与非法项)。
  • 响应(200):application/zip 二进制,Content-Disposition: attachment,附件名 媒体打包.zip(按 RFC 5987 filename*=UTF-8'' 编码)。经 archive/zip 流式 chunked 输出、边写边 flush(不一次性读入内存),用请求 context 控制取消、不设整体超时。
  • 说明:将选中媒体的原文件逐个写入 zip(不转码 / 不转换)。smb:// 路径项与磁盘不可访问项跳过(不写入 zip、不报错),故返回 zip 内文件数可能少于请求 ID 数。鉴权后访问(FR-13);软删项不计入(FR-25)。
  • 上限:选中数量 ≤ 500、预估总大小 ≤ 5 GiB,超限在写任何字节前拒绝。
  • 错误400 未提供有效 ids / 数量超限 / 总大小超限,500 查询失败

扫描去重哈希(FR-70)

  • 方法 / 路径POST /api/library/duplicates/scan
  • 响应(200):{"computed": N},N 为本次新计算并落库的 dHash 条数。
  • 说明:为全部「未软删且 dhash=0」的媒体计算并持久化基于缩略图的 64 位感知哈希(dHash);缩略图缺失时先同步生成一次再计算。已算过的天然跳过(幂等,二次调用通常 computed=0)。同步执行 + 有界并发;单条失败仅记日志跳过、不影响整体(故缺缩略图等情况返回 computed 偏小而非报错)。
  • 错误500 扫描失败

查询重复组(FR-70)

  • 方法 / 路径GET /api/library/duplicates
  • 响应(200):{"groups": [[MediaFile, ...], ...]},每个内层数组为一个近似重复组(成员两两汉明距离 ≤ 默认阈值 10、各组 ≥2 项),排除已软删项;组内按 id 升序、组间按首成员 id 升序。
  • 说明:基于已计算的 dhash 列聚类,供「重复项」页展示并选取多余项批量清理(复用批量软删端点进回收站)。新入库媒体若尚未计算 dHash 需先调扫描端点。
  • 错误500 查询失败

列出回收站(FR-25)

  • 方法 / 路径GET /api/library/recycle
  • 响应(200):{"items": [...]},含全部已软删媒体(deleted_at IS NOT NULL),按软删时间倒序。
  • 错误500 查询失败

还原媒体文件(FR-25)

  • 方法 / 路径POST /api/library/media/:id/restore
  • 响应(204):无响应体
  • 说明:清空 media_files.deleted_at,使媒体回到常规列表、从回收站消失。
  • 错误400 ID 无效,404 回收站中不存在该媒体文件

清理回收站(FR-26)

  • 方法 / 路径POST /api/library/recycle/cleanup
  • 响应(200):{"moved": N, "failed": M},分别为成功移动并删记录的项数、移动失败跳过的项数。
  • 说明:对全部软删项,把磁盘源文件移动到其所在盘符对应的回收站目录(取自设置键 recycle_bin_paths 的 JSON 映射,盘符大小写不敏感),目标按删除日期分子目录 <回收站目录>/<deleted_at 日期 YYYY-MM-DD>/<原文件名>;移动成功后删除 media_files 记录。先移动成功、后删记录,保证「记录还在 = 文件未移出库」一致。
  • 校验先行:只要存在任一软删项所在盘符未配置回收站路径(含 SMB / 无盘符项),整体拒绝,不移动任何文件、不删任何记录。
  • 错误409 存在盘符未配置回收站路径(message 含缺失盘符),500 配置非法 JSON 或清理失败,503 设置服务未启用

获取图片原始内容

  • 方法 / 路径GET /api/library/media/:id/raw
  • 响应(200):图片二进制内容,Content-Type 由文件后缀或内容探测确定
  • 说明:仅支持本地图片文件,用于前端预览;视频和 SMB 图片不走此接口。HEIC/RAW(cr2/nef/arw/dng/rw2 等)经外部 ImageMagick(magick)转成 JPEG 后返回,转换结果缓存于数据目录下 image_cache/(按「源路径 + 源修改时间」hash 命名,二次命中不重转)(FR-37)。
  • 错误400 非图片或不支持的路径,404 记录或文件不存在,503 未安装 ImageMagick 无法转换 HEIC/RAW,500 图片转换失败

下载原文件

  • 方法 / 路径GET /api/library/media/:id/download
  • 响应(200):媒体原始文件二进制,Content-Disposition: attachment,文件名为真实 file_name(按 RFC 5987 filename*=UTF-8'' 编码,兼容中文)。经流式回传,支持 HTTP Range(断点续传)。
  • 说明:图片与视频一视同仁,回传磁盘原始字节(不转码/不转换,区别于 raw 端点)。鉴权后访问(FR-13);软删项不可下载(FR-25)。SMB 远程文件暂不支持(FR-42)。
  • 错误400 ID 无效或 smb:// 路径,404 记录不存在/已软删或磁盘文件不可访问

获取媒体缩略图

  • 方法 / 路径GET /api/library/thumbnail/:id
  • 查询参数size(可选)缩略图宽度,受支持白名单 160 / 320 / 640,缺省或非白名单值回落默认 320(FR-81 P12)
  • 响应(200):缩略图 JPEG 二进制内容(按 size 缩放,视频取第 2 秒帧、图片缩放;带透明区的源已合成中性灰底,FR-81 P1)
  • 说明:缩略图在扫描入库时异步生成,存于数据目录下的 thumbnails/(按原始路径 hash 命名;默认 320 尺寸命名不变,非默认尺寸用 <hash>_<size>.jpg 并存)。普通图片/视频经 ffmpeg 生成,HEIC/RAW 经外部 ImageMagick 生成(FR-37)。请求尺寸尚未生成时返回 202 并触发后台异步生成该尺寸,前端可稍后重试。
  • 错误202 缩略图生成中,404 媒体记录不存在

设置媒体收藏(FR-41)

  • 方法 / 路径PUT /api/library/media/:id/favorite
  • 请求
    {"favorite": true}
  • 响应(200):更新后的媒体文件对象(含 favorite 字段)
  • 说明:设置或取消收藏,重复设同值幂等。
  • 错误400 请求体无效,404 媒体记录不存在

列出标签(FR-41)

  • 方法 / 路径GET /api/library/tags
  • 响应(200):{"items": [{"id": 1, "name": "旅行", "created_at": "..."}]},按名升序

创建标签(FR-41)

  • 方法 / 路径POST /api/library/tags
  • 请求
    {"name": "旅行"}
  • 响应(201):标签对象。名按去首尾空白规整,同名复用已有标签。
  • 错误400 标签名为空

列出媒体的标签(FR-41)

  • 方法 / 路径GET /api/library/media/:id/tags
  • 响应(200):{"items": [...]},该媒体绑定的标签,按名升序

给媒体打标签(FR-41)

  • 方法 / 路径POST /api/library/media/:id/tags
  • 请求{"tag_id": 1}{"name": "旅行"}(按名时先建/取标签再绑定)
  • 响应(201):绑定的标签对象。重复打同标签幂等。
  • 错误400 缺少 tag_id/name、标签名为空,或媒体/标签不存在

解除媒体标签(FR-41)

  • 方法 / 路径DELETE /api/library/media/:id/tags/:tag_id
  • 响应(204):无内容。绑定不存在视为幂等成功。

查询媒体库后缀

  • 方法 / 路径GET /api/library/extensions
  • 查询参数library_id(必填)
  • 响应(200):
    {
      "items": [
        {"library_id": 1, "extension": "jpg", "type": "image", "is_builtin": 1},
        {"library_id": 1, "extension": "foo", "type": "video", "is_builtin": 0}
      ]
    }
  • 说明:返回内置后缀和绑定到该媒体库目录的自定义后缀;自定义后缀不会影响其他目录。

添加媒体库自定义后缀

  • 方法 / 路径POST /api/library/extensions
  • 请求
    {
      "library_id": 1,
      "extension": ".foo",
      "type": "video"
    }
  • 响应(201):空
  • 说明type 仅允许 videoimage;后缀会规范化为小写且去掉前导点。内置后缀无需入库,重复添加视为幂等成功。
  • 错误400 参数无效、目录不存在或后缀格式不支持

删除媒体库自定义后缀(FR-64)

  • 方法 / 路径DELETE /api/library/extensions
  • 查询参数
    • library_id:媒体库 ID(必填)
    • extension:要删除的后缀(必填,规范化为小写、去前导点后匹配)
  • 响应(204):空
  • 说明:仅删除自定义后缀;内置后缀不可删(请求删除内置后缀返回 400)。删除不存在的自定义后缀返回 400。
  • 错误400 无效 library_id、空 extension、尝试删除内置后缀或后缀不存在

扫描媒体库目录

  • 方法 / 路径POST /api/library/scan/:id
  • 查询参数mode(可选)——full 全量扫描(遍历后对账已删文件),incremental 或缺省/非法值为增量更新(只索引新增)。
  • 响应(200):
    {"status": "queued", "task_id": 12}
  • 说明:触发扫描会建一个 pending 扫描任务入队(FR-29),由单 worker 串行执行,接口立即返回任务 ID(未启用队列时回退直接异步扫描,返回 {"status":"scanning"})。多次触发按入队顺序排队、不并发抢资源。worker 按 LibraryPath.type 分发本地递归扫描或 SMB 扫描,识别内置图片/视频后缀和该目录绑定的自定义后缀;重复扫描不会重复入库,入库的媒体文件会异步生成缩略图。可选查询参数 modefull/incremental,缺省增量,向后兼容,FR-27):full 在入库后对账——库内未软删但源文件已不存在的记录标记软删进回收站(复用 FR-25 软删,不物理删除、不动磁盘),对账仅本地扫描启用。当前进行中任务的实时进度通过「扫描进度」SSE 端点获取,任务列表通过「扫描任务列表」端点获取。
  • 错误400 ID 无效,404 目录不存在,500 入队失败

扫描进度(SSE)

  • 方法 / 路径GET /api/library/scan/progress
  • 响应Content-Type: text/event-stream,每 500ms 推送一条 progress 事件,扫描完成或出错后服务端关闭连接。事件 data 为 JSON:
    {
      "status": "scanning",
      "library_id": 1,
      "current_path": "D:/Videos/Movies/a.mp4",
      "total_files": 120,
      "scanned_files": 30,
      "error": "",
      "started_at": "2026-06-20T20:00:00Z",
      "completed_at": "0001-01-01T00:00:00Z"
    }
  • 说明status 取值 idle / scanning / completed / error,全局共享单一扫描状态(同一时刻仅跟踪一个扫描任务)。前端据此渲染进度条,completed 后自动刷新媒体列表。

扫描任务列表(FR-29)

  • 方法 / 路径GET /api/library/scan/tasks
  • 响应(200):
    {
      "tasks": [
        {
          "id": 12,
          "library_id": 1,
          "scan_type": "full",
          "status": "running",
          "scanned_files": 30,
          "total_files": 120,
          "error": "",
          "created_at": "2026-06-22T20:00:00Z",
          "started_at": "2026-06-22T20:00:01Z",
          "completed_at": null
        }
      ],
      "current": { "id": 12, "status": "running", "...": "同上" }
    }
  • 说明:返回全部扫描任务(按入队时间倒序)与当前进行中任务 current(无则 null)。status 取值 pending / running / completed / errorscan_type 取值 full / incremental(当前 worker 统一按全量执行,full/incremental 差异留 FR-27 对接)。队列以 SQLite scan_tasks 表为持久化真源,由单 worker 串行执行;服务重启时把残留 running 任务重置为 pending 重新入队。当前 running 任务的 scanned_files/total_files 用实时全局扫描状态覆盖,已完成任务返回其持久化进度。前端页眉据此常驻展示进行中任务并可点开看任务列表与各自进度。

触发媒体健康巡检(FR-73)

  • 方法 / 路径POST /api/library/health/scan
  • 响应(200):{"status": "scanning"},已有巡检在跑时返回 {"status": "already_running"}
  • 响应(503):未启用健康巡检服务时返回 {"code": "HEALTH_UNAVAILABLE", ...}
  • 说明:触发一轮后台只读巡检,遍历全部未软删媒体逐项判 0 字节 / 源文件丢失(排除 smb://)/ 视频损坏(ffprobe)/ 缩略图无法生成,问题写入 media_health_issues 表(每轮先清空再写当轮快照)。单飞执行,已有巡检在跑时不并发第二轮。全程只读、绝不改 deleted_at(软删真源归 FR-25/27)。进度经「健康巡检进度」端点查询,问题清单经「健康问题清单」端点查询。

健康巡检进度(FR-73)

  • 方法 / 路径GET /api/library/health/status
  • 响应(200):
    {
      "status": "completed",
      "total": 1200,
      "checked": 1200,
      "issue_count": 7,
      "error": "",
      "started_at": "2026-06-23T20:00:00Z",
      "completed_at": "2026-06-23T20:01:30Z"
    }
  • 说明status 取值 idle / scanning / completed / errortotal 为待巡检的未软删媒体总数,checked 为已巡检数(巡检中渐增),issue_count 为本轮发现的问题数。前端据此渲染进度并在完成后刷新问题清单。

健康问题清单(FR-73)

  • 方法 / 路径GET /api/library/health/issues
  • 响应(200):
    {
      "items": [
        {
          "id": 3,
          "media_id": 42,
          "issue_type": "missing",
          "detail": "stat D:/v/gone.mp4: no such file or directory",
          "checked_at": "2026-06-23T20:01:30Z",
          "file_name": "gone.mp4",
          "file_path": "D:/v/gone.mp4",
          "library_id": 1,
          "display_name": ""
        }
      ]
    }
  • 说明:返回最近一轮巡检的问题清单,按 issue_type 再按 media_id 排序。issue_type 取值 broken(视频损坏) / zero_byte(0 字节) / missing(源文件丢失) / no_thumbnail(缩略图无法生成),detail 为问题细节(如 ffprobe / 缩略图错误尾部)。每项附带媒体基本信息(file_name/file_path/library_id/display_name,媒体已被删除时可能缺省),供前端按类型分组直接展示。删除问题媒体复用「批量软删媒体文件」端点。

列出相册

  • 方法 / 路径GET /api/albums
  • 响应(200):
    {
      "items": [
        {"id": 1, "name": "旅行", "description": "2025 夏", "cover_media_id": 0, "created_at": "2026-06-21T08:00:00Z", "updated_at": "2026-06-21T08:00:00Z", "item_count": 3}
      ]
    }
  • 说明:按创建时间倒序返回全部相册,item_count 为相册内媒体成员数量。

创建相册

  • 方法 / 路径POST /api/albums
  • 请求
    {"name": "旅行", "description": "可选描述"}
  • 响应(201):新建的相册对象
  • 错误400 名称为空或参数无效

删除相册

  • 方法 / 路径DELETE /api/albums/:id
  • 响应(204):空
  • 说明:仅在同一事务内删除相册与其全部成员关联(albums + album_items),不删除源文件,也不删除 media_files 记录
  • 错误404 相册不存在

查询相册成员

  • 方法 / 路径GET /api/albums/:id/items
  • 响应(200):
    {"items": [ { "id": 1, "library_id": 1, "file_name": "a.mp4", "file_path": "D:/A/a.mp4" } ]}
  • 说明:返回相册内的媒体成员(MediaFile 列表),按加入顺序排列;成员可跨多个媒体库目录。

加入相册成员

  • 方法 / 路径POST /api/albums/:id/items
  • 请求
    {"media_id": 123}
  • 响应(204):空
  • 说明:把指定媒体加入相册;同一媒体重复加入幂等成功,不产生重复成员。
  • 错误404 相册或媒体不存在

移出相册成员

  • 方法 / 路径DELETE /api/albums/:id/items/:mediaId
  • 响应(204):空
  • 说明:从相册移出指定媒体,仅删除成员关联,不影响源文件与媒体记录。

获取播放地址

  • 方法 / 路径GET /api/play/:id
  • 查询参数
    • format:播放格式,direct(直出)/ hls(转码),默认自动判断
    • resolution:目标分辨率,1080p / 720p / 480p(可选,ABR 模式下自动)
  • 响应(200):
    {
      "url": "/api/play/stream/123",
      "format": "hls",
      "transcode_required": true,
      "hw_accel": "nvenc"
    }

编码协商(FR-53)

  • 方法 / 路径POST /api/play/:id/negotiate
  • 请求:客户端上报各高级编码的解码能力(来自前端 MediaSource.isTypeSupported 探测)。
    {"client_caps": {"h265": true, "av1": true, "vp9": false}}
    • 请求体可缺省,缺省视为无高级编码能力(兜底 H.264)。
  • 响应(200):播放描述符。后端按「首选优先级(FR-50)∩ 客户端能力 ∩ 实测可产出(FR-49)」协商出实际编码与播放路径。
    • H.264(含协商不出高级编码的所有情形)→ TS 路径:
      {"codec": "h264", "path": "ts", "url": "/api/play/hls/1/master"}
    • 高级编码(h265/av1/vp9)→ 后端同步产出 fMP4(FR-51),返回 fMP4 描述符:
      {
        "codec": "av1",
        "path": "fmp4",
        "url": "/api/play/hls/1/index.m3u8",
        "mime": "video/mp4; codecs=\"av01.0.05M.08\"",
        "fallback_url": "/api/play/1/stream"
      }
  • 说明url 为相对路径,前端绝对化后交自适应播放器(H.264 走 mpegts.js、高级编码走 hls.js 原生 MSE)。非 H.264 时若 fMP4 产出失败,降级返回 H.264/TS 描述符(不报错,保证可播)。协商结果(实际编码与路径)记录到内存播放会话。
  • 错误404 媒体文件不存在。

获取视频流

  • 方法 / 路径GET /api/play/stream/:id
  • 请求头:支持 Range 头部(用于 Seek)
  • 响应(200 / 206):
    • Content-Type: video/mp2t
    • 支持 Content-Range 响应头
  • 错误404 媒体文件不存在,500 转码启动失败

获取 HLS 索引

  • 方法 / 路径GET /api/play/hls/:id/index.m3u8
  • 响应(200):Content-Type: application/vnd.apple.mpegurl

获取 HLS 切片

  • 方法 / 路径GET /api/play/hls/:id/:quality_segment_001.ts
    • :quality 为码率档位:1080p / 720p / 480p
  • 响应(200):Content-Type: video/mp2t

获取 ABR Master Playlist

  • 方法 / 路径GET /api/play/hls/:id/master.m3u8
  • 响应(200):Content-Type: application/vnd.apple.mpegurl
  • 说明:返回包含多码率 EXT-X-STREAM-INF 标签的 master playlist,供 hls.js 自适应切换

上报观看位置(FR-44)

  • 方法 / 路径PUT /api/play/:id/position
  • 请求
    {"position": 123.4}
  • 响应(200):更新后的媒体文件对象(含 last_positionlast_watched_at
  • 说明:持久化「用户观看位置」(秒)到 media_files.last_position 并刷新 last_watched_at,供下次进入同一视频续播。position 为负时归零。此为用户观看位置,区别于下方「获取转码状态」中的转码/缓冲进度,二者互不复用。
  • 错误400 请求体无效,404 媒体记录不存在

标记已看(FR-44)

  • 方法 / 路径PUT /api/play/:id/watched
  • 响应(200):更新后的媒体文件对象(watched=truelast_position=0
  • 说明:标记视频已看完,并清零续播位置(已看完不再续播),刷新 last_watched_at
  • 错误404 媒体记录不存在

获取转码状态

  • 方法 / 路径GET /api/transcode/status/:media_id
  • 响应(200):
    {
      "status": "running",
      "pid": 12345,
      "hw_accel": "nvenc",
      "progress": 0.45
    }

查询硬件加速能力

  • 方法 / 路径GET /api/transcode/hwaccel
  • 响应(200):以编码器实测为真源、per-codec 表达(见 ADR-0033)。
    {
      "available": [
        {
          "name": "AMD AMF", "family": "amf", "device_type": "d3d11va", "available": true,
          "codecs": [
            { "codec": "h264", "encoder": "h264_amf", "compiled": true, "tested_ok": true },
            { "codec": "h265", "encoder": "hevc_amf", "compiled": true, "tested_ok": true },
            { "codec": "av1",  "encoder": "av1_amf",  "compiled": true, "tested_ok": false }
          ]
        },
        {
          "name": "软件编码", "family": "software", "device_type": "", "available": true,
          "codecs": [
            { "codec": "h264", "encoder": "libx264",   "compiled": true, "tested_ok": true },
            { "codec": "av1",  "encoder": "libsvtav1", "compiled": true, "tested_ok": true }
          ]
        }
      ],
      "preferred": "h264_amf",
      "codecs": ["h264", "h265", "av1", "vp9"],
      "intel_gpu": false,
      "intel_gpu_detail": "",
      "software_fallback": false,
      "from_cache": true,
      "ffmpeg_version": "ffmpeg version 7.1 ...",
      "tested_at": "2026-06-23T10:00:00Z"
    }
  • 说明available 为各硬件/软件家族的 per-codec 实测能力,家族 available = 至少一编码 tested_okpreferred 为转码默认 H.264 编码器(保证 mpegts.js 可播);codecs 为系统可输出编码并集;from_cache/ffmpeg_version/tested_at 标示实测来源;冷态(从未实测)available 为空、preferredlibx264tested_at 为空。

列出转码预设(FR-77)

  • 方法 / 路径GET /api/transcode/presets
  • 响应(200):{ "items": [ { "id", "name", "codec", "width", "height", "created_at", "updated_at" } ] },按创建时间倒序。width/height0 表示沿用源分辨率。未注入预设服务返回 503

创建转码预设(FR-77)

  • 方法 / 路径POST /api/transcode/presets
  • 请求体{ "name": "1080p HEVC", "codec": "h265", "width": 1920, "height": 1080 }codech264/h265/av1/vp9hevc 视为 h265)。
  • 响应201 返回创建的预设;空名 / 不支持编码 / 负分辨率返回 400code=INVALID_PRESET)。

更新转码预设(FR-77)

  • 方法 / 路径PUT /api/transcode/presets/:id
  • 请求体:同创建。响应200 返回更新后的预设;预设不存在 404、校验失败 400

删除转码预设(FR-77)

  • 方法 / 路径DELETE /api/transcode/presets/:id
  • 响应204;预设不存在 404。已预生成的切片缓存不受影响。

加入预生成队列(FR-77)

  • 方法 / 路径POST /api/transcode/tasks
  • 请求体{ "media_id": 42, "preset_id": 1 }
  • 响应(200):{ "status": "queued", "task_id": 7 }。按预设快照编码入预生成队列、单 worker 串行预转码切片预热首播。媒体或预设不存在返回 404;未注入预生成服务返回 503

列出预生成任务(FR-77)

  • 方法 / 路径GET /api/transcode/tasks?status=
  • 查询参数status(可选)取 pending/running/completed/error,非空时仅返回该状态任务。
  • 响应(200):{ "tasks": [ { "id", "media_id", "preset_id", "codec", "width", "height", "status", "error", "created_at", "started_at", "completed_at" } ] },按入队时间倒序。

系统信息

  • 方法 / 路径GET /api/system/info
  • 响应(200):
    {
      "app_version": "0.3.0",
      "os": "linux", "arch": "amd64", "num_cpu": 8, "hostname": "nas01",
      "go_version": "go1.22.5",
      "ffmpeg": { "available": true, "path": "/opt/jianvideo/ffmpeg", "version": "ffmpeg version 6.1.1 ..." },
      "runtime": {
        "pid": 12345, "work_dir": "/opt/jianvideo", "executable": "/opt/jianvideo/jianvideo",
        "db_path": "/opt/jianvideo/data/jianvideo.db", "uptime_seconds": 3661,
        "mem_alloc": 12582912, "mem_sys": 50331648, "num_gc": 7, "gomaxprocs": 8
      },
      "hwaccel": { "available": [], "preferred": "libx264", "codecs": [], "intel_gpu": false, "intel_gpu_detail": "", "software_fallback": true, "from_cache": false, "ffmpeg_version": "", "tested_at": "" }
    }
  • 说明hwaccel 复用 GET /api/transcode/hwaccel 的 per-codec 结构(上例为冷态,未实测);app_version 由构建期 -ldflags -X main.version 注入。runtime(FR-60)为进程与 Go 运行时信息——pid/work_dir/executableos 包)、db_path/uptime_seconds(由 main 注入数据库路径与启动时刻派生,未注入时分别为空串 / 0)、mem_alloc/mem_sys/num_gcruntime.MemStats,字节)、gomaxprocsruntime.GOMAXPROCS(0));全部来自标准库,系统级总内存 / 磁盘可用不在此列(见 GET /api/system/metrics 系统指标时序,FR-119)。

系统指标时序(FR-119)

  • 方法 / 路径GET /api/system/metrics
  • 查询参数range1h / 24h / 7d,缺省 24h
  • 响应(200):
    {
      "range": "24h",
      "points": [
        {"t": "2026-06-27T10:00:00Z", "cpu_percent": 38.2, "mem_used_bytes": 195000000, "mem_sys_bytes": 536870912, "disk_used_bytes": 1979900000000, "disk_total_bytes": 2600000000000, "transcode_active": 2, "goroutines": 120}
      ],
      "current": {"cpu_percent": 41.0, "mem_used_bytes": 198000000, "mem_sys_bytes": 536870912, "disk_used_bytes": 1979900000000, "disk_total_bytes": 2600000000000, "transcode_active": 2, "goroutines": 122}
    }
  • 说明:系统监控页(/monitor)的指标时序。后台采样器每 15s 采一行(系统 CPU% 经 gopsutil、进程内存经 runtime、数据盘用量经 gopsutil、转码并发为活跃会话数、goroutine 数)写入 SQLite metric_samples,按 7 天保留期裁剪。本端点按 range 选窗口与桶大小下采样1h→60s、24h→300s、7d→1800s,GROUP BY 时间桶 + AVG/MAX)使点数有界。current 为最新一条原始样本(供当前值卡);刚启动无样本时 points 为空数组、currentnull。机制见 ADR-0044。

编解码器实测

  • 方法 / 路径POST /api/system/codec-test(可选查询参数 ?force=true 强制重测)
  • 响应(200):
    {
      "ffmpeg_available": true,
      "results": [
        { "encoder": "libx264", "family": "software", "codec": "h264", "compiled": true, "tested_ok": true, "detail": "" },
        { "encoder": "h264_amf", "family": "amf", "codec": "h264", "compiled": true, "tested_ok": true, "detail": "" },
        { "encoder": "av1_amf", "family": "amf", "codec": "av1", "compiled": true, "tested_ok": false, "detail": "<stderr 尾部>" }
      ],
      "from_cache": true,
      "ffmpeg_version": "ffmpeg version 7.1 ...",
      "tested_at": "2026-06-23T10:00:00Z"
    }
  • 说明:对候选编码器(软件 + QSV/VAAPI/NVENC/AMF/VideoToolbox/Vulkan 的 H.264/H.265/AV1/VP9)用外部 ffmpeg 跑一小段试编码(-f lavfi … -f null)。compiled 表示是否编入当前 ffmpeg,tested_ok 表示试编码是否成功。默认读按 ffmpeg 版本持久化的缓存即时返回from_cache:true),?force=true 强制重测覆盖缓存(from_cache:false,逐个试编码可能耗时数分钟)。ffmpeg 不可用时返回 ffmpeg_available:falseresults 为空。结果与 GET /api/transcode/hwaccel 同源(见 ADR-0033)。

查看环境变量(FR-56)

  • 方法 / 路径GET /api/system/env
  • 响应(200):
    {
      "env": [
        { "key": "JIANVIDEO_FFMPEG_PATH", "description": "ffmpeg 可执行文件路径,未设置时回退同目录捆绑版或 PATH", "sensitive": false, "set": true, "value": "/opt/jianvideo/ffmpeg" },
        { "key": "JWT_SECRET", "description": "JWT 签名密钥,未设置时启动随机生成(重启后需重新登录)", "sensitive": true, "set": true, "value": "****(已设置)" },
        { "key": "SMB_MASTER_PASSWORD", "description": "SMB 凭据加解密主密码,未设置则 SMB 凭据功能不可用", "sensitive": true, "set": false, "value": "(未设置)" }
      ]
    }
  • 说明:只读返回项目已知环境变量清单(涵盖根 config 的 SERVER_PORT/DB_PATH/JWT_SECRETinternal/configJIANVIDEO_* 两套来源)。set 表示是否已设置。敏感项(JWT_SECRETSMB_MASTER_PASSWORD)绝不回显明文——value 固定为掩码(已设置 ****(已设置)、未设置 (未设置)),只暴露 set 布尔;非敏感项 value 为明文。env 为进程级,本端点只查看不修改。

检测 FFmpeg 路径(FR-56)

  • 方法 / 路径POST /api/system/ffmpeg/detect
  • 请求
    { "path": "D:/tools/ffmpeg.exe" }
  • 响应(200):
    { "ffmpeg_available": true, "ffmpeg_version": "ffmpeg version 6.1.1 ..." }
  • 说明:对指定路径跑 path -version 验证是否可用(path 可省/空 = 测当前已配置路径)。仅探测、不改写运行期全局路径,供用户保存前先验路径。不可用时返回 ffmpeg_available:falseffmpeg_version 为空串。持久化路径走 PUT /api/settings(键 ffmpeg_path/ffprobe_path),保存后即时应用到运行期。

测试代理连通性(FR-89)

  • 方法 / 路径POST /api/system/proxy/test
  • 请求
    { "proxy": "http://host:port" }
  • 响应(200):
    { "reachable": true, "detail": "HTTP 200", "latency_ms": 123, "target": "https://api.github.com" }
  • 说明:用临时 http.Client 经待测代理(proxy 可省/空 = 测直连)对默认目标 https://api.github.com(与自更新出站目标一致,FR-46)发一次轻量 GET 探测连通性。仅探测、绝不改写运行期全局代理真源netproxy.current),与「保存即生效」的 PUT /api/settings(键 network_proxy,FR-80)解耦,供用户保存前先验。只要 HTTP 层拿到任意响应(含 4xx)即 reachable:truedetailHTTP <状态码>;网络层错误(连不上代理 / 目标、超时)reachable:falsedetail 为脱敏后的原因。代理 URL 含 userinfo 凭据时返回与日志一律脱敏,绝不回显明文(安全红线)。整体超时约 10s。

自更新下载进度(FR-90)

  • 方法 / 路径GET /api/system/update/progress
  • 响应(200):
    { "state": "downloading", "downloaded": 6291456, "total": 12582912, "percent": 50 }
  • 说明:供前端在自更新(POST /api/system/update/apply,FR-46)进行中轮询展示下载进度条。stateidle(空闲,未在更新)/downloading(下载二进制中)/verifying(校验 sha256 中)/done(替换已触发,进程即将重启)/failed(下载或校验失败)。downloaded/total 为字节数,total 取响应 Content-Length为 0 表示总字节未知(响应无 Content-Length),此时 percent 为 0、前端退化为展示已下载字节。进度为进程内单例(互斥量保护、不落库,自更新本就用户显式触发、单次互斥),无外部依赖恒可用;服务重启后归零为 idle。鉴权随 /api/* 的 APIGuard。

获取字幕轨道列表

  • 方法 / 路径GET /api/play/:id/subtitles
  • 响应(200):
    {
      "tracks": [
        {
          "index": 0,
          "file_name": "电影名.srt",
          "format": "srt",
          "url": "/api/play/1/subtitles/0"
        }
      ]
    }
  • 说明:返回媒体文件同目录下的外挂字幕轨道列表(SRT/ASS/SSA/SUP),按文件名匹配

获取字幕内容

  • 方法 / 路径GET /api/play/:id/subtitles/:index
  • 响应(200):Content-Type: text/vtt; charset=utf-8
  • 说明:返回指定字幕轨道的 WebVTT 转换内容,SUP 格式返回空 WebVTT 占位
  • 错误400 索引格式无效,404 索引超出范围

保存 SMB 凭据

  • 方法 / 路径POST /api/smb/credentials
  • 请求
    {
      "host": "192.168.1.100",
      "username": "user",
      "password": "pass",
      "share": "ShareName",
      "domain": "WORKGROUP"
    }
  • 响应(204):空
  • 说明:SMB 凭据使用 AES-256-GCM 加密后存储在本地文件(data/smb_credentials.enc)。加解密主密码由服务端通过 SMB_MASTER_PASSWORD 环境变量统一配置,必须显式设置——未设置(或为空串)时服务端拒绝保存/加载凭据,不再回退弱默认主密码;请求体无需也不应包含主密码
  • 错误400 请求参数错误,503 未配置 SMB_MASTER_PASSWORD 环境变量,500 加密/存储失败

获取系统配置

  • 方法 / 路径GET /api/config
  • 响应(200):
    {
      "server_port": 8080,
      "ffmpeg_path": "/usr/bin/ffmpeg",
      "ffprobe_path": "/usr/bin/ffprobe",
      "library_paths_count": 3,
      "media_files_count": 1500
    }

读取运行期设置

  • 方法 / 路径GET /api/settings
  • 响应(200):
    {
      "settings": {
        "scan_interval": "3600",
        "recycle_bin_paths": "{\"D\":\"D:/.recycle\"}"
      }
    }
  • 说明:返回全部运行期设置(key → value,值统一为字符串;结构化值如每盘符回收站路径以 JSON 字符串存于单 key)。设置以 SQLite settings 表为真源,为回收站清理、定时扫描等能力提供配置真源(FR-24)。
  • 错误503 设置服务未启用

写入运行期设置

  • 方法 / 路径PUT /api/settings
  • 请求
    {
      "settings": {
        "scan_interval": "7200",
        "recycle_bin_paths": "{\"D\":\"D:/.recycle\"}"
      }
    }
  • 响应(200):与 GET /api/settings 同结构,返回写入后的全部设置(回读结果)。
  • 说明:批量 upsert 键值,同一 key 覆盖旧值;写入在单事务内原子完成,提交成功后回读返回。保存成功后触发设置变更回调,使定时扫描周期(scan_interval)即时重排生效、无需重启(FR-28)。含 ffmpeg_path/ffprobe_path(非空)时,落库后即时应用到转码运行期(覆盖自动发现),保存即生效(FR-56);含 magick_path(非空)时同理即时应用到 HEIC/RAW 转换运行期,保存即生效(FR-63);含 network_proxy 时落库后即时应用到后端出站 HTTP 运行期(空=直连、非空=设代理),保存即生效(FR-80),非法 URL 仅记 WARN 跳过应用、不阻断保存(保留既有代理)。含 debug_log 时落库后即时切换 GORM 日志级别("1"/"true"=开启详细 SQL/慢查询日志、其余=安静),保存即生效(FR-110);启动时读取该键决定初始级别,重启后保持。
  • 已知键scan_interval(定时扫描周期秒)、recycle_bin_paths(盘符→回收站目录 JSON)、update_channelstable/prerelease)、transcode_codec_priority(首选目标编码优先级 JSON 数组)、ffmpeg_path/ffprobe_path(FR-56,可执行文件路径,非空覆盖自动发现)、magick_path(FR-63,ImageMagick magick 可执行文件路径,非空覆盖自动发现)、network_proxy(FR-80,后端出站网络代理 URL,空=直连,支持 http/https/socks5 协议)、debug_log(FR-110,运行时调试日志开关,"1"=开启 GORM 详细日志、其余=安静)。
  • 错误400 请求参数错误或 settings 为空,503 设置服务未启用,500 保存失败

分享链接(FR-43)

分享分两层:管理端点 /api/shares(鉴权后,受 APIGuard 保护)创建/列出/撤销;公开端点 /api/share/:token(免登,APIGuard 豁免 /api/share/ 前缀)由 token 持有者只读访问。公开端点经 shareAuth 校验 token + 过期,并对每个 :mediaId 做范围校验——不在分享范围内一律 404。分享可选带访问密码与访问限次(FR-78)。

创建分享

  • 方法 / 路径POST /api/shares(鉴权后)
  • 请求体
    { "resource_type": "media", "resource_id": 12, "expires_in_hours": 168, "password": "可选密码", "max_uses": 0 }
    resource_typemediaalbumexpires_in_hours 可选,>0 设过期、缺省或 0 表示永不过期。password 可选(FR-78),非空则后端以 bcrypt 哈希存储、绝不明文落库/回显;max_uses 可选(FR-78),>0 设访问次数上限、缺省或 0 表示无限。
  • 响应(201):{ "token": "...", "resource_type": "media", "resource_id": 12, "expires_at": "..."|null, "max_uses": 0, "used_count": 0, "created_at": "..." }(不含密码哈希)
  • 错误400 参数错误或非法类型,404 被分享资源不存在,503 分享服务未启用,500 创建失败

列出分享

  • 方法 / 路径GET /api/shares(鉴权后)
  • 响应(200):{ "shares": [ ... ] }(含已过期,供管理展示)

撤销分享

  • 方法 / 路径DELETE /api/shares/:token(鉴权后)
  • 响应204

公开访问分享元信息

  • 方法 / 路径GET /api/share/:token(免登)
  • 请求头(FR-78):分享设密码时需带 X-Share-Password: <密码>
  • 响应(200):
    • 校验通过——媒体分享 { "resource_type": "media", "expires_at": ..., "requires_password": false, "media": {...} };相册分享 { "resource_type": "album", "expires_at": ..., "requires_password": false, "album": {...}, "items": [...] }
    • 需密码且未带/带错密码——{ "resource_type": "media", "requires_password": true }不含任何 media/album 内容(供前端弹密码框、不泄露内容、不区分过期/撤销)。本端点不消费访问额度。
  • 错误404 token 不存在/已过期/已撤销

公开访问分享内的媒体

  • 方法 / 路径
    • GET /api/share/:token/media/:mediaId/raw(图片在线查看)
    • GET /api/share/:token/media/:mediaId/thumbnail(缩略图)
    • GET /api/share/:token/media/:mediaId/download(原文件下载)
    • GET /api/share/:token/media/:mediaId/stream(视频渐进式在线播放,支持 Range;不开放转码/HLS)
  • 请求头(FR-78):带 X-Share-Password 头时校验密码;<img>/<video> 直链无法带头时不阻断(拿到 mediaId 已必过 ShareInfo 密码门禁)。
  • 说明:mediaId 必须在分享范围内(== 被分享媒体,或 ∈ 被分享相册成员),否则 404smb:// 路径不支持。每次成功访问对限次分享原子自增一次 used_count(FR-78),达到 max_uses 后再访问 404
  • 错误400 ID 无效,404 不在范围/不存在/已软删/密码错/次数已用尽,503 播放服务未启用(stream)