mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 14:26:37 +08:00
+1
-1
@@ -18,7 +18,7 @@ verify-downloads/
|
|||||||
.tmp_*
|
.tmp_*
|
||||||
.tmp-deploy-*
|
.tmp-deploy-*
|
||||||
.tmp-deploy-data/
|
.tmp-deploy-data/
|
||||||
.mmtl.pid
|
.mebox.pid
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Telegram MMTL 交流群
|
- name: Telegram MeBox 交流群
|
||||||
url: https://t.me/MMTL
|
url: https://t.me/MeBox
|
||||||
about: 适合快速交流部署经验、使用问题和排查线索。
|
about: 适合快速交流部署经验、使用问题和排查线索。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ assignees: ""
|
|||||||
|
|
||||||
## 期望方案
|
## 期望方案
|
||||||
|
|
||||||
请描述你希望 MMTL 如何工作。
|
请描述你希望 MeBox 如何工作。
|
||||||
|
|
||||||
## 使用场景
|
## 使用场景
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: MMTL CodeQL
|
name: MeBox CodeQL
|
||||||
|
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- internal/service/fileid_other.go
|
- internal/service/fileid_other.go
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ name: AuTo Docker Image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# 保留手动触发作为备选
|
# 保留手动触发作为备选
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -85,8 +83,8 @@ jobs:
|
|||||||
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "tag=MMTL-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
echo "tag=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "tag=mmtl-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
echo "tag=mebox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# 3. 更新 VERSION 文件
|
# 3. 更新 VERSION 文件
|
||||||
- name: Update version file
|
- name: Update version file
|
||||||
@@ -131,7 +129,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/mmtl
|
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=raw,value=${{ steps.bump_version.outputs.tag }}
|
type=raw,value=${{ steps.bump_version.outputs.tag }}
|
||||||
@@ -194,7 +192,7 @@ jobs:
|
|||||||
set -eux
|
set -eux
|
||||||
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
||||||
gh release create "$RELEASE_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 }}" \
|
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||||
--verify-tag --latest || true
|
--verify-tag --latest || true
|
||||||
|
|
||||||
@@ -240,16 +238,16 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
|
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
|
- name: Package
|
||||||
run: |
|
run: |
|
||||||
mkdir -p package/mmtl
|
mkdir -p package/mebox
|
||||||
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
|
cp "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mebox/mebox${{ matrix.ext }}
|
||||||
cp README.md package/mmtl/ 2>/dev/null || true
|
cp README.md package/mebox/ 2>/dev/null || true
|
||||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
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
|
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
|
fi
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
env:
|
env:
|
||||||
@@ -257,8 +255,8 @@ jobs:
|
|||||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
||||||
TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
|
TAR="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
|
||||||
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
|
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
|
||||||
if [ -f "$PKG" ]; then
|
if [ -f "$PKG" ]; then
|
||||||
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
|
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# 产出:
|
# 产出:
|
||||||
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
||||||
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
||||||
# 3. ghcr.io/{owner}/mmtl:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
|
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
|
||||||
#
|
#
|
||||||
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
|
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
|
||||||
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
||||||
@@ -73,21 +73,21 @@ jobs:
|
|||||||
|
|
||||||
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
||||||
- name: Build linux/amd64
|
- 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/mmtl-beta-linux-amd64 ./cmd/server
|
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
|
- 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/mmtl-beta-linux-arm64 ./cmd/server
|
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
|
- 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/mmtl-beta-windows-amd64.exe ./cmd/server
|
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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mmtl-beta-binaries
|
name: mebox-beta-binaries
|
||||||
path: dist/*
|
path: dist/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mmtl:beta)
|
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
docker-beta:
|
docker-beta:
|
||||||
name: Build & push beta Docker image
|
name: Build & push beta Docker image
|
||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
tags: ghcr.io/${{ github.repository_owner }}/mmtl:beta
|
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
org.opencontainers.image.source=${{ github.repository }}
|
org.opencontainers.image.source=${{ github.repository }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
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
|
required: true
|
||||||
type: string
|
type: string
|
||||||
ref:
|
ref:
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
# 自动使用当前仓库所有者
|
# 自动使用当前仓库所有者
|
||||||
images: ghcr.io/${{ github.repository_owner }}/mmtl
|
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=raw,value=${{ env.RELEASE_VERSION }}
|
type=raw,value=${{ env.RELEASE_VERSION }}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ logs/
|
|||||||
.tmp-deploy-cache/
|
.tmp-deploy-cache/
|
||||||
.tmp-deploy-server.*
|
.tmp-deploy-server.*
|
||||||
.tmp-live-server.*
|
.tmp-live-server.*
|
||||||
.mmtl.pid
|
.mebox.pid
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
# 贡献规范
|
# 贡献规范
|
||||||
|
|
||||||
感谢你愿意帮助 MMTL 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
|
感谢你愿意帮助 MeBox 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
|
||||||
|
|
||||||
## Issue 提交规范
|
## Issue 提交规范
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
- 期望行为:你认为正确结果应该是什么。
|
- 期望行为:你认为正确结果应该是什么。
|
||||||
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
|
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
|
||||||
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
|
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
|
||||||
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MMTL 镜像版本。
|
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MeBox 镜像版本。
|
||||||
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
|
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
|
||||||
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
|
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
|
||||||
|
|
||||||
@@ -28,14 +28,14 @@ Docker 部署常用命令:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs --tail=300 mmtl
|
docker compose logs --tail=300 mebox
|
||||||
docker compose exec mmtl sh -lc 'ls -la /data/logs || true'
|
docker compose exec mebox sh -lc 'ls -la /data/logs || true'
|
||||||
```
|
```
|
||||||
|
|
||||||
PostgreSQL 部署查询示例:
|
PostgreSQL 部署查询示例:
|
||||||
|
|
||||||
```bash
|
```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
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker/dockerfile: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 1 (frontend) : Node 20 -> static SPA bundle
|
||||||
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# Build:
|
# Build:
|
||||||
# docker buildx build --platform linux/amd64,linux/arm64 \
|
# 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:
|
# Optional Intel VAAPI/QSV runtime packages:
|
||||||
# docker buildx build --build-arg WITH_VAAPI=true ...
|
# docker buildx build --build-arg WITH_VAAPI=true ...
|
||||||
@@ -39,7 +39,7 @@ COPY . .
|
|||||||
COPY --from=frontend /app/web/dist ./web/dist
|
COPY --from=frontend /app/web/dist ./web/dist
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
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 ------------------------------------------------------
|
# ---- Stage 3: runtime ------------------------------------------------------
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
@@ -64,22 +64,22 @@ RUN apk add --no-cache \
|
|||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# Non-root user for the long-running process.
|
# 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
|
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
|
COPY --from=frontend /app/web/dist /app/web/dist
|
||||||
|
|
||||||
RUN mkdir -p /data /cache /media \
|
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`).
|
# Default environment (overridable via docker-compose / `docker run -e`).
|
||||||
ENV MMTL_APP_PORT=8080 \
|
ENV MEBOX_APP_PORT=8080 \
|
||||||
MMTL_APP_DATA_DIR=/data \
|
MEBOX_APP_DATA_DIR=/data \
|
||||||
MMTL_APP_WEB_DIR=/app/web/dist \
|
MEBOX_APP_WEB_DIR=/app/web/dist \
|
||||||
MMTL_DATABASE_DB_PATH=/data/mmtl.db \
|
MEBOX_DATABASE_DB_PATH=/data/mebox.db \
|
||||||
MMTL_CACHE_CACHE_DIR=/cache \
|
MEBOX_CACHE_CACHE_DIR=/cache \
|
||||||
MMTL_LOGGING_LEVEL=info \
|
MEBOX_LOGGING_LEVEL=info \
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
# MMTL (My Movie and TV Library)
|
# MeBox
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
|
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
|
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="#项目简介">项目简介</a> ·
|
||||||
<a href="#快速开始">快速开始</a> ·
|
<a href="#快速开始">快速开始</a> ·
|
||||||
<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="CONTRIBUTING.md">贡献规范</a> ·
|
<a href="README_EN.md">English</a> ·
|
||||||
<a href="https://mgo.3jzs.com">在线演示</a>
|
<a href="CONTRIBUTING.md">贡献规范</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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
|
```bash
|
||||||
mkdir -p MMTL
|
mkdir -p MeBox && cd MeBox
|
||||||
cd MMTL
|
|
||||||
# 最省心:单镜像 + SQLite,只启动一个容器
|
# 最省心:单镜像 + 内置 SQLite
|
||||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
|
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/MMTL/main/docker-compose.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
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:
|
浏览器访问:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://服务器IP:18080
|
http://服务器IP:18080
|
||||||
```
|
```
|
||||||
|
|
||||||
默认账号:
|
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
|
||||||
|
|
||||||
```text
|
|
||||||
admin / admin123
|
|
||||||
```
|
|
||||||
|
|
||||||
首次登录后请立刻修改管理员密码。
|
|
||||||
|
|
||||||
镜像地址:
|
镜像地址:
|
||||||
|
|
||||||
```text
|
```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.simple.yml` | MeBox + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
|
||||||
| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 |
|
| 第一档 | `docker-compose.yml` | MeBox + PostgreSQL | 大多数家庭 NAS,多用户更稳 |
|
||||||
| 第二档 | `docker-compose.standard.yml` | MMTL + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 |
|
| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 |
|
||||||
| 第三档 | `docker-compose.search.yml` | MMTL + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
|
| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) |
|
||||||
|
|
||||||
### 单镜像档:SQLite(最省心)
|
### 单镜像档要点
|
||||||
|
|
||||||
只启动 MMTL 一个镜像,主数据库用内置 SQLite,不需要 PostgreSQL / Redis / `.env`。变量最少、资源占用最低,适合新手和单人使用。日后需要多用户或更高并发时,保留 `./data` 后切换到第一档的 PostgreSQL 即可。
|
- 只启动 **一个** MeBox 容器,数据在 `./data/mebox.db`
|
||||||
|
- 通常只需改端口与媒体目录挂载
|
||||||
```bash
|
- **不要**设置 `MEBOX_DATABASE_DSN`,否则会切到 PostgreSQL
|
||||||
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` 里的这几处:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
- "18080:8080" # 改左边 18080 即可
|
- "18080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data # 必须备份
|
- ./data:/data # 必须备份
|
||||||
- ./media:/media # 改左边为你的媒体目录,例如 /vol1/1000/Media:/media
|
- ./cache:/cache # 可重建
|
||||||
# - /dev/dri:/dev/dri # Intel 核显硬解需要时取消注释
|
- ./media:/media # 改成你的媒体目录
|
||||||
```
|
```
|
||||||
|
|
||||||
网页后台添加媒体库时填写容器内路径:
|
网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。
|
||||||
|
|
||||||
```text
|
### PostgreSQL 档位要点
|
||||||
/media
|
|
||||||
/media/电影
|
|
||||||
/media/电视剧
|
|
||||||
```
|
|
||||||
|
|
||||||
关键数据目录:
|
- 主库在 `./postgres`,配置与密钥在 `./data`
|
||||||
|
- 若存在旧版 `./data/mebox.db`,首次启动会自动迁移到 PostgreSQL
|
||||||
```text
|
- 迁移完成后可将 `MEBOX_DATABASE_DB_PATH` 改为不存在路径,避免重复检查:
|
||||||
./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 上调整:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
MEBOX_DATABASE_DB_PATH: /data/no-sqlite-migration.db
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 必须备份与可重建
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `./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 示例:
|
NAS 示例:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
|
- /vol1/1000/Media:/media
|
||||||
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
|
- /vol1/1000/Downloads:/downloads
|
||||||
environment:
|
environment:
|
||||||
MMTL_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
|
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
||||||
MMTL_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
MMTL_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
|
MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||||
MMTL_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/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。
|
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
||||||
|
|
||||||
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
|
|
||||||
npm --prefix web ci
|
npm --prefix web ci
|
||||||
npm --prefix web run build
|
npm --prefix web run build
|
||||||
|
|
||||||
# 后端测试
|
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go run ./cmd/server # http://127.0.0.1:8080
|
||||||
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
|
npm --prefix web run dev # http://127.0.0.1:3000
|
||||||
go run ./cmd/server
|
|
||||||
|
|
||||||
# 本地运行前端开发服务器
|
|
||||||
npm --prefix web run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
前端开发服务器默认访问:
|
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
||||||
|
|
||||||
```text
|
---
|
||||||
http://127.0.0.1:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
后端健康检查:
|
## 鸣谢
|
||||||
|
|
||||||
```text
|
MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。
|
||||||
http://127.0.0.1:8080/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### 交叉编译单文件发布物
|
项目中许多网盘同步、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 模板,并提供部署方式、复现步骤、日志和关键配置。
|
- Bug 请附部署方式、复现步骤与相关日志
|
||||||
- 功能建议请说明使用场景、期望行为和可接受的替代方案。
|
- 功能建议请说明使用场景与期望行为
|
||||||
- 安全漏洞请不要公开发 Issue,按 [安全策略](SECURITY.md) 使用私密渠道报告。
|
- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build`
|
||||||
- Pull Request 请从独立分支或 fork 分支发起,不要直接向 `main` 推送。
|
|
||||||
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`,例如 `docs/contribution-guidelines`。
|
---
|
||||||
- 提交前按改动范围运行 `go test ./...`、`npm --prefix web run build` 或定向测试,并在 PR 中说明验证结果。
|
|
||||||
|
|
||||||
## Star History
|
## 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>
|
<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: 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=ShukeBta/MMTL&type=date&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=ShukeBta/MMTL&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>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
|
本项目采用 [GPL-3.0](LICENSE) 许可证。
|
||||||
|
|||||||
+120
-476
@@ -1,21 +1,22 @@
|
|||||||
# MMTL (My Movie and TV Library)
|
# MeBox
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</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">
|
<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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文</a> ·
|
<a href="README.md">中文</a> ·
|
||||||
|
<a href="#overview">Overview</a> ·
|
||||||
<a href="#quick-start">Quick Start</a> ·
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
<a href="#docker-compose-recommended">Docker Compose</a> ·
|
<a href="#deployment-tiers">Deployment</a> ·
|
||||||
<a href="#faq">FAQ</a> ·
|
<a href="#acknowledgements">Acknowledgements</a> ·
|
||||||
<a href="https://mgo.3jzs.com">Live Demo</a>
|
<a href="#development">Development</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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.
|
- A modern **web media library**
|
||||||
- Create multiple user accounts for family members, friends, or different devices.
|
- An **Emby/Jellyfin-compatible protocol gateway** for third-party players
|
||||||
- Scan files and enrich posters, summaries, years, seasons, and episodes.
|
- A single panel for **local disks, download folders, and cloud storage**
|
||||||
- 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.
|
|
||||||
|
|
||||||
> 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, recycle bin, 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.
|
- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch
|
||||||
- **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.
|
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand
|
||||||
- **Multi-user management**: supports admins, regular users, account enable/disable, expiry dates, device management, Bot registration, and redeem codes.
|
- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases
|
||||||
- **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## 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
|
```bash
|
||||||
mkdir -p MMTL
|
mkdir -p MeBox && cd MeBox
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
# Or PostgreSQL tier for multi-user setups
|
||||||
vi docker-compose.yml
|
# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml
|
||||||
```
|
|
||||||
|
|
||||||
Start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,478 +82,148 @@ Open:
|
|||||||
http://SERVER_IP:18080
|
http://SERVER_IP:18080
|
||||||
```
|
```
|
||||||
|
|
||||||
Default login:
|
Default login: `admin` / `admin123` — change the password immediately.
|
||||||
|
|
||||||
|
Image:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Username: admin
|
ghcr.io/truewhile/mebox:latest
|
||||||
Password: admin123
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 |
|
- Only one MeBox container; database lives in `./data/mebox.db`
|
||||||
| --- | --- | --- |
|
- Do **not** set `MEBOX_DATABASE_DSN` or it switches to PostgreSQL
|
||||||
| 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 |
|
- Back up `./data`; `./cache` can be rebuilt
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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:
|
| Path | Notes |
|
||||||
|
| --- | --- |
|
||||||
```yaml
|
| `./data` | JWT secret, config, SQLite DB or migration source |
|
||||||
environment:
|
| `./postgres` | PostgreSQL primary DB |
|
||||||
MMTL_DATABASE_TYPE: postgres
|
| `./cache`, `./redis`, `./opensearch` | Rebuildable |
|
||||||
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
|
|
||||||
|
|
||||||
### Update
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull mmtl
|
docker compose pull mebox
|
||||||
docker compose up -d --no-deps mmtl
|
docker compose up -d --no-deps mebox
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logs
|
---
|
||||||
|
|
||||||
```bash
|
## Path mapping
|
||||||
docker compose logs -f mmtl
|
|
||||||
tail -f ./data/logs/app.log
|
The most common Docker mistake is mixing host paths with container paths.
|
||||||
tail -f ./data/logs/error.log
|
|
||||||
|
- 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
|
1. Create a library with a container path such as `/media/Movies`, then scan
|
||||||
|
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
|
||||||
For the default PostgreSQL deployment, back up:
|
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
|
||||||
|
4. Optionally configure cloud accounts under STRM management
|
||||||
```text
|
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## 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
|
**Hardlink fails with cross-device link**
|
||||||
docker ps
|
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
|
||||||
docker compose logs --tail=100 mmtl
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open:
|
**External player cannot connect**
|
||||||
|
Use `http://IP:18080` and a MeBox user account; reverse proxies need correct external URL and HTTPS headers.
|
||||||
```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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## Development
|
||||||
|
|
||||||
Regular users should use Docker. Developers can run:
|
The backend embeds `web/dist` via `go:embed`. Build the frontend first.
|
||||||
|
|
||||||
```bash
|
```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 ./...
|
go test ./...
|
||||||
cd web && npm run build
|
go run ./cmd/server
|
||||||
|
npm --prefix web run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
|
||||||
|
|
||||||
## 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/)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## 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>
|
<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: 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=ShukeBta/MMTL&type=date&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=ShukeBta/MMTL&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>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License and Non-Commercial Statement
|
## License
|
||||||
|
|
||||||
This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE).
|
This project is licensed under [GPL-3.0](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>
|
|
||||||
|
|||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
# 安全策略
|
# 安全策略
|
||||||
|
|
||||||
MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
|
MeBox 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
|
||||||
|
|
||||||
## 支持范围
|
## 支持范围
|
||||||
|
|
||||||
我们优先支持以下版本和部署方式的安全修复:
|
我们优先支持以下版本和部署方式的安全修复:
|
||||||
|
|
||||||
- 当前 `main` 分支。
|
- 当前 `main` 分支。
|
||||||
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
|
- 最新发布镜像:`ghcr.io/truewhile/mebox:latest`。
|
||||||
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
|
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
|
||||||
|
|
||||||
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
|
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
|
||||||
@@ -16,7 +16,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
|
|||||||
|
|
||||||
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
|
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
|
||||||
|
|
||||||
<https://github.com/ShukeBta/MMTL/security/advisories/new>
|
<https://github.com/truewhile/MeBox/security/advisories/new>
|
||||||
|
|
||||||
如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。
|
如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。
|
||||||
|
|
||||||
@@ -68,14 +68,14 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
|
|||||||
|
|
||||||
## 自托管安全基线
|
## 自托管安全基线
|
||||||
|
|
||||||
部署 MMTL 时建议:
|
部署 MeBox 时建议:
|
||||||
|
|
||||||
- 首次登录后立即修改默认 `admin / admin123`。
|
- 首次登录后立即修改默认 `admin / admin123`。
|
||||||
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
|
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
|
||||||
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
|
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
|
||||||
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
|
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
|
||||||
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
|
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
|
||||||
- Docker `volumes` 只挂载 MMTL 需要访问的目录,媒体库目录需要写入时再授予写权限。
|
- Docker `volumes` 只挂载 MeBox 需要访问的目录,媒体库目录需要写入时再授予写权限。
|
||||||
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
|
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
|
||||||
|
|
||||||
## 安全修复 PR
|
## 安全修复 PR
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultLogMaxSizeMB = 20
|
const defaultLogMaxSizeMB = 20
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newLogger 根据 cfg.Logging 构建 Zap。
|
// newLogger 根据 cfg.Logging 构建 Zap。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) {
|
func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) {
|
||||||
|
|||||||
+9
-9
@@ -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:
|
// adopting the same tech stack as cropflre/nowen-video:
|
||||||
//
|
//
|
||||||
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
|
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
|
||||||
@@ -21,10 +21,10 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/database"
|
"github.com/truewhile/MeBox/internal/database"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is overwritten at build time via -ldflags="-X main.version=...".
|
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||||
@@ -35,7 +35,7 @@ func effectiveVersion(buildVersion string) string {
|
|||||||
if buildVersion != "" && buildVersion != "dev" {
|
if buildVersion != "" && buildVersion != "dev" {
|
||||||
return buildVersion
|
return buildVersion
|
||||||
}
|
}
|
||||||
if envVersion := strings.TrimSpace(os.Getenv("MMTL_VERSION")); envVersion != "" {
|
if envVersion := strings.TrimSpace(os.Getenv("MEBOX_VERSION")); envVersion != "" {
|
||||||
return envVersion
|
return envVersion
|
||||||
}
|
}
|
||||||
if buildVersion == "" {
|
if buildVersion == "" {
|
||||||
@@ -59,7 +59,7 @@ func main() {
|
|||||||
defer func() { _ = logger.Sync() }()
|
defer func() { _ = logger.Sync() }()
|
||||||
|
|
||||||
appVersion := effectiveVersion(version)
|
appVersion := effectiveVersion(version)
|
||||||
logger.Info("starting MMTL",
|
logger.Info("starting MeBox",
|
||||||
zap.String("version", appVersion),
|
zap.String("version", appVersion),
|
||||||
zap.Int("port", cfg.App.Port),
|
zap.Int("port", cfg.App.Port),
|
||||||
zap.String("data_dir", cfg.App.DataDir),
|
zap.String("data_dir", cfg.App.DataDir),
|
||||||
@@ -135,5 +135,5 @@ func main() {
|
|||||||
logger.Error("graceful shutdown failed", zap.Error(err))
|
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
services.Close()
|
services.Close()
|
||||||
logger.Info("MMTL stopped")
|
logger.Info("MeBox stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestEffectiveVersionPrefersBuildVersion(t *testing.T) {
|
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" {
|
if got := effectiveVersion("MeBox-v0.1.16"); got != "MeBox-v0.1.16" {
|
||||||
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
|
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) {
|
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" {
|
if got := effectiveVersion("dev"); got != "MeBox-v0.1.16" {
|
||||||
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
|
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
|
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
|
||||||
t.Setenv("MMTL_VERSION", "")
|
t.Setenv("MEBOX_VERSION", "")
|
||||||
|
|
||||||
if got := effectiveVersion(""); got != "dev" {
|
if got := effectiveVersion(""); got != "dev" {
|
||||||
t.Fatalf("effectiveVersion = %q, want dev", got)
|
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 {
|
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(webDir, "artwork-cache-sw.js"), []byte("self.addEventListener('fetch', () => {})"), 0o644); err != nil {
|
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)
|
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()
|
brandResp := httptest.NewRecorder()
|
||||||
router.ServeHTTP(brandResp, brandReq)
|
router.ServeHTTP(brandResp, brandReq)
|
||||||
if brandResp.Code != http.StatusOK {
|
if brandResp.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/handler"
|
"github.com/truewhile/MeBox/internal/handler"
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"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 {
|
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||||
@@ -78,7 +78,7 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
|||||||
setNoCacheHeaders(c)
|
setNoCacheHeaders(c)
|
||||||
data, err := fs.ReadFile(root, "index.html")
|
data, err := fs.ReadFile(root, "index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "MMTL web UI not found")
|
c.String(http.StatusNotFound, "MeBox web UI not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
||||||
@@ -27,7 +27,7 @@ type tlsPair struct {
|
|||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
|
// serverManager 负责 MeBox 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
|
||||||
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
|
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
|
||||||
//
|
//
|
||||||
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
|
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) {
|
func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) {
|
||||||
|
|||||||
+38
-42
@@ -1,7 +1,7 @@
|
|||||||
# MMTL 第三档完整 Docker Compose 部署文件
|
# MeBox 第三档完整 Docker Compose 部署文件
|
||||||
#
|
#
|
||||||
# 组件:
|
# 组件:
|
||||||
# MMTL + PostgreSQL + Redis + OpenSearch
|
# MeBox + PostgreSQL + Redis + OpenSearch
|
||||||
#
|
#
|
||||||
# 使用方式二选一:
|
# 使用方式二选一:
|
||||||
# 1. 保存为 docker-compose.yml 后执行:
|
# 1. 保存为 docker-compose.yml 后执行:
|
||||||
@@ -19,12 +19,8 @@
|
|||||||
# admin / admin123
|
# admin / admin123
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mmtl:
|
mebox:
|
||||||
# 镜像二选一:
|
image: ghcr.io/truewhile/mebox:latest
|
||||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
|
||||||
image: ghcr.io/shukebta/mmtl:latest
|
|
||||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
|
||||||
# image: shukbet/mmtl:latest
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
@@ -78,45 +74,45 @@ services:
|
|||||||
PUID: "1000"
|
PUID: "1000"
|
||||||
PGID: "1000"
|
PGID: "1000"
|
||||||
|
|
||||||
MMTL_APP_HOST: 0.0.0.0
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
MMTL_APP_PORT: 8080
|
MEBOX_APP_PORT: 8080
|
||||||
MMTL_APP_WEB_DIR: /app/web/dist
|
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||||
MMTL_APP_DATA_DIR: /data
|
MEBOX_APP_DATA_DIR: /data
|
||||||
MMTL_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MMTL_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
MMTL_DATABASE_TYPE: postgres
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||||
|
|
||||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
|
||||||
MMTL_CACHE_CACHE_DIR: /cache
|
MEBOX_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
|
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
|
||||||
MMTL_SEARCH_BACKEND: opensearch
|
MEBOX_SEARCH_BACKEND: opensearch
|
||||||
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
|
MEBOX_SEARCH_OPENSEARCH_URL: http://opensearch:9200
|
||||||
MMTL_SEARCH_INDEX: mmtl_media
|
MEBOX_SEARCH_INDEX: mebox_media
|
||||||
|
|
||||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||||
|
|
||||||
# 默认推荐在网页里使用容器路径 /media。
|
# 默认推荐在网页里使用容器路径 /media。
|
||||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||||
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
||||||
MMTL_MEDIA_DIR: /media
|
MEBOX_MEDIA_DIR: /media
|
||||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
MMTL_DOWNLOAD_DIR: /downloads
|
MEBOX_DOWNLOAD_DIR: /downloads
|
||||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||||
|
|
||||||
MMTL_TRANSCODER_ENABLED: "true"
|
MEBOX_TRANSCODER_ENABLED: "true"
|
||||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||||
MMTL_TRANSCODER_REALTIME: "true"
|
MEBOX_TRANSCODER_REALTIME: "true"
|
||||||
MMTL_TRANSCODER_THREADS: "2"
|
MEBOX_TRANSCODER_THREADS: "2"
|
||||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||||
@@ -133,18 +129,18 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||||
pull_policy: missing
|
pull_policy: missing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mmtl
|
POSTGRES_DB: mebox
|
||||||
POSTGRES_USER: mmtl
|
POSTGRES_USER: mebox
|
||||||
POSTGRES_PASSWORD: mmtl
|
POSTGRES_PASSWORD: mebox
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres:/var/lib/postgresql/data
|
- ./postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+23
-43
@@ -1,4 +1,4 @@
|
|||||||
# MMTL 单镜像部署模板(SQLite)
|
# MeBox 单镜像部署模板(SQLite)
|
||||||
#
|
#
|
||||||
# 适合:新手、单人使用、低配 NAS / 小主机。
|
# 适合:新手、单人使用、低配 NAS / 小主机。
|
||||||
# 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。
|
# 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。
|
||||||
@@ -12,12 +12,10 @@
|
|||||||
# 首次登录后请立刻修改密码。
|
# 首次登录后请立刻修改密码。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mmtl:
|
mebox:
|
||||||
image: ghcr.io/shukebta/mmtl:latest
|
image: ghcr.io/truewhile/mebox:latest
|
||||||
# Docker Hub 备用:
|
|
||||||
# image: shukbet/mmtl:latest
|
|
||||||
|
|
||||||
container_name: mmtl
|
container_name: mebox
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
|
|
||||||
@@ -33,23 +31,7 @@ services:
|
|||||||
- ./cache:/cache
|
- ./cache:/cache
|
||||||
|
|
||||||
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
|
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
|
||||||
# NAS 示例:source: /vol1/1000/Media
|
- ./media:/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
|
|
||||||
|
|
||||||
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
|
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
|
||||||
# - /dev/dri:/dev/dri
|
# - /dev/dri:/dev/dri
|
||||||
@@ -61,32 +43,30 @@ services:
|
|||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
|
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
|
||||||
PUID: "1000"
|
PUID: "0"
|
||||||
PGID: "1000"
|
PGID: "0"
|
||||||
|
|
||||||
MMTL_APP_HOST: 0.0.0.0
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
MMTL_APP_PORT: 8080
|
MEBOX_APP_PORT: 8080
|
||||||
MMTL_APP_WEB_DIR: /app/web/dist
|
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||||
MMTL_APP_DATA_DIR: /data
|
MEBOX_APP_DATA_DIR: /data
|
||||||
|
|
||||||
# 单镜像档固定使用 SQLite。主数据库文件:./data/mmtl.db。
|
# 单镜像档固定使用 SQLite。主数据库文件:./data/mebox.db。
|
||||||
MMTL_DATABASE_TYPE: sqlite
|
MEBOX_DATABASE_TYPE: sqlite
|
||||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||||
MMTL_CACHE_CACHE_DIR: /cache
|
MEBOX_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
|
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
|
||||||
MMTL_MEDIA_DIR: /media
|
MEBOX_MEDIA_DIR: /media
|
||||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
MMTL_DOWNLOAD_DIR: /downloads
|
|
||||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
|
||||||
|
|
||||||
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
|
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
|
||||||
MMTL_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MMTL_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
|
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
|
||||||
|
|||||||
+35
-39
@@ -1,7 +1,7 @@
|
|||||||
# MMTL 第二档完整 Docker Compose 部署文件
|
# MeBox 第二档完整 Docker Compose 部署文件
|
||||||
#
|
#
|
||||||
# 组件:
|
# 组件:
|
||||||
# MMTL + PostgreSQL + Redis
|
# MeBox + PostgreSQL + Redis
|
||||||
#
|
#
|
||||||
# 使用方式二选一:
|
# 使用方式二选一:
|
||||||
# 1. 保存为 docker-compose.yml 后执行:
|
# 1. 保存为 docker-compose.yml 后执行:
|
||||||
@@ -16,12 +16,8 @@
|
|||||||
# admin / admin123
|
# admin / admin123
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mmtl:
|
mebox:
|
||||||
# 镜像二选一:
|
image: ghcr.io/truewhile/mebox:latest
|
||||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
|
||||||
image: ghcr.io/shukebta/mmtl:latest
|
|
||||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
|
||||||
# image: shukbet/mmtl:latest
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
@@ -73,39 +69,39 @@ services:
|
|||||||
PUID: "1000"
|
PUID: "1000"
|
||||||
PGID: "1000"
|
PGID: "1000"
|
||||||
|
|
||||||
MMTL_APP_HOST: 0.0.0.0
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
MMTL_APP_PORT: 8080
|
MEBOX_APP_PORT: 8080
|
||||||
MMTL_APP_WEB_DIR: /app/web/dist
|
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||||
MMTL_APP_DATA_DIR: /data
|
MEBOX_APP_DATA_DIR: /data
|
||||||
MMTL_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MMTL_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
MMTL_DATABASE_TYPE: postgres
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||||
|
|
||||||
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
||||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
|
||||||
MMTL_CACHE_CACHE_DIR: /cache
|
MEBOX_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||||
|
|
||||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||||
MMTL_MEDIA_DIR: /media
|
MEBOX_MEDIA_DIR: /media
|
||||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
MMTL_DOWNLOAD_DIR: /downloads
|
MEBOX_DOWNLOAD_DIR: /downloads
|
||||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||||
|
|
||||||
MMTL_TRANSCODER_ENABLED: "true"
|
MEBOX_TRANSCODER_ENABLED: "true"
|
||||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||||
MMTL_TRANSCODER_REALTIME: "true"
|
MEBOX_TRANSCODER_REALTIME: "true"
|
||||||
MMTL_TRANSCODER_THREADS: "2"
|
MEBOX_TRANSCODER_THREADS: "2"
|
||||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||||
@@ -122,18 +118,18 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||||
pull_policy: missing
|
pull_policy: missing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mmtl
|
POSTGRES_DB: mebox
|
||||||
POSTGRES_USER: mmtl
|
POSTGRES_USER: mebox
|
||||||
POSTGRES_PASSWORD: mmtl
|
POSTGRES_PASSWORD: mebox
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- ./postgres:/var/lib/postgresql/data
|
- ./postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+35
-39
@@ -1,4 +1,4 @@
|
|||||||
# MMTL 最简单 Docker Compose 部署文件
|
# MeBox 最简单 Docker Compose 部署文件
|
||||||
#
|
#
|
||||||
# 新手建议:
|
# 新手建议:
|
||||||
# 1. 不用 .env。
|
# 1. 不用 .env。
|
||||||
@@ -15,12 +15,8 @@
|
|||||||
# admin / admin123
|
# admin / admin123
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mmtl:
|
mebox:
|
||||||
# 镜像二选一:
|
image: ghcr.io/truewhile/mebox:latest
|
||||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
|
||||||
image: ghcr.io/shukebta/mmtl:latest
|
|
||||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
|
||||||
# image: shukbet/mmtl:latest
|
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
@@ -87,52 +83,52 @@ services:
|
|||||||
PGID: "1000"
|
PGID: "1000"
|
||||||
|
|
||||||
# 程序基础配置,通常不用改。
|
# 程序基础配置,通常不用改。
|
||||||
MMTL_APP_HOST: 0.0.0.0
|
MEBOX_APP_HOST: 0.0.0.0
|
||||||
MMTL_APP_PORT: 8080
|
MEBOX_APP_PORT: 8080
|
||||||
MMTL_APP_WEB_DIR: /app/web/dist
|
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||||
MMTL_APP_DATA_DIR: /data
|
MEBOX_APP_DATA_DIR: /data
|
||||||
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
|
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
|
||||||
# 排查复杂问题时可临时改成 debug。
|
# 排查复杂问题时可临时改成 debug。
|
||||||
MMTL_LOGGING_LEVEL: info
|
MEBOX_LOGGING_LEVEL: info
|
||||||
MMTL_LOGGING_FORMAT: console
|
MEBOX_LOGGING_FORMAT: console
|
||||||
MMTL_LOGGING_OUTPUT_PATH: /data/logs
|
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
MMTL_LOGGING_MAX_SIZE_MB: "50"
|
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||||
MMTL_LOGGING_MAX_BACKUPS: "20"
|
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||||
MMTL_LOGGING_MAX_AGE_DAYS: "30"
|
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||||
|
|
||||||
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
||||||
# 旧版 ./data/mmtl.db 存在时,首次启动会自动迁移到 PostgreSQL。
|
# 旧版 ./data/mmtl.db 或 ./data/mebox.db 存在时,首次启动会自动迁移到 PostgreSQL。
|
||||||
MMTL_DATABASE_TYPE: postgres
|
MEBOX_DATABASE_TYPE: postgres
|
||||||
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
|
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||||
|
|
||||||
# SQLite 旧库迁移源:
|
# SQLite 旧库迁移源:
|
||||||
# - 首次从旧版 ./data/mmtl.db 导入时保持此路径。
|
# - 首次从旧版 SQLite(mmtl.db / mebox.db)导入时保持此路径。
|
||||||
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
|
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
|
||||||
MMTL_DATABASE_DB_PATH: /data/mmtl.db
|
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||||
MMTL_CACHE_CACHE_DIR: /cache
|
MEBOX_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
|
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
|
||||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||||
|
|
||||||
# 路径换算配置。
|
# 路径换算配置。
|
||||||
# 默认推荐在网页里使用容器路径 /media。
|
# 默认推荐在网页里使用容器路径 /media。
|
||||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||||
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
|
||||||
MMTL_MEDIA_DIR: /media
|
MEBOX_MEDIA_DIR: /media
|
||||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
|
|
||||||
# 默认推荐下载器保存路径使用 /downloads。
|
# 默认推荐下载器保存路径使用 /downloads。
|
||||||
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
|
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
|
||||||
MMTL_DOWNLOAD_DIR: /downloads
|
MEBOX_DOWNLOAD_DIR: /downloads
|
||||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||||
|
|
||||||
# NAS 友好的低负载默认值。
|
# NAS 友好的低负载默认值。
|
||||||
MMTL_TRANSCODER_ENABLED: "true"
|
MEBOX_TRANSCODER_ENABLED: "true"
|
||||||
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
|
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||||
MMTL_TRANSCODER_REALTIME: "true"
|
MEBOX_TRANSCODER_REALTIME: "true"
|
||||||
MMTL_TRANSCODER_THREADS: "2"
|
MEBOX_TRANSCODER_THREADS: "2"
|
||||||
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
|
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||||
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||||
@@ -150,20 +146,20 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
# 首次部署允许拉取;日常更新请只 pull mmtl。
|
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||||
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
|
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
|
||||||
pull_policy: missing
|
pull_policy: missing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mmtl
|
POSTGRES_DB: mebox
|
||||||
POSTGRES_USER: mmtl
|
POSTGRES_USER: mebox
|
||||||
POSTGRES_PASSWORD: mmtl
|
POSTGRES_PASSWORD: mebox
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
|
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
|
||||||
- ./postgres:/var/lib/postgresql/data
|
- ./postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
Vendored
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
run_uid="${PUID:-$(id -u mmtl 2>/dev/null || echo 1000)}"
|
run_uid="${PUID:-$(id -u mebox 2>/dev/null || echo 1000)}"
|
||||||
run_gid="${PGID:-$(id -g mmtl 2>/dev/null || echo 1000)}"
|
run_gid="${PGID:-$(id -g mebox 2>/dev/null || echo 1000)}"
|
||||||
|
|
||||||
case "$run_uid" in
|
case "$run_uid" in
|
||||||
''|*[!0-9]*)
|
''|*[!0-9]*)
|
||||||
@@ -19,10 +19,10 @@ case "$run_gid" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$run_uid" = "0" ]; then
|
if [ "$run_uid" = "0" ]; then
|
||||||
exec mmtl
|
exec mebox
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
|
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
|
||||||
chown "$run_uid:$run_gid" /media 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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/ShukeBta/MMTL
|
module github.com/truewhile/MeBox
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
@@ -14,10 +14,12 @@ require (
|
|||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.9.0
|
||||||
|
github.com/ulikunitz/xz v0.5.12
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.21.0
|
||||||
golang.org/x/sys v0.20.0
|
golang.org/x/sys v0.20.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.5.7
|
gorm.io/driver/postgres v1.5.7
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.0
|
||||||
)
|
)
|
||||||
@@ -68,7 +70,6 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.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.2.11 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
@@ -77,7 +78,6 @@ require (
|
|||||||
golang.org/x/text v0.20.0 // indirect
|
golang.org/x/text v0.20.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // 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/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 830 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -4,7 +4,7 @@
|
|||||||
// 1. 内置默认值
|
// 1. 内置默认值
|
||||||
// 2. 工作目录中的 config.yaml(嵌套格式)
|
// 2. 工作目录中的 config.yaml(嵌套格式)
|
||||||
// 3. config/*.yaml 分片文件(按模块)
|
// 3. config/*.yaml 分片文件(按模块)
|
||||||
// 4. 以 MMTL_ 为前缀的环境变量
|
// 4. 以 MEBOX_ 为前缀的环境变量
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
|
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
|
||||||
const EnvPrefix = "MMTL"
|
const EnvPrefix = "MeBox"
|
||||||
|
|
||||||
// Load 从默认值 / 文件 / 环境读取配置。
|
// Load 从默认值 / 文件 / 环境读取配置。
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
|
||||||
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
|
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)
|
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
|
||||||
}
|
}
|
||||||
if cfg.Cache.MediaTTLSeconds != 15 {
|
if cfg.Cache.MediaTTLSeconds != 15 {
|
||||||
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
|
t.Fatalf("expected default media cache ttl 15, 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)
|
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||||
}
|
}
|
||||||
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
|
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) {
|
func TestEnvOverride(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
@@ -83,16 +83,16 @@ func TestEnvOverride(t *testing.T) {
|
|||||||
t.Fatalf("chdir: %v", err)
|
t.Fatalf("chdir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv("MMTL_APP_PORT", "9090")
|
t.Setenv("MEBOX_APP_PORT", "9090")
|
||||||
t.Setenv("MMTL_DATABASE_TYPE", "postgres")
|
t.Setenv("MEBOX_DATABASE_TYPE", "postgres")
|
||||||
t.Setenv("MMTL_DATABASE_DSN", "postgres://mmtl:secret@postgres:5432/mmtl?sslmode=disable")
|
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
|
||||||
t.Setenv("MMTL_CACHE_REDIS_URL", "redis://redis:6379/0")
|
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
|
||||||
t.Setenv("MMTL_CACHE_MEDIA_TTL_SECONDS", "30")
|
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
|
||||||
t.Setenv("MMTL_SEARCH_BACKEND", "opensearch")
|
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
|
||||||
t.Setenv("MMTL_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
||||||
t.Setenv("MMTL_LICENSE_SERVER_URL", "https://license.example.com")
|
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
|
||||||
t.Setenv("MMTL_LICENSE_HMAC_SECRET", "override-secret")
|
t.Setenv("MEBOX_LICENSE_HMAC_SECRET", "override-secret")
|
||||||
t.Setenv("MMTL_LICENSE_PUBLIC_KEY", "override-public-key")
|
t.Setenv("MEBOX_LICENSE_PUBLIC_KEY", "override-public-key")
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error: %v", err)
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("app.server_url", "")
|
v.SetDefault("app.server_url", "")
|
||||||
|
|
||||||
v.SetDefault("database.type", "auto")
|
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.dsn", "")
|
||||||
v.SetDefault("database.wal_mode", true)
|
v.SetDefault("database.wal_mode", true)
|
||||||
v.SetDefault("database.busy_timeout", 5000)
|
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_open_conns", defaultDatabaseMaxOpenConns)
|
||||||
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
||||||
|
|
||||||
@@ -43,14 +43,15 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("logging.max_backups", 10)
|
v.SetDefault("logging.max_backups", 10)
|
||||||
|
|
||||||
v.SetDefault("cache.cache_dir", "./cache")
|
v.SetDefault("cache.cache_dir", "./cache")
|
||||||
|
v.SetDefault("cache.images_max_size_mb", 500)
|
||||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||||
v.SetDefault("cache.redis_url", "")
|
v.SetDefault("cache.redis_url", "")
|
||||||
v.SetDefault("cache.redis_prefix", "mmtl")
|
v.SetDefault("cache.redis_prefix", "mebox")
|
||||||
v.SetDefault("cache.media_ttl_seconds", 15)
|
v.SetDefault("cache.media_ttl_seconds", 15)
|
||||||
|
|
||||||
v.SetDefault("search.backend", "")
|
v.SetDefault("search.backend", "")
|
||||||
v.SetDefault("search.opensearch_url", "")
|
v.SetDefault("search.opensearch_url", "")
|
||||||
v.SetDefault("search.index", "mmtl_media")
|
v.SetDefault("search.index", "mebox_media")
|
||||||
v.SetDefault("search.username", "")
|
v.SetDefault("search.username", "")
|
||||||
v.SetDefault("search.password", "")
|
v.SetDefault("search.password", "")
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func setDefaults(v *viper.Viper) {
|
|||||||
|
|
||||||
v.SetDefault("flaresolverr.enabled", false)
|
v.SetDefault("flaresolverr.enabled", false)
|
||||||
v.SetDefault("flaresolverr.url", "http://localhost:8191")
|
v.SetDefault("flaresolverr.url", "http://localhost:8191")
|
||||||
v.SetDefault("flaresolverr.session", "mmtl")
|
v.SetDefault("flaresolverr.session", "mebox")
|
||||||
v.SetDefault("flaresolverr.timeout", 60)
|
v.SetDefault("flaresolverr.timeout", 60)
|
||||||
|
|
||||||
v.SetDefault("downloads.smart_classify", true)
|
v.SetDefault("downloads.smart_classify", true)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (c *Config) normalize() error {
|
|||||||
c.App.DataDir = "./data"
|
c.App.DataDir = "./data"
|
||||||
}
|
}
|
||||||
if c.Database.DBPath == "" {
|
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 == "" {
|
if c.Database.Type == "" {
|
||||||
c.Database.Type = "auto"
|
c.Database.Type = "auto"
|
||||||
@@ -44,15 +44,18 @@ func (c *Config) normalize() error {
|
|||||||
if c.Cache.CacheDir == "" {
|
if c.Cache.CacheDir == "" {
|
||||||
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
|
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
|
||||||
}
|
}
|
||||||
|
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||||
|
c.Cache.ImagesMaxSizeMB = 0
|
||||||
|
}
|
||||||
if c.Cache.RedisPrefix == "" {
|
if c.Cache.RedisPrefix == "" {
|
||||||
c.Cache.RedisPrefix = "mmtl"
|
c.Cache.RedisPrefix = "mebox"
|
||||||
}
|
}
|
||||||
if c.Cache.MediaTTLSeconds < 1 {
|
if c.Cache.MediaTTLSeconds < 1 {
|
||||||
c.Cache.MediaTTLSeconds = 15
|
c.Cache.MediaTTLSeconds = 15
|
||||||
}
|
}
|
||||||
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
|
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
|
||||||
if c.Search.Index == "" {
|
if c.Search.Index == "" {
|
||||||
c.Search.Index = "mmtl_media"
|
c.Search.Index = "mebox_media"
|
||||||
}
|
}
|
||||||
if c.Secrets.JWTSecret == "" {
|
if c.Secrets.JWTSecret == "" {
|
||||||
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
|
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestSaveDatabaseConfig(t *testing.T) {
|
|||||||
t.Fatalf("chdir: %v", err)
|
t.Fatalf("chdir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable"
|
dsn := "postgres://admin:pass@127.0.0.1:5432/mebox?sslmode=disable"
|
||||||
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
|
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
|
||||||
t.Fatalf("SaveDatabaseConfig error: %v", err)
|
t.Fatalf("SaveDatabaseConfig error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
|
|||||||
|
|
||||||
// AppConfig 保存运行时应用参数。
|
// AppConfig 保存运行时应用参数。
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
Debug bool `mapstructure:"debug"`
|
Debug bool `mapstructure:"debug"`
|
||||||
Env string `mapstructure:"env"`
|
Env string `mapstructure:"env"`
|
||||||
DataDir string `mapstructure:"data_dir"`
|
DataDir string `mapstructure:"data_dir"`
|
||||||
WebDir string `mapstructure:"web_dir"`
|
WebDir string `mapstructure:"web_dir"`
|
||||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
||||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||||
@@ -59,7 +59,7 @@ type AppConfig struct {
|
|||||||
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
|
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
|
||||||
SSLCertPath string `mapstructure:"ssl_cert_path"`
|
SSLCertPath string `mapstructure:"ssl_cert_path"`
|
||||||
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
|
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
|
||||||
SSLKeyPath string `mapstructure:"ssl_key_path"`
|
SSLKeyPath string `mapstructure:"ssl_key_path"`
|
||||||
FFmpegPath string `mapstructure:"ffmpeg_path"`
|
FFmpegPath string `mapstructure:"ffmpeg_path"`
|
||||||
FFprobePath string `mapstructure:"ffprobe_path"`
|
FFprobePath string `mapstructure:"ffprobe_path"`
|
||||||
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
|
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
|
||||||
@@ -116,6 +116,7 @@ type LoggingConfig struct {
|
|||||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
CacheDir string `mapstructure:"cache_dir"`
|
CacheDir string `mapstructure:"cache_dir"`
|
||||||
|
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||||
TTLHours int `mapstructure:"ttl_hours"`
|
TTLHours int `mapstructure:"ttl_hours"`
|
||||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||||
@@ -151,7 +152,7 @@ type AIConfig struct {
|
|||||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseConfig configures the optional MMTL license server bridge.
|
// LicenseConfig configures the optional MeBox license server bridge.
|
||||||
type LicenseConfig struct {
|
type LicenseConfig struct {
|
||||||
ServerURL string `mapstructure:"server_url"`
|
ServerURL string `mapstructure:"server_url"`
|
||||||
HMACSecret string `mapstructure:"hmac_secret"`
|
HMACSecret string `mapstructure:"hmac_secret"`
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"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
|
// Open initialises the configured GORM database. database.type=auto chooses
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseStatus describes the currently active database engine and runtime metrics.
|
// DatabaseStatus describes the currently active database engine and runtime metrics.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskDSN(t *testing.T) {
|
func TestMaskDSN(t *testing.T) {
|
||||||
@@ -16,16 +16,16 @@ func TestMaskDSN(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable",
|
in: "postgres://admin:secret123@localhost:5432/mebox?sslmode=disable",
|
||||||
want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable",
|
want: "postgres://admin:******@localhost:5432/mebox?sslmode=disable",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable",
|
in: "host=localhost port=5432 user=admin password=secret dbname=mebox sslmode=disable",
|
||||||
want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable",
|
want: "host=localhost port=5432 user=admin password=****** dbname=mebox sslmode=disable",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
in: "sqlite://data/mmtl.db",
|
in: "sqlite://data/mebox.db",
|
||||||
want: "sqlite://data/mmtl.db",
|
want: "sqlite://data/mebox.db",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
in: "",
|
in: "",
|
||||||
@@ -53,7 +53,7 @@ func TestInspectDatabaseStatus(t *testing.T) {
|
|||||||
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Database.Type = "sqlite"
|
cfg.Database.Type = "sqlite"
|
||||||
cfg.Database.DBPath = "./data/mmtl.db"
|
cfg.Database.DBPath = "./data/mebox.db"
|
||||||
|
|
||||||
st := InspectDatabaseStatus(db, cfg)
|
st := InspectDatabaseStatus(db, cfg)
|
||||||
if st == nil {
|
if st == nil {
|
||||||
@@ -62,7 +62,7 @@ func TestInspectDatabaseStatus(t *testing.T) {
|
|||||||
if st.Type != "sqlite" {
|
if st.Type != "sqlite" {
|
||||||
t.Fatalf("expected sqlite, got %s", st.Type)
|
t.Fatalf("expected sqlite, got %s", st.Type)
|
||||||
}
|
}
|
||||||
if st.DBPath != "./data/mmtl.db" {
|
if st.DBPath != "./data/mebox.db" {
|
||||||
t.Fatalf("expected db_path, got %s", st.DBPath)
|
t.Fatalf("expected db_path, got %s", st.DBPath)
|
||||||
}
|
}
|
||||||
if st.TableCounts["users"] != 1 {
|
if st.TableCounts["users"] != 1 {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOpenRequiresConfig(t *testing.T) {
|
func TestOpenRequiresConfig(t *testing.T) {
|
||||||
@@ -28,7 +28,7 @@ func TestOpenRequiresConfig(t *testing.T) {
|
|||||||
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
|
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Database.Type = "sqlite"
|
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.WALMode = true
|
||||||
cfg.Database.BusyTimeout = 5000
|
cfg.Database.BusyTimeout = 5000
|
||||||
cfg.Database.CacheSize = -2000
|
cfg.Database.CacheSize = -2000
|
||||||
@@ -278,7 +278,7 @@ func TestSQLiteMigrationCompleteMarker(t *testing.T) {
|
|||||||
|
|
||||||
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
sqlitePath := filepath.Join(dir, "mmtl.db")
|
sqlitePath := filepath.Join(dir, "mebox.db")
|
||||||
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -362,7 +362,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
sqlitePath := filepath.Join(dir, "mmtl.db")
|
sqlitePath := filepath.Join(dir, "mebox.db")
|
||||||
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
|
|||||||
_ = sqlDB2.Close()
|
_ = sqlDB2.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
_, copied, err := copyModelTables(src2, dst, 2)
|
_, copied, err := copyModelTables(src2, dst, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if copied != 2 {
|
if copied != 2 {
|
||||||
t.Fatalf("copied rows = %d, want 2", copied)
|
t.Fatalf("copied rows = %d, want 2", copied)
|
||||||
}
|
}
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
|
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
|
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"gorm.io/gorm"
|
"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.
|
// 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 {
|
if err := ensureLibraryRootsCompatibility(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureEmbyMountsCompatibility(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if isSQLite(db) {
|
if isSQLite(db) {
|
||||||
return ensureMediaSearchIndex(db)
|
if err := ensureMediaSearchIndex(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ensureSQLiteQueryOptimizer(db)
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||||
if !isPostgres(db) {
|
if !isPostgres(db) {
|
||||||
return nil
|
return nil
|
||||||
@@ -77,3 +88,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into
|
// MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"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 {
|
func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
|
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"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) {
|
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")
|
// Prefer the new default filename, then fall back to legacy MMTL SQLite files.
|
||||||
if fallback == "" || sameCleanPath(configured, fallback) {
|
candidates := []string{
|
||||||
return "", nil
|
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mebox.db"),
|
||||||
|
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db"),
|
||||||
}
|
}
|
||||||
exists, err := regularFileExists(fallback)
|
for _, fallback := range candidates {
|
||||||
if err != nil {
|
if fallback == "" || sameCleanPath(configured, fallback) {
|
||||||
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
|
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
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func regularFileExists(path string) (bool, error) {
|
func regularFileExists(path string) (bool, error) {
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func installSQLiteWriteGate(db *gorm.DB) {
|
func installSQLiteWriteGate(db *gorm.DB) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lockedKey = "mmtl:sqlite_write_locked"
|
const lockedKey = "mebox:sqlite_write_locked"
|
||||||
gate := newSQLiteWriteGate()
|
gate := newSQLiteWriteGate()
|
||||||
lock := func(tx *gorm.DB) {
|
lock := func(tx *gorm.DB) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -39,14 +39,14 @@ func installSQLiteWriteGate(db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
lock(tx)
|
lock(tx)
|
||||||
}
|
}
|
||||||
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
|
_ = db.Callback().Create().Before("gorm:create").Register("mebox:sqlite_write_lock", lock)
|
||||||
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
|
_ = db.Callback().Create().After("gorm:create").Register("mebox:sqlite_write_unlock", unlock)
|
||||||
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
|
_ = db.Callback().Update().Before("gorm:update").Register("mebox:sqlite_write_lock", lock)
|
||||||
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
|
_ = db.Callback().Update().After("gorm:update").Register("mebox:sqlite_write_unlock", unlock)
|
||||||
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
|
_ = db.Callback().Delete().Before("gorm:delete").Register("mebox:sqlite_write_lock", lock)
|
||||||
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
|
_ = db.Callback().Delete().After("gorm:delete").Register("mebox:sqlite_write_unlock", unlock)
|
||||||
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock)
|
_ = db.Callback().Raw().Before("gorm:raw").Register("mebox:sqlite_write_lock", rawLock)
|
||||||
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
|
_ = db.Callback().Raw().After("gorm:raw").Register("mebox:sqlite_write_unlock", unlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isReadOnlySQL(sql string) bool {
|
func isReadOnlySQL(sql string) bool {
|
||||||
@@ -114,7 +114,10 @@ func buildSQLiteDSN(cfg *config.Config) string {
|
|||||||
if cfg.Database.CacheSize != 0 {
|
if cfg.Database.CacheSize != 0 {
|
||||||
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
|
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
|
||||||
}
|
}
|
||||||
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
|
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)"
|
||||||
|
if cfg.Database.WALMode {
|
||||||
|
dsn += "&_pragma=wal_autocheckpoint(1000)"
|
||||||
|
}
|
||||||
return dsn
|
return dsn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const embyCtxUserName = "emby_user_name"
|
const embyCtxUserName = "emby_user_name"
|
||||||
|
|||||||
+129
-65
@@ -10,8 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -31,7 +31,66 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
for i := range users {
|
for i := range users {
|
||||||
users[i].PopulateComputedFields()
|
users[i].PopulateComputedFields()
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, users)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,77 +251,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.IsActive {
|
if req.IsActive {
|
||||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
|
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
|
||||||
} else {
|
} else {
|
||||||
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
|
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
|
||||||
}
|
|
||||||
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updated.PopulateComputedFields()
|
|
||||||
c.JSON(http.StatusOK, updated)
|
|
||||||
}
|
}
|
||||||
|
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated.PopulateComputedFields()
|
||||||
|
c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type adminUpdateUserLibrariesReq struct {
|
type adminUpdateUserLibrariesReq struct {
|
||||||
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req adminUpdateUserLibrariesReq
|
var req adminUpdateUserLibrariesReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawJSON string
|
var rawJSON string
|
||||||
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
|
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
|
||||||
var cleanIDs []string
|
var cleanIDs []string
|
||||||
for _, id := range *req.AllowedLibraryIDs {
|
for _, id := range *req.AllowedLibraryIDs {
|
||||||
trimmed := strings.TrimSpace(id)
|
trimmed := strings.TrimSpace(id)
|
||||||
if trimmed != "" {
|
if trimmed != "" {
|
||||||
cleanIDs = append(cleanIDs, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(cleanIDs) > 0 {
|
||||||
updates := map[string]any{"allowed_library_ids": rawJSON}
|
data, err := json.Marshal(cleanIDs)
|
||||||
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
rawJSON = string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||||
@@ -288,7 +347,12 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) {
|
|||||||
case errors.Is(err, service.ErrUsernameTaken):
|
case errors.Is(err, service.ErrUsernameTaken):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||||||
case errors.Is(err, service.ErrUserLimitReached):
|
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:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DatabaseConnectionPayload struct {
|
type DatabaseConnectionPayload struct {
|
||||||
@@ -38,7 +38,7 @@ func (p *DatabaseConnectionPayload) BuildDSN() string {
|
|||||||
user := strings.TrimSpace(p.User)
|
user := strings.TrimSpace(p.User)
|
||||||
dbname := strings.TrimSpace(p.DBName)
|
dbname := strings.TrimSpace(p.DBName)
|
||||||
if dbname == "" {
|
if dbname == "" {
|
||||||
dbname = "mmtl"
|
dbname = "mebox"
|
||||||
}
|
}
|
||||||
sslmode := strings.TrimSpace(p.SSLMode)
|
sslmode := strings.TrimSpace(p.SSLMode)
|
||||||
if sslmode == "" {
|
if sslmode == "" {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildDSN(t *testing.T) {
|
func TestBuildDSN(t *testing.T) {
|
||||||
@@ -30,10 +30,10 @@ func TestBuildDSN(t *testing.T) {
|
|||||||
Port: 5432,
|
Port: 5432,
|
||||||
User: "postgres",
|
User: "postgres",
|
||||||
Password: "secretpassword",
|
Password: "secretpassword",
|
||||||
DBName: "mmtl_prod",
|
DBName: "mebox_prod",
|
||||||
SSLMode: "disable",
|
SSLMode: "disable",
|
||||||
},
|
},
|
||||||
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable",
|
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mebox_prod?sslmode=disable",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func TestGetDatabaseStatusHandler(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Database.Type = "sqlite"
|
cfg.Database.Type = "sqlite"
|
||||||
cfg.Database.DBPath = "./data/mmtl.db"
|
cfg.Database.DBPath = "./data/mebox.db"
|
||||||
|
|
||||||
svc := &service.Container{
|
svc := &service.Container{
|
||||||
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
|
||||||
@@ -89,7 +89,7 @@ func TestSaveDatabaseConfigHandler(t *testing.T) {
|
|||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
|
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
|
||||||
|
|
||||||
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`)
|
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 := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type settingReq struct {
|
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" {
|
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||||
svc.Transcoder.StopAll()
|
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)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
||||||
@@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
|||||||
if w.Code != http.StatusConflict {
|
if w.Code != http.StatusConflict {
|
||||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||||
t.Fatal("recent realtime user should not be deleted")
|
t.Fatal("recent realtime user should not be deleted")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateUserLibraries(t *testing.T) {
|
func TestUpdateUserLibraries(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApiConfigHandler API 配置 HTTP 处理。
|
// ApiConfigHandler API 配置 HTTP 处理。
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginReq struct {
|
type loginReq struct {
|
||||||
@@ -68,6 +68,15 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
|
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
|
||||||
return
|
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
|
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetAccessTokenCookie(t *testing.T) {
|
func TestSetAccessTokenCookie(t *testing.T) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// refreshHandler returns a fresh token signed for the current user.
|
// refreshHandler returns a fresh token signed for the current user.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
|
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
|
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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.
|
// dlnaControlPath maps the action name to the AVTransport SOAP body.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
|
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
|
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
||||||
@@ -24,7 +24,7 @@ func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
|
|||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
|
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" {
|
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
|
||||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
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-Proto", "https")
|
||||||
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
|
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" {
|
if payload["LocalAddress"] != "https://media.example.test" {
|
||||||
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
var embyPlaceholderPNG = []byte{
|
var embyPlaceholderPNG = []byte{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
|
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
|
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
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// embyMountView 挂载的对外 JSON(附带账号信息)。
|
// embyMountView 挂载的对外 JSON(附带账号信息)。
|
||||||
@@ -195,4 +195,28 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type embyPlayingReq struct {
|
type embyPlayingReq struct {
|
||||||
ItemId string `json:"ItemId"`
|
ItemId string `json:"ItemId"`
|
||||||
|
ItemIDLower string `json:"itemId"`
|
||||||
|
ID string `json:"Id"`
|
||||||
|
IDLower string `json:"id"`
|
||||||
PositionTicks int64 `json:"PositionTicks"`
|
PositionTicks int64 `json:"PositionTicks"`
|
||||||
|
PositionLower int64 `json:"positionTicks"`
|
||||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||||
|
RunTimeLower int64 `json:"runTimeTicks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
var req embyPlayingReq
|
var req embyPlayingReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
if req.ItemId == "" {
|
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
|
||||||
req.ItemId = c.Query("ItemId")
|
if itemID == "" {
|
||||||
|
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
|
||||||
}
|
}
|
||||||
if req.PositionTicks == 0 {
|
pos := req.PositionTicks
|
||||||
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
|
if pos == 0 {
|
||||||
|
pos = req.PositionLower
|
||||||
}
|
}
|
||||||
if req.RunTimeTicks == 0 {
|
if pos == 0 {
|
||||||
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
|
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)
|
c.Status(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,7 +57,10 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
return
|
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")
|
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
|
||||||
if svc.Sessions != nil {
|
if svc.Sessions != nil {
|
||||||
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
|
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
|
||||||
@@ -51,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
clientInfo.DeviceName,
|
clientInfo.DeviceName,
|
||||||
clientInfo.Client,
|
clientInfo.Client,
|
||||||
c.ClientIP(),
|
c.ClientIP(),
|
||||||
req.ItemId,
|
itemID,
|
||||||
req.PositionTicks,
|
pos,
|
||||||
req.RunTimeTicks,
|
runTime,
|
||||||
stopped)
|
stopped)
|
||||||
}
|
}
|
||||||
if svc.Device != nil && !stopped {
|
if svc.Device != nil && !stopped {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
|
// 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("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
||||||
auth.GET("/Items/Resume", embyResumeItemsHandler(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("/Items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
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("/users/:userId/items/counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
||||||
auth.GET("/items/resume", embyResumeItemsHandler(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("/items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
|
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
|
||||||
@@ -213,10 +213,13 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
|
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
var users []model.User
|
var payload struct {
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
|
Users []model.User `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
t.Fatalf("decode users: %v", err)
|
t.Fatalf("decode users: %v", err)
|
||||||
}
|
}
|
||||||
|
users := payload.Users
|
||||||
if len(users) != 1 {
|
if len(users) != 1 {
|
||||||
t.Fatalf("users = %#v", users)
|
t.Fatalf("users = %#v", users)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -29,7 +29,7 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
row := gin.H{
|
row := gin.H{
|
||||||
"Id": sess.ID,
|
"Id": sess.ID,
|
||||||
"ServerId": "mmtl-001",
|
"ServerId": "mebox-001",
|
||||||
"Client": sess.Client,
|
"Client": sess.Client,
|
||||||
"DeviceId": sess.DeviceID,
|
"DeviceId": sess.DeviceID,
|
||||||
"DeviceName": sess.DeviceName,
|
"DeviceName": sess.DeviceName,
|
||||||
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
"SupportsRemoteControl": true,
|
"SupportsRemoteControl": true,
|
||||||
}
|
}
|
||||||
if itemID != "" && sess.IsPlaying {
|
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)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
|
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
|
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
|
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
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, svc.Emby.SystemInfoPublic())
|
||||||
}
|
}
|
||||||
return embyWithRequestAddress(c, map[string]any{
|
return embyWithRequestAddress(c, map[string]any{
|
||||||
"Id": "mmtl-001",
|
"Id": "mebox-001",
|
||||||
"ServerId": "mmtl-001",
|
"ServerId": "mebox-001",
|
||||||
"ServerName": "MMTL",
|
"ServerName": "MeBox",
|
||||||
"Version": "4.8.10.0",
|
"Version": "4.8.10.0",
|
||||||
"ServerVersion": "4.8.10.0",
|
"ServerVersion": "4.8.10.0",
|
||||||
"ProductName": "Emby Server",
|
"ProductName": "Emby Server",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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 {
|
func signedTestToken(t *testing.T, secret string) string {
|
||||||
@@ -23,7 +23,7 @@ func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string {
|
|||||||
Purpose: purpose,
|
Purpose: purpose,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
Issuer: "mmtl-test",
|
Issuer: "mebox-test",
|
||||||
Subject: "user-1",
|
Subject: "user-1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Users / Auth ────────────────────────────────────────────────────────────
|
// ─── Users / Auth ────────────────────────────────────────────────────────────
|
||||||
@@ -67,7 +67,7 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
embyRememberCompatSession(c, accessToken)
|
embyRememberCompatSession(c, accessToken)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"AccessToken": accessToken,
|
"AccessToken": accessToken,
|
||||||
"ServerId": "mmtl-001",
|
"ServerId": "mebox-001",
|
||||||
"User": userPayload,
|
"User": userPayload,
|
||||||
"SessionInfo": gin.H{
|
"SessionInfo": gin.H{
|
||||||
"Id": resp.User.ID,
|
"Id": resp.User.ID,
|
||||||
@@ -150,12 +150,12 @@ func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func embyFallbackUser(id string) gin.H {
|
func embyFallbackUser(id string) gin.H {
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
id = "mmtl-user"
|
id = "mebox-user"
|
||||||
}
|
}
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"Id": id,
|
"Id": id,
|
||||||
"Name": "MMTL",
|
"Name": "MeBox",
|
||||||
"ServerId": "mmtl-001",
|
"ServerId": "mebox-001",
|
||||||
"HasPassword": true,
|
"HasPassword": true,
|
||||||
"HasConfiguredPassword": true,
|
"HasConfiguredPassword": true,
|
||||||
"HasConfiguredEasyPassword": false,
|
"HasConfiguredEasyPassword": false,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileFolderReq struct {
|
type fileFolderReq struct {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register attaches every API route to the engine.
|
// Register attaches every API route to the engine.
|
||||||
@@ -46,7 +46,7 @@ func healthCheck(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func versionInfo(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 包装 ────────────────────────────────────────────────────────
|
// ─── 权限 Handler 包装 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type manualScrapeApplyReq struct {
|
type manualScrapeApplyReq struct {
|
||||||
|
|||||||
+74
-49
@@ -7,31 +7,32 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type createLibraryReq struct {
|
type createLibraryReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
Roots []service.LibraryRootInput `json:"roots"`
|
Roots []service.LibraryRootInput `json:"roots"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
CreatePerSubfolder bool `json:"create_per_subfolder"`
|
CreatePerSubfolder bool `json:"create_per_subfolder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
|
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
|
||||||
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
|
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
|
||||||
type webLibraryPayload struct {
|
type webLibraryPayload struct {
|
||||||
model.Library
|
model.Library
|
||||||
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
||||||
RemoteSource string `json:"remote_source,omitempty"`
|
RemoteSource string `json:"remote_source,omitempty"`
|
||||||
Total int64 `json:"total,omitempty"`
|
Total int64 `json:"total,omitempty"`
|
||||||
Cards []service.SeriesCard `json:"cards,omitempty"`
|
Cards []service.SeriesCard `json:"cards,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,22 +97,42 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 远程 Emby 挂载库追加在本地库之后。
|
// 远程 Emby 挂载库追加在本地库之后。
|
||||||
if svc.EmbyRemote != nil {
|
if svc.EmbyRemote != nil {
|
||||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||||
for _, v := range views {
|
remotePayloads := make([]webLibraryPayload, len(views))
|
||||||
wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
for i, v := range views {
|
||||||
if withPreview {
|
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||||
if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil {
|
}
|
||||||
|
if withPreview && len(views) > 0 {
|
||||||
|
const maxRemotePreviewWorkers = 6
|
||||||
|
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, v := range views {
|
||||||
|
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}}
|
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||||
wl.Total = total
|
remotePayloads[i].Total = total
|
||||||
}
|
}
|
||||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||||
wl.Cards = cards
|
remotePayloads[i].Cards = cards
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
}
|
}
|
||||||
out = append(out, wl)
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
out = append(out, remotePayloads...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
@@ -171,38 +192,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||||
roots = append(roots, service.LibraryRootInput{Path: 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
|
|
||||||
}
|
}
|
||||||
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
var l *model.Library
|
||||||
if err != nil {
|
if req.CreatePerSubfolder {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
parent := ""
|
||||||
return
|
if len(roots) > 0 {
|
||||||
}
|
parent = roots[0].Path
|
||||||
uid, _ := c.Get("ctx_user_id")
|
} else if strings.TrimSpace(req.Path) != "" {
|
||||||
for i := range created {
|
parent = req.Path
|
||||||
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 {
|
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
||||||
if root.Enabled {
|
if err != nil {
|
||||||
queueLibraryRootScan(svc, lib.ID, root.ID)
|
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})
|
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||||
return
|
|
||||||
}
|
|
||||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -488,8 +509,11 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
// 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。
|
// 远程 Emby 条目:按挂载代理配置分流——代理走 MeBox 反代,否则 302 直连。
|
||||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||||
|
if !enforceScopedPlaybackToken(c, id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
|
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
|
||||||
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
||||||
if mount == nil || acct == nil {
|
if mount == nil || acct == nil {
|
||||||
@@ -509,6 +533,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setRedirectNoStoreHeaders(c)
|
||||||
c.Redirect(http.StatusFound, target)
|
c.Redirect(http.StatusFound, target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func recentMediaHandler(svc *service.Container) gin.HandlerFunc {
|
func recentMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/ShukeBta/MMTL/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// addMediaFavoriteHandler ensures the (user, media) row exists. If it
|
// addMediaFavoriteHandler ensures the (user, media) row exists. If it
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listLibraryRootsHandler(svc *service.Container) gin.HandlerFunc {
|
func listLibraryRootsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MMTL/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user