Compare commits

...

120 Commits

Author SHA1 Message Date
github-actions[bot] d0e29a0d8d chore: bump version to 0.0.98 [skip ci] 2026-09-03 09:43:17 +00:00
truewhile a7eeb8fbeb 优化 2026-09-03 17:42:52 +08:00
github-actions[bot] 64906d659d chore: bump version to 0.0.97 [skip ci] 2026-09-03 08:53:39 +00:00
truewhile c702a68cfb 优化 2026-09-03 16:53:12 +08:00
github-actions[bot] 4150d03852 chore: bump version to 0.0.96 [skip ci] 2026-09-03 06:13:05 +00:00
truewhile a79bbb80a8 Remove unused recycle bin and add optional local file delete (#26)
Soft-delete restore/purge/list and the recycle_purge job are gone.
Admin delete now removes library index rows, with an optional
delete_files query that also deletes local media + sidecar NFO.
UI copy is renamed from 移入回收站 to 删除, with a confirm checkbox.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 14:12:52 +08:00
github-actions[bot] 205c71b0ad chore: bump version to 0.0.95 [skip ci] 2026-09-03 04:46:22 +00:00
truewhile 5474784107 优化 2026-09-03 12:46:02 +08:00
github-actions[bot] fb92373f0d chore: bump version to 0.0.94 [skip ci] 2026-09-03 02:39:57 +00:00
truewhile 2b99f5f108 fix: enforce user library ACL on mounted Emby libraries (#25)
Mounted Emby libraries were always appended to web/Emby library lists and
detail/play routes without checking allowed_library_ids, so restricted
users could still see and open them. Filter remotes with the same
visibility policy as local libraries across list/detail/series/stream and
Emby Views/Items/search/playback, and label mounts in the admin ACL UI.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:39:44 +08:00
github-actions[bot] 9ab32c10ca chore: bump version to 0.0.93 [skip ci] 2026-09-03 02:26:16 +00:00
truewhile e872223b4e feat: honor pinned libraries in Emby client Views order (#24)
Sort /Users/.../Views so libraries the user pinned on the web appear
first for Infuse/Emby/Jellyfin clients. Unpinned libraries keep their
existing sort_order / remote-mount order.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:26:04 +08:00
github-actions[bot] c7441e06d4 chore: bump version to 0.0.92 [skip ci] 2026-09-03 02:13:21 +00:00
truewhile 936be24328 fix: stop pinned-library toggle from saving an empty list (#23)
The pin hook captured the optimistic ID list inside a React setState
updater. That updater can run after the following lines, so save ran with
optimistic=[] and overwrote server pins — matching the flash then wipe
when pinning a mounted Emby library after a local pin.

Compute previous/optimistic synchronously from a ref before calling setState.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:13:04 +08:00
github-actions[bot] b7f2760728 chore: bump version to 0.0.91 [skip ci] 2026-09-03 01:41:06 +00:00
truewhile ea732e5bd7 fix: allow pinning mounted Emby libraries without wiping local pins (#22)
accessibleLibraryIDSet only consulted the local libraries table, so
embyremote~ mount IDs were stripped on every GET/PUT. Saving a pin that
included a remote library could then persist a filtered list and clear
existing local pins from the client state. Include enabled Emby mounts in
the accessible set, and refuse pin toggles until the server list has
loaded successfully.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 09:40:52 +08:00
github-actions[bot] 4e02692aeb chore: bump version to 0.0.90 [skip ci] 2026-09-03 00:50:58 +00:00
truewhile e8c710c261 fix: sync mounted Emby favourites across web and Emby clients (#21)
Unify favourite writes through SyncUserFavorite so remote mounted media is
stored locally and proxied to the upstream Emby server. Emby clients now
query favourites from the local table (including remote pseudo IDs), and
remote item payloads merge local IsFavorite state.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 08:50:46 +08:00
github-actions[bot] 27d2b79e1f chore: bump version to 0.0.89 [skip ci] 2026-09-03 00:47:22 +00:00
truewhile 1e291df647 feat: persist pinned libraries per user with cross-device sync (#20)
Store pinned library IDs on the user record and expose GET/PUT
/me/pinned-libraries endpoints. The web client now loads and saves pins
through the API, migrates legacy localStorage data once, and applies
pinned ordering on both the libraries and home pages.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 08:47:06 +08:00
github-actions[bot] 1025e3c693 chore: bump version to 0.0.88 [skip ci] 2026-09-02 15:05:55 +00:00
truewhile ba27d48d10 优化 2026-09-02 23:05:38 +08:00
github-actions[bot] 6d20dc65de chore: bump version to 0.0.87 [skip ci] 2026-09-02 14:15:40 +00:00
truewhile d4c82ac07e 优化 2026-09-02 22:15:24 +08:00
github-actions[bot] acdb7b2ead chore: bump version to 0.0.86 [skip ci] 2026-09-02 12:56:27 +00:00
truewhile 32ae1aeba9 优化 2026-09-02 20:56:09 +08:00
github-actions[bot] 55badfa6ad chore: bump version to 0.0.85 [skip ci] 2026-09-02 12:38:08 +00:00
truewhile 8754ee615f 优化 2026-09-02 20:37:54 +08:00
github-actions[bot] 4943d28743 chore: bump version to 0.0.84 [skip ci] 2026-09-02 11:08:22 +00:00
truewhile ee2272f743 优化 2026-09-02 19:08:06 +08:00
github-actions[bot] e4f41c0b3d chore: bump version to 0.0.83 [skip ci] 2026-09-02 11:00:16 +00:00
truewhile a02583ee7d 优化 2026-09-02 19:00:01 +08:00
github-actions[bot] e079a3cbb2 chore: bump version to 0.0.82 [skip ci] 2026-09-02 10:26:53 +00:00
truewhile 5cd1aa3e91 优化 2026-09-02 18:26:31 +08:00
github-actions[bot] e923c9d7a1 chore: bump version to 0.0.81 [skip ci] 2026-09-02 09:10:18 +00:00
truewhile bbb512760a feat: make user limit configurable from admin user management (#18)
Store the per-instance user cap in settings (default 20) and expose
GET/PUT /admin/users/limit endpoints. The user management page now lets
admins view and update the limit without touching system settings.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 17:10:07 +08:00
github-actions[bot] da99182222 chore: bump version to 0.0.80 [skip ci] 2026-09-02 08:55:31 +00:00
truewhile c43d1ca637 优化部署配置 2026-09-02 16:55:16 +08:00
github-actions[bot] b51eee3d94 chore: bump version to 0.0.79 [skip ci] 2026-09-02 08:26:41 +00:00
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00
github-actions[bot] a50ceaf1a7 chore: bump version to 0.0.78 [skip ci] 2026-09-02 07:51:44 +00:00
truewhile bf49323789 feat(strm): auto-append remote directory tail to local output path (#16)
When adding or editing a cloud sync path, append the last segment of the
remote directory to the local output directory (e.g. /home/ubuntu/strm + 电影).
Remote changes replace the previous auto-appended tail; local browse and blur
commit the base path with the current remote tail.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:51:31 +08:00
github-actions[bot] 47118c8a53 chore: bump version to 0.0.77 [skip ci] 2026-09-02 07:16:02 +00:00
truewhile 67b85840dc perf(strm): 优化 OpenList 元数据下载速度 (#15)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix remote Emby playback history and continue watching

- Preserve duration_ms when web player reports duration=0
- Add GET /playback/:id/resume for reliable per-item resume
- Switch home continue watching to /watch-history/continue API
- Set library_id on remote media for play-profile visibility
- Keep continue-watching rows when remote metadata hydration fails

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix mobile sort dropdown overflowing off-screen

Align dropdown to button left edge on small screens and cap width
to viewport so sort options stay fully visible on mobile.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Remove stale Windows binary backup files

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix STRM account edit form not echoing saved config

Return config_preview from account API for server/url/username and
secret flags. Merge partial config updates so empty password/token
fields do not wipe stored credentials.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* perf(strm): speed up OpenList metadata downloads with provider-aware concurrency

- Split download semaphore: 115 stays capped at 3 for WAF safety; OpenList/CloudDrive2
  WebDAV metadata uses strm.download_threads (default raised to 6, max 16)
- Scope 115 WAF cooldown to 115 tasks only so OpenList downloads are not paused
- Requeue claimed tasks back to pending when slot acquisition fails or 115 is cooling
- Add tests for separate semaphores and requeue behavior

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:15:49 +08:00
github-actions[bot] fbc33862fb chore: bump version to 0.0.76 [skip ci] 2026-09-02 06:14:43 +00:00
truewhile 04fc50a4b0 Add library favourite buttons and fix remote favourites list (#14)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 14:14:32 +08:00
github-actions[bot] dab9a1073a chore: bump version to 0.0.75 [skip ci] 2026-09-02 05:48:38 +00:00
truewhile bd3d4ad357 Add favourite buttons to library list and series detail pages (#13)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 13:48:25 +08:00
github-actions[bot] bd5bfdcc10 chore: bump version to 0.0.74 [skip ci] 2026-09-02 03:43:54 +00:00
truewhile 73dc37fef8 Remove obsolete file manager organize options (#12)
Remove UI options for features that no longer exist:
- qB download auto-organize
- downloader smart classification
- seeding (keep seeding)

Also remove related warning banners and simplify move transfer labels.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:43:39 +08:00
github-actions[bot] f1b7d99a33 chore: bump version to 0.0.73 [skip ci] 2026-09-02 03:35:57 +00:00
truewhile 638dae0c91 fix(layout): hide duplicate media nav in mobile drawer (#11)
On narrow screens the media drawer duplicated bottom navigation and
user menu links (home, libraries, favourites, playlists, history).
Show only management links in the drawer when browsing media.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:44 +08:00
truewhile 791722aec6 feat(libraries): add pin-to-top for media library list (#10)
Allow users to pin frequently used libraries to the top of the
Libraries page. Pinned order is stored in localStorage and applied
to both entry cards and preview shelves.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:20 +08:00
github-actions[bot] 1c1da494b7 chore: bump version to 0.0.72 [skip ci] 2026-09-02 03:20:07 +00:00
truewhile 97b3a7bd7f feat(emby): support multiple connection lines for remote mount (#9)
Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:19:56 +08:00
github-actions[bot] 543615a2a8 chore: bump version to 0.0.71 [skip ci] 2026-09-02 03:02:51 +00:00
truewhile 2b7828e1fe fix: close user menu on navigation and outside click (#8)
Portal the profile dropdown to document.body with a full-screen backdrop
so clicks outside reliably dismiss it above transformed page layers. Also
close the menu whenever the route changes.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:02:41 +08:00
github-actions[bot] 0b1fe52e24 chore: bump version to 0.0.70 [skip ci] 2026-09-02 02:49:11 +00:00
truewhile 2fc2c17b05 fix: prevent mobile sidebar nav items from stacking on short screens (#7)
Narrow drawers rendered media links in a flex-1 overflow region above a
non-shrinking admin block, so the browse items were clipped into a
compressed active pill. Scroll media and admin together, and distinguish
/libraries from /libraries?from=admin when highlighting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 10:48:58 +08:00
github-actions[bot] be96acb035 chore: bump version to 0.0.69 [skip ci] 2026-09-02 02:44:26 +00:00
truewhile 6a72e7ab82 Merge pull request #6 from truewhile/cursor/frontend-nav-mobile-af8f
Improve mobile navigation and fix playlists page routing
2026-09-02 10:44:10 +08:00
Cursor Agent 4ebd477ca2 Fix playlists route matching /play and improve playlists page UI
- Add isPlayerRoute() so /playlists no longer hides header and bottom nav
- Add mobile back navigation for playlists, favourites, and history
- Redesign PlaylistsPage with PageHeader, empty state, and card list

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:30:14 +00:00
github-actions[bot] 0320d956ed chore: bump version to 0.0.68 [skip ci] 2026-09-02 02:23:41 +00:00
truewhile 95395c479a 优化前端返回导航与移动端操作体验
优化前端返回导航与移动端操作体验
2026-09-02 10:23:30 +08:00
Cursor Agent eba7904c99 Improve frontend navigation and mobile UX
- Add mobile bottom tab bar for media browsing (Home, Libraries, Favourites, Playlists, More)
- Split mobile drawer: media links first, admin links under a Management section
- Show contextual back button in mobile header for deep routes
- Add reusable PageBackButton and PageHeader components
- Add back navigation to playlist detail, library page, and missing media state
- Embed queue panels in TaskQueuePage to avoid duplicate page titles
- Expose theme toggle in user menu on small screens

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:19:25 +00:00
github-actions[bot] 1171fe9464 chore: bump version to 0.0.67 [skip ci] 2026-09-02 02:09:43 +00:00
truewhile 06b96102c8 Merge pull request #4 from truewhile/cursor/rewrite-readme-af8f
更新 README 与 Docker 镜像为 truewhile 仓库
2026-09-02 10:09:31 +08:00
Cursor Agent 077458df31 Update Docker images to ghcr.io/truewhile/mmtl
Replace legacy shukebta/shukbet image references across all compose
templates, system update defaults, and security docs.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:59:48 +00:00
github-actions[bot] 9124c9be9c chore: bump version to 0.0.66 [skip ci] 2026-09-02 01:56:22 +00:00
truewhile 3ae3c6540d 重写 README:更新 fork 说明与当前项目描述
重写 README:更新 fork 说明与当前项目描述
2026-09-02 09:56:06 +08:00
Cursor Agent 313ebdd402 Rewrite README for truewhile fork and current feature set
- Clarify fork lineage from MediaStationGo
- Document current capabilities: Emby mounts, STRM queues, task queue, etc.
- Add acknowledgements for MediaStationGo and qmediasync
- Update repo/image URLs to truewhile/MMTL
- Streamline deployment docs while keeping essential guidance

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:53:59 +00:00
github-actions[bot] db52792ada chore: bump version to 0.0.65 [skip ci] 2026-09-02 01:49:22 +00:00
truewhile a8065680bb 优化首页/媒体库加载速度与 SQLite 查询性能
优化首页/媒体库加载速度与 SQLite 查询性能
2026-09-02 09:49:07 +08:00
Cursor Agent 3304f09b1d Fix Auto Docker workflow failing on pull requests
The publish workflow bumps VERSION, pushes commits/tags, and publishes
images. It must not run on pull_request events where checkout is a
detached HEAD and git push fails. PR validation is already covered by ci.yml.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:45:32 +00:00
Cursor Agent ba621cfc4a Optimize home/library loading and SQLite query performance
- Replace home preview N+1 queries with a single window-function batch query
- Cache library previews and series card lists (15s TTL, shared with media cache)
- Tune SQLite: larger page cache, mmap, wal_autocheckpoint, ANALYZE on migrate
- Defer history fetch on library page until last_played sort is selected
- Yield to browser between paginated library loads to keep UI responsive

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:39:28 +00:00
github-actions[bot] deca7735a7 chore: bump version to 0.0.64 [skip ci] 2026-09-01 15:16:32 +00:00
truewhile b503fdee7a 优化续播 2026-09-01 23:16:11 +08:00
github-actions[bot] 3fe37e050b chore: bump version to 0.0.63 [skip ci] 2026-09-01 14:43:48 +00:00
truewhile d3233a62c0 优化 2026-09-01 22:43:30 +08:00
github-actions[bot] 165eee7b36 chore: bump version to 0.0.62 [skip ci] 2026-09-01 13:25:14 +00:00
truewhile 78526afc9c 优化 2026-09-01 21:24:55 +08:00
github-actions[bot] f534e0607a chore: bump version to 0.0.61 [skip ci] 2026-09-01 10:55:32 +00:00
truewhile db64a6c093 优化 2026-09-01 18:55:05 +08:00
github-actions[bot] 5c9e7fcaa6 chore: bump version to 0.0.60 [skip ci] 2026-09-01 08:26:41 +00:00
truewhile af67f4cd6e 优化 2026-09-01 16:26:25 +08:00
github-actions[bot] 73de139d1f chore: bump version to 0.0.59 [skip ci] 2026-09-01 06:33:57 +00:00
truewhile e0cc481b96 Merge pull request #1 from truewhile/beta
添加emby挂载功能,整合上传,下载,刮削队列到任务队列
2026-09-01 14:33:38 +08:00
truewhile 7ac75ec69a fix: 修复 beta CI 失败的两个平台/网络敏感测试
- TestSanitizePathWithSpecialChars:Windows 盘符断言按 runtime.GOOS 分支
  (Linux 下反斜杠统一为分隔符,期望 D/test/...)
- TestEnrichOneAdultScrapesArtwork:AdultProvider 改用 RoundTripper 全量
  mock,拦截所有外网请求(CI 环境可达 javdb/dmm 时会把真实封面写入
  断言导致失败);断言放宽为封面路径后缀
2026-09-01 14:22:52 +08:00
truewhile 0e73e43cbd ci: 新增 beta 分支自动构建流水线
- 触发:push beta / PR 到 beta / 手动触发
- test-and-build:SPA 构建 + go vet/test/build + 三平台交叉编译
  (linux/amd64、linux/arm64、windows/amd64),版本号为
  {VERSION}-beta.{sha},二进制上传为 artifact
- docker-beta:多架构构建并推送 ghcr.io/{owner}/mmtl:beta
  (PR 事件不推镜像,仅推送 beta / 手动触发时推)
- 与 main 的发布流隔离:不做版本递增、不打 release tag
2026-09-01 14:12:31 +08:00
truewhile bd41ab3fb4 feat: 整合刮削/下载/上传队列为统一的「任务队列」
- 新增 /queue 任务队列页:全部 / 刮削 / 下载 / 上传 类型 Tab(URL 参数
  ?type= 同步),Tab 上实时显示各自任务计数徽章
- 「全部」视图纵向堆叠三类队列,单类视图直接复用原队列组件
  (ScraperQueuePage / StrmQueuePanel,后者已导出)
- 侧边栏导航:删除「刮削队列 / 下载队列 / 上传队列」三个入口,合并为
  「任务队列」单一菜单项;旧路由 /scraper/queue、/strm/downloads、
  /strm/uploads 保留直达
2026-09-01 14:02:25 +08:00
truewhile 73b95a8e38 feat: Emby 挂载独立管理(媒体库级选择挂载 + 每库代理开关)
- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
2026-09-01 13:53:15 +08:00
truewhile 5a5555d28a fix: 远程 Emby 无图条目不再下发无效图片 URL(封面破图修复)
- 远程条目仅当 ImageTags 含 Primary/Backdrop 时才生成图片 URL;
  无图条目 poster_url 为空,前端走 No Poster 优雅占位而非裂图
- 剧集海报回退使用 Emby 的 SeriesPrimaryImageTag(Fields=SeriesPrimaryImage),
  仅当系列真实有图时才回退,避免连环无效请求
- 列表/剧集接口带 SeriesPrimaryImage 字段
2026-09-01 13:26:57 +08:00
truewhile 4dc9cbe2e7 feat: 网页端完整浏览/播放远程 Emby 挂载库
- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard
  与本地完全同构,前端无感知
- /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、
  /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、
  /api/media/:id/episodes 全部支持远程伪装 ID 透传映射
- /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机)
- 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series),
  RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500
- SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404)
- 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/
  HLS 转码等不适用操作;后台管理面板过滤远程库
2026-09-01 11:32:54 +08:00
truewhile 6af0e5fdcf fix: Emby 远程挂载播放地址强制下发 + 凭据状态显示修正
- PlaybackInfo 始终构造 DirectStreamUrl(远程 PlaybackInfo 默认不带该字段,
  此前客户端拿不到播放地址):默认指向远程 Emby 原地址(不代理),
  开启代理时指向 MMTL /Videos/{encoded} 反代端点
- HasStrmAccountCredential 对 emby_remote 放宽为 url+用户名/密码 即视为
  已配置(此前只认 api_key,导致列表误显示「凭据:未配置」)
2026-09-01 11:06:36 +08:00
truewhile f0055b76fe fix: Emby 远程挂载账号编辑保留凭据 + 代理开关回显
- UpdateStrmAccount 对 emby_remote 做合并式更新:只覆盖传入键,未提及的
  地址/用户名/密码/token 密文保留,避免编辑代理开关时清空凭据
- 账号列表/编辑表单回显 proxy_play(服务端解密后下发)
- 前端 StrmAccount 类型与编辑对话框支持 proxy_play 回显
2026-09-01 10:45:52 +08:00
truewhile 0eb3f104f4 feat: Emby 远程挂载(联邦聚合)功能 beta
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
2026-09-01 10:44:01 +08:00
github-actions[bot] c171b38155 chore: bump version to 0.0.58 [skip ci] 2026-08-30 14:32:56 +00:00
truewhile 89d7a6cbb2 优化 2026-08-30 22:32:39 +08:00
github-actions[bot] 85918d1196 chore: bump version to 0.0.57 [skip ci] 2026-08-30 13:41:17 +00:00
truewhile fa9307e2e1 优化 2026-08-30 21:40:58 +08:00
github-actions[bot] 5ab18c2725 chore: bump version to 0.0.56 [skip ci] 2026-08-30 12:05:41 +00:00
truewhile a4a2bde1a4 优化 2026-08-30 20:05:27 +08:00
github-actions[bot] 28113f5fdc chore: bump version to 0.0.55 [skip ci] 2026-08-30 10:50:29 +00:00
truewhile 42b8805e94 优化 2026-08-30 18:50:15 +08:00
github-actions[bot] 51a64f41b9 chore: bump version to 0.0.54 [skip ci] 2026-08-30 09:28:16 +00:00
truewhile a7bd9a942c 优化显示 2026-08-30 17:28:00 +08:00
github-actions[bot] 97491a6175 chore: bump version to 0.0.53 [skip ci] 2026-08-29 14:16:40 +00:00
truewhile ea5cb3a130 优化 2026-08-29 22:16:21 +08:00
github-actions[bot] 921010926b chore: bump version to 0.0.52 [skip ci] 2026-08-28 14:38:24 +00:00
truewhile 7425c3d57b 优化 2026-08-28 22:38:09 +08:00
github-actions[bot] 1b7d4eef46 chore: bump version to 0.0.51 [skip ci] 2026-08-28 14:20:18 +00:00
truewhile c30dab56a3 优化 2026-08-28 22:20:01 +08:00
github-actions[bot] e365250440 chore: bump version to 0.0.50 [skip ci] 2026-08-28 10:28:29 +00:00
truewhile 47d10e1f58 优化 2026-08-28 18:28:09 +08:00
github-actions[bot] e6473300a7 chore: bump version to 0.0.49 [skip ci] 2026-08-28 08:27:32 +00:00
truewhile 994f64f753 优化 2026-08-28 16:27:11 +08:00
github-actions[bot] 90064a5480 chore: bump version to 0.0.48 [skip ci] 2026-08-27 15:40:31 +00:00
truewhile 6e8eac9887 优化 2026-08-27 23:40:12 +08:00
github-actions[bot] d3051eaffe chore: bump version to 0.0.47 [skip ci] 2026-08-27 13:24:18 +00:00
truewhile 496a897782 优化 2026-08-27 21:24:01 +08:00
579 changed files with 22734 additions and 4721 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ verify-downloads/
.tmp_*
.tmp-deploy-*
.tmp-deploy-data/
.mmtl.pid
.mebox.pid
*.db
*.db-journal
*.db-shm
+2 -2
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Telegram MMTL 交流群
url: https://t.me/MMTL
- name: Telegram MeBox 交流群
url: https://t.me/MeBox
about: 适合快速交流部署经验、使用问题和排查线索。
+1 -1
View File
@@ -12,7 +12,7 @@ assignees: ""
## 期望方案
请描述你希望 MMTL 如何工作。
请描述你希望 MeBox 如何工作。
## 使用场景
+1 -1
View File
@@ -1,4 +1,4 @@
name: MMTL CodeQL
name: MeBox CodeQL
paths-ignore:
- internal/service/fileid_other.go
+52 -15
View File
@@ -4,9 +4,7 @@ name: AuTo Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
# 保留手动触发作为备选
workflow_dispatch:
inputs:
@@ -85,8 +83,8 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=MMTL-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=mmtl-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=mebox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
# 3. 更新 VERSION 文件
- name: Update version file
@@ -131,7 +129,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mmtl
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=latest
type=raw,value=${{ steps.bump_version.outputs.tag }}
@@ -194,7 +192,7 @@ jobs:
set -eux
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
gh release create "$RELEASE_TAG" \
--title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \
--title "MeBox ${{ needs.version-and-publish.outputs.new_version }}" \
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
--verify-tag --latest || true
@@ -240,16 +238,16 @@ jobs:
run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
-o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
mkdir -p package/mmtl
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
cp README.md package/mmtl/ 2>/dev/null || true
mkdir -p package/mebox
cp "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mebox/mebox${{ matrix.ext }}
cp README.md package/mebox/ 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl)
(cd package && zip -r "../mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mebox)
else
tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl
tar -czf "mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mebox
fi
- name: Upload to GitHub Release
env:
@@ -257,8 +255,8 @@ jobs:
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
run: |
set -eux
PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
TAR="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
if [ -f "$PKG" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
@@ -266,3 +264,42 @@ jobs:
if [ -f "$TAR" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
fi
deploy:
name: Deploy to Server
needs: [version-and-publish]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
# 判断 compose 命令版本兼容性(docker compose 或 docker-compose)
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
+133
View File
@@ -0,0 +1,133 @@
# Beta 分支自动构建流水线
#
# 触发:push 到 beta 分支 / PR 到 beta / 手动触发。
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
#
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
# 不打 release tag,只构建带 -beta 标识的产物供测试。
name: Beta Build
on:
push:
branches: [beta]
pull_request:
branches: [beta]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
BETA_VERSION_PREFIX: beta
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
# ─────────────────────────────────────────────────────────────────────────────
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
working-directory: web
run: |
npm ci
npm run build
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test ./...
- name: go build (host)
run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist)
- name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: mebox-beta-binaries
path: dist/*
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
needs: test-and-build
runs-on: ubuntu-latest
# PR 事件不推送镜像,仅 push beta / 手动触发时推送
if: github.event_name != 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+40 -2
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Image tag to publish, for example MMTL-v0.0.32'
description: 'Image tag to publish, for example MeBox-v0.0.32'
required: true
type: string
ref:
@@ -42,7 +42,7 @@ jobs:
uses: docker/metadata-action@v5
with:
# 自动使用当前仓库所有者
images: ghcr.io/${{ github.repository_owner }}/mmtl
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=latest
type=raw,value=${{ env.RELEASE_VERSION }}
@@ -61,3 +61,41 @@ jobs:
VERSION=${{ env.RELEASE_VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Server
needs: [docker]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
+1 -1
View File
@@ -32,7 +32,7 @@ logs/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mmtl.pid
.mebox.pid
*.log
*.db
*.db-journal
+5 -5
View File
@@ -1,6 +1,6 @@
# 贡献规范
感谢你愿意帮助 MMTL 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
感谢你愿意帮助 MeBox 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
## Issue 提交规范
@@ -16,7 +16,7 @@
- 期望行为:你认为正确结果应该是什么。
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MMTL 镜像版本。
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MeBox 镜像版本。
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
@@ -28,14 +28,14 @@ Docker 部署常用命令:
```bash
docker compose ps
docker compose logs --tail=300 mmtl
docker compose exec mmtl sh -lc 'ls -la /data/logs || true'
docker compose logs --tail=300 mebox
docker compose exec mebox sh -lc 'ls -la /data/logs || true'
```
PostgreSQL 部署查询示例:
```bash
docker compose exec postgres psql -U mmtl -d mmtl -c "select key,value,updated_at from settings order by updated_at desc limit 30;"
docker compose exec postgres psql -U mebox -d mebox -c "select key,value,updated_at from settings order by updated_at desc limit 30;"
```
请勿公开粘贴以下敏感信息:
+12 -12
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.6
# =============================================================================
# Multi-architecture build for MMTL.
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
@@ -8,7 +8,7 @@
#
# Build:
# docker buildx build --platform linux/amd64,linux/arm64 \
# --build-arg VERSION=MMTL-v0.1.16 -t mmtl:latest --push .
# --build-arg VERSION=MeBox-v0.1.16 -t mebox:latest --push .
#
# Optional Intel VAAPI/QSV runtime packages:
# docker buildx build --build-arg WITH_VAAPI=true ...
@@ -39,7 +39,7 @@ COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mmtl ./cmd/server
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mebox ./cmd/server
# ---- Stage 3: runtime ------------------------------------------------------
FROM alpine:3.23
@@ -64,22 +64,22 @@ RUN apk add --no-cache \
&& rm -rf /var/cache/apk/*
# Non-root user for the long-running process.
RUN addgroup -S mmtl && adduser -S mmtl -G mmtl
RUN addgroup -S mebox && adduser -S mebox -G mebox
WORKDIR /app
COPY --from=backend /app/mmtl /usr/local/bin/mmtl
COPY --from=backend /app/mebox /usr/local/bin/mebox
COPY --from=frontend /app/web/dist /app/web/dist
RUN mkdir -p /data /cache /media \
&& chown -R mmtl:mmtl /data /cache /media
&& chown -R mebox:mebox /data /cache /media
# Default environment (overridable via docker-compose / `docker run -e`).
ENV MMTL_APP_PORT=8080 \
MMTL_APP_DATA_DIR=/data \
MMTL_APP_WEB_DIR=/app/web/dist \
MMTL_DATABASE_DB_PATH=/data/mmtl.db \
MMTL_CACHE_CACHE_DIR=/cache \
MMTL_LOGGING_LEVEL=info \
ENV MEBOX_APP_PORT=8080 \
MEBOX_APP_DATA_DIR=/data \
MEBOX_APP_WEB_DIR=/app/web/dist \
MEBOX_DATABASE_DB_PATH=/data/mebox.db \
MEBOX_CACHE_CACHE_DIR=/cache \
MEBOX_LOGGING_LEVEL=info \
TZ=Asia/Shanghai
EXPOSE 8080
+141 -318
View File
@@ -1,23 +1,23 @@
# MMTL (My Movie and TV Library)
# MeBox
<p align="center">
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MeBox Logo" />
</p>
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
<p align="center">
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
</p>
<p align="center">
<a href="#项目简介">项目简介</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#三挡部署">三挡部署</a> ·
<a href="#路径映射">路径映射</a> ·
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
<a href="#部署档位">部署档位</a> ·
<a href="#鸣谢">鸣谢</a> ·
<a href="#开发构建">开发构建</a> ·
<a href="CONTRIBUTING.md">贡献规范</a> ·
<a href="https://mgo.3jzs.com">在线演示</a>
<a href="README_EN.md">English</a> ·
<a href="CONTRIBUTING.md">贡献规范</a>
</p>
<p align="center">
@@ -31,400 +31,223 @@
## 项目简介
MMTL 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
**MeBox** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
核心能力:
你可以把 MeBox 理解为:
- 一个带现代 Web UI 的**媒体库后台**
- 一个兼容 Emby/Jellyfin 客户端的**协议网关**
- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口**
### 核心能力
| 模块 | 说明 |
| --- | --- |
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
### 技术栈
- **后端**:Go · Gin · GORM · SQLite / PostgreSQL · 可选 Redis · 可选 OpenSearch
- **前端**:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- **部署**:Docker Compose 多档模板,支持 amd64 / arm64 镜像与单文件可执行发布
---
- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。
- **Emby 协议兼容**:Infuse、VidHub、SenPlayer、Fileball 等客户端可按 Emby/Jellyfin 方式添加服务器。
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
- **灵活部署**:单镜像 SQLite 一键起步,或按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
## 快速开始
最推荐使用 Docker Compose。仓库提供四份独立完整模板,全部不依赖 `.env`。想最省心就下载单镜像档(SQLite,只有一个镜像);只需要按需修改访问端口、媒体目录、下载目录和可选硬件设备。需要多用户/高并发再选第一档起的 PostgreSQL 档位。
推荐使用 Docker Compose。仓库提供四份**互相独立**的完整模板,无需 `.env` 即可起步。
```bash
mkdir -p MMTL
cd MMTL
# 最省心:单镜像 + SQLite,只启动一个容器
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
# 或第一档:PostgreSQL(多用户/高并发更稳)
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
mkdir -p MeBox && cd MeBox
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# 或多用户场景:PostgreSQL 第一档
# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
启动后访问:
浏览器访问:
```text
http://服务器IP:18080
```
默认账号:
```text
admin / admin123
```
首次登录后请立刻修改管理员密码。
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
镜像地址:
```text
GHCR:ghcr.io/truewhile/mmtl:latest
ghcr.io/truewhile/mebox:latest
```
---
## 部署档位
MMTL 推荐按机器资源和用户规模选择部署档位。每份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。想一个镜像跑起来就选单镜像档(SQLite);需要多用户 / 高并发时再用 PostgreSQL 三档。Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
按机器资源选择档位。每份 Compose 文件均可单独使用,**不要**叠加多个 `-f`。
| 档位 | 完整配置文件 | 组件 | 适合场景 |
| 档位 | 配置文件 | 组件 | 适合场景 |
| --- | --- | --- | --- |
| 单镜像档 | `docker-compose.simple.yml` | MMTL + 内置 SQLite | 新手、单人使用、只想一个镜像跑起来的低配机器 |
| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 |
| 第二档 | `docker-compose.standard.yml` | MMTL + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 |
| 第三档 | `docker-compose.search.yml` | MMTL + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
| 单镜像档 | `docker-compose.simple.yml` | MeBox + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
| 第一档 | `docker-compose.yml` | MeBox + PostgreSQL | 大多数家庭 NAS,多用户更稳 |
| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 |
| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) |
### 单镜像档:SQLite(最省心)
### 单镜像档要点
只启动 MMTL 一个镜像,主数据库用内置 SQLite,不需要 PostgreSQL / Redis / `.env`。变量最少、资源占用最低,适合新手和单人使用。日后需要多用户或更高并发时,保留 `./data` 后切换到第一档的 PostgreSQL 即可。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
docker compose up -d
```
第一次部署通常只需要改 `docker-compose.yml` 里的这几处:
- 只启动 **一个** MeBox 容器,数据在 `./data/mebox.db`
- 通常只需改端口与媒体目录挂载
- **不要**设置 `MEBOX_DATABASE_DSN`,否则会切到 PostgreSQL
```yaml
ports:
- "18080:8080" # 改左边 18080 即可
- "18080:8080"
volumes:
- ./data:/data # 必须备份
- ./media:/media # 改左边为你的媒体目录,例如 /vol1/1000/Media:/media
# - /dev/dri:/dev/dri # Intel 核显硬解需要时取消注释
- ./cache:/cache # 可重建
- ./media:/media # 改成你的媒体目录
```
网页后台添加媒体库时填写容器内路径:
网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。
```text
/media
/media/电影
/media/电视剧
```
### PostgreSQL 档位要点
关键数据目录:
```text
./data JWT 密钥、运行配置、SQLite 主数据库(mmtl.db)——必须备份
./cache 海报/临时缓存,可重建
./media 媒体库
```
> 单镜像模式请不要配置 `MMTL_DATABASE_DSN`;一旦填了 DSN 就会切回 PostgreSQL。
### 第一档:PostgreSQL
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
关键数据目录:
```text
./postgres PostgreSQL 主数据库,必须备份
./data JWT 密钥、运行配置、旧 SQLite 迁移源
./cache 海报、临时文件、转码缓存,可删除重建
```
### 第二档:PostgreSQL + Redis
第二档是独立完整文件,包含第一档全部配置并额外启用 Redis。Redis 用作热缓存,能减轻多用户和 Emby 客户端频繁刷新时的数据库压力。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.standard.yml -o docker-compose.yml
docker compose up -d
```
Redis 数据目录是 `./redis`。它主要保存缓存,通常可重建;真正需要备份的仍然是 `./postgres` 和 `./data`。
### 第三档:PostgreSQL + Redis + OpenSearch
第三档是独立完整文件,包含第二档全部配置并额外启用 OpenSearch,用于大库全文搜索和独立搜索索引。OpenSearch 常驻内存明显更高,低配 NAS 不建议开启。
```bash
mkdir -p MMTL
cd MMTL
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.search.yml -o docker-compose.yml
docker compose up -d
```
OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大库索引会花时间;机器资源足够时再开启第三档。
## 配置示例
仓库内提供四份推荐 Compose 文件:
```text
docker-compose.simple.yml 单镜像档:MMTL + 内置 SQLite
docker-compose.yml 第一档:MMTL + PostgreSQL
docker-compose.standard.yml 第二档:MMTL + PostgreSQL + Redis
docker-compose.search.yml 第三档:MMTL + PostgreSQL + Redis + OpenSearch
```
仓库只保留面向用户部署和项目维护的必要文件。旧的本地部署脚本、发包脚本、开发机辅助脚本、`.env` 示例和旧高级 Compose 模板已经移除;Linux / Docker 用户按上面四个 Compose 文件部署即可。开发者本地生成的 `bin/`、`data/`、`cache/`、`logs/`、`.tmp/`、`tools/` 等目录已列入 `.gitignore`,不应提交到仓库。
如果直接下载为 `docker-compose.yml`,启动命令统一是:
```bash
docker compose up -d
```
如果保留原始文件名,也可以这样启动:
```bash
docker compose -f docker-compose.simple.yml up -d
docker compose -f docker-compose.standard.yml up -d
docker compose -f docker-compose.search.yml up -d
```
常用配置片段如下,注释保留为中文,方便直接复制到 NAS 上调整:
- 主库在 `./postgres`,配置与密钥在 `./data`
- 若存在旧版 `./data/mebox.db`,首次启动会自动迁移到 PostgreSQL
- 迁移完成后可将 `MEBOX_DATABASE_DB_PATH` 改为不存在路径,避免重复检查:
```yaml
services:
mmtl:
image: ghcr.io/truewhile/MMTL:latest
ports:
# 左边是宿主机访问端口,右边是容器内端口。
- "18080:8080"
volumes:
# 运行数据:JWT 密钥、配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报、临时文件、转码缓存,可删除重建。
- ./cache:/cache
# 媒体库目录:自动整理/重命名/入库需要写权限。
- /vol1/1000/Media:/media
environment:
TZ: Asia/Shanghai
# PostgreSQL 主数据库。
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# 旧 SQLite 迁移源:只在从旧版 data/mmtl.db 导入时使用。
MMTL_DATABASE_DB_PATH: /data/mmtl.db
# 路径换算:宿主机路径和容器路径必须一一对应。
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
### 必须备份与可重建
| 路径 | 说明 |
| --- | --- |
| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 |
| `./postgres` | PostgreSQL 主库(PG 档位) |
| `./cache` | 海报/转码缓存,可重建 |
| `./redis` | 热缓存,可重建 |
| `./opensearch` | 搜索索引,可重建 |
### 更新镜像
```bash
docker compose pull mebox
docker compose up -d --no-deps mebox
```
日常更新只拉 `mebox` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。
---
## 路径映射
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
Docker 部署最常见的问题是路径填错。记住:
- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径
- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`)
- 若使用自动整理/下载入库,`MEBOX_MEDIA_DIR` 与 `MEBOX_DOWNLOAD_DIR` 需与挂载一致
NAS 示例:
```yaml
volumes:
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
MMTL_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media
MMTL_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/downloads
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
```
Windows Docker Desktop 示例:
---
```yaml
volumes:
- D:/Media:/media
environment:
MMTL_MEDIA_DIR: D:/Media
MMTL_MEDIA_CONTAINER_DIR: /media
```
## 首次使用建议
如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
## 旧 SQLite 迁移
新版推荐 PostgreSQL 作为主数据库。`MMTL_DATABASE_DB_PATH` 不是主库路径,而是旧 SQLite 数据的迁移源。
迁移步骤:
1. 把旧版 `mmtl.db` 放到 `./data/mmtl.db`。
2. 保持 `MMTL_DATABASE_DB_PATH: /data/mmtl.db`。
3. 启动一次,确认日志显示迁移完成,网页数据正常。
4. 备份 `./postgres` 和 `./data`。
5. 确认不再需要 SQLite 后,把迁移源改成不存在的路径,例如:
```yaml
environment:
# 已完成 SQLite 迁移后,建议改成不存在的路径,避免下次启动重复检查旧库。
MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。
## 日志与 STRM 路径
Compose 模板默认把完整应用日志写入 `./data/logs/app.log`,同时拆分 `./data/logs/warn.log` 和 `./data/logs/error.log`。Docker 自身日志也会保留 10 个 50MB 文件:
```bash
docker compose logs -f mmtl
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
```
如果要排查订阅、站点搜索、自动整理或 STRM 生成问题,保持 `MMTL_LOGGING_LEVEL: info`;需要更细日志时临时改成 `debug`,确认后再改回 `info`。
STRM 输出目录请使用容器内可写路径,例如 `/data/strm`,或你已经挂载进容器的媒体目录。旧版本保存过 `/app/data/strm` 的部署会在生成时自动迁移到当前 `MMTL_APP_DATA_DIR`,默认就是 `/data`。
## 更新与备份
更新镜像:
```bash
docker compose pull mmtl
docker compose up -d --no-deps mmtl
```
不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis / OpenSearch 是数据与缓存基础组件,compose 已设置为 `pull_policy: missing`,首次部署缺镜像时会拉取,日常更新只建议拉取 `mmtl`。需要升级这些基础组件时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。
如果第二档或第三档保留了原始文件名,更新时指定对应完整文件:
```bash
# 第二档
docker compose -f docker-compose.standard.yml pull mmtl
docker compose -f docker-compose.standard.yml up -d --no-deps mmtl
# 第三档
docker compose -f docker-compose.search.yml pull mmtl
docker compose -f docker-compose.search.yml up -d --no-deps mmtl
```
必须备份:
```text
./postgres PostgreSQL 主数据库
./data JWT 密钥、运行配置、旧 SQLite 迁移源
```
可重建:
```text
./cache 图片缓存、临时文件、转码缓存
./redis Redis 热缓存
./opensearch 搜索索引
```
---
## 常见问题
**启动后还是反复迁移 SQLite?**
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
确认旧数据已经迁移成功后,把 `MMTL_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
**qBittorrent 下载后无法整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
**扫库或入库速度很慢?**
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
**qBittorrent 下载完成后无法整理?**
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MMTL 容器,并且 `MMTL_DOWNLOAD_DIR` 与 `MMTL_DOWNLOAD_CONTAINER_DIR` 对应正确。
**硬链接目录在 Docker / NAS 上看不到内容?**
硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。
**第三方播放器无法连接?**
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MMTL 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
---
## 开发构建
本地开发需要 Go、Node.js 和 npm。
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
```bash
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
npm --prefix web ci
npm --prefix web run build
# 后端测试
go test ./...
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
go run ./cmd/server
# 本地运行前端开发服务器
npm --prefix web run dev
go run ./cmd/server # http://127.0.0.1:8080
npm --prefix web run dev # http://127.0.0.1:3000
```
前端开发服务器默认访问:
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
```text
http://127.0.0.1:3000
```
---
后端健康检查:
## 鸣谢
```text
http://127.0.0.1:8080/api/health
```
MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。
### 交叉编译单文件发布物
项目中许多网盘同步、STRM 与媒体整理相关的设计与实现,也参考了 [qmediasync](https://github.com/qicfan/qmediasync)。感谢该项目的思路与实践经验。
CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。
本地手动交叉编译某个平台:
```bash
# 先构建前端
npm --prefix web ci && npm --prefix web run build
# 例如:构建 Linux amd64 单文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server
```
---
## 贡献与反馈
提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。
提交 Issue 或 Pull Request 前,请阅读 [贡献规范](CONTRIBUTING.md) 与 [安全策略](SECURITY.md)。
- Bug 反馈请使用 Issue 模板,并提供部署方式、复现步骤、日志和关键配置。
- 功能建议请说明使用场景、期望行为和可接受的替代方案。
- 安全漏洞请不要公开发 Issue,按 [安全策略](SECURITY.md) 使用私密渠道报告。
- Pull Request 请从独立分支或 fork 分支发起,不要直接向 `main` 推送。
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`,例如 `docs/contribution-guidelines`。
- 提交前按改动范围运行 `go test ./...`、`npm --prefix web run build` 或定向测试,并在 PR 中说明验证结果。
- Bug 请附部署方式、复现步骤与相关日志
- 功能建议请说明使用场景与期望行为
- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build`
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=truewhile%2FMeBox&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
</picture>
</a>
---
## 许可证
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
本项目采用 [GPL-3.0](LICENSE) 许可证。
+120 -476
View File
@@ -1,21 +1,22 @@
# MMTL (My Movie and TV Library)
# MeBox
<p align="center">
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MeBox Logo" />
</p>
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
<p align="center">
<strong>Docker-first setup · Multi-user management · Media library · Metadata · Downloads · Emby-protocol clients · Cloud playback</strong>
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
</p>
<p align="center">
<a href="README.md">中文</a> ·
<a href="#overview">Overview</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#docker-compose-recommended">Docker Compose</a> ·
<a href="#faq">FAQ</a> ·
<a href="https://mgo.3jzs.com">Live Demo</a>
<a href="#deployment-tiers">Deployment</a> ·
<a href="#acknowledgements">Acknowledgements</a> ·
<a href="#development">Development</a>
</p>
<p align="center">
@@ -27,78 +28,51 @@
---
## What is it?
## Overview
MMTL is a self-hosted media center for personal libraries, home NAS, and home-theater users.
**MeBox** is a self-hosted private media management system for NAS, mini PCs, family sharing, and multi-device playback. This repository is a maintained fork of [MediaStationGo](https://github.com/ShukeBta/MediaStationGo), extended with stronger cloud playback, task queues, remote mounts, and permission controls.
It helps you:
In practice, MeBox gives you:
- Manage movies, TV shows, anime, variety shows, music, and adult libraries.
- Create multiple user accounts for family members, friends, or different devices.
- Scan files and enrich posters, summaries, years, seasons, and episodes.
- Play in the web UI, or log in with a MMTL account from Emby-protocol apps such as Infuse, VidHub, SenPlayer, and Emby clients.
- Connect qBittorrent for search, subscriptions, downloads, and post-download organization.
- Connect OpenList, CloudDrive2, WebDAV, and other storage backends with STRMURL or 302 redirect playback.
- Run on NAS, mini PCs, VPS, Linux, Windows Docker Desktop, or any Docker-friendly host.
- A modern **web media library**
- An **Emby/Jellyfin-compatible protocol gateway** for third-party players
- A single panel for **local disks, download folders, and cloud storage**
> The project is moving fast. With the default PostgreSQL deployment, back up both `data/` and `postgres/`.
### Key capabilities
---
| Area | Highlights |
| --- | --- |
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
| **Emby protocol** | Add MeBox in Infuse, SenPlayer, Fileball, etc. and sign in with MeBox accounts |
| **Remote Emby mounts** | Browse remote Emby libraries inside MeBox without a separate Emby client |
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs |
## Key Highlights
### Tech stack
- **One server, many clients**: deploy MMTL once; you do not need to run a separate Emby server.
- **Emby-protocol compatibility**: add the server in third-party players as an Emby/Jellyfin-compatible server, then log in with your MMTL username and password.
- **Multi-user management**: supports admins, regular users, account enable/disable, expiry dates, device management, Bot registration, and redeem codes.
- **Local + cloud media in one place**: manage local disks, download folders, OpenList, CloudDrive2, WebDAV, and other storage backends from one panel.
- **Download-to-library workflow**: connect qBittorrent for search, subscriptions, download completion organization, and metadata matching.
- **NAS-friendly**: simple Docker Compose deployment. The primary database lives under `postgres/`, while runtime secrets and files live under `data/`.
---
## Who is it for?
- **Beginners** who want to edit one `docker-compose.yml` and start the service.
- **NAS users** who want a low-resource media center for local disks and cloud storage.
- **PT/download users** who want downloads, organization, metadata, and playback in one panel.
- **External-player users** who want to log in to Emby-protocol third-party apps with one MMTL account.
- **Family-sharing users** who want separate user accounts without deploying a separate media server for each person.
- **Developers** who want to study or extend a Go + React self-hosted media app.
---
## Live Demo
- URL: [https://mgo.3jzs.com](https://mgo.3jzs.com)
- Username: `admin`
- Password: `admin123`
> The demo is for feature preview only. Do not save private API keys, tracker cookies, or personal data there.
- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand
- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases
---
## Quick Start
Docker Compose is the recommended path. Beginners do not need `.env`, bare-metal binaries, or source builds. Use the single-image SQLite template if you want the smallest possible setup.
Docker Compose is the recommended path. The repo ships four **standalone** templates; no `.env` is required.
```bash
mkdir -p MMTL
cd MMTL
# Simplest option: one MMTL container + SQLite
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
# Or tier 1: MMTL + PostgreSQL
# curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.yml -o docker-compose.yml
```
mkdir -p MeBox && cd MeBox
Edit `docker-compose.yml`:
# Simplest: one container with built-in SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
```bash
vi docker-compose.yml
```
# Or PostgreSQL tier for multi-user setups
# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml
Start:
```bash
docker compose up -d
```
@@ -108,478 +82,148 @@ Open:
http://SERVER_IP:18080
```
Default login:
Default login: `admin` / `admin123` — change the password immediately.
Image:
```text
Username: admin
Password: admin123
ghcr.io/truewhile/mebox:latest
```
---
## Docker Compose Recommended
## Deployment tiers
The repository `docker-compose.yml` is the lightweight recommended template: no `.env` required, and by default it only starts `MMTL + PostgreSQL`. This is the best starting point for most NAS users.
Pick one compose file. Do **not** stack multiple `-f` files.
If you already have an older `./data/mmtl.db`, the first start with the new compose file automatically imports it into PostgreSQL. Keep `./data`; it still stores the JWT secret, runtime data, and the old SQLite migration source.
| Tier | File | Stack | Best for |
| --- | --- | --- | --- |
| Single image | `docker-compose.simple.yml` | MeBox + SQLite | Beginners, single-user, low-resource NAS |
| Tier 1 | `docker-compose.yml` | MeBox + PostgreSQL | Most home NAS deployments |
| Tier 2 | `docker-compose.standard.yml` | + Redis | Multi-user, frequent Emby client refreshes |
| Tier 3 | `docker-compose.search.yml` | + OpenSearch | Very large libraries, advanced full-text search |
### Deployment modes
### Single-image notes
| Mode | Command | Best for |
| --- | --- | --- |
| Single image: SQLite | `docker compose -f docker-compose.simple.yml up -d` | Beginners and single-user setups that want one image only, no PostgreSQL/Redis |
| Lightweight: PG only | `docker compose up -d` | Most NAS devices, lowest resource use |
| Standard: PG + Redis | `docker compose -f docker-compose.standard.yml up -d` | Multi-user use and frequent Emby client refreshes |
| Search enhanced: PG + Redis + OpenSearch | `docker compose -f docker-compose.search.yml up -d` | Huge libraries and future standalone search indexing |
- Only one MeBox container; database lives in `./data/mebox.db`
- Do **not** set `MEBOX_DATABASE_DSN` or it switches to PostgreSQL
- Back up `./data`; `./cache` can be rebuilt
Each compose file is standalone. Do not stack multiple `-f` files together.
### PostgreSQL notes
The single-image `docker-compose.simple.yml` runs only MMTL with a built-in SQLite database — the simplest starting point. Do not set `MMTL_DATABASE_DSN` there, or it switches back to PostgreSQL. Move up to the PostgreSQL modes for multi-user or high-concurrency use (keep `./data` when you switch). Redis and OpenSearch are enhancement layers, not source databases. Do not enable OpenSearch by default on low-memory NAS devices.
- Primary DB: `./postgres`; secrets and runtime files: `./data`
- Existing `./data/mebox.db` migrates automatically on first start
- After migration, point `MEBOX_DATABASE_DB_PATH` at a non-existent file to disable re-checks
### Database Choice And Disabling SQLite
### Backup
The current Docker Compose setup uses PostgreSQL by default. SQLite is no longer the primary database in the recommended Docker deployment. The runtime database is controlled by:
```yaml
environment:
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
```
`MMTL_DATABASE_DB_PATH` is only used as a one-time migration source for old SQLite data:
- Fresh installs: `docker compose up -d` uses PostgreSQL and does not create a new SQLite primary database.
- Upgrades: if `./data/mmtl.db` exists, the first start with the new compose file imports it into PostgreSQL.
- Migration fills missing rows by primary key and skips rows that already exist. If it fails partway through, a later start continues the remaining tables.
- After a successful import, PostgreSQL gets a completion marker in the `settings` table, so the old SQLite file is not imported again.
- Redis is a hot cache and OpenSearch is a search index; neither is a source database.
Recommended SQLite to PostgreSQL upgrade flow:
```bash
docker compose pull mmtl
docker compose up -d --no-deps mmtl
docker compose logs -f mmtl
```
After you see `sqlite data migrated to postgres`, or after the web UI shows your users, libraries, and settings correctly, you can stop using the old SQLite file as a migration source.
To make the deployment PostgreSQL-only after migration, keep PostgreSQL selected and point the old SQLite migration path at a non-existent file:
> Only do this after the web UI confirms that users, libraries, settings, and media rows are already present in PostgreSQL.
```yaml
environment:
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/disabled-sqlite-migration.db
```
Then rename or move the old host-side SQLite file as an offline backup:
```bash
mv data/mmtl.db data/mmtl.sqlite.bak
```
For bare-metal or custom `config.yaml` deployments, use the same idea:
```yaml
database:
type: postgres
dsn: postgres://mmtl:mmtl@127.0.0.1:5432/mmtl?sslmode=disable
db_path: ""
```
Do not delete `./postgres`. After migration, it is the real primary database. Keep `./data` too, because it stores the JWT secret and runtime files.
### Choose an image source
Both image sources are supported. Pick one and put it in `image:`:
| Source | Image | Best for |
| --- | --- | --- |
| GitHub Container Registry (GHCR) | `ghcr.io/shukebta/mmtl:latest` | Recommended default, follows repository releases |
| Docker Hub | `shukbet/mmtl:latest` | Backup source when GHCR is slow or unavailable |
To pin a version, first confirm the tag exists on the repository Packages page. Use this format:
```yaml
image: ghcr.io/shukebta/mmtl:<version-tag>
# If GHCR does not have that tag, use Docker Hub as the backup:
# image: shukbet/mmtl:MMTL-v0.0.72
```
For the simplest setup, keep GHCR `latest`.
Manual pull examples:
```bash
# GitHub Container Registry
docker pull ghcr.io/shukebta/mmtl:latest
# Docker Hub backup
docker pull shukbet/mmtl:latest
```
Focus on this part:
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- ./media:/media
- ./downloads:/downloads
```
Meaning:
| Host path | Container path | Purpose |
| --- | --- | --- |
| `./data` | app `/data` | Settings, JWT secret, old SQLite migration source; the primary DB is under `./postgres` |
| `./cache` | app `/cache` | Cache; safe to clean when needed |
| `./media` | `/media` | Media libraries; use `/media/...` in the web UI |
| `./downloads` | `/downloads` | Download directory and organization source |
| `./postgres` | PostgreSQL `/var/lib/postgresql/data` | New default primary database; back this up |
| `./redis` | Redis `/data` | Used only in standard mode; hot cache, rebuildable |
| `./opensearch` | OpenSearch `/usr/share/opensearch/data` | Used only in search-enhanced mode; higher memory use |
If your NAS paths are:
```text
/vol1/1000/Media
/vol1/1000/Downloads
```
change the compose file to:
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MMTL_MEDIA_DIR: /vol1/1000/Media
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
```
Rules:
- The left side of `volumes` is the real path on your host/NAS.
- The right side is the container path. Keep `/media` and `/downloads` unless you know why you are changing them.
- In the web UI, create libraries with container paths such as `/media/Movies` or `/media/TV`.
- Do not write NAS absolute paths as `./vol1/...`; `./` means a folder under the current compose directory.
- On Windows Docker Desktop, paths like `D:/Media:/media` and `D:/Downloads:/downloads` are fine.
- If you only scan/play existing media and never organize into the library, you may add `:ro`; if you use organize/rename/ingest, the media mount must stay writable.
### Minimal compose example
The root `docker-compose.yml` follows this style:
```yaml
services:
mmtl:
# Pick one image source:
# GitHub Container Registry (GHCR):
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub backup:
# image: shukbet/mmtl:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
# Browser: http://SERVER_IP:18080
ports:
- "18080:8080"
# Let the container reach qBittorrent running on the host:
# qB URL example: http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# Application data. Back this up before upgrades.
- ./data:/data
- ./cache:/cache
# Beginners can create ./media and ./downloads.
# NAS users should replace source with real absolute paths.
# create_host_path=false prevents Docker from silently creating an empty
# folder when the host path is wrong.
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
# Lightweight mode uses PostgreSQL by default.
# Old SQLite data migrates from this path on first start.
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# After migration, change this to /data/disabled-sqlite-migration.db to disable the SQLite migration source.
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
# Use /media and /downloads in the web UI and downloader by default.
# Only set MMTL_*_DIR to real host paths when migrating old
# libraries/tasks that already stored host paths.
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
interval: 10s
timeout: 5s
retries: 10
```
> Note: PostgreSQL is the primary database. Lightweight mode still has short in-process caching. Redis is a cross-process hot cache, and OpenSearch is a search enhancement layer; neither is a source database.
---
## First-time Setup
1. **Create a library**
- Go to the library page.
- Use a container path such as `/media/Movies`.
- Start a scan.
2. **Connect qBittorrent**
- Go to download client settings.
- If qBittorrent runs on the host, try `http://host.docker.internal:8085`.
3. **Configure metadata providers**
- Go to system settings / external APIs.
- Add TMDb, Bangumi, TheTVDB, Fanart, Douban, or other providers when needed.
4. **Use external players**
- Add the server as an Emby/Jellyfin-compatible server.
- Server URL: `http://SERVER_IP:18080`.
- Use the username and password created in MMTL. No separate Emby server is required.
- Admins can create regular users in the web UI or Bot so each person can log in with their own account.
5. **Use cloud playback**
- Configure OpenList, CloudDrive2, WebDAV, or another provider in storage settings.
- Choose STRMURL or 302 redirect playback in the admin settings.
- The enabled option takes priority. If both are disabled, playback falls back to the normal server playback path.
---
## Update, Backup, Logs
| Path | Notes |
| --- | --- |
| `./data` | JWT secret, config, SQLite DB or migration source |
| `./postgres` | PostgreSQL primary DB |
| `./cache`, `./redis`, `./opensearch` | Rebuildable |
### Update
```bash
docker compose pull mmtl
docker compose up -d --no-deps mmtl
docker compose pull mebox
docker compose up -d --no-deps mebox
```
### Logs
---
```bash
docker compose logs -f mmtl
tail -f ./data/logs/app.log
tail -f ./data/logs/error.log
## Path mapping
The most common Docker mistake is mixing host paths with container paths.
- Left side of `volumes` = real host/NAS path
- Right side = container path; use `/media/...` in the web UI
- Keep `MEBOX_MEDIA_DIR` / `MEBOX_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads
Example:
```yaml
volumes:
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
```
The compose templates keep full application logs in `./data/logs/app.log` and split warnings/errors into `warn.log` and `error.log`. Keep `MMTL_LOGGING_LEVEL=info` while diagnosing subscription, site search, organizer, or STRM generation issues; temporarily switch to `debug` only when deeper tracing is needed.
---
Use a writable container path for STRM output, such as `/data/strm` or a mounted media path. Deployments that previously saved `/app/data/strm` are migrated automatically to the configured `MMTL_APP_DATA_DIR`, which defaults to `/data`.
## First-time setup
### Backup
For the default PostgreSQL deployment, back up:
```text
data/
postgres/
```
`postgres/` is the primary database and contains users, libraries, settings, and media metadata. `data/` stores the JWT secret, runtime files, and optional old SQLite migration source.
If you enabled the extended modes, these are optional:
```text
redis/ # hot cache, safe to rebuild
opensearch/ # search index, rebuildable; backing it up can save reindex time on huge libraries
```
`cache/` is usually not important. If you explicitly still use `database.type=sqlite`, the primary database remains `data/mmtl.db`.
### Stop
```bash
docker compose down
```
1. Create a library with a container path such as `/media/Movies`, then scan
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
4. Optionally configure cloud accounts under STRM management
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials
---
## FAQ
### 1. The web page does not open
**Library scan is slow**
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
Check the container:
**qBittorrent downloads are not organized**
Ensure the download directory is mounted into the container and env vars match.
```bash
docker ps
docker compose logs --tail=100 mmtl
```
**Hardlink fails with cross-device link**
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
Then open:
```text
http://SERVER_IP:18080
```
### 2. The library cannot find files
Most cases are path mistakes.
- Docker maps media to `/media`.
- In the web UI, use `/media/Movies`, not the original NAS path.
- Docker maps downloads to `/downloads`; use `/downloads` as the organization source when possible.
### 3. qBittorrent cannot connect
If qBittorrent is on the host, try:
```text
http://host.docker.internal:8085
```
If qBittorrent is on another machine, use that machine's LAN IP.
### 4. NAS CPU usage is high
Suggested settings:
- Set `ffprobe.max_concurrent` to `1`.
- Enable automatic organization, scrape-after-scan, and boot cloud scan only when you really need them.
- Avoid frequent full-library scans on large libraries. Prefer manual scan or scheduled night sync.
### 5. Should I use `.env`?
Beginners should not. Editing `docker-compose.yml` directly is easier to understand.
`.env` is not required by the provided deployment templates. For the single-image template, edit `docker-compose.simple.yml` directly and only adjust the port, volume paths, and optional hardware device mapping.
**External player cannot connect**
Use `http://IP:18080` and a MeBox user account; reverse proxies need correct external URL and HTTPS headers.
---
## Features
| Area | Features |
| --- | --- |
| Libraries | Movies, TV shows, anime, variety, music, adult content |
| Metadata | NFO, local artwork, TMDb, TheTVDB, Bangumi, Douban, Fanart, JavBus/JavDB |
| Playback | Web playback, HTTP Range, HLS transcoding, direct links, STRMURL, 302 redirect |
| External clients | Emby-protocol compatible APIs; MMTL accounts can log in to third-party players |
| User management | Multi-user accounts, admin/regular users, expiry dates, device management, Bot registration and redeem codes |
| Downloads | qBittorrent, site search, subscriptions, post-download organization |
| File manager | Browse, organize, copy, move, hardlink, symlink |
| Operations | Task queue, recycle bin, duplicate files, notifications, logs |
| AI | OpenAI-compatible API, AI search, recommendations, assistant |
Directory hardlinks are handled by recreating the directory tree and hardlinking
each contained file. Linux cannot hardlink a directory itself. Hardlinks still
require the source and target files to be on the same filesystem/subvolume from
inside the container; if media and downloads are separate bind mounts, disks,
btrfs subvolumes, or cloud mounts, use copy or symlink instead.
## Development
Regular users should use Docker. Developers can run:
The backend embeds `web/dist` via `go:embed`. Build the frontend first.
```bash
go run ./cmd/server
```
npm --prefix web ci
npm --prefix web run build
Frontend:
```bash
cd web
npm install
npm run dev
```
Tests:
```bash
go test ./...
cd web && npm run build
go run ./cmd/server
npm --prefix web run dev
```
---
## Community and Friends
- Telegram group: <https://t.me/MMTL>
- NodeSeek: [https://www.nodeseek.com/](https://www.nodeseek.com/)
- LINUX DO: [https://linux.do/](https://linux.do/)
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
---
## Donation
## Acknowledgements
If MMTL saves you time, feel free to buy the author a bowl of noodles.
MeBox is forked from and continues to evolve [MediaStationGo](https://github.com/ShukeBta/MediaStationGo). Thank you to the upstream project for the media-library architecture, Emby-protocol compatibility, and self-hosted foundation.
<img width="200" height="200" alt="WeChat Donation QR" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
Many cloud sync, STRM, and media-organization ideas in this project were also informed by [qmediasync](https://github.com/qicfan/qmediasync). Thank you for the reference implementation and design patterns.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening issues or pull requests.
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=truewhile%2FMeBox&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
</picture>
</a>
---
## License and Non-Commercial Statement
## License
This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE).
The maintainers also state and request:
- The project is intended for personal learning, home NAS, self-hosted media, non-commercial research, and community collaboration.
- Without explicit written permission from the author, do not use this project or derivative versions for commercial resale, paid hosting, paid SaaS, pre-installed commercial devices, closed-source redistribution, or other profit-oriented commercial use.
- For commercial cooperation, enterprise deployment, custom development, integrated redistribution, or commercial authorization, contact the author first.
- If there is any interpretive difference between this README and the formal `GPL-3.0` license text, the code license is governed by [LICENSE](LICENSE); commercial usage should additionally obtain author permission.
---
<p align="center">Made with ❤️ by ShukeBta</p>
This project is licensed under [GPL-3.0](LICENSE).
+5 -5
View File
@@ -1,13 +1,13 @@
# 安全策略
MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
MeBox 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
## 支持范围
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
- 最新发布镜像:`ghcr.io/truewhile/mebox:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
@@ -16,7 +16,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
<https://github.com/ShukeBta/MMTL/security/advisories/new>
<https://github.com/truewhile/MeBox/security/advisories/new>
如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。
@@ -68,14 +68,14 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
## 自托管安全基线
部署 MMTL 时建议:
部署 MeBox 时建议:
- 首次登录后立即修改默认 `admin / admin123`。
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
- Docker `volumes` 只挂载 MMTL 需要访问的目录,媒体库目录需要写入时再授予写权限。
- Docker `volumes` 只挂载 MeBox 需要访问的目录,媒体库目录需要写入时再授予写权限。
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
## 安全修复 PR
+1 -1
View File
@@ -1 +1 @@
0.0.46
0.0.98
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sync"
"time"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
const defaultLogMaxSizeMB = 20
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
// newLogger 根据 cfg.Logging 构建 Zap。
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) {
+9 -9
View File
@@ -1,6 +1,6 @@
// Package main is the MMTL HTTP server entry point.
// Package main is the MeBox HTTP server entry point.
//
// MMTL is a Go rewrite of the legacy Python implementation,
// MeBox is a Go rewrite of the legacy Python implementation,
// adopting the same tech stack as cropflre/nowen-video:
//
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
@@ -21,10 +21,10 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
@@ -35,7 +35,7 @@ func effectiveVersion(buildVersion string) string {
if buildVersion != "" && buildVersion != "dev" {
return buildVersion
}
if envVersion := strings.TrimSpace(os.Getenv("MMTL_VERSION")); envVersion != "" {
if envVersion := strings.TrimSpace(os.Getenv("MEBOX_VERSION")); envVersion != "" {
return envVersion
}
if buildVersion == "" {
@@ -59,7 +59,7 @@ func main() {
defer func() { _ = logger.Sync() }()
appVersion := effectiveVersion(version)
logger.Info("starting MMTL",
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
@@ -135,5 +135,5 @@ func main() {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MMTL stopped")
logger.Info("MeBox stopped")
}
+9 -9
View File
@@ -12,23 +12,23 @@ import (
)
func TestEffectiveVersionPrefersBuildVersion(t *testing.T) {
t.Setenv("MMTL_VERSION", "MMTL-v0.1.15")
t.Setenv("MEBOX_VERSION", "MeBox-v0.1.15")
if got := effectiveVersion("MMTL-v0.1.16"); got != "MMTL-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
if got := effectiveVersion("MeBox-v0.1.16"); got != "MeBox-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
}
}
func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) {
t.Setenv("MMTL_VERSION", " MMTL-v0.1.16 ")
t.Setenv("MEBOX_VERSION", " MeBox-v0.1.16 ")
if got := effectiveVersion("dev"); got != "MMTL-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
if got := effectiveVersion("dev"); got != "MeBox-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
}
}
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
t.Setenv("MMTL_VERSION", "")
t.Setenv("MEBOX_VERSION", "")
if got := effectiveVersion(""); got != "dev" {
t.Fatalf("effectiveVersion = %q, want dev", got)
@@ -85,7 +85,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "brand", "mmtl-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "artwork-cache-sw.js"), []byte("self.addEventListener('fetch', () => {})"), 0o644); err != nil {
@@ -105,7 +105,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
t.Fatalf("asset Cache-Control = %q, want immutable", got)
}
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mmtl-logo.svg", nil)
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
brandResp := httptest.NewRecorder()
router.ServeHTTP(brandResp, brandReq)
if brandResp.Code != http.StatusOK {
+13 -7
View File
@@ -11,12 +11,12 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/handler"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/handler"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
"github.com/ShukeBta/MMTL/web"
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
@@ -53,6 +53,7 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
func serveSPA(r *gin.Engine, root fs.FS) {
assets := r.Group("/assets")
assets.Use(middleware.GzipStatic())
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
@@ -69,7 +70,7 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(func(c *gin.Context) {
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
@@ -78,7 +79,7 @@ func serveSPA(r *gin.Engine, root fs.FS) {
setNoCacheHeaders(c)
data, err := fs.ReadFile(root, "index.html")
if err != nil {
c.String(http.StatusNotFound, "MMTL web UI not found")
c.String(http.StatusNotFound, "MeBox web UI not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
@@ -173,6 +174,11 @@ func isFrontendLibraryRoute(path string) bool {
if strings.Contains(id, "/") {
return false
}
// 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由,
// 需要交给 SPA 而非当作 Emby API 路径 404。
if strings.HasPrefix(id, "embyremote~") {
return true
}
if len(id) != 36 {
return false
}
+3 -3
View File
@@ -13,8 +13,8 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
@@ -27,7 +27,7 @@ type tlsPair struct {
version string
}
// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
// serverManager 负责 MeBox 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
//
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) {
+38 -42
View File
@@ -1,7 +1,7 @@
# MMTL 第三档完整 Docker Compose 部署文件
# MeBox 第三档完整 Docker Compose 部署文件
#
# 组件:
# MMTL + PostgreSQL + Redis + OpenSearch
# MeBox + PostgreSQL + Redis + OpenSearch
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
@@ -19,12 +19,8 @@
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
@@ -78,45 +74,45 @@ services:
PUID: "1000"
PGID: "1000"
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
MMTL_SEARCH_BACKEND: opensearch
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MMTL_SEARCH_INDEX: mmtl_media
MEBOX_SEARCH_BACKEND: opensearch
MEBOX_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MEBOX_SEARCH_INDEX: mebox_media
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
@@ -133,18 +129,18 @@ services:
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
# 首次部署允许拉取;日常更新请只 pull mebox。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
+23 -43
View File
@@ -1,4 +1,4 @@
# MMTL 单镜像部署模板(SQLite)
# MeBox 单镜像部署模板(SQLite)
#
# 适合:新手、单人使用、低配 NAS / 小主机。
# 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。
@@ -12,12 +12,10 @@
# 首次登录后请立刻修改密码。
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub 备用:
# image: shukbet/mmtl:latest
mebox:
image: ghcr.io/truewhile/mebox:latest
container_name: mmtl
container_name: mebox
restart: unless-stopped
init: true
@@ -33,23 +31,7 @@ services:
- ./cache:/cache
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。qBittorrent / Transmission 的保存目录建议也对齐到 /downloads。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
- ./media:/media
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
# - /dev/dri:/dev/dri
@@ -61,32 +43,30 @@ services:
TZ: Asia/Shanghai
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
PUID: "1000"
PGID: "1000"
PUID: "0"
PGID: "0"
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
# 单镜像档固定使用 SQLite。主数据库文件:./data/mmtl.db。
MMTL_DATABASE_TYPE: sqlite
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
# 单镜像档固定使用 SQLite。主数据库文件:./data/mebox.db。
MEBOX_DATABASE_TYPE: sqlite
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MEBOX_CACHE_CACHE_DIR: /cache
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
+35 -39
View File
@@ -1,7 +1,7 @@
# MMTL 第二档完整 Docker Compose 部署文件
# MeBox 第二档完整 Docker Compose 部署文件
#
# 组件:
# MMTL + PostgreSQL + Redis
# MeBox + PostgreSQL + Redis
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
@@ -16,12 +16,8 @@
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
@@ -73,39 +69,39 @@ services:
PUID: "1000"
PGID: "1000"
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
@@ -122,18 +118,18 @@ services:
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
# 首次部署允许拉取;日常更新请只 pull mebox。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
+35 -39
View File
@@ -1,4 +1,4 @@
# MMTL 最简单 Docker Compose 部署文件
# MeBox 最简单 Docker Compose 部署文件
#
# 新手建议:
# 1. 不用 .env。
@@ -15,12 +15,8 @@
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
@@ -87,52 +83,52 @@ services:
PGID: "1000"
# 程序基础配置,通常不用改。
MMTL_APP_HOST: 0.0.0.0
MMTL_APP_PORT: 8080
MMTL_APP_WEB_DIR: /app/web/dist
MMTL_APP_DATA_DIR: /data
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
# 排查复杂问题时可临时改成 debug。
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
# 旧版 ./data/mmtl.db 存在时,首次启动会自动迁移到 PostgreSQL。
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# 旧版 ./data/mmtl.db 或 ./data/mebox.db 存在时,首次启动会自动迁移到 PostgreSQL。
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
# SQLite 旧库迁移源:
# - 首次从旧版 ./data/mmtl.db 导入时保持此路径。
# - 首次从旧版 SQLite(mmtl.db / mebox.db)导入时保持此路径。
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MEBOX_CACHE_CACHE_DIR: /cache
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
# 默认推荐下载器保存路径使用 /downloads。
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
# NAS 友好的低负载默认值。
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
@@ -150,20 +146,20 @@ services:
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
# 首次部署允许拉取;日常更新请只 pull mebox。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
+4 -4
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
run_uid="${PUID:-$(id -u mmtl 2>/dev/null || echo 1000)}"
run_gid="${PGID:-$(id -g mmtl 2>/dev/null || echo 1000)}"
run_uid="${PUID:-$(id -u mebox 2>/dev/null || echo 1000)}"
run_gid="${PGID:-$(id -g mebox 2>/dev/null || echo 1000)}"
case "$run_uid" in
''|*[!0-9]*)
@@ -19,10 +19,10 @@ case "$run_gid" in
esac
if [ "$run_uid" = "0" ]; then
exec mmtl
exec mebox
fi
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
chown "$run_uid:$run_gid" /media 2>/dev/null || true
exec su-exec "$run_uid:$run_gid" mmtl
exec su-exec "$run_uid:$run_gid" mebox
+29 -21
View File
@@ -1,11 +1,12 @@
module github.com/ShukeBta/MMTL
module github.com/truewhile/MeBox
go 1.25.0
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
@@ -13,30 +14,35 @@ require (
github.com/redis/go-redis/v9 v9.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
golang.org/x/sys v0.20.0
golang.org/x/crypto v0.48.0
golang.org/x/sys v0.41.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.30.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -44,17 +50,19 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -67,16 +75,16 @@ require (
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+62 -53
View File
@@ -4,14 +4,16 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -24,12 +26,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -42,17 +46,17 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@@ -74,21 +78,20 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -96,20 +99,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -133,15 +140,15 @@ github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMV
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -150,41 +157,44 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -205,4 +215,3 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+2 -2
View File
@@ -4,7 +4,7 @@
// 1. 内置默认值
// 2. 工作目录中的 config.yaml(嵌套格式)
// 3. config/*.yaml 分片文件(按模块)
// 4. 以 MMTL_ 为前缀的环境变量
// 4. 以 MEBOX_ 为前缀的环境变量
package config
import (
@@ -17,7 +17,7 @@ import (
)
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
const EnvPrefix = "MMTL"
const EnvPrefix = "MeBox"
// Load 从默认值 / 文件 / 环境读取配置。
//
+15 -15
View File
@@ -40,13 +40,13 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
}
if cfg.Cache.RedisPrefix != "mmtl" {
if cfg.Cache.RedisPrefix != "mebox" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mmtl_media" {
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
@@ -74,7 +74,7 @@ func TestLoadDefaults(t *testing.T) {
}
}
// TestEnvOverride checks that MMTL_* env vars override the defaults.
// TestEnvOverride checks that MEBOX_* env vars override the defaults.
func TestEnvOverride(t *testing.T) {
dir := t.TempDir()
wd, _ := os.Getwd()
@@ -83,16 +83,16 @@ func TestEnvOverride(t *testing.T) {
t.Fatalf("chdir: %v", err)
}
t.Setenv("MMTL_APP_PORT", "9090")
t.Setenv("MMTL_DATABASE_TYPE", "postgres")
t.Setenv("MMTL_DATABASE_DSN", "postgres://mmtl:secret@postgres:5432/mmtl?sslmode=disable")
t.Setenv("MMTL_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MMTL_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MMTL_SEARCH_BACKEND", "opensearch")
t.Setenv("MMTL_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MMTL_LICENSE_SERVER_URL", "https://license.example.com")
t.Setenv("MMTL_LICENSE_HMAC_SECRET", "override-secret")
t.Setenv("MMTL_LICENSE_PUBLIC_KEY", "override-public-key")
t.Setenv("MEBOX_APP_PORT", "9090")
t.Setenv("MEBOX_DATABASE_TYPE", "postgres")
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
t.Setenv("MEBOX_LICENSE_HMAC_SECRET", "override-secret")
t.Setenv("MEBOX_LICENSE_PUBLIC_KEY", "override-public-key")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
+9 -8
View File
@@ -3,8 +3,8 @@ package config
import "github.com/spf13/viper"
const (
defaultDatabaseMaxOpenConns = 4
defaultDatabaseMaxIdleConns = 2
defaultDatabaseMaxOpenConns = 16
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
)
@@ -25,11 +25,11 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("app.server_url", "")
v.SetDefault("database.type", "auto")
v.SetDefault("database.db_path", "./data/mmtl.db")
v.SetDefault("database.db_path", "./data/mebox.db")
v.SetDefault("database.dsn", "")
v.SetDefault("database.wal_mode", true)
v.SetDefault("database.busy_timeout", 5000)
v.SetDefault("database.cache_size", -20000)
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
@@ -43,14 +43,15 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mmtl")
v.SetDefault("cache.media_ttl_seconds", 15)
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
v.SetDefault("search.index", "mmtl_media")
v.SetDefault("search.index", "mebox_media")
v.SetDefault("search.username", "")
v.SetDefault("search.password", "")
@@ -63,7 +64,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("flaresolverr.enabled", false)
v.SetDefault("flaresolverr.url", "http://localhost:8191")
v.SetDefault("flaresolverr.session", "mmtl")
v.SetDefault("flaresolverr.session", "mebox")
v.SetDefault("flaresolverr.timeout", 60)
v.SetDefault("downloads.smart_classify", true)
+7 -4
View File
@@ -15,7 +15,7 @@ func (c *Config) normalize() error {
c.App.DataDir = "./data"
}
if c.Database.DBPath == "" {
c.Database.DBPath = filepath.Join(c.App.DataDir, "mmtl.db")
c.Database.DBPath = filepath.Join(c.App.DataDir, "mebox.db")
}
if c.Database.Type == "" {
c.Database.Type = "auto"
@@ -44,15 +44,18 @@ func (c *Config) normalize() error {
if c.Cache.CacheDir == "" {
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
}
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mmtl"
c.Cache.RedisPrefix = "mebox"
}
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 15
c.Cache.MediaTTLSeconds = 90
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mmtl_media"
c.Search.Index = "mebox_media"
}
if c.Secrets.JWTSecret == "" {
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
+41
View File
@@ -0,0 +1,41 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SaveDatabaseConfig updates or creates config.yaml with the specified database configuration.
func SaveDatabaseConfig(dbType, dsn string) error {
configPath := "config.yaml"
data := make(map[string]any)
content, err := os.ReadFile(configPath)
if err == nil {
if err := yaml.Unmarshal(content, &data); err != nil {
data = make(map[string]any)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read config.yaml: %w", err)
}
dbSection, ok := data["database"].(map[string]any)
if !ok {
dbSection = make(map[string]any)
}
dbSection["type"] = dbType
dbSection["dsn"] = dsn
data["database"] = dbSection
out, err := yaml.Marshal(data)
if err != nil {
return fmt.Errorf("marshal config.yaml: %w", err)
}
if err := os.WriteFile(configPath, out, 0644); err != nil {
return fmt.Errorf("write config.yaml: %w", err)
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestSaveDatabaseConfig(t *testing.T) {
dir := t.TempDir()
wd, _ := os.Getwd()
defer func() { _ = os.Chdir(wd) }()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
dsn := "postgres://admin:pass@127.0.0.1:5432/mebox?sslmode=disable"
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
t.Fatalf("SaveDatabaseConfig error: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
t.Fatalf("expected config.yaml to exist: %v", err)
}
loaded, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if loaded.Database.Type != "postgres" {
t.Fatalf("expected database.type=postgres, got %s", loaded.Database.Type)
}
if loaded.Database.DSN != dsn {
t.Fatalf("expected dsn=%s, got %s", dsn, loaded.Database.DSN)
}
}
+8 -7
View File
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
// AppConfig 保存运行时应用参数。
type AppConfig struct {
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"`
@@ -59,7 +59,7 @@ type AppConfig struct {
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
SSLKeyPath string `mapstructure:"ssl_key_path"`
SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
@@ -116,6 +116,7 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转码/刮削缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
@@ -151,7 +152,7 @@ type AIConfig struct {
MaxConcurrent int `mapstructure:"max_concurrent"`
}
// LicenseConfig configures the optional MMTL license server bridge.
// LicenseConfig configures the optional MeBox license server bridge.
type LicenseConfig struct {
ServerURL string `mapstructure:"server_url"`
HMACSecret string `mapstructure:"hmac_secret"`
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
// Open initialises the configured GORM database. database.type=auto chooses
+223
View File
@@ -0,0 +1,223 @@
package database
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// DatabaseStatus describes the currently active database engine and runtime metrics.
type DatabaseStatus struct {
Type string `json:"type"`
DSN string `json:"dsn,omitempty"`
DBPath string `json:"db_path,omitempty"`
OpenConns int `json:"open_conns"`
InUse int `json:"in_use"`
Idle int `json:"idle"`
MaxOpenConns int `json:"max_open_conns"`
TableCounts map[string]int64 `json:"table_counts"`
}
// PostgresTestResult returns latency and version info after testing connection.
type PostgresTestResult struct {
Success bool `json:"success"`
LatencyMS int64 `json:"latency_ms"`
Version string `json:"version,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// DatabaseMigrationResult returns row counts and execution duration of migration.
type DatabaseMigrationResult struct {
Success bool `json:"success"`
TotalRows int64 `json:"total_rows"`
TableRows map[string]int64 `json:"table_rows"`
DurationMS int64 `json:"duration_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// InspectDatabaseStatus queries the currently active database for metrics and table rows.
func InspectDatabaseStatus(db *gorm.DB, cfg *config.Config) *DatabaseStatus {
st := &DatabaseStatus{
Type: "sqlite",
TableCounts: make(map[string]int64),
}
if cfg != nil {
st.DBPath = cfg.Database.DBPath
if cfg.Database.Type == "postgres" || (cfg.Database.Type == "auto" && strings.TrimSpace(cfg.Database.DSN) != "") {
st.Type = "postgres"
st.DSN = MaskDSN(cfg.Database.DSN)
}
}
if isPostgres(db) {
st.Type = "postgres"
}
if db != nil {
if sqlDB, err := db.DB(); err == nil {
stats := sqlDB.Stats()
st.OpenConns = stats.OpenConnections
st.InUse = stats.InUse
st.Idle = stats.Idle
st.MaxOpenConns = stats.MaxOpenConnections
}
// Count rows for major model tables
for _, m := range model.AllModels() {
if tbl, err := modelTableName(db, m); err == nil {
if db.Migrator().HasTable(tbl) {
var count int64
if err := db.Raw("SELECT COUNT(1) FROM " + quoteIdent(tbl)).Scan(&count).Error; err == nil {
st.TableCounts[tbl] = count
}
}
}
}
}
return st
}
// TestPostgres establishes a temporary connection to verify reachability and permissions.
func TestPostgres(dsn string) (*PostgresTestResult, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return &PostgresTestResult{
Success: false,
Error: "PostgreSQL DSN 不能为空",
}, nil
}
start := time.Now()
testDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("连接失败: %v", err),
}, nil
}
sqlDB, err := testDB.DB()
if err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("获取底层连接失败: %v", err),
}, nil
}
defer sqlDB.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("Ping 超时或失败: %v", err),
}, nil
}
var version string
if err := testDB.WithContext(ctx).Raw("SELECT version()").Scan(&version).Error; err != nil {
version = "PostgreSQL (unknown version)"
}
latency := time.Since(start).Milliseconds()
return &PostgresTestResult{
Success: true,
LatencyMS: latency,
Version: version,
Message: "连接成功",
}, nil
}
// MigrateCurrentToPostgres performs schema initialization and full table data copy into target PostgreSQL.
func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log *zap.Logger) (*DatabaseMigrationResult, error) {
targetDSN = strings.TrimSpace(targetDSN)
if targetDSN == "" {
return nil, fmt.Errorf("target PostgreSQL DSN cannot be empty")
}
if src == nil {
return nil, fmt.Errorf("current database is not available")
}
started := time.Now()
targetDB, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
Logger: newGormLogger(log),
})
if err != nil {
return nil, fmt.Errorf("open target PostgreSQL: %w", err)
}
targetSQLDB, err := targetDB.DB()
if err == nil {
defer targetSQLDB.Close()
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
// 2. 安全重置目标数据库的初始默认数据
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, targetDB, log); err != nil {
return nil, fmt.Errorf("reset target bootstrap data: %w", err)
}
// 3. 执行数据批量复制
tableRows, totalRows, err := copyModelTables(src, targetDB, batchSize)
if err != nil {
return nil, fmt.Errorf("copy tables: %w", err)
}
// 4. 标记迁移完成
if err := markSQLiteMigrationComplete(targetDB); err != nil {
return nil, fmt.Errorf("mark migration complete: %w", err)
}
duration := time.Since(started).Milliseconds()
return &DatabaseMigrationResult{
Success: true,
TotalRows: totalRows,
TableRows: tableRows,
DurationMS: duration,
Message: fmt.Sprintf("成功迁移 %d 条记录至 PostgreSQL", totalRows),
}, nil
}
// MaskDSN masks the password in a connection string for safe API responses.
func MaskDSN(rawDSN string) string {
rawDSN = strings.TrimSpace(rawDSN)
if rawDSN == "" {
return ""
}
if u, err := url.Parse(rawDSN); err == nil && u.User != nil {
if pass, hasPassword := u.User.Password(); hasPassword && pass != "" {
rawUserPass := u.User.String()
user := u.User.Username()
maskedUserPass := user + ":******"
return strings.Replace(rawDSN, rawUserPass+"@", maskedUserPass+"@", 1)
}
}
// Fallback for keyword-style DSN (e.g. host=... password=...)
if strings.Contains(rawDSN, "password=") {
parts := strings.Fields(rawDSN)
for i, p := range parts {
if strings.HasPrefix(p, "password=") {
parts[i] = "password=******"
}
}
return strings.Join(parts, " ")
}
return rawDSN
}
+71
View File
@@ -0,0 +1,71 @@
package database
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestMaskDSN(t *testing.T) {
cases := []struct {
in string
want string
}{
{
in: "postgres://admin:secret123@localhost:5432/mebox?sslmode=disable",
want: "postgres://admin:******@localhost:5432/mebox?sslmode=disable",
},
{
in: "host=localhost port=5432 user=admin password=secret dbname=mebox sslmode=disable",
want: "host=localhost port=5432 user=admin password=****** dbname=mebox sslmode=disable",
},
{
in: "sqlite://data/mebox.db",
want: "sqlite://data/mebox.db",
},
{
in: "",
want: "",
},
}
for _, c := range cases {
got := MaskDSN(c.in)
if got != c.want {
t.Errorf("MaskDSN(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestInspectDatabaseStatus(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Media{}); err != nil {
t.Fatal(err)
}
_ = db.Create(&model.User{Username: "testuser", PasswordHash: "h", Role: "user"}).Error
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = "./data/mebox.db"
st := InspectDatabaseStatus(db, cfg)
if st == nil {
t.Fatal("expected non-nil DatabaseStatus")
}
if st.Type != "sqlite" {
t.Fatalf("expected sqlite, got %s", st.Type)
}
if st.DBPath != "./data/mebox.db" {
t.Fatalf("expected db_path, got %s", st.DBPath)
}
if st.TableCounts["users"] != 1 {
t.Fatalf("expected 1 user, got %d", st.TableCounts["users"])
}
}
+10 -10
View File
@@ -8,8 +8,8 @@ import (
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestOpenRequiresConfig(t *testing.T) {
@@ -28,7 +28,7 @@ func TestOpenRequiresConfig(t *testing.T) {
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(t.TempDir(), "mmtl.db")
cfg.Database.DBPath = filepath.Join(t.TempDir(), "mebox.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.CacheSize = -2000
@@ -159,7 +159,7 @@ func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
t.Fatal(err)
}
copied, err := copyModelTables(src, dst, 2)
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -222,7 +222,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
t.Fatal(err)
}
copied, err := copyModelTables(src, dst, 2)
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -240,7 +240,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
}
copied, err = copyModelTables(src, dst, 2)
_, copied, err = copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -278,7 +278,7 @@ func TestSQLiteMigrationCompleteMarker(t *testing.T) {
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mmtl.db")
sqlitePath := filepath.Join(dir, "mebox.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
@@ -332,7 +332,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
t.Fatal(err)
}
copied, err := copyModelTables(src2, dst, 2)
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -362,7 +362,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mmtl.db")
sqlitePath := filepath.Join(dir, "mebox.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
@@ -408,7 +408,7 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
_ = sqlDB2.Close()
}
}()
copied, err := copyModelTables(src2, dst, 2)
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/truewhile/MeBox/internal/model"
)
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
+44 -3
View File
@@ -3,7 +3,7 @@ package database
import (
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/truewhile/MeBox/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
@@ -20,12 +20,23 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if err := ensureEmbyMountsCompatibility(db); err != nil {
return err
}
if isSQLite(db) {
return ensureMediaSearchIndex(db)
if err := ensureMediaSearchIndex(db); err != nil {
return err
}
return ensureSQLiteQueryOptimizer(db)
}
return nil
}
func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
// Refresh planner statistics so indexes on large media tables are used.
return db.Exec("ANALYZE").Error
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
@@ -49,8 +60,16 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
// (release_date, year, updated_at, created_at, id) DESC 与索引列完全一致,
// LIMIT 分页沿索引顺序直取,免去对整库行做临时 B-tree 排序。
`CREATE INDEX IF NOT EXISTS idx_media_library_recent_active ON media(library_id, release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC) WHERE deleted_at IS NULL`,
// 计数覆盖索引:首页 CountByLibraries 的 GROUP BY library_id + nsfw 谓词
// 全部落在索引键/部分索引条件上,纯索引扫描即可完成,不回表。
`CREATE INDEX IF NOT EXISTS idx_media_library_nsfw_active ON media(library_id, nsfw) WHERE deleted_at IS NULL`,
// 旧的两键前缀索引被上面的完整排序索引完全覆盖,删除以降低写放大。
`DROP INDEX IF EXISTS idx_media_library_release_active`,
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
@@ -77,3 +96,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
}
return nil
}
func ensureEmbyMountsCompatibility(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.EmbyMount{}) {
return nil
}
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil {
return err
}
}
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
var zeroCount int64
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
var mounts []model.EmbyMount
if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
for i, m := range mounts {
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
}
}
}
return nil
}
@@ -0,0 +1,62 @@
package database
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
func TestEnsureEmbyMountsCompatibility(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Create a table without sort_order simulating an older schema
if err := db.Exec(`CREATE TABLE emby_mounts (
id varchar(36) PRIMARY KEY,
created_at datetime,
updated_at datetime,
deleted_at datetime,
account_id text,
remote_view_id text,
remote_view_name text,
collection_type text,
name text,
proxy_play numeric DEFAULT false,
enabled numeric DEFAULT true
)`).Error; err != nil {
t.Fatal(err)
}
// Insert older rows
now := time.Now()
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error
// Run compatibility migration
if err := ensureEmbyMountsCompatibility(db); err != nil {
t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err)
}
// Verify column sort_order exists and values are initialized sequentially
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
t.Fatal("expected sort_order column to be added")
}
var m1, m2 model.EmbyMount
if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil {
t.Fatal(err)
}
if m1.SortOrder != 0 || m2.SortOrder != 1 {
t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder)
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
// MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into
@@ -48,7 +48,7 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
return err
}
copied, err := copyModelTables(src, target, 500)
_, copied, err := copyModelTables(src, target, 500)
if err != nil {
return err
}
@@ -9,7 +9,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/truewhile/MeBox/internal/model"
)
func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error {
+17 -14
View File
@@ -10,55 +10,56 @@ import (
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/truewhile/MeBox/internal/model"
)
func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
if batchSize <= 0 {
batchSize = 500
}
var copied int64
tableCounts := make(map[string]int64)
var totalCopied int64
for _, m := range model.AllModels() {
table, err := modelTableName(src, m)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
primaryColumns, err := modelPrimaryColumns(src, m)
if err != nil {
return copied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
}
exists, err := sqliteTableExists(src, table)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
if !exists {
continue
}
var sourceCount int64
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&sourceCount).Error; err != nil {
return copied, fmt.Errorf("count sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("count sqlite table %s: %w", table, err)
}
if sourceCount == 0 {
continue
}
var targetCount int64
if err := target.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&targetCount).Error; err != nil {
return copied, fmt.Errorf("count target table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
}
modelType := reflect.TypeOf(m)
if modelType.Kind() != reflect.Ptr {
return copied, fmt.Errorf("model %T is not a pointer", m)
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
}
sliceType := reflect.SliceOf(modelType.Elem())
slicePtr := reflect.New(sliceType)
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
return copied, fmt.Errorf("read sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
}
filtered := slicePtr.Elem()
if targetCount > 0 {
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
}
@@ -68,11 +69,13 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
return copied, fmt.Errorf("copy sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copied += int64(filtered.Len())
copiedForTable := int64(filtered.Len())
tableCounts[table] = copiedForTable
totalCopied += copiedForTable
}
return copied, nil
return tableCounts, totalCopied, nil
}
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
+23 -16
View File
@@ -12,7 +12,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
func openSQLiteMigrationSource(cfg *config.Config, sqlitePath string) (*gorm.DB, error) {
@@ -36,23 +36,30 @@ func sqliteMigrationSourcePath(cfg *config.Config, log *zap.Logger) (string, err
}
}
fallback := filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db")
if fallback == "" || sameCleanPath(configured, fallback) {
return "", nil
// Prefer the new default filename, then fall back to legacy MMTL SQLite files.
candidates := []string{
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mebox.db"),
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db"),
}
exists, err := regularFileExists(fallback)
if err != nil {
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
for _, fallback := range candidates {
if fallback == "" || sameCleanPath(configured, fallback) {
continue
}
exists, err := regularFileExists(fallback)
if err != nil {
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
}
if !exists {
continue
}
if log != nil && configured != "" {
log.Warn("configured sqlite migration source not found; using data-dir default",
zap.String("configured", configured),
zap.String("fallback", fallback))
}
return fallback, nil
}
if !exists {
return "", nil
}
if log != nil && configured != "" {
log.Warn("configured sqlite migration source not found; using data-dir default",
zap.String("configured", configured),
zap.String("fallback", fallback))
}
return fallback, nil
return "", nil
}
func regularFileExists(path string) (bool, error) {
+37 -11
View File
@@ -4,17 +4,18 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/truewhile/MeBox/internal/config"
)
func installSQLiteWriteGate(db *gorm.DB) {
if db == nil {
return
}
const lockedKey = "mmtl:sqlite_write_locked"
const lockedKey = "mebox:sqlite_write_locked"
gate := newSQLiteWriteGate()
lock := func(tx *gorm.DB) {
ctx := context.Background()
@@ -32,14 +33,35 @@ func installSQLiteWriteGate(db *gorm.DB) {
gate.Unlock()
}
}
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
rawLock := func(tx *gorm.DB) {
if tx.Statement != nil && isReadOnlySQL(tx.Statement.SQL.String()) {
return
}
lock(tx)
}
_ = db.Callback().Create().Before("gorm:create").Register("mebox:sqlite_write_lock", lock)
_ = db.Callback().Create().After("gorm:create").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Update().Before("gorm:update").Register("mebox:sqlite_write_lock", lock)
_ = db.Callback().Update().After("gorm:update").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Delete().Before("gorm:delete").Register("mebox:sqlite_write_lock", lock)
_ = db.Callback().Delete().After("gorm:delete").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Raw().Before("gorm:raw").Register("mebox:sqlite_write_lock", rawLock)
_ = db.Callback().Raw().After("gorm:raw").Register("mebox:sqlite_write_unlock", unlock)
}
func isReadOnlySQL(sql string) bool {
trimmed := strings.TrimSpace(sql)
if len(trimmed) == 0 {
return false
}
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "EXPLAIN") {
return true
}
if strings.HasPrefix(upper, "WITH") && !strings.Contains(upper, "INSERT") && !strings.Contains(upper, "UPDATE") && !strings.Contains(upper, "DELETE") {
return true
}
return false
}
// sqliteWriteGate serializes in-process SQLite writes while respecting the
@@ -84,7 +106,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
}
dsn := dbPath + "?_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)"
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
if cfg.Database.BusyTimeout > 0 {
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
@@ -92,6 +114,10 @@ func buildSQLiteDSN(cfg *config.Config) string {
if cfg.Database.CacheSize != 0 {
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
}
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)"
if cfg.Database.WALMode {
dsn += "&_pragma=wal_autocheckpoint(1000)"
}
return dsn
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
const embyCtxUserName = "emby_user_name"
+130 -4
View File
@@ -3,14 +3,15 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
@@ -27,7 +28,69 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
if svc.Sessions != nil {
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
}
c.JSON(http.StatusOK, users)
for i := range users {
users[i].PopulateComputedFields()
}
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"max_users": maxUsers,
"current_users": len(users),
})
}
}
type updateUserLimitReq struct {
MaxUsers int `json:"max_users" binding:"required"`
}
func getUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": maxUsers,
"current_users": currentUsers,
})
}
}
func updateUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req updateUserLimitReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := service.SaveMaxUsers(c.Request.Context(), svc.Repo, req.MaxUsers); err != nil {
if errors.Is(err, service.ErrInvalidMaxUsers) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": req.MaxUsers,
"current_users": currentUsers,
})
}
}
@@ -198,6 +261,64 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
}
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed)
}
}
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
}
}
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
@@ -226,7 +347,12 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) {
case errors.Is(err, service.ErrUsernameTaken):
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
case errors.Is(err, service.ErrUserLimitReached):
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit})
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
+145
View File
@@ -0,0 +1,145 @@
package handler
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
type DatabaseConnectionPayload struct {
Type string `json:"type"`
DSN string `json:"dsn"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
SSLMode string `json:"sslmode"`
}
func (p *DatabaseConnectionPayload) BuildDSN() string {
raw := strings.TrimSpace(p.DSN)
if raw != "" {
return raw
}
host := strings.TrimSpace(p.Host)
if host == "" {
return ""
}
port := p.Port
if port <= 0 {
port = 5432
}
user := strings.TrimSpace(p.User)
dbname := strings.TrimSpace(p.DBName)
if dbname == "" {
dbname = "mebox"
}
sslmode := strings.TrimSpace(p.SSLMode)
if sslmode == "" {
sslmode = "disable"
}
userInfo := url.User(user)
if p.Password != "" {
userInfo = url.UserPassword(user, p.Password)
}
u := url.URL{
Scheme: "postgres",
User: userInfo,
Host: fmt.Sprintf("%s:%d", host, port),
Path: "/" + dbname,
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
}
return u.String()
}
func getDatabaseStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Database == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database service unavailable"})
return
}
status := svc.Database.GetStatus(c.Request.Context())
c.JSON(http.StatusOK, status)
}
}
func testDatabaseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dsn := req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
return
}
res, err := svc.Database.TestPostgres(c.Request.Context(), dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func migrateDatabaseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dsn := req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供目标 PostgreSQL 连接信息或 DSN"})
return
}
res, err := svc.Database.MigrateToPostgres(c.Request.Context(), dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "迁移失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func saveDatabaseConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dbType := strings.ToLower(strings.TrimSpace(req.Type))
if dbType == "" {
dbType = "postgres"
}
var dsn string
if dbType == "postgres" {
dsn = req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
return
}
}
if err := svc.Database.SaveConfig(c.Request.Context(), dbType, dsn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "数据库配置已成功保存至配置文件,重启服务后将以新数据库运行",
"type": dbType,
})
}
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
func TestBuildDSN(t *testing.T) {
cases := []struct {
payload DatabaseConnectionPayload
want string
}{
{
payload: DatabaseConnectionPayload{
DSN: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
},
want: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
},
{
payload: DatabaseConnectionPayload{
Host: "127.0.0.1",
Port: 5432,
User: "postgres",
Password: "secretpassword",
DBName: "mebox_prod",
SSLMode: "disable",
},
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mebox_prod?sslmode=disable",
},
}
for _, c := range cases {
got := c.payload.BuildDSN()
if got != c.want {
t.Errorf("BuildDSN() = %q, want %q", got, c.want)
}
}
}
func TestGetDatabaseStatusHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = "./data/mebox.db"
svc := &service.Container{
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
}
r := gin.New()
r.GET("/api/admin/database/status", getDatabaseStatusHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/admin/database/status", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "sqlite" {
t.Fatalf("expected type=sqlite, got %v", resp["type"])
}
}
func TestSaveDatabaseConfigHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.Type = "sqlite"
svc := &service.Container{
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
}
r := gin.New()
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mebox"}`)
req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
}
+5 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
type settingReq struct {
@@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
c.Status(http.StatusNoContent)
}
}
+89 -3
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -10,9 +11,9 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
@@ -47,3 +48,88 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
t.Fatal("recent realtime user should not be deleted")
}
}
func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true}
lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"}
lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"}
lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"}
if err := repos.DB.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos}
router := gin.New()
router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc))
// 1. 设置限制为 lib-1 和 lib-2
body := `{"allowed_library_ids":["lib-1","lib-2"]}`
req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
found, err := repos.User.FindByID(t.Context(), "u1")
if err != nil || found == nil {
t.Fatal("user not found")
}
allowed := found.DecodeAllowedLibraryIDs()
if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" {
t.Fatalf("expected [lib-1, lib-2], got %v", allowed)
}
// 验证可见性
vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(vis.AllowedLibraryIDs) != 2 {
t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) {
t.Fatal("lib1 should be visible")
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) {
t.Fatal("lib2 should be visible")
}
if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) {
t.Fatal("lib3 should not be visible")
}
// 2. 清空限制,恢复全部可见
bodyEmpty := `{"allowed_library_ids":[]}`
reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty))
reqEmpty.Header.Set("Content-Type", "application/json")
wEmpty := httptest.NewRecorder()
router.ServeHTTP(wEmpty, reqEmpty)
if wEmpty.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String())
}
foundReset, _ := repos.User.FindByID(t.Context(), "u1")
if len(foundReset.DecodeAllowedLibraryIDs()) != 0 {
t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs())
}
visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(visReset.AllowedLibraryIDs) != 0 {
t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) {
t.Fatal("lib3 should now be visible")
}
}
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestUserLimitHandlers(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &service.Container{Repo: repos, Auth: service.NewAuthService(nil, zap.NewNop(), repos, service.NewTokenService(nil, zap.NewNop(), repos), service.NewPermissionService(zap.NewNop(), repos))}
router := gin.New()
router.GET("/admin/users/limit", getUserLimitHandler(cfg))
router.PUT("/admin/users/limit", updateUserLimitHandler(cfg))
req := httptest.NewRequest(http.MethodGet, "/admin/users/limit", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET limit status = %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"max_users":20`) {
t.Fatalf("expected default max_users=20, got %s", w.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/admin/users/limit", strings.NewReader(`{"max_users":42}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT limit status = %d body=%s", w.Code, w.Body.String())
}
got, err := service.LoadMaxUsers(t.Context(), repos)
if err != nil || got != 42 {
t.Fatalf("stored max users = %d err=%v, want 42", got, err)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
// ApiConfigHandler API 配置 HTTP 处理。
+12 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
type loginReq struct {
@@ -68,6 +68,15 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
return
}
if errors.Is(err, service.ErrUserLimitReached) {
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -89,6 +98,7 @@ func meHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
u.PopulateComputedFields()
c.JSON(http.StatusOK, u)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/truewhile/MeBox/internal/middleware"
)
func TestSetAccessTokenCookie(t *testing.T) {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// refreshHandler returns a fresh token signed for the current user.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
// dlnaControlPath maps the action name to the AVTransport SOAP body.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
+4 -4
View File
@@ -14,10 +14,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
+4 -4
View File
@@ -13,10 +13,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
@@ -12,10 +12,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
@@ -24,7 +24,7 @@ func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
payload := embyWithRequestAddress(c, map[string]any{"Id": "mebox-001"})
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
@@ -42,7 +42,7 @@ func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
c.Request.Header.Set("X-Forwarded-Proto", "https")
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
payload := embyWithRequestAddress(c, map[string]any{"Id": "mebox-001"})
if payload["LocalAddress"] != "https://media.example.test" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
var embyPlaceholderPNG = []byte{
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
+4 -4
View File
@@ -14,10 +14,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
@@ -0,0 +1,148 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "test_viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
logger := zap.NewNop()
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, logger, repos),
Sessions: service.NewSessionTrackerService(logger),
Playback: service.NewPlaybackService(logger, repos),
}
router := gin.New()
// 注册带认证的路由,模拟已登录用户
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
router.GET("/Items", embyItemsHandler(svc))
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
router.GET("/Sessions", embySessionsHandler(svc))
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
nil,
)
wProgress := httptest.NewRecorder()
router.ServeHTTP(wProgress, progressReq)
if wProgress.Code != http.StatusNoContent {
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
}
// 验证已持久化到 PlaybackHistory
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
t.Fatalf("playback history not saved: %v", err)
}
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
http.MethodGet,
"/Items?Filters=IsResumable",
nil,
)
wResumable := httptest.NewRecorder()
router.ServeHTTP(wResumable, resumableReq)
if wResumable.Code != http.StatusOK {
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
}
var resumableEnvelope map[string]any
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
t.Fatalf("decode resumable: %v", err)
}
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
if resumableEnvelope["TotalRecordCount"] == nil {
t.Fatalf("missing TotalRecordCount in resumable envelope")
}
// 3. 测试 /Users/:userId/Items/Resume 别名路由
resumeAliasReq := httptest.NewRequest(
http.MethodGet,
"/Users/"+user.ID+"/Items/Resume",
nil,
)
wResumeAlias := httptest.NewRecorder()
router.ServeHTTP(wResumeAlias, resumeAliasReq)
if wResumeAlias.Code != http.StatusOK {
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
}
// 4. 测试 /Sessions 返回 NowPlayingItem
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
wSessions := httptest.NewRecorder()
router.ServeHTTP(wSessions, sessionsReq)
if wSessions.Code != http.StatusOK {
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
}
var sessionsList []map[string]any
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
t.Fatalf("decode sessions: %v", err)
}
if len(sessionsList) == 0 {
t.Fatalf("expected at least 1 session")
}
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
if !ok || nowPlaying["Id"] != remoteMediaID {
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
}
}
func signMockToken(secret, userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour).Unix(),
})
s, _ := token.SignedString([]byte(secret))
return s
}
+222
View File
@@ -0,0 +1,222 @@
// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD,
// 以及账号远程媒体库(View)列表预览。
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// embyMountView 挂载的对外 JSON(附带账号信息)。
type embyMountView struct {
model.EmbyMount
AccountName string `json:"account_name"`
}
// embyMountInput 创建挂载的请求体(单个或批量)。
type embyMountInput struct {
AccountID string `json:"account_id" binding:"required"`
Views []embyViewInput `json:"views" binding:"required,min=1"`
}
type embyViewInput struct {
RemoteViewID string `json:"remote_view_id" binding:"required"`
RemoteViewName string `json:"remote_view_name"`
CollectionType string `json:"collection_type"`
Name string `json:"name"`
ProxyPlay bool `json:"proxy_play"`
}
func embyMountViews(mounts []model.EmbyMount, accounts map[string]string) []embyMountView {
out := make([]embyMountView, 0, len(mounts))
for _, m := range mounts {
out = append(out, embyMountView{EmbyMount: m, AccountName: accounts[m.AccountID]})
}
return out
}
// embyAccountViewsHandler 列出账号上的远程媒体库(View),供挂载选择。
func embyAccountViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
return
}
views, err := svc.EmbyRemote.RemoteViews(c.Request.Context(), acct)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
type viewEntry struct {
RemoteViewID string `json:"remote_view_id"`
RemoteViewName string `json:"remote_view_name"`
CollectionType string `json:"collection_type"`
ChildCount int `json:"child_count"`
AlreadyMounted bool `json:"already_mounted"`
}
mounted := map[string]bool{}
if mounts, err := svc.EmbyRemote.ListMountsByAccount(c.Request.Context(), acct.ID); err == nil {
for _, m := range mounts {
mounted[m.RemoteViewID] = true
}
}
out := make([]viewEntry, 0, len(views))
for _, v := range views {
viewID := service.RemoteItemIDString(v)
if strings.TrimSpace(viewID) == "" {
continue
}
out = append(out, viewEntry{
RemoteViewID: viewID,
RemoteViewName: service.RemoteItemNameString(v),
CollectionType: service.RemoteItemCollectionType(v),
ChildCount: service.RemoteItemChildCount(v),
AlreadyMounted: mounted[viewID],
})
}
c.JSON(http.StatusOK, out)
}
}
// listEmbyMountsHandler 列出全部挂载。
func listEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
mounts, err := svc.EmbyRemote.ListMounts(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
names := map[string]string{}
if accounts, err := svc.EmbyRemote.ListAccounts(c.Request.Context()); err == nil {
for _, a := range accounts {
names[a.ID] = a.Name
}
}
out := embyMountViews(mounts, names)
if out == nil {
out = []embyMountView{}
}
c.JSON(http.StatusOK, out)
}
}
// createEmbyMountsHandler 批量创建挂载(同一账号下的多个远程媒体库)。
func createEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req embyMountInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mounts := make([]*model.EmbyMount, 0, len(req.Views))
for _, v := range req.Views {
mounts = append(mounts, &model.EmbyMount{
AccountID: req.AccountID,
RemoteViewID: v.RemoteViewID,
RemoteViewName: v.RemoteViewName,
CollectionType: v.CollectionType,
Name: v.Name,
ProxyPlay: v.ProxyPlay,
Enabled: true,
})
}
if _, err := svc.EmbyRemote.CreateMounts(c.Request.Context(), mounts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "created": len(mounts)})
}
}
// fullMountEmbyAccountHandler 全量挂载:把账号所有远程媒体库一次挂载进来。
func fullMountEmbyAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
return
}
proxy := c.Query("proxy") == "1" || c.Query("proxy") == "true"
n, err := svc.EmbyRemote.FullMountAccount(c.Request.Context(), acct, proxy)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "created": n})
}
}
// updateEmbyMountHandler 更新挂载(显示名 / 代理开关 / 启用)。
func updateEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name *string `json:"name"`
ProxyPlay *bool `json:"proxy_play"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mount, err := svc.EmbyRemote.MountByID(c.Request.Context(), c.Param("id"))
if err != nil || mount == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "挂载不存在"})
return
}
if req.Name != nil {
mount.Name = *req.Name
}
if req.ProxyPlay != nil {
mount.ProxyPlay = *req.ProxyPlay
}
if req.Enabled != nil {
mount.Enabled = *req.Enabled
}
if _, err := svc.EmbyRemote.UpdateMount(c.Request.Context(), mount.ID, mount); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mount)
}
}
// deleteEmbyMountHandler 删除挂载。
func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.EmbyRemote.DeleteMount(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderEmbyMountsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
@@ -0,0 +1,65 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestReorderEmbyMountsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
_ = repos.EmbyMount.Create(ctx, m1)
_ = repos.EmbyMount.Create(ctx, m2)
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
body, _ := json.Marshal(map[string]any{
"ids": []string{m2.ID, m1.ID},
})
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
}
}
+38 -6
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
@@ -34,10 +34,19 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams.
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
// 条目的字幕直接反向代理远程。
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
@@ -213,12 +222,26 @@ func embyAppendAPIKey(raw, token string) string {
return u.String()
}
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
// /api/stream/{id}(同一个 ServeFile)。
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
c.Status(http.StatusNotFound)
return
}
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -296,6 +319,11 @@ func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container,
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
@@ -319,6 +347,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
@@ -13,10 +13,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
@@ -12,10 +12,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
+29 -12
View File
@@ -7,13 +7,18 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
ItemIDLower string `json:"itemId"`
ID string `json:"Id"`
IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"`
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
if req.ItemId == "" {
req.ItemId = c.Query("ItemId")
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
if itemID == "" {
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
}
if req.PositionTicks == 0 {
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
pos := req.PositionTicks
if pos == 0 {
pos = req.PositionLower
}
if req.RunTimeTicks == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
if pos == 0 {
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
}
if req.ItemId == "" {
runTime := req.RunTimeTicks
if runTime == 0 {
runTime = req.RunTimeLower
}
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
if itemID == "" {
c.Status(http.StatusOK)
return
}
@@ -43,7 +57,10 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
if svc.Sessions != nil {
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
@@ -51,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
req.ItemId,
req.PositionTicks,
req.RunTimeTicks,
itemID,
pos,
runTime,
stopped)
}
if svc.Device != nil && !stopped {
+4 -2
View File
@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
+3 -1
View File
@@ -3,7 +3,7 @@ package handler
import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) {
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(svc))
auth.GET("/items/resume", embyResumeItemsHandler(svc))
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
+9 -6
View File
@@ -14,10 +14,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
@@ -213,10 +213,13 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
}
var users []model.User
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
var payload struct {
Users []model.User `json:"users"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode users: %v", err)
}
users := payload.Users
if len(users) != 1 {
t.Fatalf("users = %#v", users)
}
+14 -4
View File
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
@@ -29,7 +29,7 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
}
row := gin.H{
"Id": sess.ID,
"ServerId": "mmtl-001",
"ServerId": "mebox-001",
"Client": sess.Client,
"DeviceId": sess.DeviceID,
"DeviceName": sess.DeviceName,
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
"SupportsRemoteControl": true,
}
if itemID != "" && sess.IsPlaying {
row["NowPlayingItem"] = gin.H{"Id": itemID}
nowPlaying := gin.H{"Id": itemID}
if svc.Emby != nil {
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
if val, ok := item[key]; ok && val != nil {
nowPlaying[key] = val
}
}
}
}
row["NowPlayingItem"] = nowPlaying
}
out = append(out, row)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
@@ -14,10 +14,10 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
@@ -84,9 +84,9 @@ func embyPublicSystemInfoPayload(c *gin.Context, svc *service.Container) map[str
return embyWithRequestAddress(c, svc.Emby.SystemInfoPublic())
}
return embyWithRequestAddress(c, map[string]any{
"Id": "mmtl-001",
"ServerId": "mmtl-001",
"ServerName": "MMTL",
"Id": "mebox-001",
"ServerId": "mebox-001",
"ServerName": "MeBox",
"Version": "4.8.10.0",
"ServerVersion": "4.8.10.0",
"ProductName": "Emby Server",
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/truewhile/MeBox/internal/middleware"
)
func signedTestToken(t *testing.T, secret string) string {
@@ -23,7 +23,7 @@ func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string {
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Issuer: "mmtl-test",
Issuer: "mebox-test",
Subject: "user-1",
},
}
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
// ─── Users / Auth ────────────────────────────────────────────────────────────
@@ -67,7 +67,7 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
embyRememberCompatSession(c, accessToken)
c.JSON(http.StatusOK, gin.H{
"AccessToken": accessToken,
"ServerId": "mmtl-001",
"ServerId": "mebox-001",
"User": userPayload,
"SessionInfo": gin.H{
"Id": resp.User.ID,
@@ -150,12 +150,12 @@ func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyFallbackUser(id string) gin.H {
if strings.TrimSpace(id) == "" {
id = "mmtl-user"
id = "mebox-user"
}
return gin.H{
"Id": id,
"Name": "MMTL",
"ServerId": "mmtl-001",
"Name": "MeBox",
"ServerId": "mebox-001",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
type fileFolderReq struct {
+5 -3
View File
@@ -8,13 +8,15 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// Register attaches every API route to the engine.
func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.Container) {
api := r.Group("/api")
api.Use(middleware.GzipAPI())
{
api.GET("/health", healthCheck)
api.GET("/version", versionInfo)
@@ -46,7 +48,7 @@ func healthCheck(c *gin.Context) {
}
func versionInfo(c *gin.Context) {
c.JSON(200, gin.H{"name": "MMTL", "version": "0.1.0"})
c.JSON(200, gin.H{"name": "MeBox", "version": "0.1.0"})
}
// ─── 权限 Handler 包装 ────────────────────────────────────────────────────────
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/service"
)
type manualScrapeApplyReq struct {
+363 -61
View File
@@ -7,27 +7,51 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
type createLibraryReq struct {
Name string `json:"name"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
CreatePerSubfolder bool `json:"create_per_subfolder"`
Name string `json:"name"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
CreatePerSubfolder bool `json:"create_per_subfolder"`
}
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
switch collectionType {
case "movies":
return "Movie"
case "tvshows":
return "Series"
}
return ""
}
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Media.ListLibraries(c.Request.Context())
ctx := c.Request.Context()
libs, err := svc.Media.ListLibraries(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -35,23 +59,116 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
c.JSON(http.StatusOK, libs)
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
limit, _ = strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
if limit <= 0 {
limit = 10
} else if limit > 100 {
limit = 100
}
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
}
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
allowedViews = append(allowedViews, v)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
for i, v := range allowedViews {
i, v := i, v
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
}
}()
}
wg.Wait()
}
out = append(out, remotePayloads...)
}
}
c.JSON(http.StatusOK, out)
}
}
func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 挂载库详情。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID)
if err != nil || view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, view.Library, mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
return
}
lib, err := svc.Repo.Library.FindByID(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -63,14 +180,14 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
libs := service.FilterDisplayCloudLibraries(ctx, svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(ctx, svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, libs[0])
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
} else {
c.JSON(http.StatusOK, lib)
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
}
}
}
@@ -89,38 +206,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
var l *model.Library
if req.CreatePerSubfolder {
parent := ""
if len(roots) > 0 {
parent = roots[0].Path
} else if strings.TrimSpace(req.Path) != "" {
parent = req.Path
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
for i := range created {
lib := &created[i]
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
var l *model.Library
if req.CreatePerSubfolder {
parent := ""
if len(roots) > 0 {
parent = roots[0].Path
} else if strings.TrimSpace(req.Path) != "" {
parent = req.Path
}
for _, root := range lib.Roots {
if root.Enabled {
queueLibraryRootScan(svc, lib.ID, root.ID)
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
for i := range created {
lib := &created[i]
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
}
for _, root := range lib.Roots {
if root.Enabled {
queueLibraryRootScan(svc, lib.ID, root.ID)
}
}
}
c.JSON(http.StatusCreated, gin.H{"libraries": created})
return
}
c.JSON(http.StatusCreated, gin.H{"libraries": created})
return
}
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -147,7 +264,9 @@ roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
type updateLibraryReq struct {
CoverURL string `json:"cover_url"`
CoverURL *string `json:"cover_url"`
SortOrder *int `json:"sort_order"`
CarouselEnabled *bool `json:"carousel_enabled"`
}
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
@@ -157,9 +276,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if req.CoverURL != nil {
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.SortOrder != nil || req.CarouselEnabled != nil {
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || lib == nil {
@@ -170,6 +297,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
type reorderLibrariesReq struct {
IDs []string `json:"ids" binding:"required"`
}
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
@@ -187,8 +333,41 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
// 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
}
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, mount, acct, remoteID, itemTypes, (page-1)*size, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": size,
})
return
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
@@ -226,7 +405,37 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
func getMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -265,62 +474,155 @@ func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc {
}
}
func paginateSlice[T any](items []T, page, size int) []T {
if page < 1 {
page = 1
}
if size <= 0 {
size = 50
}
if len(items) == 0 {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
}
end := start + size
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
items, total, err := svc.Media.SearchMediaVisiblePage(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
items, total, err := svc.Media.SearchMediaVisiblePageGrouped(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if !groupVersions {
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
items, err := svc.Media.SearchMediaVisibleGrouped(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc))
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:按挂载代理配置分流——代理走 MeBox 反代,否则 302 直连。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
if !enforceScopedPlaybackToken(c, id) {
return
}
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !service.EmbyMountLibraryAllowed(mediaVisibilityForRequest(c, svc), mount) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
+35
View File
@@ -0,0 +1,35 @@
// Package handler — media delete endpoint.
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
deleteFiles := parseDeleteFilesQuery(c.Query("delete_files"))
if err := svc.Media.DeleteMedia(c.Request.Context(), c.Param("id"), deleteFiles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func parseDeleteFilesQuery(raw string) bool {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return false
}
if raw == "1" || raw == "true" || raw == "yes" {
return true
}
v, err := strconv.ParseBool(raw)
return err == nil && v
}
+23
View File
@@ -0,0 +1,23 @@
package handler
import "testing"
func TestParseDeleteFilesQuery(t *testing.T) {
cases := []struct {
raw string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"1", true},
{"true", true},
{"TRUE", true},
{"yes", true},
}
for _, tc := range cases {
if got := parseDeleteFilesQuery(tc.raw); got != tc.want {
t.Fatalf("parseDeleteFilesQuery(%q)=%v, want %v", tc.raw, got, tc.want)
}
}
}

Some files were not shown because too many files have changed in this diff Show More