Skip to content

拾光盒(MediaDock)

閱讀 2 次

把 NAS 上那堆散著的東西收進一個庫:視頻、音樂、小說、漫畫、圖片走同一套服務端和同一個客戶端,原文件只讀不改、不轉碼。

拾光盒(倉庫 xu-media,代號 MediaDock)是自己家 NAS 上的媒體服務:FastAPI 後端 + PostgreSQL + Vue 管理後台 + Flutter 客戶端(安卓手機 / Android TV / macOS 桌面)。媒體目錄以只讀方式掛進容器,後端只做發現和索引,不刪除、不修改原始文件。

起因很直白:視頻和音樂有 Jellyfin 管,漫畫和小說得另起一個 Komga,兩套賬號兩套接口,而 txt 小說在誰的語彙裡都不存在。v1.0.0 我就是這麼搭的 —— 九天後把這三個依賴全拆了,五類媒體收進一套接口自己寫。

客戶端首頁:全出血 Hero 大圖 + 最新入庫網格

三張圖都是 2026-09-15 的真實資料實拍(macOS 桌面殼)。那之後首頁又過了兩輪改動 —— v1.7.1 把首頁三個區塊收成只展示視頻、v1.8.0 重做續播首頁 —— 圖裡的「最新入庫」和輪播圓點不是現在的樣子。

它能做什麼 ​

一句話:打開客戶端,NAS 上的影音小說漫畫圖片在同一個庫裡,接著上次的位置播。

  1. 五類媒體一套接口:movie / music / novel / manga / image。各自的最小有效大小不同(視頻 1 MB、音樂 100 KB、小說 1 KB、漫畫和圖片 10 KB),低於閾值的不入庫也不計數 —— 但同目錄的 .lrc 歌詞和封面圖不受這個門檻限制,2 KB 也照樣匹配上
  2. 不轉碼:/api/media/{id}/stream 走 HTTP Range 直出原文件。能不能播取決於你的解碼器,不取決於我的服務端 —— 這是我唯一敢確定自己不會做錯的一件事
  3. 三個殼一套代碼:寬屏桌面殼(可折疊側欄、⌘K 搜索)、手機殼(底部五 tab + 懸浮迷你播放器)、Android TV 殼(方向鍵焦點導航、確認鍵激活)。TV 不是單獨的包:同一個 APK 用 UiModeManager 檢測 LEANBACK 走不同分支,manifest 裡 touchscreen 和 leanback 都聲明為非必需
  4. 中文目錄名的刮削:文件名猜詞(Reply.1988.S01E01.1080p.NF... → 「Reply 1988」,中文片名含數字不誤拆年份)→ TMDB 搜索 → 整季批量寫入 → 全庫自動刮削。自動落庫的門檻是搜索詞與候選標題的 difflib 相似度 ≥ 0.6,不達標的進人工清單,已有元數據的組絕不覆蓋
  5. 閱讀:小說仿真卷頁翻書(自研 Canvas 貝塞爾卷頁)、目錄 / 字號 / 主題 / 書籤、章節加頁碼進度;漫畫單頁 / 雙頁 / 豎排三種模式 + 縮略圖導航條

視頻庫列表:828 項真實資料、TMDB 海報與評分、MKV / 容量標籤

客戶端其實接兩個源 ​

自建後端不是唯一資料源。客戶端同時接綠聯 NAS 自帶的影視中心 —— 那是一個 Emby 兼容接口,家裡已經有的東西,不接就是兩套歷史、兩套續播。

接得挺深:設備發現(/System/Info/Public 探活)、AuthenticateByName 登錄、/Sessions/Playing 與 /Sessions/Playing/Progress 實時上報進度(PositionTicks = 秒 × 10⁷)、/PlayedItems 與 /FavoriteItems 寫回已看和收藏,外加 harvestUgreenProgress() 反向收割綠聯側的歷史進度合併進首頁。兩邊用 ugreen: 前綴分命名空間,各集共享的 Emby SeriesId 當劇集分組鍵。

還得給播放器和自家後端之間搭一座橋:客戶端在本地起一個 loopback HttpServer 代理 /stream/{id},讓 media_kit 拿到一個不帶 token 的 URL,Range 透傳 —— 直接把憑據拼進媒體 URL 是我一開始的寫法,後來想想不對。

劇集詳情:全出血 Hero + 共 24 集 · 3 季網格

幾個真正花時間的坑 ​

這個項目最值錢的部分不是功能清單,是這幾個 bug 的根因:

  • 本地全綠、NAS 上全炸(v1.2.2):asyncpg 拒絕把帶時區的 datetime 寫進 TIMESTAMP WITHOUT TIME ZONE 列,事務反覆回滾,掃描任務永遠卡 queued 且無痕跡;SQLite 寬容地收下了同樣的寫法,所以測試全過。修法不是只改時間函數,是補一個用 SQLAlchemy 事件鉤子在 SQLite 上模擬 asyncpg 嚴格性的測試 —— 讓這個坑在本地就能復現
  • content-length 說謊(v1.2.6):JSON 統一信封中間件重打包響應時把原來的 content-length 頭一起帶進新響應,新體變長就超過舊頭聲明的長度,uvicorn 嚴格校驗直接中斷響應。所有非信封接口和 404 在線上必崩,而 TestClient / httpx 根本不校驗這一項,本地永遠復現不了
  • 全屏按鈕在所有平台都是死鍵(v1.7.4):media_kit 自帶的 toggleFullscreen(context) 要求調用上下文位於 Video 組件子樹內部(它靠 dependOnInheritedWidgetOfExactType 找祖先),而播放器底欄是 Video 的兄弟節點 —— 拿不到就斷言失敗,release 下斷言被剝掉就變成點了沒反應。改自研:鎖橫屏 + 沉浸式系統欄,PopScope 分層(手勢返回一次退全屏、二次才保存退出)
  • 翻著翻著字體就糊了(v1.7.6 深夜):小說翻頁先接的 page_flip,它在動畫期間把頁面截圖成位圖再變形繪製,而截圖沒乘設備像素比 —— 2x/3x 屏上文字必然發虛。換成自研 Canvas 方案(623 行):整章頁面預渲染成設備像素分辨率的矢量 ui.Picture,翻頁全程 drawPicture 矢量重繪,高分屏永遠銳利

版本記錄 ​

下面的日期來自三處證據:deploy/CHANGELOG.md(v1.0.0 → v1.7.0)、docs/2026-09-17-工作記錄.md(v1.7.1 → v1.7.6,精確到小時)、output/ 裡每個 APK 的實際構建時間與二進制裡的 versionName。一條對應一次能跑的改動。

2026-08-28

v1.0.0 起步。FastAPI + PostgreSQL + Alembic(0001-0003)+ SoybeanAdmin 後台。那個版本的 Compose 裡掛著六個服務:api、worker、postgres、jellyfin、komga、web —— 自己不寫媒體服務,站在別人肩膀上。

2026-09-06

v1.1.0 把肩膀拆了。Jellyfin、Komga、下載功能、Worker 全部移除,五類媒體改由自己一套 API 收斂;歷史數據表和原文件保留,不做破壞性清庫 —— 到今天 0002_download_details 和 0003_download_runtime 兩張下載器的遷移還躺在遷移鏈裡。同一天定下三端邊界:iPhone/iPad、安卓手機、Android TV,另加 macOS 桌面,不含 Apple TV。

2026-09-08

v1.2.0 → v1.2.3。補 0004_local_library 六張表;查出掃描循環把異常靜默吞掉(v1.2.1);找到 asyncpg 時區嚴格性這個根因並統一 utcnow_naive()(v1.2.2);v1.2.3 定下固定四類目錄和全量 / 增量掃描語義 —— 全量開始時把該目錄全部標記不可用,掃完仍不可用的才刪除,增量只標記不刪。

2026-09-09

v1.2.6 → v1.2.9,同一天兩次踩到「mock 的形狀不是真實響應的形狀」。一次是 content-length 頭體不一致(本地測試框架不校驗),一次是元數據頁 422:後端直接返回 TMDB 原始 results(tv 用 name、無 type),前端按自己歸一化的契約取值就是空。順帶把 TMDB 連通性修好:後端超時改可配的 8s 並給連接階段封頂 5s,保證後端的 502 先於前端 axios 的 10s 到期;Compose 透傳出網代理。整季批量匹配 apply_scope=directory 上線,當天實測《請回答 1988》一次寫入 20 集。

2026-09-10

v1.3.0 全庫自動刮削:按 (目錄, 文件夾) 聚合 → 代表文件猜詞 → 搜索 → 相似度 ≥ 0.6 才整組自動寫,已有元數據的組跳過,不達標的進「需手動匹配」清單;媒體列表加季集號數字序(E2 排在 E10 前,無季集號的排最後)。v1.4.0 海報代理:局域網客戶端連不上 image.tmdb.org,改由後端經代理拉、按 poster_path 內容尋址緩存到 NAS,整組劇集共享一份,非可信域的完整 URL 一律拒絕。同一天客戶端 v1.5.0 按 12 張三端設計稿全面重做。

2026-09-14

v1.5.1 補打包。海報拉取尺寸從 w500 換成 original —— 全出血大圖下 w500 糊得很明顯,緩存文件名帶上尺寸,舊緩存自然棄用。無本地封面的視頻 /cover 兜底返回同款 TMDB 海報,解決首頁大圖黑屏。客戶端首頁改成「背景層 + 內容層」的全出血大圖。順帶處理一個兼容性教訓:NAS 上實際跑的還是 v1.4.0,媒體列表只接受 title / season_episode,客戶端遇到那個固定 422 文案才降級重試,其他 422 原樣報錯、不掩蓋真實錯誤。

2026-09-15

v1.6.0 加第五類媒體「圖片」:單張圖即一個條目,10 KB 門檻過濾界面圖標和縮略圖,/stream 原文件帶 Range、/cover 直接返回原圖並帶 24 小時緩存頭,掛 NAS 真實照片目錄只讀。同版修掉 v1.5.1 的 list_media() takes 10 positional arguments —— 那個 bug 存在於 09-15 上午打好的包裡,因為 main.py 打包之後又被繼續改過。

2026-09-16

v1.7.0。TMDB 候選詳情加 append_to_response=credits,存前 12 位演職人員,新增頭像代理(內容尋址緩存 w185,越界或無圖 404、出網失敗 502,客戶端回退首字母);存量已匹配組缺 cast 自動進補刮隊列,只補缺不覆蓋。小說修了四處編碼,最陰的一個是 EPUB zip 條目名沒帶 UTF-8 標誌時被按 cp437 誤解碼,GBK 書名直接 KeyError 靜默丟章 —— 讀取前先還原條目名。

2026-09-17

v1.7.1 → v1.7.6,六個安卓包,全是在真機上暴露的。首頁三個區塊和播放記錄頁統一只展示視頻(其他類型進度一條不刪,只是不在首頁出現);查清綠聯視頻進度從未入庫的三層原因並補上實時上報;全屏按鈕自研;Android 15 強制 edge-to-edge 把底欄蓋在手勢條下面;窄屏底欄兩行溢出把全屏和選集裁到屏幕外。深夜把小說翻頁從 page_flip 換成自研 Canvas 貝塞爾卷頁。這一天 flutter test 從 164 項長到 185 項。

2026-09-20

v1.7.7,包名開始帶「手機電視通用」:一個 APK 同時是手機應用和 Android TV 應用,靠 LEANBACK 檢測分殼。

2026-09-22

v1.7.8 播放記錄修復。

2026-09-23

v1.8.0 → v1.8.3 四個包。續播首頁重設計:selectResumePositionSeconds 選續播位置、selectResumeEpisode 決定從哪一集接著看、時長已知後最多三次重試 seek;音樂和圖片列表加分頁與「加載更多」。v1.8.2 試過上滑自動加載播放列表,當天回退 —— 它的產物和 v1.8.0 字節一致,v1.8.3 又和 v1.8.1 字節一致。這四個標籤的內部 versionName 全都是 1.7.8:那是構建標籤,不是應用版本。

技術棧 ​

  • 服務端:Python 3.12 + FastAPI + SQLAlchemy + Alembic(6 個遷移)、PostgreSQL 16.4、mutagen 1.47.0 讀音頻標籤、ffprobe 可選讀時長。server/app 4,098 行,測試 2,273 行 / 125 項(最後一次有文檔記錄的全綠運行是 v1.7.0)
  • 管理後台:SoybeanAdmin(Vue3 + Vite)脚手架,自己寫的部分是 6 個頁面 2,254 行 + 541 行 API 封裝,其餘是腳手架自帶
  • 客戶端:Flutter + media_kit(mpv)+ flutter_secure_storage。client/lib 16,495 行、client/test 5,895 行 / 約 188 個用例(靜態計數,未在當前工作樹重跑)
  • 部署:Docker Compose 三個服務(postgres / api / web),Web 側 Nginx 同源代理 /api/,對外一個端口

還沒解決的問題 ​

不裝:

  1. NAS 上跑的還是 v1.6.0。今天實測 /api/version 返回 v1.6.0 —— v1.7.0 的服務端改動(演職人員、小說編碼修復)根本沒生效。大概率是部署時只 docker compose up -d 而沒 build --no-cache,而這個坑 09-17 的工作記錄裡已經寫過一次,寫完沒人回去修。文檔聲明的「當前版本 v1.7.0」和實際運行的東西差了三個小版本
  2. CHANGELOG 停在 v1.7.0。v1.7.1 之後只有每日工作記錄和 APK 文件名;v1.7.7 → v1.8.3 這七個包沒有一個有 changelog 條目,pubspec.yaml 從 v1.7.8 起沒再動過
  3. 各庫頁面還不放對應類型的播放記錄。首頁和播放記錄頁只展示視頻,音樂 / 小說 / 漫畫 / 圖片的進度老老實實存在服務端和本地進度表裡,但沒有入口 —— 「最近在聽」「繼續閱讀」欠著
  4. 圖片是半個功能:收藏頁只分四類(圖片收藏會被丟掉)、全局搜索不覆蓋圖片、圖片頁沒有收藏按鈕,也一個自動化測試都沒有
  5. DEVELOPMENT.md 的待辦和代碼對不上。§12 還寫著「Flutter 端接入音樂 cover 與 lyrics 接口」,那個 v1.4.0 就做完了;文檔落後於代碼的地方不止一處
  6. 桌面端只有代碼,沒有產物。三端殼都寫了,output/ 裡實際交付過的只有安卓 APK —— macOS 那條路徑最後一次跑起來是 09-17 的調試版

為什麼沒有下載 ​

這個東西搬不走,所以不放安裝包:

  • 它要你自己有一台 NAS、自己的媒體目錄,服務端用 Docker Compose 起 PostgreSQL 和 API,目錄以只讀掛進容器。客戶端第一屏要填的是你自己後端的地址和賬號 —— 沒有庫,它連登錄都過不去,全程只在局域網內有用
  • 最後一個包是 debug 密鑰簽名的,applicationId 還是 Flutter 模板的 com.example.client。自用覆蓋安裝沒問題,拿去向別人分發不合適
  • 早期版本(v1.5.1)登錄頁預填過自用的地址、用戶名和密碼,密碼會進二進制。現在改成編譯期 --dart-define 注入,而且密碼不再參與構建,落盤走 flutter_secure_storage(Android 開 encryptedSharedPreferences)

下一個目標:把 NAS 上的服務端真正升到 v1.7.0 並補一次全量掃描,然後把 CHANGELOG 從 v1.7.1 接上 —— 現在欠著七個包的記錄。

上次更新於: