├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── config.yaml │ └── feat.yaml └── workflows │ ├── binary-builder.yaml │ ├── dev.yaml │ ├── docker-builder.yaml │ └── release.yaml ├── .gitignore ├── .gitmodules ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── config └── config.yaml.example ├── constants ├── color.go ├── config.go ├── logo.go ├── mediaserver.go ├── referer.go ├── regexp.go ├── regexp_test.go ├── strm.go └── time.go ├── custom └── .gitkeep ├── docker ├── Dockerfile └── Dockerfile-goreleaser ├── docs ├── DEV.md ├── UA.md └── UpdateLog.md ├── go.mod ├── go.sum ├── img ├── client_filter.png ├── danmaku.png ├── index.jpg ├── movie.jpg └── series.jpg ├── internal ├── config │ ├── config.go │ ├── type.go │ └── version.go ├── handler │ ├── emby.go │ ├── jellyfin.go │ ├── rule.go │ ├── server.go │ └── utils.go ├── logging │ ├── access.go │ ├── logger.go │ └── service.go ├── middleware │ ├── case.go │ ├── fliter.go │ ├── log.go │ ├── recover.go │ └── referer.go ├── router │ └── router.go └── service │ ├── alist.go │ ├── alist │ ├── alist.go │ └── schema.go │ ├── emby │ ├── emby.go │ └── schema.go │ └── jellyfin │ ├── jellyfin.go │ └── schema.go ├── logs └── .gitkeep ├── main.go ├── static └── embed.go └── utils ├── fs.go ├── http.go ├── set.go ├── slice.go ├── string.go ├── string_test.go ├── subtitle.go └── subtitle_test.go /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: 问题反馈 2 | description: File a bug report 3 | title: "[错误报告]: 请在此处简单描述你的问题" 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | 请确认以下信息: 11 | 1. 请按此模板提交issues,不按模板提交的问题将直接关闭。 12 | 2. 如果你的问题可以直接在以往 issue 中找到,那么你的 issue 将会被直接关闭。 13 | 3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。 14 | 5. **$\color{red}{不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。 15 | - type: checkboxes 16 | id: ensure 17 | attributes: 18 | label: 确认 19 | description: 在提交 issue 之前,请确认你已经阅读并确认以下内容 20 | options: 21 | - label: 我的版本是最新版本,我的版本号与 [version](https://github.com/Akimio521/MediaWarp/releases/latest) 相同。 22 | required: true 23 | - label: 我已经 [issue](https://github.com/Akimio521/MediaWarp/issues) 中搜索过,确认我的问题没有被提出过。 24 | required: true 25 | - label: 我已经修改标题,将标题中的 描述 替换为我遇到的问题。 26 | required: true 27 | 28 | - type: input 29 | id: version 30 | attributes: 31 | label: 当前程序版本 32 | description: 遇到问题时程序所在的版本号 33 | validations: 34 | required: true 35 | - type: input 36 | id: version-info 37 | attributes: 38 | label: 当前程序版本的详细信息 39 | description: 访问 `http://:/MediaWarp/version` 或通过 `./MediaWarp --version` 获取 40 | validations: 41 | required: true 42 | - type: dropdown 43 | id: environment 44 | attributes: 45 | label: 运行环境 46 | description: 当前程序运行环境 47 | options: 48 | - Docker 49 | - Windows 50 | - Linux 51 | - MacOS 52 | validations: 53 | required: true 54 | - type: dropdown 55 | id: mediaserver 56 | attributes: 57 | label: 媒体服务器类型 58 | description: 上游媒体服务器类型 59 | options: 60 | - Emby 61 | - Jellyfin 62 | validations: 63 | required: true 64 | - type: input 65 | id: mediaserver-version 66 | attributes: 67 | label: 媒体服务器版本 68 | description: 上游媒体服务器版本(如果使用 docker 最好可以提供详细标签信息,例如`linuxserver/emby:4.9.0-beta`) 69 | validations: 70 | required: true 71 | - type: input 72 | id: client-version 73 | attributes: 74 | label: 客户端版本 75 | description: 使用的客户端版本及版本号(Fileball/238 CFNetwork/1410.0.3 Darwin/22.6.0、Infuse-Direct/7.8、Emby/3.2.32-17.41 (Linux;Android 14) ExoPlayerLib/2.13.2) 76 | validations: 77 | required: true 78 | - type: dropdown 79 | id: type 80 | attributes: 81 | label: 问题类型 82 | description: 你在以下哪个部分碰到了问题 83 | options: 84 | - Strm 文件播放问题 85 | - Web 美化/额外功能问题 86 | - 配置文件读取问题 87 | - 其他问题 88 | validations: 89 | required: true 90 | - type: textarea 91 | id: what-happened 92 | attributes: 93 | label: 问题描述 94 | description: 请详细描述你碰到的问题 95 | placeholder: "问题描述" 96 | validations: 97 | required: true 98 | - type: textarea 99 | id: logs 100 | attributes: 101 | label: 发生问题时相关日志 102 | description: 问题出现时,程序运行日志请复制到这里。 103 | render: bash 104 | - type: textarea 105 | id: config 106 | attributes: 107 | label: 发生问题时配置文件 108 | description: 问题出现时,程序配置文件请复制到这里。 109 | render: yaml 110 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 项目讨论 4 | url: https://github.com/Akimio521/MediaWarp/discussions/new/choose 5 | about: discussion -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feat.yaml: -------------------------------------------------------------------------------- 1 | name: 功能改进 2 | description: Feature Request 3 | title: "[功能改进]: " 4 | labels: ["feat"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 请说明你希望添加的功能。 10 | - type: input 11 | id: version 12 | attributes: 13 | label: 当前程序版本 14 | description: 遇到问题时程序所在的版本号 15 | validations: 16 | required: true 17 | - type: input 18 | id: version-info 19 | attributes: 20 | label: 当前程序版本的详细信息 21 | description: 访问 `http://:/MediaWarp/version` 或通过 `./MediaWarp --version` 获取 22 | validations: 23 | required: false 24 | - type: textarea 25 | id: feature-request 26 | attributes: 27 | label: 功能改进 28 | description: 请详细描述需要改进或者添加的功能。 29 | placeholder: "功能改进" 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: references 34 | attributes: 35 | label: 参考资料 36 | description: 可以列举一些参考资料,但是不要引用同类但商业化软件的任何内容。 37 | placeholder: "参考资料" -------------------------------------------------------------------------------- /.github/workflows/binary-builder.yaml: -------------------------------------------------------------------------------- 1 | name: MediaWarp Binary Builder 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | APP_VERSION: 7 | description: "用于生成可执行文件的版本号" 8 | required: true 9 | type: string 10 | 11 | env: 12 | APP_VERSION: ${{ inputs.APP_VERSION }} 13 | APP_NAME: MediaWarp 14 | BINARY_DIR: ${{ github.workspace }}/bin 15 | 16 | jobs: 17 | binary-builder: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | os: [linux, windows, darwin] 22 | arch: [amd64, arm64] 23 | 24 | steps: 25 | - name: Show Information 26 | run: | 27 | echo "项目名:${{ env.APP_NAME }}" 28 | echo "版本号:${{ env.APP_VERSION }}" 29 | echo "操作系统:${{ matrix.os }}" 30 | echo "架构:${{ matrix.arch }}" 31 | echo "可执行文件:${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }}" 32 | 33 | - name: Clone Repository 34 | uses: actions/checkout@v4 35 | with: 36 | submodules: true 37 | fetch-depth: 0 38 | 39 | - name: Set Up Golong Environment 40 | uses: actions/setup-go@v5 41 | with: 42 | go-version: "1.22.5" 43 | 44 | - name: Build Binary for ${{ matrix.os }}-${{ matrix.arch }} 45 | run: | 46 | GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o ${{ env.BINARY_DIR }}/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} 47 | 48 | - name: Upload Binary 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: ${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} 52 | path: ${{ env.BINARY_DIR }}/${{ env.APP_NAME }}-${{ env.APP_VERSION }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} 53 | -------------------------------------------------------------------------------- /.github/workflows/dev.yaml: -------------------------------------------------------------------------------- 1 | name: MediaWarp DEV 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - "**.go" 10 | - "go.mod" 11 | - "go.sum" 12 | 13 | jobs: 14 | builder: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | submodules: true 21 | fetch-depth: 0 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version-file: "go.mod" # 使用 go.mod 文件中的 golang 版本 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Login to DockerHub 32 | uses: docker/login-action@v3 33 | with: 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | 37 | - name: Run GoReleaser 38 | uses: goreleaser/goreleaser-action@v6 39 | with: 40 | distribution: goreleaser 41 | version: "~> v2" 42 | args: release --snapshot --clean 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | # - name: Push Dev Docker Image 47 | # run: | 48 | # docker images akimio/mediawarp 49 | # VERSION=$(docker images akimio/mediawarp | grep -oP 'akimio/mediawarp\s+\K[^\s]+(?=-amd64)') 50 | # echo $VERSION 51 | # IMAGE_NAME="akimio/mediawarp" 52 | # docker manifest create ${IMAGE_NAME}:${VERSION} \ 53 | # ${IMAGE_NAME}:${VERSION}-amd64 \ 54 | # ${IMAGE_NAME}:${VERSION}-arm64 \ 55 | # ${IMAGE_NAME}:${VERSION}-armv6 \ 56 | # ${IMAGE_NAME}:${VERSION}-armv7 57 | 58 | # docker manifest annotate ${IMAGE_NAME}:${VERSION} ${IMAGE_NAME}:${VERSION}-amd64 --arch amd64 59 | # docker manifest annotate ${IMAGE_NAME}:${VERSION} ${IMAGE_NAME}:${VERSION}-arm64 --arch arm64 60 | # docker manifest annotate ${IMAGE_NAME}:${VERSION} ${IMAGE_NAME}:${VERSION}-armv7 --arch arm --variant v7 61 | # docker manifest annotate ${IMAGE_NAME}:${VERSION} ${IMAGE_NAME}:${VERSION}-armv6 --arch arm --variant v6 62 | 63 | # docker manifest push ${IMAGE_NAME}:${VERSION} 64 | 65 | - name: Upload assets 66 | uses: actions/upload-artifact@v4 67 | with: 68 | path: | 69 | ./dist/*.zip 70 | ./dist/*.tar.gz 71 | ./dist/*.json 72 | ./dist/*.yaml 73 | -------------------------------------------------------------------------------- /.github/workflows/docker-builder.yaml: -------------------------------------------------------------------------------- 1 | name: MediaWarp Docker Builder 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | APP_VERSION: 7 | description: "用于Docker镜像版本的标签号" 8 | required: true 9 | type: string 10 | IS_LATEST: 11 | description: "是否发布为Docker镜像最新版本" 12 | required: true 13 | type: boolean 14 | secrets: 15 | DOCKERHUB_USERNAME: 16 | required: true 17 | DOCKERHUB_TOKEN: 18 | required: true 19 | 20 | env: 21 | APP_VERSION: ${{ inputs.APP_VERSION }} 22 | IS_LATEST: ${{ inputs.IS_LATEST }} 23 | 24 | jobs: 25 | docker-builder: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Show Information 29 | run: | 30 | echo "Docker镜像版本的标签号:${{ env.APP_VERSION }}" 31 | echo "是否发布为Docker镜像最新版本:${{ env.IS_LATEST }}" 32 | 33 | - name: Clone Repository 34 | uses: actions/checkout@v4 35 | with: 36 | submodules: true 37 | fetch-depth: 0 38 | 39 | - name: Docker Meta 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ${{ secrets.DOCKERHUB_USERNAME }}/mediawarp 44 | tags: | 45 | type=raw,value=latest,enable=${{ env.IS_LATEST }} 46 | type=raw,value=${{ env.APP_VERSION }},enable=true 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v3 53 | 54 | - name: Login to DockerHub 55 | uses: docker/login-action@v3 56 | with: 57 | username: ${{ secrets.DOCKERHUB_USERNAME }} 58 | password: ${{ secrets.DOCKERHUB_TOKEN }} 59 | 60 | - name: Build Image 61 | uses: docker/build-push-action@v5 62 | with: 63 | context: . 64 | file: ./Dockerfile 65 | platforms: | 66 | linux/amd64 67 | linux/arm/v7 68 | linux/arm64/v8 69 | linux/s390x 70 | push: true 71 | build-args: | 72 | MEDIAWARP_VERSION=${{ env.APP_VERSION }} 73 | tags: ${{ steps.meta.outputs.tags }} 74 | labels: ${{ steps.meta.outputs.labels }} 75 | cache-from: type=gha, scope=${{ github.workflow }}-docker 76 | cache-to: type=gha, scope=${{ github.workflow }}-docker 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: MediaWarp Releaser 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | submodules: true 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version-file: "go.mod" # 使用 go.mod 文件中的 golang 版本 25 | 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKERHUB_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | 35 | - name: Run GoReleaser 36 | uses: goreleaser/goreleaser-action@v6 37 | with: 38 | distribution: goreleaser 39 | version: "~> v2" 40 | args: release --clean 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | config/* 3 | !config/.gitkeep 4 | logs/* 5 | !logs/.gitkeep 6 | /custom/* 7 | !/custom/.gitkeep 8 | 9 | # 执行文件 10 | dist/ 11 | MediaWarp* 12 | 13 | # 其他 14 | test/* 15 | .DS_Store -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "static/embyExternalUrl"] 2 | path = static/embyExternalUrl 3 | url = https://github.com/bpking1/embyExternalUrl.git 4 | [submodule "static/dd-danmaku"] 5 | path = static/dd-danmaku 6 | url = https://github.com/9channel/dd-danmaku.git 7 | [submodule "static/emby-web-mod"] 8 | path = static/emby-web-mod 9 | url = https://github.com/newday-life/emby-web-mod.git 10 | [submodule "static/jellyfin-crx"] 11 | path = static/jellyfin-crx 12 | url = https://github.com/newday-life/jellyfin-crx.git 13 | [submodule "static/emby-crx"] 14 | path = static/emby-crx 15 | url = https://github.com/Nolovenodie/emby-crx.git 16 | [submodule "static/jellyfin-danmaku"] 17 | path = static/jellyfin-danmaku 18 | url = https://github.com/Izumiko/jellyfin-danmaku.git 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: MediaWarp 4 | 5 | snapshot: 6 | version_template: "{{ incpatch .Version }}-PreRelease-{{ .ShortCommit }}" 7 | 8 | release: 9 | name_template: "{{ .Tag }}" 10 | before: 11 | hooks: 12 | - go mod download 13 | 14 | builds: 15 | - env: 16 | - GOPROXY=https://goproxy.io,direct 17 | - CGO_ENABLED=0 18 | goos: 19 | - linux 20 | - windows 21 | - darwin 22 | goarch: 23 | - amd64 24 | - arm 25 | - arm64 26 | goarm: 27 | - 5 28 | - 6 29 | - 7 30 | ignore: 31 | - goos: windows 32 | goarm: 5 33 | - goos: windows 34 | goarm: 6 35 | - goos: windows 36 | goarm: 7 37 | binary: "{{ .ProjectName }}" 38 | ldflags: 39 | - -s -w 40 | - -X MediaWarp/internal/config.appVersion={{ .Version }} 41 | - -X MediaWarp/internal/config.commitHash={{ .FullCommit }} 42 | - -X MediaWarp/internal/config.buildDate={{ .Date}} 43 | 44 | dockers: 45 | - dockerfile: docker/Dockerfile-goreleaser 46 | use: buildx 47 | skip_push: false 48 | build_flag_templates: 49 | - "--platform=linux/amd64" 50 | - "--label=org.opencontainers.image.created={{ .Date }}" 51 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 52 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 53 | - "--label=org.opencontainers.image.version={{ .Version }}" 54 | goos: linux 55 | goarch: amd64 56 | goamd64: v1 57 | image_templates: 58 | - "akimio/mediawarp:{{ .Version }}-amd64" 59 | 60 | - dockerfile: docker/Dockerfile-goreleaser 61 | use: buildx 62 | skip_push: false 63 | build_flag_templates: 64 | - "--platform=linux/arm64" 65 | - "--label=org.opencontainers.image.created={{ .Date }}" 66 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 67 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 68 | - "--label=org.opencontainers.image.version={{ .Version }}" 69 | goos: linux 70 | goarch: arm64 71 | image_templates: 72 | - "akimio/mediawarp:{{ .Version }}-arm64" 73 | 74 | - dockerfile: docker/Dockerfile-goreleaser 75 | use: buildx 76 | skip_push: false 77 | build_flag_templates: 78 | - "--platform=linux/arm/v6" 79 | - "--label=org.opencontainers.image.created={{ .Date }}" 80 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 81 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 82 | - "--label=org.opencontainers.image.version={{ .Version }}" 83 | goos: linux 84 | goarch: arm 85 | goarm: "6" 86 | image_templates: 87 | - "akimio/mediawarp:{{ .Version }}-armv6" 88 | 89 | - dockerfile: docker/Dockerfile-goreleaser 90 | use: buildx 91 | skip_push: false 92 | build_flag_templates: 93 | - "--platform=linux/arm/v7" 94 | - "--label=org.opencontainers.image.created={{ .Date }}" 95 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 96 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 97 | - "--label=org.opencontainers.image.version={{ .Version }}" 98 | goos: linux 99 | goarch: arm 100 | goarm: "7" 101 | image_templates: 102 | - "akimio/mediawarp:{{ .Version }}-armv7" 103 | 104 | docker_manifests: 105 | - name_template: "akimio/mediawarp:latest" 106 | image_templates: 107 | - "akimio/mediawarp:{{ .Version }}-amd64" 108 | - "akimio/mediawarp:{{ .Version }}-arm64" 109 | - "akimio/mediawarp:{{ .Version }}-armv6" 110 | - "akimio/mediawarp:{{ .Version }}-armv7" 111 | - name_template: "akimio/mediawarp:{{ .Version }}" 112 | image_templates: 113 | - "akimio/mediawarp:{{ .Version }}-amd64" 114 | - "akimio/mediawarp:{{ .Version }}-arm64" 115 | - "akimio/mediawarp:{{ .Version }}-armv6" 116 | - "akimio/mediawarp:{{ .Version }}-armv7" 117 | 118 | archives: 119 | - format: tar.gz 120 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{- .Os }}_{{ .Arch }}{{- if .Arm}}v{{ .Arm }}{{- end}}" 121 | # 对于 Windows 存档使用 zip 格式 122 | format_overrides: 123 | - goos: windows 124 | format: zip 125 | files: 126 | - src: LICENSE 127 | - src: README.md 128 | - src: config/config.yaml.example 129 | dst: config.yaml.example 130 | 131 | changelog: 132 | sort: asc 133 | filters: 134 | exclude: 135 | - "^docs:" 136 | - "^test:" 137 | - "^build:" 138 | - "^release:" 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [license]: /LICENSE 2 | [license-badge]: https://img.shields.io/github/license/Akimio521/MediaWarp?style=flat-square&a=1 3 | [prs]: https://github.com/Akimio521/MediaWarp 4 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 5 | [issues]: https://github.com/Akimio521/MediaWarp/issues/new 6 | [issues-badge]: https://img.shields.io/badge/Issues-welcome-brightgreen.svg?style=flat-square 7 | [release]: https://github.com/Akimio521/MediaWarp/releases/latest 8 | [release-badge]: https://img.shields.io/github/v/release/Akimio521/MediaWarp?style=flat-square 9 | [docker]: https://hub.docker.com/r/akimio/mediawarp 10 | [docker-badge]: https://img.shields.io/docker/pulls/akimio/mediawarp?color=%2348BB78&logo=docker&label=pulls 11 | 12 |
13 | 14 | # MediaWarp 15 | 16 | MediaWarp 是**前置于 EmbyServer/Jellyfin 的反向代理服务器**,修改了原媒体服务器返回响应以实现特殊功能 17 | 18 | [![license][license-badge]][license] 19 | [![prs][prs-badge]][prs] 20 | [![issues][issues-badge]][issues] 21 | [![release][release-badge]][release] 22 | [![docker][docker-badge]][docker] 23 | 24 | 25 | 26 | [功能](#功能) • 27 | [TODO LIST](#todo-list) • 28 | [文档](#相关文档) • 29 | [鸣谢](#鸣谢) • 30 | [Star History](#star-history) 31 | 32 |
33 | 34 | # 功能 35 | - Strm 文件可以实现 302 直链播放,流量不经过 EmbyServer/Jellyfin 36 | - **推荐配合 [AutoFilm](https://github.com/Akimio521/AutoFilm) 使用** 37 | - 已通过测试客户端(Web、iOS Emby、Infuse、Conflux、Fileball、Vidhub) 38 | - 支持 Strm: 39 | - HTTPStrm:Strm 文件内容是 HTTP 链接,浏览器访问链接可以直接下载到视频文件(**客户端需要可以访问到该链接,MediaWarp 不需要访问到该地址**) 40 | - AlistStrm:Strm 文件内容是 Alist 上的路径,需要拼接 Alist 的地址可以访问到文件(**客户端无需访问到 Alist 服务器,仅需要 MediaWarp 可以访问到 Alist 服务器,但是需要可以访问到 Alist 服务器上文件的 raw_url 属性,如果使用网盘存储则无需在意这一点,但目前兼容性较差且不支持转码,通过挂载真实目录可以缓解这一问题**) 41 | 42 | - 屏蔽特定客户端访问 43 | 44 | 45 | 46 | - 自定义 Web 前端样式(HTML、CSS、JavaScript) 47 | - 效果演示: 48 | 49 | 首页 50 | 电影 51 | 电视剧 52 | danmaku 弹幕 53 | 54 | - 嵌入功能 55 | - ExternalPlayerUrl:调用外部播放器(仅 Emby) 56 | - crx:美化包 [emby-crx](https://github.com/Nolovenodie/emby-crx);[jellyfin-crx](https://github.com/newday-life/jellyfin-crx) 57 | - ActorPlus:隐藏没有头像的演员和制作人员 58 | - FanartShow:显示同人图(fanart 图) 59 | - Danmaku:Web 弹幕 [Emby](https://github.com/9channel/dd-danmaku);[Jellyfin](https://github.com/Izumiko/jellyfin-danmaku) 60 | - ~~BeautifyCSS:Emby 美化 CSS 样式~~(已移除,若有需求请实用通过自定义 Web.Head 功能实现) 61 | 62 | # TODO LIST 63 | - [x] HTTPStrm 实现 302 重定向 64 | - [x] 屏蔽特定客户端访问 65 | - [x] 提供多种 Web 前端美化功能 66 | - [x] AlistStrm 实现 302 重定向 67 | - [x] 嵌入一些实用的 JavaScript 方便使用 68 | - [x] ~~缓存图片、字幕提高性能~~(为避免内存泄漏问题已经暂时移除) 69 | - [x] 多格式配置文件(优先级:JSON > TOML > YAML > YML > Java properties > Java props,格式参考[config.yaml.example](./config/config.yaml.example)) 70 | - [x] 支持通过 `--config` 参数指定配置文件地址(默认在执行文件的目录下的 config 子目录中查询配置文件) 71 | - [x] ART 字幕转 ASS 字幕(仅 Emby) 72 | - [ ] ASS 字幕字体子集化并嵌入字体 73 | - [x] 适配 Emby 74 | - [x] 适配 Jellyfin 75 | - [ ] 适配 Plex 76 | 77 | - [ ] ~~利用 Redis 做数据缓存~~ 78 | > 需求不大,放弃,有需要可以直接使用 Nginx 或者其他反向代理工具的缓存 79 | 80 | - [ ] ~~多服务器转码推流~~ 81 | > 需求不大,放弃 82 | 83 | - [ ] ~~利用 Mysql / PostgreSQL / Redis 优化 Infuse 媒体库模式下扫库体验~~ 84 | > 有需要可以参考 [MisakaFxxk/MisakaF_Emby/Infuse](https://github.com/MisakaFxxk/MisakaF_Emby/tree/main/Infuse) 自行实现 85 | 86 | - [ ] ~~多服务器负载均衡~~ 87 | > 在服务器前面加一个负载均衡可能更好 88 | 89 | # 相关文档 90 | - [教程文档](https://blog.akimio.top/posts/1041/) 91 | - [更新日志](./docs/UpdateLog.md) 92 | - [开发文档](./docs/DEV.md) 93 | - [User-Agent参考](./docs/UA.md) 94 | 95 | # 鸣谢 96 | 感谢一下人员、组织提供技术支持,仓库提供相关思路、脚本、前端样式。**排名不分先后** 97 | - [chen3861229](https://github.com/chen3861229) 98 | - [bpking1/embyExternalUrl](https://github.com/bpking1/embyExternalUrl) 99 | - [newday-life/emby-front-end-mod](https://github.com/newday-life/emby-front-end-mod) 100 | - [9channel/dd-danmaku](https://github.com/9channel/dd-danmaku) 101 | - [Nolovenodie/emby-crx](https://github.com/Nolovenodie/emby-crx) 102 | - [newday-life/jellyfin-crx](https://github.com/newday-life/jellyfin-crx) 103 | - [RiderLty/fontInAss](https://github.com/RiderLty/fontInAss) 104 | 105 | # Star History 106 | 107 | Star History Chart 108 | -------------------------------------------------------------------------------- /config/config.yaml.example: -------------------------------------------------------------------------------- 1 | Port: 9000 # MideWarp 监听端口 2 | 3 | MediaServer: # 媒体服务器相关设置 4 | Type: Emby # 媒体服务器类型(可选选项:Emby、Jellyfin) 5 | ADDR: http://localhost:8096 # 媒体服务器地址 6 | AUTH: 2eaxxxxxxxxxa8 # 媒体服务器认证方式 7 | 8 | Logger: # 日志设定 9 | AccessLogger: # 访问日志设定 10 | Console: True # 是否将访问日志文件输出到终端中 11 | File: False # 是否将访问日志文件记录到文件中 12 | ServiceLogger: # 服务日志设定 13 | Console: True # 是否将服务日志文件输出到终端中 14 | File: True # 是否将服务日志文件记录到文件中 15 | 16 | Web: # Web 页面修改相关设置 17 | Enable: True # 总开关 18 | Custom: True # 是否加载自定义静态资源 19 | Index: True # 是否从 custom 目录读取 index.html 文件 20 | Head: | # 是否添加自定义字段到 index.html 的头部中 21 | 22 | 23 | 24 | 25 | 26 | 27 | Crx: True # crx 美化(Emby:https://github.com/Nolovenodie/emby-crx;Jellyfin:https://github.com/newday-life/jellyfin-crx) 28 | ActorPlus: True # 过滤没有头像的演员和制作人员 29 | FanartShow: False # 显示同人图(fanart 图) 30 | ExternalPlayerUrl: True # 是否开启外置播放器(仅 Emby) 31 | Danmaku: True # Web 弹幕(Emby:https://github.com/9channel/dd-danmaku;Jellyfin:https://github.com/Izumiko/jellyfin-danmaku) 32 | VideoTogether: True # 共同观影,详情见 https://videotogether.github.io/ 33 | 34 | ClientFilter: # 客户端过滤器 35 | Enable: False # 是否启用客户端过滤器 36 | Mode: BlackList # WhileList / BlackList # 黑白名单模式 37 | ClientList: # 名单列表 38 | - Fileball 39 | - Infuse 40 | 41 | HTTPStrm: # HTTPStrm 相关配置(Strm 文件内容是 标准 HTTP URL) 42 | Enable: True # 是否开启 HttpStrm 重定向 43 | TransCode: False # False:强制关闭转码 True:保持原有转码设置 44 | FinalURL: True # 对 URL 进行重定向判断,找到非重定向地址再重定向给客户端,减少客户端重定向次数(适用于 Strm 内容是局域网地址但是想要在公网之中播放) 45 | PrefixList: # EmbyServer 中 Strm 文件的前缀(符合该前缀的 Strm 文件且被正确识别为 HTTP 协议都会路由到该规则下) 46 | - /media/strm/http 47 | - /media/strm/https 48 | 49 | AlistStrm: # AlistStrm 相关配置(Strm 文件内容是 Alist 上文件的路径,目前仅支持适配 Alist V3) 50 | Enable: True # 是否启用 AlistStrm 重定向 51 | TransCode: True # False:强制关闭转码 True:保持原有转码设置 52 | RawURL: False # Fasle:响应 Alist 服务器的直链(要求客户端可以访问到 Alist) True:直接响应 Alist 上游的真实链接(alist api 中的 raw_url 属性) 53 | List: # Alist 服务关配置列表 54 | - ADDR: http://192.168.1.100:5244 # Alist 服务器地址 55 | Username: admin # Alist 服务器账号 56 | Password: adminadmin # Alist 服务器密码 57 | PrefixList: # EmbyServer 中 Strm 文件的前缀(符合该前缀的 Strm 文件都会路由到该规则下) 58 | - /media/strm/MyAlist # 同一个 Alist 可以有多个前缀规则 59 | - /mnt/cd2/strm 60 | - ADDR: https://xiaoya.com # 可以填写多个配置 61 | Token: xxxxxxx # Token 优先级高于 Username 和 Password 62 | PrefixList: 63 | - /media/strm 64 | 65 | Subtitle: # 字体相关设置(仅 Emby 支持) 66 | Enable: True # 启用 67 | SRT2ASS: True # SRT 字幕转 ASS 字幕 68 | ASSStyle: # SRT 字幕转 ASS 字幕使用的样式 69 | - "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding" 70 | - "Style: Default,楷体,20,&H03FFFFFF,&H00FFFFFF,&H00000000,&H02000000,-1,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1" -------------------------------------------------------------------------------- /constants/color.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // 基础颜色枚举 4 | const ( 5 | ColorBlack uint8 = iota // 黑色 6 | ColorRed // 红色 7 | ColorGreen // 绿色 8 | ColorYellow // 黄色 9 | ColorBlue // 蓝色 10 | ColorPurple // 紫色 11 | ColorCyan // 青色 12 | ColorGray // 灰色 13 | ) 14 | 15 | // HTTP 状态码对应颜色 16 | const ( 17 | StatusCode200Color = ColorGreen // HTTP 200 成功响应颜色 18 | StatusCode300Color = ColorGray // HTTP 300 重定向颜色 19 | StatusCode400Color = ColorYellow // HTTP 400 客户端错误颜色 20 | StatusCode500Color = ColorRed // HTTP 500 服务器错误颜色 21 | ) 22 | 23 | // HTTP 方法对应颜色 24 | const ( 25 | MethodGetColor = ColorBlue // GET 方法颜色 26 | MethodPostColor = ColorCyan // POST 方法颜色 27 | MethodPutColor = ColorYellow // PUT 方法颜色 28 | MethodPatchColor = ColorGreen // PATCH 方法颜色 29 | MethodDeleteColor = ColorRed // DELETE 方法颜色 30 | MethodHeadColor = ColorPurple // HEAD 方法颜色 31 | MethodOptionsColor = ColorGray // OPTIONS 方法颜色 32 | ) 33 | -------------------------------------------------------------------------------- /constants/config.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type FliterMode string 4 | 5 | const ( 6 | WHITELIST FliterMode = "WhiteList" // 白名单 7 | BLACKLIST FliterMode = "BlackList" // 黑名单 8 | ) 9 | -------------------------------------------------------------------------------- /constants/logo.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const LOGO = ` 4 | ███╗ ███╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗ █████╗ ██████╗ ██████╗ 5 | ████╗ ████║██╔════╝██╔══██╗██║██╔══██╗██║ ██║██╔══██╗██╔══██╗██╔══██╗ 6 | ██╔████╔██║█████╗ ██║ ██║██║███████║██║ █╗ ██║███████║██████╔╝██████╔╝ 7 | ██║╚██╔╝██║██╔══╝ ██║ ██║██║██╔══██║██║███╗██║██╔══██║██╔══██╗██╔═══╝ 8 | ██║ ╚═╝ ██║███████╗██████╔╝██║██║ ██║╚███╔███╔╝██║ ██║██║ ██║██║ 9 | ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ 10 | ` 11 | -------------------------------------------------------------------------------- /constants/mediaserver.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type MediaServerType string // 媒体服务器类型 4 | 5 | const ( 6 | EMBY MediaServerType = "Emby" // 媒体服务器类型:EmbyServer 7 | JELLYFIN MediaServerType = "Jellyfin" // 媒体服务器类型:Jellyfin 8 | PLEX MediaServerType = "Plex" // 媒体服务器类型:Plex 9 | ) 10 | -------------------------------------------------------------------------------- /constants/referer.go: -------------------------------------------------------------------------------- 1 | package constants // 包名保持简洁,若范围明确可保留 2 | 3 | // ReferrerPolicy 定义 HTTP Referer 策略类型 4 | type ReferrerPolicy string 5 | 6 | const ( 7 | NoReferrer ReferrerPolicy = "no-referrer" // 不发送 Referer 头部 8 | NoReferrerWhenDowngrade ReferrerPolicy = "no-referrer-when-downgrade" // 从 HTTPS 到 HTTP 时不发送 9 | Origin ReferrerPolicy = "origin" // 跨域发送域名,同域发送完整 URL 10 | OriginWhenCrossOrigin ReferrerPolicy = "origin-when-cross-origin" // 跨域仅发送域名 11 | SameOrigin ReferrerPolicy = "same-origin" // 仅同域发送 12 | StrictOrigin ReferrerPolicy = "strict-origin" // HTTPS 到 HTTPS 时发送域名 13 | StrictOriginWhenCrossOrigin ReferrerPolicy = "strict-origin-when-cross-origin" // 跨域发送域名,同域完整 URL(仅 HTTPS) 14 | UnsafeURL ReferrerPolicy = "unsafe-url" // 始终发送完整 URL 15 | ) 16 | -------------------------------------------------------------------------------- /constants/regexp.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "regexp" 4 | 5 | type EmbyRegexps struct { 6 | Router RouterRegexps 7 | Others OthersRegexps 8 | } 9 | 10 | type RouterRegexps struct { 11 | VideosHandler *regexp.Regexp // 普通视频处理接口匹配 12 | ModifyBaseHtmlPlayer *regexp.Regexp // 修改 Web 的 basehtmlplayer.js 13 | ModifyIndex *regexp.Regexp // Web 首页 14 | ModifyPlaybackInfo *regexp.Regexp // 播放信息处理接口 15 | ModifySubtitles *regexp.Regexp // 字幕处理接口 16 | } 17 | 18 | type OthersRegexps struct { 19 | VideoRedirectReg *regexp.Regexp // 视频重定向匹配,统一视频请求格式 20 | } 21 | 22 | var EmbyRegexp = &EmbyRegexps{ 23 | Router: RouterRegexps{ 24 | VideosHandler: regexp.MustCompile(`(?i)^(/emby)?/Videos/\d+/(stream|original)(\.\w+)?$`), 25 | ModifyBaseHtmlPlayer: regexp.MustCompile(`(?i)^/web/modules/htmlvideoplayer/basehtmlplayer.js$`), 26 | ModifyIndex: regexp.MustCompile(`^/web/index.html$`), 27 | ModifyPlaybackInfo: regexp.MustCompile(`(?i)^(/emby)?/Items/\d+/PlaybackInfo$`), 28 | ModifySubtitles: regexp.MustCompile(`(?i)^(/emby)?/Videos/\d+/\w+/subtitles$`), 29 | }, 30 | Others: OthersRegexps{ 31 | VideoRedirectReg: regexp.MustCompile(`(?i)^(/emby)?/videos/(.*)/stream/(.*)`), 32 | }, 33 | } 34 | 35 | type JellyfinRouterRegexps struct { 36 | VideosHandler *regexp.Regexp // 普通视频处理接口匹配 37 | ModifyIndex *regexp.Regexp // Web 首页 38 | ModifyPlaybackInfo *regexp.Regexp // 播放信息处理接口 39 | ModifySubtitles *regexp.Regexp // 字幕处理接口 40 | } 41 | type JellyfinRegexps struct { 42 | Router JellyfinRouterRegexps 43 | } 44 | 45 | var JellyfinRegexp = &JellyfinRegexps{ 46 | Router: JellyfinRouterRegexps{ 47 | VideosHandler: regexp.MustCompile(`/Videos/\w+/(stream|original)(\.\w+)?$`), // /Videos/813a630bcf9c3f693a2ec8c498f868d2/stream /Videos/205953b114bb8c9dc2c7ba7e44b8024c/stream.mp4 48 | ModifyIndex: regexp.MustCompile(`^/web/$`), 49 | ModifyPlaybackInfo: regexp.MustCompile(`^/Items/\w+$`), 50 | ModifySubtitles: regexp.MustCompile(`/Videos/\d+/\w+/subtitles$`), 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /constants/regexp_test.go: -------------------------------------------------------------------------------- 1 | package constants_test 2 | 3 | // func TestEmbyRoute(t *testing.T) { 4 | // type RouteTestCase struct { 5 | // URI string 6 | // Target string 7 | // } 8 | // var embyRouteTestCases = map[string]RouteTestCase{ 9 | // "字幕": { 10 | // "/Videos/88697/21ed6a9972693ffa82571197cb406b64/Subtitles/3/0/Stream", 11 | // "ModifySubtitles", 12 | // }, 13 | // "4.9+字幕": { 14 | // "/emby/Videos/45/mediasource_45/Subtitles/0/0/Stream.subrip?api_key=e12acc0815f74e9da6a86c9e8c2d45d8", 15 | // "ModifySubtitles", 16 | // }, 17 | // "4.9+字幕2": { 18 | // "/emby/Videos/146/mediasource_146/Subtitles/3/0/Stream.srt?api_key=4b988503e747491ca53ff22527a13f08", 19 | // "ModifySubtitles", 20 | // }, 21 | // "视频1": { 22 | // "/Videos/88697/stream?mediasourceid=21ed6a9972693ffa82571197cb406b64&static=true", 23 | // "VideosHandler", 24 | // }, 25 | // "视频2(增加前缀,修改大小写)": { 26 | // "/emby/videos/88697/stream?mediasourceid=21ed6a9972693ffa82571197cb406b64&static=true", 27 | // "VideosHandler", 28 | // }, 29 | // "PlaybackInfo": { 30 | // "/Items/88697/PlaybackInfo?userid=9d882dc8ec514b2ca14652262df0afad", 31 | // "ModifyPlaybackInfo", 32 | // }, 33 | // "WEB JavaScript": { 34 | // "/web/videos/videos.js?v=4.8.10.0", 35 | // "", 36 | // }, 37 | // } 38 | // for caseName, testCase := range embyRouteTestCases { 39 | // t.Run(caseName, func(t *testing.T) { 40 | // for result, reg := range constants.EmbyRegexp["router"] { 41 | // if reg.MatchString(testCase.URI) && result != testCase.Target { // 匹配但不相等 42 | // t.Errorf("%s 路由错误。期望: %s, 实际: %s", caseName, testCase.Target, result) 43 | // } 44 | // } 45 | // }) 46 | // } 47 | // } 48 | -------------------------------------------------------------------------------- /constants/strm.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type StrmFileType string // Strm 文件类型 4 | 5 | var ( 6 | HTTPStrm StrmFileType = "HTTPStrm" 7 | AlistStrm StrmFileType = "AlistStrm" 8 | UnknownStrm StrmFileType = "UnknownStrm" 9 | ) 10 | -------------------------------------------------------------------------------- /constants/time.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const FORMATE_TIME = "2006-01-02 15:04:05" // 时间格式化 4 | -------------------------------------------------------------------------------- /custom/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/custom/.gitkeep -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS builder 2 | ENV GO111MODULE=on \ 3 | GOPROXY=https://goproxy.io,direct \ 4 | CGO_ENABLED=0 5 | 6 | WORKDIR /builder 7 | COPY . . 8 | RUN go mod download && \ 9 | go build -ldflags " \ 10 | -s -w \ 11 | -X MediaWarp/internal/config.commitHash=$(git rev-parse HEAD) \ 12 | -X MediaWarp/internal/config.buildDate=$(date -u '+%Y-%m-%d %H:%M:%S')" \ 13 | -o MediaWarp 14 | 15 | FROM alpine:latest 16 | COPY --from=builder /builder/MediaWarp /MediaWarp 17 | 18 | RUN chmod +x /MediaWarp 19 | 20 | EXPOSE 9000 21 | VOLUME ["/etc/localtime", "/etc/timezone", "/config", "/logs", "/custom"] 22 | ENTRYPOINT ["/MediaWarp"] 23 | -------------------------------------------------------------------------------- /docker/Dockerfile-goreleaser: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | COPY MediaWarp ./MediaWarp 4 | 5 | RUN chmod +x ./MediaWarp 6 | 7 | EXPOSE 9000 8 | VOLUME ["/etc/localtime", "/etc/timezone", "/config", "/logs", "/custom"] 9 | ENTRYPOINT ["/MediaWarp"] -------------------------------------------------------------------------------- /docs/DEV.md: -------------------------------------------------------------------------------- 1 | # 开发相关文档 2 | 3 | ## 项目目录结构 4 | ``` 5 | MEDIAWARP 6 | ├─.github 7 | │ └─workflows # 工作流相关 8 | ├─api # Alist、EmbyServer等服务器的开放接口 9 | ├─config # MediaWarp配置文件 10 | ├─constants # 存放一些常量 11 | ├─core # MediaWarp核心组织,包括配置读取、日志等 12 | ├─docs # MediaWarp相关文档 13 | ├─handlers # MediaWarp请求处理函数 14 | │ └─handlers_emby # 处理服务器为EmbySever的一些函数 15 | ├─img # MediaWarp演示图片 16 | ├─logs # MediaWarp输出日志 17 | ├─middleware # Gin框架用到的中间件,包括访问日志、客户端过滤 18 | ├─utils # 一些工具集合 19 | ├─resources # 内嵌的一些JavaScript脚本、CSS样式 20 | │ ├─css 21 | │ └─js 22 | ├─router # MediaWarp的API路由部分 23 | ├─schemas # API响应结构体 24 | │ ├─schemas_alist 25 | │ └─schemas_emby 26 | └─static # MediaWarp自定义静态文件存储目录 27 | ``` 28 | 29 | # 本地测试 30 | ## 构建所有版本 31 | ```BASH 32 | goreleaser release --snapshot --clean --skip-publish 33 | ``` 34 | ## 流程测试 35 | ```BASH 36 | goreleaser release --snapshot --clean --skip-publish --rm-dist 37 | ``` 38 | 39 | ## 参数含义 40 | 1. **snapshot**: 这将跳过标签验证,并生成一个快照版本 41 | 2. **clean**: 清除上一次生成的 dist 42 | 3. **skip-publish**: 跳过推送镜像到 Docker Registry 43 | 4. **rm-dist**:删除 dist 44 | 45 | # 媒体服务器部分 API 具体响应 46 | ## EmbyServer 47 | ### playbackInfo 48 | **EmbyServer version 4.8** 49 | AlistStrm 50 | 51 | ``` 52 | { 53 | "MediaSources": [ 54 | { 55 | "Protocol": "File", 56 | "Id": "37bcb3827aeb2095f1b6293e9a189072", 57 | "Path": "/资源盘/ANIOpen/2024-10/[ANi] 魔王陛下,RETRY!R - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4", 58 | "Type": "Default", 59 | "Container": "strm", 60 | "Size": 0, 61 | "Name": "重来吧,魔王大人! S02E04 -1080p -AVC -AAC -ANi -Baha", 62 | "IsRemote": false, 63 | "HasMixedProtocols": false, 64 | "SupportsTranscoding": true, 65 | "SupportsDirectStream": false, 66 | "SupportsDirectPlay": false, 67 | "IsInfiniteStream": false, 68 | "RequiresOpening": false, 69 | "RequiresClosing": false, 70 | "RequiresLooping": false, 71 | "SupportsProbing": false, 72 | "MediaStreams": [], 73 | "Formats": [], 74 | "RequiredHttpHeaders": {}, 75 | "DirectStreamUrl": "/videos/61162/master.m3u8?DeviceId=e12ab84a-2948-463a-9cb0-ecebcac2949e&MediaSourceId=37bcb3827aeb2095f1b6293e9a189072&PlaySessionId=e643532a23ca4a60b2c70d4210c018a9&api_key=e824c422e02047dfa820dbcab5490bb9&VideoCodec=h264,h265,hevc,av1&VideoBitrate=1400000&TranscodingMaxAudioChannels=2&SegmentContainer=ts&MinSegments=1&BreakOnNonKeyFrames=True&SubtitleStreamIndexes=-1&ManifestSubtitles=vtt&h264-profile=high,main,baseline,constrainedbaseline,high10&h264-level=62&hevc-codectag=hvc1,hev1,hevc,hdmv&TranscodeReasons=ContainerBitrateExceedsLimit", 76 | "AddApiKeyToDirectStreamUrl": false, 77 | "TranscodingUrl": "/videos/61162/master.m3u8?DeviceId=e12ab84a-2948-463a-9cb0-ecebcac2949e&MediaSourceId=37bcb3827aeb2095f1b6293e9a189072&PlaySessionId=e643532a23ca4a60b2c70d4210c018a9&api_key=e824c422e02047dfa820dbcab5490bb9&VideoCodec=h264,h265,hevc,av1&VideoBitrate=1400000&TranscodingMaxAudioChannels=2&SegmentContainer=ts&MinSegments=1&BreakOnNonKeyFrames=True&SubtitleStreamIndexes=-1&ManifestSubtitles=vtt&h264-profile=high,main,baseline,constrainedbaseline,high10&h264-level=62&hevc-codectag=hvc1,hev1,hevc,hdmv&TranscodeReasons=ContainerBitrateExceedsLimit", 78 | "TranscodingSubProtocol": "hls", 79 | "TranscodingContainer": "ts", 80 | "ReadAtNativeFramerate": false, 81 | "ItemId": "61162" 82 | } 83 | ], 84 | "PlaySessionId": "e643532a23ca4a60b2c70d4210c018a9" 85 | } 86 | ``` 87 | 普通视频 88 | ``` 89 | { 90 | "MediaSources": [ 91 | { 92 | "Protocol": "File", 93 | "Id": "56f692be38b2c4ae3c8f155e77006d46", 94 | "Path": "/media/媒体库/动漫追番/青之箱 (2024)/Season 1/青之箱 S01E04 -HEVC -AAC -KitaujiSub.mkv", 95 | "Type": "Default", 96 | "Container": "mkv", 97 | "Size": 470388402, 98 | "Name": "青之箱 S01E04 -HEVC -AAC -KitaujiSub", 99 | "IsRemote": false, 100 | "HasMixedProtocols": false, 101 | "RunTimeTicks": 14172790000, 102 | "SupportsTranscoding": true, 103 | "SupportsDirectStream": false, 104 | "SupportsDirectPlay": false, 105 | "IsInfiniteStream": false, 106 | "RequiresOpening": false, 107 | "RequiresClosing": false, 108 | "RequiresLooping": false, 109 | "SupportsProbing": false, 110 | "MediaStreams": [ 111 | { 112 | "Codec": "hevc", 113 | "ColorSpace": "bt709", 114 | "TimeBase": "1/1000", 115 | "VideoRange": "SDR", 116 | "DisplayTitle": "1080p HEVC", 117 | "IsInterlaced": false, 118 | "BitRate": 2655163, 119 | "BitDepth": 10, 120 | "RefFrames": 1, 121 | "IsDefault": true, 122 | "IsForced": false, 123 | "IsHearingImpaired": false, 124 | "Height": 1080, 125 | "Width": 1920, 126 | "AverageFrameRate": 23.976025, 127 | "RealFrameRate": 23.976025, 128 | "Profile": "Main 10", 129 | "Type": "Video", 130 | "AspectRatio": "16:9", 131 | "Index": 0, 132 | "IsExternal": false, 133 | "IsTextSubtitleStream": false, 134 | "SupportsExternalStream": false, 135 | "Protocol": "File", 136 | "PixelFormat": "yuv420p10le", 137 | "Level": 120, 138 | "IsAnamorphic": false, 139 | "ExtendedVideoType": "None", 140 | "ExtendedVideoSubType": "None", 141 | "ExtendedVideoSubTypeDescription": "None", 142 | "AttachmentSize": 0 143 | }, 144 | { 145 | "Codec": "aac", 146 | "Language": "jpn", 147 | "TimeBase": "1/1000", 148 | "DisplayTitle": "Japanese AAC 5.1 (默认)", 149 | "DisplayLanguage": "Japanese", 150 | "IsInterlaced": false, 151 | "ChannelLayout": "5.1", 152 | "BitRate": 320000, 153 | "Channels": 6, 154 | "SampleRate": 48000, 155 | "IsDefault": true, 156 | "IsForced": false, 157 | "IsHearingImpaired": false, 158 | "Profile": "LC", 159 | "Type": "Audio", 160 | "Index": 1, 161 | "IsExternal": false, 162 | "IsTextSubtitleStream": false, 163 | "SupportsExternalStream": false, 164 | "Protocol": "File", 165 | "ExtendedVideoType": "None", 166 | "ExtendedVideoSubType": "None", 167 | "ExtendedVideoSubTypeDescription": "None", 168 | "AttachmentSize": 0 169 | }, 170 | { 171 | "Codec": "ass", 172 | "Language": "chi", 173 | "TimeBase": "1/1000", 174 | "Title": "chs_jp", 175 | "DisplayTitle": "Chinese Simplified (默认 ASS)", 176 | "DisplayLanguage": "Chinese Simplified", 177 | "IsInterlaced": false, 178 | "IsDefault": true, 179 | "IsForced": false, 180 | "IsHearingImpaired": false, 181 | "Type": "Subtitle", 182 | "Index": 2, 183 | "IsExternal": false, 184 | "DeliveryMethod": "External", 185 | "DeliveryUrl": "/Videos/61256/56f692be38b2c4ae3c8f155e77006d46/Subtitles/2/0/Stream.ass?api_key=e824c422e02047dfa820dbcab5490bb9", 186 | "IsExternalUrl": false, 187 | "IsTextSubtitleStream": true, 188 | "SupportsExternalStream": true, 189 | "Protocol": "File", 190 | "ExtendedVideoType": "None", 191 | "ExtendedVideoSubType": "None", 192 | "ExtendedVideoSubTypeDescription": "None", 193 | "AttachmentSize": 0, 194 | "SubtitleLocationType": "InternalStream" 195 | }, 196 | { 197 | "Codec": "ass", 198 | "Language": "chi", 199 | "TimeBase": "1/1000", 200 | "Title": "cht_jp", 201 | "DisplayTitle": "Chinese Simplified (ASS)", 202 | "DisplayLanguage": "Chinese Simplified", 203 | "IsInterlaced": false, 204 | "IsDefault": false, 205 | "IsForced": false, 206 | "IsHearingImpaired": false, 207 | "Type": "Subtitle", 208 | "Index": 3, 209 | "IsExternal": false, 210 | "DeliveryMethod": "External", 211 | "DeliveryUrl": "/Videos/61256/56f692be38b2c4ae3c8f155e77006d46/Subtitles/3/0/Stream.ass?api_key=e824c422e02047dfa820dbcab5490bb9", 212 | "IsExternalUrl": false, 213 | "IsTextSubtitleStream": true, 214 | "SupportsExternalStream": true, 215 | "Protocol": "File", 216 | "ExtendedVideoType": "None", 217 | "ExtendedVideoSubType": "None", 218 | "ExtendedVideoSubTypeDescription": "None", 219 | "AttachmentSize": 0, 220 | "SubtitleLocationType": "InternalStream" 221 | }, 222 | { 223 | "Codec": "otf", 224 | "TimeBase": "1/90000", 225 | "IsInterlaced": false, 226 | "IsDefault": false, 227 | "IsForced": false, 228 | "IsHearingImpaired": false, 229 | "Type": "Attachment", 230 | "Index": 4, 231 | "IsExternal": false, 232 | "IsTextSubtitleStream": false, 233 | "SupportsExternalStream": false, 234 | "Path": "FOT-ModeMinBLargeStd-B.0.E8KEG6LU.otf", 235 | "Protocol": "File", 236 | "ExtendedVideoType": "None", 237 | "ExtendedVideoSubType": "None", 238 | "ExtendedVideoSubTypeDescription": "None", 239 | "AttachmentSize": 49752, 240 | "MimeType": "application/vnd.ms-opentype" 241 | }, 242 | { 243 | "Codec": "otf", 244 | "TimeBase": "1/90000", 245 | "IsInterlaced": false, 246 | "IsDefault": false, 247 | "IsForced": false, 248 | "IsHearingImpaired": false, 249 | "Type": "Attachment", 250 | "Index": 5, 251 | "IsExternal": false, 252 | "IsTextSubtitleStream": false, 253 | "SupportsExternalStream": false, 254 | "Path": "FOT-SeuratProN-DB.0.JIIL4FRM.otf", 255 | "Protocol": "File", 256 | "ExtendedVideoType": "None", 257 | "ExtendedVideoSubType": "None", 258 | "ExtendedVideoSubTypeDescription": "None", 259 | "AttachmentSize": 196388, 260 | "MimeType": "application/vnd.ms-opentype" 261 | }, 262 | { 263 | "Codec": "otf", 264 | "TimeBase": "1/90000", 265 | "IsInterlaced": false, 266 | "IsDefault": false, 267 | "IsForced": false, 268 | "IsHearingImpaired": false, 269 | "Type": "Attachment", 270 | "Index": 6, 271 | "IsExternal": false, 272 | "IsTextSubtitleStream": false, 273 | "SupportsExternalStream": false, 274 | "Path": "FOT-TsukuMinPr5N-B.0.8X1XX3RE.otf", 275 | "Protocol": "File", 276 | "ExtendedVideoType": "None", 277 | "ExtendedVideoSubType": "None", 278 | "ExtendedVideoSubTypeDescription": "None", 279 | "AttachmentSize": 82360, 280 | "MimeType": "application/vnd.ms-opentype" 281 | }, 282 | { 283 | "Codec": "ttf", 284 | "TimeBase": "1/90000", 285 | "IsInterlaced": false, 286 | "IsDefault": false, 287 | "IsForced": false, 288 | "IsHearingImpaired": false, 289 | "Type": "Attachment", 290 | "Index": 7, 291 | "IsExternal": false, 292 | "IsTextSubtitleStream": false, 293 | "SupportsExternalStream": false, 294 | "Path": "FZLANTINGHEI-DB-GBK.0.WOAP9IVQ.ttf", 295 | "Protocol": "File", 296 | "ExtendedVideoType": "None", 297 | "ExtendedVideoSubType": "None", 298 | "ExtendedVideoSubTypeDescription": "None", 299 | "AttachmentSize": 15908, 300 | "MimeType": "application/x-truetype-font" 301 | }, 302 | { 303 | "Codec": "ttf", 304 | "TimeBase": "1/90000", 305 | "IsInterlaced": false, 306 | "IsDefault": false, 307 | "IsForced": false, 308 | "IsHearingImpaired": false, 309 | "Type": "Attachment", 310 | "Index": 8, 311 | "IsExternal": false, 312 | "IsTextSubtitleStream": false, 313 | "SupportsExternalStream": false, 314 | "Path": "方正粗雅宋_GBK.0.ELFFB6DY.ttf", 315 | "Protocol": "File", 316 | "ExtendedVideoType": "None", 317 | "ExtendedVideoSubType": "None", 318 | "ExtendedVideoSubTypeDescription": "None", 319 | "AttachmentSize": 89012, 320 | "MimeType": "application/x-truetype-font" 321 | }, 322 | { 323 | "Codec": "ttf", 324 | "TimeBase": "1/90000", 325 | "IsInterlaced": false, 326 | "IsDefault": false, 327 | "IsForced": false, 328 | "IsHearingImpaired": false, 329 | "Type": "Attachment", 330 | "Index": 9, 331 | "IsExternal": false, 332 | "IsTextSubtitleStream": false, 333 | "SupportsExternalStream": false, 334 | "Path": "方正兰亭大黑_GBK.0.5PPVFM7L.ttf", 335 | "Protocol": "File", 336 | "ExtendedVideoType": "None", 337 | "ExtendedVideoSubType": "None", 338 | "ExtendedVideoSubTypeDescription": "None", 339 | "AttachmentSize": 13900, 340 | "MimeType": "application/x-truetype-font" 341 | }, 342 | { 343 | "Codec": "ttf", 344 | "TimeBase": "1/90000", 345 | "IsInterlaced": false, 346 | "IsDefault": false, 347 | "IsForced": false, 348 | "IsHearingImpaired": false, 349 | "Type": "Attachment", 350 | "Index": 10, 351 | "IsExternal": false, 352 | "IsTextSubtitleStream": false, 353 | "SupportsExternalStream": false, 354 | "Path": "方正兰亭黑_GBK.0.OAU1SQQO.ttf", 355 | "Protocol": "File", 356 | "ExtendedVideoType": "None", 357 | "ExtendedVideoSubType": "None", 358 | "ExtendedVideoSubTypeDescription": "None", 359 | "AttachmentSize": 33180, 360 | "MimeType": "application/x-truetype-font" 361 | }, 362 | { 363 | "Codec": "ttf", 364 | "TimeBase": "1/90000", 365 | "IsInterlaced": false, 366 | "IsDefault": false, 367 | "IsForced": false, 368 | "IsHearingImpaired": false, 369 | "Type": "Attachment", 370 | "Index": 11, 371 | "IsExternal": false, 372 | "IsTextSubtitleStream": false, 373 | "SupportsExternalStream": false, 374 | "Path": "方正兰亭圆_GBK_准.0.D3ABRYMS.ttf", 375 | "Protocol": "File", 376 | "ExtendedVideoType": "None", 377 | "ExtendedVideoSubType": "None", 378 | "ExtendedVideoSubTypeDescription": "None", 379 | "AttachmentSize": 354176, 380 | "MimeType": "application/x-truetype-font" 381 | }, 382 | { 383 | "Codec": "ttf", 384 | "TimeBase": "1/90000", 385 | "IsInterlaced": false, 386 | "IsDefault": false, 387 | "IsForced": false, 388 | "IsHearingImpaired": false, 389 | "Type": "Attachment", 390 | "Index": 12, 391 | "IsExternal": false, 392 | "IsTextSubtitleStream": false, 393 | "SupportsExternalStream": false, 394 | "Path": "方正准雅宋_GBK.0.T77HXJCC.ttf", 395 | "Protocol": "File", 396 | "ExtendedVideoType": "None", 397 | "ExtendedVideoSubType": "None", 398 | "ExtendedVideoSubTypeDescription": "None", 399 | "AttachmentSize": 69468, 400 | "MimeType": "application/x-truetype-font" 401 | }, 402 | { 403 | "Codec": "ttf", 404 | "TimeBase": "1/90000", 405 | "IsInterlaced": false, 406 | "IsDefault": false, 407 | "IsForced": false, 408 | "IsHearingImpaired": false, 409 | "Type": "Attachment", 410 | "Index": 13, 411 | "IsExternal": false, 412 | "IsTextSubtitleStream": false, 413 | "SupportsExternalStream": false, 414 | "Path": "华康翩翩体W3-A.0.B7WYAJQK.ttf", 415 | "Protocol": "File", 416 | "ExtendedVideoType": "None", 417 | "ExtendedVideoSubType": "None", 418 | "ExtendedVideoSubTypeDescription": "None", 419 | "AttachmentSize": 29816, 420 | "MimeType": "application/x-truetype-font" 421 | }, 422 | { 423 | "Codec": "ttf", 424 | "TimeBase": "1/90000", 425 | "IsInterlaced": false, 426 | "IsDefault": false, 427 | "IsForced": false, 428 | "IsHearingImpaired": false, 429 | "Type": "Attachment", 430 | "Index": 14, 431 | "IsExternal": false, 432 | "IsTextSubtitleStream": false, 433 | "SupportsExternalStream": false, 434 | "Path": "华康翩翩体W5-A.0.CVH5DGCU.ttf", 435 | "Protocol": "File", 436 | "ExtendedVideoType": "None", 437 | "ExtendedVideoSubType": "None", 438 | "ExtendedVideoSubTypeDescription": "None", 439 | "AttachmentSize": 58720, 440 | "MimeType": "application/x-truetype-font" 441 | }, 442 | { 443 | "Codec": "ttf", 444 | "TimeBase": "1/90000", 445 | "IsInterlaced": false, 446 | "IsDefault": false, 447 | "IsForced": false, 448 | "IsHearingImpaired": false, 449 | "Type": "Attachment", 450 | "Index": 15, 451 | "IsExternal": false, 452 | "IsTextSubtitleStream": false, 453 | "SupportsExternalStream": false, 454 | "Path": "华康手札体W5-A.0.B8DWXF5W.ttf", 455 | "Protocol": "File", 456 | "ExtendedVideoType": "None", 457 | "ExtendedVideoSubType": "None", 458 | "ExtendedVideoSubTypeDescription": "None", 459 | "AttachmentSize": 56332, 460 | "MimeType": "application/x-truetype-font" 461 | } 462 | ], 463 | "Formats": [], 464 | "Bitrate": 2655163, 465 | "RequiredHttpHeaders": {}, 466 | "DirectStreamUrl": "/videos/61256/master.m3u8?DeviceId=e12ab84a-2948-463a-9cb0-ecebcac2949e&MediaSourceId=56f692be38b2c4ae3c8f155e77006d46&PlaySessionId=592ccdf484644f3db25993c423f392ad&api_key=e824c422e02047dfa820dbcab5490bb9&VideoCodec=h264,h265,hevc,av1&AudioCodec=ac3,mp3,aac&VideoBitrate=1430000&AudioBitrate=320000&AudioStreamIndex=1&TranscodingMaxAudioChannels=2&SegmentContainer=ts&MinSegments=1&BreakOnNonKeyFrames=True&SubtitleStreamIndexes=-1&ManifestSubtitles=vtt&h264-profile=high,main,baseline,constrainedbaseline,high10&h264-level=62&hevc-codectag=hvc1,hev1,hevc,hdmv&TranscodeReasons=ContainerBitrateExceedsLimit", 467 | "AddApiKeyToDirectStreamUrl": false, 468 | "TranscodingUrl": "/videos/61256/master.m3u8?DeviceId=e12ab84a-2948-463a-9cb0-ecebcac2949e&MediaSourceId=56f692be38b2c4ae3c8f155e77006d46&PlaySessionId=592ccdf484644f3db25993c423f392ad&api_key=e824c422e02047dfa820dbcab5490bb9&VideoCodec=h264,h265,hevc,av1&AudioCodec=ac3,mp3,aac&VideoBitrate=1430000&AudioBitrate=320000&AudioStreamIndex=1&TranscodingMaxAudioChannels=2&SegmentContainer=ts&MinSegments=1&BreakOnNonKeyFrames=True&SubtitleStreamIndexes=-1&ManifestSubtitles=vtt&h264-profile=high,main,baseline,constrainedbaseline,high10&h264-level=62&hevc-codectag=hvc1,hev1,hevc,hdmv&TranscodeReasons=ContainerBitrateExceedsLimit", 469 | "TranscodingSubProtocol": "hls", 470 | "TranscodingContainer": "ts", 471 | "ReadAtNativeFramerate": false, 472 | "DefaultAudioStreamIndex": 1, 473 | "DefaultSubtitleStreamIndex": 2, 474 | "ItemId": "61256" 475 | } 476 | ], 477 | "PlaySessionId": "592ccdf484644f3db25993c423f392ad" 478 | } 479 | ``` -------------------------------------------------------------------------------- /docs/UA.md: -------------------------------------------------------------------------------- 1 | # 常见客户端UA参考 2 | 3 | ## 安卓移动客户端 4 | 1. Emby/3.2.32-17.41 (Linux;Android 14) ExoPlayerLib/2.13.2 5 | 2. Emby/3.2.32-17.24 (Linux;Android 13) ExoPlayerLib/2.13.2 6 | 3. libmpv 7 | 8 | ## 安卓TV客户端 9 | 1. Emby/2.0.95g (Linux;Android 9) ExoPlayerLib/2.18.7 10 | 11 | ## Windows客户端 12 | 1. Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EmbyTheater/3.0.20-3.0 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36 13 | 14 | ## iOS第三方客户端 15 | 1. Fileball/238 CFNetwork/1410.0.3 Darwin/22.6.0 16 | 2. Infuse-Direct/7.8 17 | 3. Infuse-Library/7.8 18 | 4. VidHub/1.7.6 19 | 5. Conflux/1.2.0 (app.svn.Conflux; build:1200; iOS 17.3.1) Alamofire/5.9.1 20 | 21 | ## 第三方客户端 22 | 1. dandanplay/android 4.1.0 23 | 2. VLC/3.0.18 LibVLC/3.0.18 24 | 3. VLC/4.0.0-dev LibVLC/4.0.0-dev 25 | 4. MXPlayer/1.68.4 (Linux; Android 14; zh-CN; 2311DRK48C Build/UP1A.230905.011) 26 | 5. (Windows NT 10.0; Win64; x64) PotPlayer/23.7.7 27 | 28 | ## 网页浏览器 29 | 1. Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0 30 | 2. Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0 31 | 3. AppleCoreMedia/1.0.0.20G81 (iPad; U; CPU OS 16_6_1 like Mac OS X; zh_cn) 32 | 4. AppleCoreMedia/1.0.0.21F90 (iPhone; U; CPU OS 17_5_1 like Mac OS X; zh_cn) 33 | 5. Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.200720.011; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/89.0.4389.72 MQQBrowser/6.2 TBS/045913 Mobile Safari/537.36 V1_AND_SQ_8.8.68_2538_YYB_D A_8086800 QQ/8.8.68.7265 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/76 SimpleUISwitch/1 QQTheme/2971 InMagicWin/0 StudyMode/0 CurrentMode/1 CurrentFontScale/1.0 GlobalDensityScale/0.9818182 AppId/537112567 Edg/98.0.4758.102 -------------------------------------------------------------------------------- /docs/UpdateLog.md: -------------------------------------------------------------------------------- 1 | # 版本更新日志 2 | - 2024-8-21: v0.0.1 3 | - 实现 MediaWarp 基本功能 4 | - 2024-8-25: v0.0.2 5 | - 增加返回自定义静态资源功能 6 | - 优化播放直链视频 302 重定向逻辑 7 | - 增加自定义客户端过滤功能 8 | - 2024-9-14: v0.0.3 9 | - 实现 AlistStrm 重定向 10 | - 适配 EmbyServer 4.9 视频播放逻辑 11 | - 嵌入实用功能(外部播放器、弹幕、美化等) 12 | - 支持WebSocket 13 | - 2024-9-29: v0.0.4 14 | - 优化 mediaSourceID 处理逻辑 15 | - 使用 httputil.ReverseProxy 处理 HTTP 和 WebSocket 请求 16 | - 设置浏览器referer策略,跳转时减少服务器站点泄露 17 | - 使用正则表达式进行路由匹配 18 | - 定义缓存接口、实现内存缓存逻辑 19 | - 修改项目结构 20 | - 用户自定义资源设为 Custom、从 config 中读取需要额外添加的 HEAD 21 | - 日志允许设置是否输出到终端或文件 22 | - 日志根据日期分割 23 | - 2024.11.1: v0.0.5 24 | - 扩大 VideosHandler 匹配范围(修复 FileBall 下 Strm 文件播放问题,不完美) 25 | - 中间件缓存可选是否开启(避免内存缓存缓存大量图片、css、js 等资源) 26 | - 修改 internal/config、internal/logger、internal/cache 这几个包,使其使用方式更加 “golang” 27 | - 支持加载多种格式的配置文件(JSON、TOML、YAML、YML、Java properties、Java props) 28 | - 2024.12.22: v0.0.6 29 | - 提供 API 接口查看版本具体信息 30 | - 小幅度修改项目子包结构 31 | - 使用 goreleaser 进行构建 32 | - 减小 EmbyServer.VideosHandler 匹配范围 33 | - 需要修改响应体时复用之前实例化的 httputil.ReverseProxy 34 | - EmbyServerHandler.PlaybackInfoHandler 拦截修改响应修改 AlistStrm 正确播放地址 35 | - 2025.2.20: v0.0.7 36 | - 提供字幕接口相关正则,新增 SRT 字幕转 ASS 字幕功能 37 | - 修复当 MediaStreams 为空数组的情况移除该字段导致导致部分客户端报错问题 38 | - 优化 VideosHandler 函数中优先判断是否为 HEAD 请求 39 | - 优化 ModifyPlaybackInfo 函数,减少无意义请求数,降低响应延迟 40 | - 新增转码设置选项以支持 HTTPStrm 和 AlistStrm 是否返回 PlaybackInfo 通告客户端禁止转码 41 | - f新增 RawURL 配置选项以控制 AlistStrm 的重定向链接 42 | - 2025.3.22: v0.0.8 43 | - 优化 AlistStrm 重定向连接 44 | - 重构 EmbyRegexp 结构,优化正则表达式管理 45 | - 优化 EmbyServerHandler.ModifySubtitles 和 EmbyServerHandler.ModifyBaseHtmlPlayer 性能 46 | - 优化 SRT2ASS 性能 47 | - 修改正则路由处理,使用不带查询参数的 URL 路径(Path)进行路由匹配 48 | - 添加版本信息标志,支持显示当前版本信息 49 | - 2025.3.26: v0.0.9 50 | - 调整静态资源、自定义目录 51 | - 使用 git 子模块的方式引入 js、css 等文件,移除 emby css 美化功能 52 | - 移除内存缓存相关设计,以避免内存泄漏 53 | - 修复 ModifyPlaybackInfo 和 ModifySubtitles 函数中的响应体关闭顺序,确保正确读取 HTTP 响应 54 | - 优化 EmbyServerHandler.responseModifyCreater 闭包逻辑 55 | - 使用 switch 语句重构客户端过滤器逻辑,移除未知模式处理 56 | - 使用 gin.Recovery() 捕获 panic,避免 MediaWarp 出现意外错误后软件崩溃 57 | - 优化程序启动逻辑,改进错误处理 58 | - 解析构建时间格式,改进构建日期处理逻辑 59 | - 更新 go-lang 版本及第三方依赖 60 | - 2025.3.27: v0.0.10 61 | - 主函数添加信号处理和错误处理机制,优化服务退出流程 62 | - 修复配置初始化失败时错误调用日志输出,改为使用标准输出 63 | - 将 responseModifyCreater、recgonizeStrmFileType、updateBody 函数移至 utils.go 并独立与 EmbyServerHandler,便于后续复用 64 | - 修复日志中间件颜色控制输出错误问题 65 | - 将反向代理逻辑从 emby.EmbyServer 移至 handler.EmbyServerHandler 66 | - 重构媒体服务器处理器初始化逻辑,改为返回错误以便更好地处理初始化失败情况 67 | - 添加 GZIP、Brotli 解压支持并重构读取响应体的逻辑 68 | - 在正则路由处理器中添加调试日志以记录匹配成功的 URL 69 | - 在恢复中间件中添加详细的错误日志记录以处理 panic 错误 70 | - 支持通过命令行参数指定配置文件路径 71 | - 2025.3.29: v0.1.0 72 | - 添加 Jellyfin 支持 73 | - 添加 crx 美化功能(支持 Emby 和 Jellyfin) 74 | - 调整某些常量命名,更符合 go-lang 命名 75 | - 2025.6.2: v0.1.1 76 | - 更新上游依赖 77 | - 提高对 EmbyServer beta 版本的兼容性 78 | - HTTPStrm 添加获取最终 URL 获取功能,减少客户端重定向次数 79 | - 优化 responseModifyCreater 函数,可以捕捉内层函数的 panic 信息 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module MediaWarp 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/andybalholm/brotli v1.1.1 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/sirupsen/logrus v1.9.3 9 | github.com/spf13/viper v1.20.0 10 | ) 11 | 12 | require ( 13 | github.com/bytedance/sonic v1.13.2 // indirect 14 | github.com/bytedance/sonic/loader v0.2.4 // indirect 15 | github.com/cloudwego/base64x v0.1.5 // indirect 16 | github.com/fsnotify/fsnotify v1.8.0 // indirect 17 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 18 | github.com/gin-contrib/sse v1.0.0 // indirect 19 | github.com/go-playground/locales v0.14.1 // indirect 20 | github.com/go-playground/universal-translator v0.18.1 // indirect 21 | github.com/go-playground/validator/v10 v10.25.0 // indirect 22 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 23 | github.com/goccy/go-json v0.10.5 // indirect 24 | github.com/json-iterator/go v1.1.12 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 26 | github.com/leodido/go-urn v1.4.0 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 29 | github.com/modern-go/reflect2 v1.0.2 // indirect 30 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 31 | github.com/sagikazarmark/locafero v0.8.0 // indirect 32 | github.com/sourcegraph/conc v0.3.0 // indirect 33 | github.com/spf13/afero v1.14.0 // indirect 34 | github.com/spf13/cast v1.7.1 // indirect 35 | github.com/spf13/pflag v1.0.6 // indirect 36 | github.com/subosito/gotenv v1.6.0 // indirect 37 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 38 | github.com/ugorji/go/codec v1.2.12 // indirect 39 | go.uber.org/multierr v1.11.0 // indirect 40 | golang.org/x/arch v0.15.0 // indirect 41 | golang.org/x/crypto v0.36.0 // indirect 42 | golang.org/x/net v0.37.0 // indirect 43 | golang.org/x/sys v0.31.0 // indirect 44 | golang.org/x/text v0.23.0 // indirect 45 | google.golang.org/protobuf v1.36.6 // indirect 46 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 2 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 3 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= 4 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 5 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 6 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 7 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 8 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 9 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 10 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 15 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 16 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 17 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 18 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 19 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 20 | github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= 21 | github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= 22 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 23 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 24 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 25 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 26 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 27 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 28 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 29 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 30 | github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= 31 | github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= 32 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 33 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 34 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 35 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 36 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 37 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 38 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 39 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 40 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 41 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 42 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 43 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 44 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 45 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 53 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 54 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 55 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 56 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 59 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 60 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 61 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 62 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 63 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 66 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 67 | github.com/sagikazarmark/locafero v0.8.0 h1:mXaMVw7IqxNBxfv3LdWt9MDmcWDQ1fagDH918lOdVaQ= 68 | github.com/sagikazarmark/locafero v0.8.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= 69 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 70 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 71 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 72 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 73 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 74 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 75 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 76 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 77 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 78 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 79 | github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= 80 | github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 81 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 82 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 83 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 84 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 85 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 86 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 87 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 88 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 89 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 90 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 91 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 92 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 93 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 94 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 95 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 96 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 97 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 98 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 99 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 100 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 101 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 102 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 103 | golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= 104 | golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 105 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 106 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 107 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 108 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 109 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 112 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 113 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 114 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 115 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 116 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 119 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 120 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 121 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 122 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 123 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 124 | -------------------------------------------------------------------------------- /img/client_filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/img/client_filter.png -------------------------------------------------------------------------------- /img/danmaku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/img/danmaku.png -------------------------------------------------------------------------------- /img/index.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/img/index.jpg -------------------------------------------------------------------------------- /img/movie.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/img/movie.jpg -------------------------------------------------------------------------------- /img/series.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/img/series.jpg -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "time" 10 | 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var ( 15 | version = VersionInfo{ 16 | AppVersion: appVersion, 17 | CommitHash: commitHash, 18 | BuildData: parseBuildTime(buildDate), 19 | GoVersion: runtime.Version(), 20 | OS: runtime.GOOS, 21 | Arch: runtime.GOARCH, 22 | } 23 | 24 | Port int // MediaWarp开放端口 25 | MediaServer MediaServerSetting // 上游媒体服务器设置 26 | Logger LoggerSetting // 日志设置 27 | Web WebSetting // Web服务器设置 28 | ClientFilter ClientFilterSetting // 客户端过滤设置 29 | HTTPStrm HTTPStrmSetting // HTTPSTRM设置 30 | AlistStrm AlistStrmSetting // AlistStrm设置 31 | Subtitle SubtitleSetting // 字幕设置 32 | ) 33 | 34 | // 获取版本信息 35 | func Version() *VersionInfo { 36 | return &version 37 | } 38 | 39 | // 二进制文件目录 40 | func RootDir() string { 41 | executablePath, err := os.Executable() 42 | if err != nil { 43 | panic(err) 44 | } 45 | return filepath.Dir(executablePath) 46 | } 47 | 48 | // 配置文件目录 49 | func ConfigDir() string { 50 | return filepath.Join(RootDir(), "config") 51 | } 52 | 53 | // 配置文件路径 54 | func ConfigPath() string { 55 | return filepath.Join(ConfigDir(), "config.yaml") 56 | } 57 | 58 | // 获取日志目录 59 | // 60 | // 总日志目录 61 | // ./logs 62 | func LogDir() string { 63 | return filepath.Join(RootDir(), "logs") 64 | } 65 | 66 | // 获取日志目录 67 | // 68 | // 带有日期 69 | // ./logs/2024-9-29 70 | func LogDirWithDate() string { 71 | return filepath.Join(LogDir(), time.Now().Format("2006-1-2")) 72 | } 73 | 74 | // 访问日志文件路径 75 | func AccessLogPath() string { 76 | return filepath.Join(LogDirWithDate(), "access.log") 77 | } 78 | 79 | // 服务日志文件路径 80 | func ServiceLogPath() string { 81 | return filepath.Join(LogDirWithDate(), "service.log") 82 | } 83 | 84 | // 静态资源文件目录 85 | // 86 | // 用户自定义静态文件存放地址 87 | func CostomDir() string { 88 | return filepath.Join(RootDir(), "static") 89 | } 90 | 91 | // MediaWarp监听地址 92 | // 93 | // 监听所有网卡 94 | func ListenAddr() string { 95 | return fmt.Sprintf(":%d", Port) 96 | } 97 | 98 | // 初始化configManager 99 | func Init(path string) error { 100 | if err := loadConfig(path); err != nil { 101 | return err 102 | } 103 | if err := createDir(); err != nil { 104 | return err 105 | } 106 | return nil 107 | } 108 | 109 | // 读取并解析配置文件 110 | func loadConfig(path string) error { 111 | if path != "" { 112 | viper.SetConfigFile(path) 113 | } else { 114 | viper.AddConfigPath(ConfigDir()) 115 | viper.SetConfigName("config") 116 | } 117 | 118 | if err := viper.ReadInConfig(); err != nil { 119 | return fmt.Errorf("读取配置文件失败: %v", err) 120 | } 121 | 122 | Port = viper.GetInt("Port") 123 | MediaServer.Type = constants.MediaServerType(viper.GetString("MediaServer.Type")) 124 | MediaServer.ADDR = viper.GetString("MediaServer.ADDR") 125 | MediaServer.AUTH = viper.GetString("MediaServer.AUTH") 126 | 127 | if err := viper.UnmarshalKey("Logger", &Logger); err != nil { 128 | return fmt.Errorf("LoggerSetting 解析失败, %v", err) 129 | } 130 | if err := viper.UnmarshalKey("Web", &Web); err != nil { 131 | return fmt.Errorf("WebSetting 解析失败, %v", err) 132 | } 133 | if err := viper.UnmarshalKey("ClientFilter", &ClientFilter); err != nil { 134 | return fmt.Errorf("ClientFilterSetting 解析失败, %v", err) 135 | } 136 | if err := viper.UnmarshalKey("HTTPStrm", &HTTPStrm); err != nil { 137 | return fmt.Errorf("HTTPStrmSetting 解析失败, %v", err) 138 | } 139 | if err := viper.UnmarshalKey("AlistStrm", &AlistStrm); err != nil { 140 | return fmt.Errorf("AlistStrmSetting 解析失败, %v", err) 141 | } 142 | if err := viper.UnmarshalKey("Subtitle", &Subtitle); err != nil { 143 | return fmt.Errorf("SubtitleSetting 解析失败, %v", err) 144 | } 145 | return nil 146 | } 147 | 148 | // 创建文件夹 149 | func createDir() error { 150 | if err := os.MkdirAll(ConfigDir(), os.ModePerm); err != nil { 151 | return fmt.Errorf("创建配置文件夹失败: %v", err) 152 | } 153 | if err := os.MkdirAll(LogDir(), os.ModePerm); err != nil { 154 | return fmt.Errorf("创建日志文件夹失败: %v", err) 155 | } 156 | if err := os.MkdirAll(CostomDir(), os.ModePerm); err != nil { 157 | return fmt.Errorf("创建自定义静态资源文件夹失败: %v", err) 158 | } 159 | return nil 160 | } 161 | -------------------------------------------------------------------------------- /internal/config/type.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "MediaWarp/constants" 4 | 5 | // 程序版本信息 6 | type VersionInfo struct { 7 | AppVersion string // 程序版本号 8 | CommitHash string // GIt Commit Hash 9 | BuildData string // 编译时间 10 | GoVersion string // 编译 Golang 版本 11 | OS string // 操作系统 12 | Arch string // 架构 13 | } 14 | 15 | // 上游媒体服务器相关设置 16 | type MediaServerSetting struct { 17 | Type constants.MediaServerType // 媒体服务器类型 18 | ADDR string // 地址 19 | AUTH string // 认证授权KEY 20 | } 21 | 22 | // 日志设置 23 | type LoggerSetting struct { 24 | AccessLogger BaseLoggerSetting // 访问日志相关配置 25 | ServiceLogger BaseLoggerSetting // 服务日志相关配置 26 | } 27 | 28 | // 基础日志配置字段 29 | type BaseLoggerSetting struct { 30 | Console bool // 是否将日志输出到终端中 31 | File bool // 是否将日志输出到文件中 32 | } 33 | 34 | // Web前端自定义设置 35 | type WebSetting struct { 36 | Enable bool // 启用自定义前端设置 37 | Custom bool // 启用用户自定义静态资源 38 | Index bool // 是否从 custom 目录读取 index.html 文件作为首页 39 | Head string // 添加到 index.html 的 HEAD 中 40 | ExternalPlayerUrl bool // 是否开启外置播放器 41 | Crx bool // crx 美化 42 | ActorPlus bool // 过滤没有头像的演员和制作人员 43 | FanartShow bool // 显示同人图(fanart图) 44 | Danmaku bool // Web 弹幕 45 | VideoTogether bool // VideoTogether 46 | } 47 | 48 | // 客户端User-Agent过滤设置 49 | type ClientFilterSetting struct { 50 | Enable bool 51 | Mode constants.FliterMode 52 | ClientList []string 53 | } 54 | 55 | // HTTPStrm播放设置 56 | type HTTPStrmSetting struct { 57 | Enable bool 58 | TransCode bool // false->强制关闭转码 true->保持原有转码设置 59 | FinalURL bool // 对 URL 进行重定向判断,找到非重定向地址再重定向给客户端,减少客户端重定向次数 60 | PrefixList []string 61 | } 62 | 63 | // AlistStrm具体设置 64 | type AlistSetting struct { 65 | ADDR string 66 | Username string 67 | Password string 68 | Token *string 69 | PrefixList []string 70 | } 71 | 72 | // AlistStrm播放设置 73 | type AlistStrmSetting struct { 74 | Enable bool 75 | TransCode bool // false->强制关闭转码 true->保持原有转码设置 76 | RawURL bool // 是否使用原始 URL 77 | List []AlistSetting 78 | } 79 | 80 | // 字幕设置 81 | type SubtitleSetting struct { 82 | Enable bool 83 | SRT2ASS bool // SRT 字幕转 ASS 字幕 84 | ASSStyle []string 85 | SubSet bool // ASS 字幕字体子集化 86 | } 87 | -------------------------------------------------------------------------------- /internal/config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "time" 6 | ) 7 | 8 | var ( 9 | appVersion string = "v0.1.1" 10 | commitHash string = "Unkown" 11 | buildDate string = "Unkown" 12 | ) 13 | 14 | func parseBuildTime(s string) string { 15 | if t, err := time.Parse(time.RFC3339, s); err != nil { 16 | return "Unkown" 17 | } else { 18 | return t.Local().Format(constants.FORMATE_TIME + " -07:00") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/handler/emby.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/logging" 7 | "MediaWarp/internal/service" 8 | "MediaWarp/internal/service/emby" 9 | "MediaWarp/utils" 10 | "bytes" 11 | "encoding/json" 12 | "fmt" 13 | "net/http" 14 | "net/http/httputil" 15 | "net/url" 16 | "os" 17 | "path" 18 | "strings" 19 | 20 | "github.com/gin-gonic/gin" 21 | ) 22 | 23 | // Emby服务器处理器 24 | type EmbyServerHandler struct { 25 | server *emby.EmbyServer // Emby 服务器 26 | routerRules []RegexpRouteRule // 正则路由规则 27 | proxy *httputil.ReverseProxy // 反向代理 28 | } 29 | 30 | // 初始化 31 | func NewEmbyServerHandler(addr string, apiKey string) (*EmbyServerHandler, error) { 32 | var embyServerHandler = EmbyServerHandler{} 33 | embyServerHandler.server = emby.New(addr, apiKey) 34 | target, err := url.Parse(embyServerHandler.server.GetEndpoint()) 35 | if err != nil { 36 | return nil, err 37 | } 38 | embyServerHandler.proxy = httputil.NewSingleHostReverseProxy(target) 39 | 40 | { // 初始化路由规则 41 | embyServerHandler.routerRules = []RegexpRouteRule{ 42 | { 43 | Regexp: constants.EmbyRegexp.Router.VideosHandler, 44 | Handler: embyServerHandler.VideosHandler, 45 | }, 46 | { 47 | Regexp: constants.EmbyRegexp.Router.ModifyPlaybackInfo, 48 | Handler: responseModifyCreater( 49 | &httputil.ReverseProxy{Director: embyServerHandler.proxy.Director}, 50 | embyServerHandler.ModifyPlaybackInfo, 51 | ), 52 | }, 53 | { 54 | Regexp: constants.EmbyRegexp.Router.ModifyBaseHtmlPlayer, 55 | Handler: responseModifyCreater( 56 | &httputil.ReverseProxy{Director: embyServerHandler.proxy.Director}, 57 | embyServerHandler.ModifyBaseHtmlPlayer, 58 | ), 59 | }, 60 | } 61 | 62 | if config.Web.Enable { 63 | if config.Web.Index || config.Web.Head != "" || config.Web.ExternalPlayerUrl || config.Web.VideoTogether { 64 | embyServerHandler.routerRules = append(embyServerHandler.routerRules, 65 | RegexpRouteRule{ 66 | Regexp: constants.EmbyRegexp.Router.ModifyIndex, 67 | Handler: responseModifyCreater( 68 | &httputil.ReverseProxy{Director: embyServerHandler.proxy.Director}, 69 | embyServerHandler.ModifyIndex, 70 | ), 71 | }, 72 | ) 73 | } 74 | } 75 | if config.Subtitle.Enable && config.Subtitle.SRT2ASS { 76 | embyServerHandler.routerRules = append(embyServerHandler.routerRules, 77 | RegexpRouteRule{ 78 | Regexp: constants.EmbyRegexp.Router.ModifySubtitles, 79 | Handler: responseModifyCreater( 80 | &httputil.ReverseProxy{Director: embyServerHandler.proxy.Director}, 81 | embyServerHandler.ModifySubtitles, 82 | ), 83 | }, 84 | ) 85 | } 86 | } 87 | return &embyServerHandler, nil 88 | } 89 | 90 | // 转发请求至上游服务器 91 | func (embyServerHandler *EmbyServerHandler) ReverseProxy(rw http.ResponseWriter, req *http.Request) { 92 | embyServerHandler.proxy.ServeHTTP(rw, req) 93 | } 94 | 95 | // 正则路由表 96 | func (embyServerHandler *EmbyServerHandler) GetRegexpRouteRules() []RegexpRouteRule { 97 | return embyServerHandler.routerRules 98 | } 99 | 100 | // 修改播放信息请求 101 | // 102 | // /Items/:itemId/PlaybackInfo 103 | // 强制将 HTTPStrm 设置为支持直链播放和转码、AlistStrm 设置为支持直链播放并且禁止转码 104 | func (embyServerHandler *EmbyServerHandler) ModifyPlaybackInfo(rw *http.Response) error { 105 | defer rw.Body.Close() 106 | body, err := readBody(rw) 107 | if err != nil { 108 | logging.Warning("读取 Body 出错:", err) 109 | return err 110 | } 111 | 112 | var playbackInfoResponse emby.PlaybackInfoResponse 113 | if err = json.Unmarshal(body, &playbackInfoResponse); err != nil { 114 | logging.Warning("解析 emby.PlaybackInfoResponse Json 错误:", err) 115 | return err 116 | } 117 | 118 | for index, mediasource := range playbackInfoResponse.MediaSources { 119 | logging.Debug("请求 ItemsServiceQueryItem:" + *mediasource.ID) 120 | itemResponse, err := embyServerHandler.server.ItemsServiceQueryItem(strings.Replace(*mediasource.ID, "mediasource_", "", 1), 1, "Path,MediaSources") // 查询 item 需要去除前缀仅保留数字部分 121 | if err != nil { 122 | logging.Warning("请求 ItemsServiceQueryItem 失败:", err) 123 | continue 124 | } 125 | item := itemResponse.Items[0] 126 | strmFileType, opt := recgonizeStrmFileType(*item.Path) 127 | switch strmFileType { 128 | case constants.HTTPStrm: // HTTPStrm 设置支持直链播放并且支持转码 129 | if !config.HTTPStrm.TransCode { 130 | *playbackInfoResponse.MediaSources[index].SupportsDirectPlay = true 131 | *playbackInfoResponse.MediaSources[index].SupportsDirectStream = true 132 | playbackInfoResponse.MediaSources[index].TranscodingURL = nil 133 | playbackInfoResponse.MediaSources[index].TranscodingSubProtocol = nil 134 | playbackInfoResponse.MediaSources[index].TranscodingContainer = nil 135 | if mediasource.DirectStreamURL != nil { 136 | apikeypair, err := utils.ResolveEmbyAPIKVPairs(*mediasource.DirectStreamURL) 137 | if err != nil { 138 | logging.Warning("解析API键值对失败:", err) 139 | continue 140 | } 141 | directStreamURL := fmt.Sprintf("/videos/%s/stream?MediaSourceId=%s&Static=true&%s", *mediasource.ItemID, *mediasource.ID, apikeypair) 142 | playbackInfoResponse.MediaSources[index].DirectStreamURL = &directStreamURL 143 | logging.Infof("%s 强制禁止转码,直链播放链接为:%s", *mediasource.Name, directStreamURL) 144 | } 145 | } 146 | 147 | case constants.AlistStrm: // AlistStm 设置支持直链播放并且禁止转码 148 | if !config.AlistStrm.TransCode { 149 | *playbackInfoResponse.MediaSources[index].SupportsDirectPlay = true 150 | *playbackInfoResponse.MediaSources[index].SupportsDirectStream = true 151 | *playbackInfoResponse.MediaSources[index].SupportsTranscoding = false 152 | playbackInfoResponse.MediaSources[index].TranscodingURL = nil 153 | playbackInfoResponse.MediaSources[index].TranscodingSubProtocol = nil 154 | playbackInfoResponse.MediaSources[index].TranscodingContainer = nil 155 | apikeypair, err := utils.ResolveEmbyAPIKVPairs(*mediasource.DirectStreamURL) 156 | if err != nil { 157 | logging.Warning("解析API键值对失败:", err) 158 | continue 159 | } 160 | directStreamURL := fmt.Sprintf("/videos/%s/stream?MediaSourceId=%s&Static=true&%s", *mediasource.ItemID, *mediasource.ID, apikeypair) 161 | playbackInfoResponse.MediaSources[index].DirectStreamURL = &directStreamURL 162 | container := strings.TrimPrefix(path.Ext(*mediasource.Path), ".") 163 | playbackInfoResponse.MediaSources[index].Container = &container 164 | logging.Infof("%s 强制禁止转码,直链播放链接为:%s,容器为:%s", *mediasource.Name, directStreamURL, container) 165 | } else { 166 | logging.Infof("%s 保持原有转码设置", *mediasource.Name) 167 | } 168 | 169 | if playbackInfoResponse.MediaSources[index].Size == nil { 170 | alistServer, err := service.GetAlistServer(opt.(string)) 171 | if err != nil { 172 | logging.Warning("获取 AlistServer 失败:", err) 173 | continue 174 | } 175 | fsGetData, err := alistServer.FsGet(*mediasource.Path) 176 | if err != nil { 177 | logging.Warning("请求 FsGet 失败:", err) 178 | continue 179 | } 180 | playbackInfoResponse.MediaSources[index].Size = &fsGetData.Size 181 | logging.Infof("%s 设置文件大小为:%d", *mediasource.Name, fsGetData.Size) 182 | } 183 | } 184 | } 185 | 186 | body, err = json.Marshal(playbackInfoResponse) 187 | if err != nil { 188 | logging.Warning("序列化 emby.PlaybackInfoResponse Json 错误:", err) 189 | return err 190 | } 191 | 192 | rw.Header.Set("Content-Type", "application/json") // 更新 Content-Type 头 193 | return updateBody(rw, body) 194 | } 195 | 196 | // 视频流处理器 197 | // 198 | // 支持播放本地视频、重定向 HttpStrm、AlistStrm 199 | func (embyServerHandler *EmbyServerHandler) VideosHandler(ctx *gin.Context) { 200 | if ctx.Request.Method == http.MethodHead { // 不额外处理 HEAD 请求 201 | embyServerHandler.ReverseProxy(ctx.Writer, ctx.Request) 202 | logging.Debug("VideosHandler 不处理 HEAD 请求,转发至上游服务器") 203 | return 204 | } 205 | 206 | orginalPath := ctx.Request.URL.Path 207 | matches := constants.EmbyRegexp.Others.VideoRedirectReg.FindStringSubmatch(orginalPath) 208 | if len(matches) == 2 { 209 | redirectPath := fmt.Sprintf("/videos/%s/stream", matches[0]) 210 | logging.Debugf("%s 重定向至:%s", orginalPath, redirectPath) 211 | ctx.Redirect(http.StatusFound, redirectPath) 212 | return 213 | } 214 | 215 | // EmbyServer <= 4.8 ====> mediaSourceID = 343121 216 | // EmbyServer >= 4.9 ====> mediaSourceID = mediasource_31 217 | mediaSourceID := ctx.Query("mediasourceid") 218 | 219 | logging.Debugf("请求 ItemsServiceQueryItem:%s", mediaSourceID) 220 | itemResponse, err := embyServerHandler.server.ItemsServiceQueryItem(strings.Replace(mediaSourceID, "mediasource_", "", 1), 1, "Path,MediaSources") // 查询 item 需要去除前缀仅保留数字部分 221 | if err != nil { 222 | logging.Warning("请求 ItemsServiceQueryItem 失败:", err) 223 | embyServerHandler.ReverseProxy(ctx.Writer, ctx.Request) 224 | return 225 | } 226 | 227 | item := itemResponse.Items[0] 228 | 229 | if !strings.HasSuffix(strings.ToLower(*item.Path), ".strm") { // 不是 Strm 文件 230 | logging.Debug("播放本地视频:" + *item.Path + ",不进行处理") 231 | embyServerHandler.ReverseProxy(ctx.Writer, ctx.Request) 232 | return 233 | } 234 | 235 | strmFileType, opt := recgonizeStrmFileType(*item.Path) 236 | for _, mediasource := range item.MediaSources { 237 | if *mediasource.ID == mediaSourceID { // EmbyServer >= 4.9 返回的ID带有前缀mediasource_ 238 | switch strmFileType { 239 | case constants.HTTPStrm: 240 | if *mediasource.Protocol == emby.HTTP { 241 | redirectURL := *mediasource.Path 242 | if config.HTTPStrm.FinalURL { 243 | logging.Debug("HTTPStrm 启用获取最终 URL,开始尝试获取最终 URL") 244 | if finalURL, err := getFinalURL(redirectURL, ctx.Request.UserAgent()); err != nil { 245 | logging.Warning("获取最终 URL 失败,使用原始 URL:", err) 246 | } else { 247 | redirectURL = finalURL 248 | } 249 | } else { 250 | logging.Debug("HTTPStrm 未启用获取最终 URL,直接使用原始 URL") 251 | } 252 | logging.Info("HTTPStrm 重定向至:", redirectURL) 253 | ctx.Redirect(http.StatusFound, redirectURL) 254 | } 255 | return 256 | case constants.AlistStrm: // 无需判断 *mediasource.Container 是否以Strm结尾,当 AlistStrm 存储的位置有对应的文件时,*mediasource.Container 会被设置为文件后缀 257 | alistServerAddr := opt.(string) 258 | alistServer, err := service.GetAlistServer(alistServerAddr) 259 | if err != nil { 260 | logging.Warning("获取 AlistServer 失败:", err) 261 | return 262 | } 263 | fsGetData, err := alistServer.FsGet(*mediasource.Path) 264 | if err != nil { 265 | logging.Warning("请求 FsGet 失败:", err) 266 | return 267 | } 268 | var redirectURL string 269 | if config.AlistStrm.RawURL { 270 | redirectURL = fsGetData.RawURL 271 | } else { 272 | redirectURL = fmt.Sprintf("%s/d%s", alistServerAddr, *mediasource.Path) 273 | if fsGetData.Sign != "" { 274 | redirectURL += "?sign=" + fsGetData.Sign 275 | } 276 | } 277 | logging.Info("AlistStrm 重定向至:", redirectURL) 278 | ctx.Redirect(http.StatusFound, redirectURL) 279 | return 280 | case constants.UnknownStrm: 281 | embyServerHandler.ReverseProxy(ctx.Writer, ctx.Request) 282 | return 283 | } 284 | } 285 | } 286 | } 287 | 288 | // 修改字幕 289 | // 290 | // 将 SRT 字幕转 ASS 291 | func (embyServerHandler *EmbyServerHandler) ModifySubtitles(rw *http.Response) error { 292 | defer rw.Body.Close() 293 | subtitile, err := readBody(rw) // 读取字幕文件 294 | if err != nil { 295 | logging.Warning("读取原始字幕 Body 出错:", err) 296 | return err 297 | } 298 | 299 | if utils.IsSRT(subtitile) { // 判断是否为 SRT 格式 300 | logging.Info("字幕文件为 SRT 格式") 301 | if config.Subtitle.SRT2ASS { 302 | logging.Info("已将 SRT 字幕已转为 ASS 格式") 303 | assSubtitle := utils.SRT2ASS(subtitile, config.Subtitle.ASSStyle) 304 | return updateBody(rw, assSubtitle) 305 | } 306 | } 307 | return nil 308 | } 309 | 310 | // 修改 basehtmlplayer.js 311 | // 312 | // 用于修改播放器 JS,实现跨域播放 Strm 文件(302 重定向) 313 | func (embyServerHandler *EmbyServerHandler) ModifyBaseHtmlPlayer(rw *http.Response) error { 314 | defer rw.Body.Close() 315 | body, err := readBody(rw) 316 | if err != nil { 317 | return err 318 | } 319 | 320 | body = bytes.ReplaceAll(body, []byte(`mediaSource.IsRemote&&"DirectPlay"===playMethod?null:"anonymous"`), []byte("null")) // 修改响应体 321 | return updateBody(rw, body) 322 | 323 | } 324 | 325 | // 修改首页函数 326 | func (embyServerHandler *EmbyServerHandler) ModifyIndex(rw *http.Response) error { 327 | var ( 328 | htmlFilePath string = path.Join(config.CostomDir(), "index.html") 329 | htmlContent []byte 330 | addHEAD []byte 331 | err error 332 | ) 333 | 334 | defer rw.Body.Close() // 无论哪种情况,最终都要确保原 Body 被关闭,避免内存泄漏 335 | if !config.Web.Index { // 从上游获取响应体 336 | if htmlContent, err = readBody(rw); err != nil { 337 | return err 338 | } 339 | } else { // 从本地文件读取index.html 340 | if htmlContent, err = os.ReadFile(htmlFilePath); err != nil { 341 | logging.Warning("读取文件内容出错,错误信息:", err) 342 | return err 343 | } 344 | } 345 | 346 | if config.Web.Head != "" { // 用户自定义HEAD 347 | addHEAD = append(addHEAD, []byte(config.Web.Head+"\n")...) 348 | } 349 | if config.Web.ExternalPlayerUrl { // 外部播放器 350 | addHEAD = append(addHEAD, []byte(``+"\n")...) 351 | } 352 | if config.Web.Crx { // crx 美化 353 | addHEAD = append(addHEAD, []byte(` 354 | 355 | 356 | 357 | `+"\n")...) 358 | } 359 | if config.Web.ActorPlus { // 过滤没有头像的演员和制作人员 360 | addHEAD = append(addHEAD, []byte(``+"\n")...) 361 | } 362 | if config.Web.FanartShow { // 显示同人图(fanart图) 363 | addHEAD = append(addHEAD, []byte(``+"\n")...) 364 | } 365 | if config.Web.Danmaku { // 弹幕 366 | addHEAD = append(addHEAD, []byte(``+"\n")...) 367 | } 368 | if config.Web.VideoTogether { // VideoTogether 369 | addHEAD = append(addHEAD, []byte(``+"\n")...) 370 | } 371 | htmlContent = bytes.Replace(htmlContent, []byte(""), append(addHEAD, []byte("")...), 1) // 将添加HEAD 372 | return updateBody(rw, htmlContent) 373 | } 374 | 375 | var _ MediaServerHandler = (*EmbyServerHandler)(nil) // 确保 EmbyServerHandler 实现 MediaServerHandler 接口 376 | -------------------------------------------------------------------------------- /internal/handler/jellyfin.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/logging" 7 | "MediaWarp/internal/service" 8 | "MediaWarp/internal/service/jellyfin" 9 | "MediaWarp/utils" 10 | "bytes" 11 | "encoding/json" 12 | "fmt" 13 | "net/http" 14 | "net/http/httputil" 15 | "net/url" 16 | "os" 17 | "path" 18 | "strings" 19 | 20 | "github.com/gin-gonic/gin" 21 | ) 22 | 23 | // Jellyfin 服务器处理器 24 | type JellyfinHandler struct { 25 | server *jellyfin.Jellyfin // Jellyfin 服务器 26 | routerRules []RegexpRouteRule // 正则路由规则 27 | proxy *httputil.ReverseProxy // 反向代理 28 | } 29 | 30 | func NewJellyfinHander(addr string, apiKey string) (*JellyfinHandler, error) { 31 | jellyfinHandler := JellyfinHandler{} 32 | jellyfinHandler.server = jellyfin.New(addr, apiKey) 33 | target, err := url.Parse(jellyfinHandler.server.GetEndpoint()) 34 | if err != nil { 35 | return nil, err 36 | } 37 | jellyfinHandler.proxy = httputil.NewSingleHostReverseProxy(target) 38 | 39 | { // 初始化路由规则 40 | jellyfinHandler.routerRules = []RegexpRouteRule{ 41 | { 42 | Regexp: constants.JellyfinRegexp.Router.ModifyPlaybackInfo, 43 | Handler: responseModifyCreater( 44 | &httputil.ReverseProxy{Director: jellyfinHandler.proxy.Director}, 45 | jellyfinHandler.ModifyPlaybackInfo, 46 | ), 47 | }, 48 | { 49 | Regexp: constants.JellyfinRegexp.Router.VideosHandler, 50 | Handler: jellyfinHandler.VideosHandler, 51 | }, 52 | } 53 | if config.Web.Enable { 54 | if config.Web.Index || config.Web.Head != "" || config.Web.ExternalPlayerUrl || config.Web.VideoTogether { 55 | jellyfinHandler.routerRules = append( 56 | jellyfinHandler.routerRules, 57 | RegexpRouteRule{ 58 | Regexp: constants.JellyfinRegexp.Router.ModifyIndex, 59 | Handler: responseModifyCreater( 60 | &httputil.ReverseProxy{Director: jellyfinHandler.proxy.Director}, 61 | jellyfinHandler.ModifyIndex, 62 | ), 63 | }, 64 | ) 65 | } 66 | } 67 | } 68 | return &jellyfinHandler, nil 69 | } 70 | 71 | // 转发请求至上游服务器 72 | func (jellyfinHandler *JellyfinHandler) ReverseProxy(rw http.ResponseWriter, req *http.Request) { 73 | jellyfinHandler.proxy.ServeHTTP(rw, req) 74 | } 75 | 76 | // 正则路由表 77 | func (jellyfinHandler *JellyfinHandler) GetRegexpRouteRules() []RegexpRouteRule { 78 | return jellyfinHandler.routerRules 79 | } 80 | 81 | // 修改播放信息请求 82 | // 83 | // /Items/:itemId 84 | // 强制将 HTTPStrm 设置为支持直链播放和转码、AlistStrm 设置为支持直链播放并且禁止转码 85 | func (jellyfinHandler *JellyfinHandler) ModifyPlaybackInfo(rw *http.Response) error { 86 | defer rw.Body.Close() 87 | data, err := readBody(rw) 88 | if err != nil { 89 | logging.Warning("读取响应体失败:", err) 90 | return err 91 | } 92 | 93 | var playbackInfoResponse jellyfin.PlaybackInfoResponse 94 | if err = json.Unmarshal(data, &playbackInfoResponse); err != nil { 95 | logging.Warning("解析 jellyfin.PlaybackInfoResponse JSON 错误:", err) 96 | return err 97 | } 98 | 99 | for index, mediasource := range playbackInfoResponse.MediaSources { 100 | logging.Debug("请求 ItemsServiceQueryItem:" + *mediasource.ID) 101 | itemResponse, err := jellyfinHandler.server.ItemsServiceQueryItem(*mediasource.ID, 1, "Path,MediaSources") // 查询 item 需要去除前缀仅保留数字部分 102 | if err != nil { 103 | logging.Warning("请求 ItemsServiceQueryItem 失败:", err) 104 | continue 105 | } 106 | item := itemResponse.Items[0] 107 | strmFileType, opt := recgonizeStrmFileType(*item.Path) 108 | switch strmFileType { 109 | case constants.HTTPStrm: // HTTPStrm 设置支持直链播放并且支持转码 110 | if !config.HTTPStrm.TransCode { 111 | *playbackInfoResponse.MediaSources[index].SupportsDirectPlay = true 112 | *playbackInfoResponse.MediaSources[index].SupportsDirectStream = true 113 | playbackInfoResponse.MediaSources[index].TranscodingURL = nil 114 | playbackInfoResponse.MediaSources[index].TranscodingSubProtocol = nil 115 | playbackInfoResponse.MediaSources[index].TranscodingContainer = nil 116 | if mediasource.DirectStreamURL != nil { 117 | apikeypair, err := utils.ResolveEmbyAPIKVPairs(*mediasource.DirectStreamURL) 118 | if err != nil { 119 | logging.Warning("解析API键值对失败:", err) 120 | continue 121 | } 122 | directStreamURL := fmt.Sprintf("/Videos/%s/stream?MediaSourceId=%s&Static=true&%s", *mediasource.ID, *mediasource.ID, apikeypair) 123 | playbackInfoResponse.MediaSources[index].DirectStreamURL = &directStreamURL 124 | logging.Info(*mediasource.Name, " 强制禁止转码,直链播放链接为: ", directStreamURL) 125 | } 126 | } 127 | 128 | case constants.AlistStrm: // AlistStm 设置支持直链播放并且禁止转码 129 | if !config.AlistStrm.TransCode { 130 | *playbackInfoResponse.MediaSources[index].SupportsDirectPlay = true 131 | *playbackInfoResponse.MediaSources[index].SupportsDirectStream = true 132 | *playbackInfoResponse.MediaSources[index].SupportsTranscoding = false 133 | playbackInfoResponse.MediaSources[index].TranscodingURL = nil 134 | playbackInfoResponse.MediaSources[index].TranscodingSubProtocol = nil 135 | playbackInfoResponse.MediaSources[index].TranscodingContainer = nil 136 | directStreamURL := fmt.Sprintf("/Videos/%s/stream?MediaSourceId=%s&Static=true", *mediasource.ID, *mediasource.ID) 137 | if mediasource.DirectStreamURL != nil { 138 | logging.Debugf("%s 原直链播放链接: %s", *mediasource.Name, *mediasource.DirectStreamURL) 139 | apikeypair, err := utils.ResolveEmbyAPIKVPairs(*mediasource.DirectStreamURL) 140 | if err != nil { 141 | logging.Warning("解析API键值对失败:", err) 142 | continue 143 | } 144 | directStreamURL += "&" + apikeypair 145 | } 146 | playbackInfoResponse.MediaSources[index].DirectStreamURL = &directStreamURL 147 | container := strings.TrimPrefix(path.Ext(*mediasource.Path), ".") 148 | playbackInfoResponse.MediaSources[index].Container = &container 149 | logging.Infof("%s 强制禁止转码,直链播放链接为:%s,容器为: %s", *mediasource.Name, directStreamURL, container) 150 | } else { 151 | logging.Infof("%s 保持原有转码设置", *mediasource.Name) 152 | } 153 | 154 | if playbackInfoResponse.MediaSources[index].Size == nil { 155 | alistServer, err := service.GetAlistServer(opt.(string)) 156 | if err != nil { 157 | logging.Warning("获取 AlistServer 失败:", err) 158 | continue 159 | } 160 | fsGetData, err := alistServer.FsGet(*mediasource.Path) 161 | if err != nil { 162 | logging.Warning("请求 FsGet 失败:", err) 163 | continue 164 | } 165 | playbackInfoResponse.MediaSources[index].Size = &fsGetData.Size 166 | logging.Infof("%s 设置文件大小为:%d", *mediasource.Name, fsGetData.Size) 167 | } 168 | } 169 | } 170 | 171 | if data, err = json.Marshal(playbackInfoResponse); err != nil { 172 | logging.Warning("序列化 jellyfin.PlaybackInfoResponse Json 错误:", err) 173 | return err 174 | } 175 | 176 | rw.Header.Set("Content-Type", "application/json") // 更新 Content-Type 头 177 | return updateBody(rw, data) 178 | } 179 | 180 | // 视频流处理器 181 | // 182 | // 支持播放本地视频、重定向 HttpStrm、AlistStrm 183 | func (jellyfinHandler *JellyfinHandler) VideosHandler(ctx *gin.Context) { 184 | if ctx.Request.Method == http.MethodHead { // 不额外处理 HEAD 请求 185 | jellyfinHandler.ReverseProxy(ctx.Writer, ctx.Request) 186 | logging.Debug("VideosHandler 不处理 HEAD 请求,转发至上游服务器") 187 | return 188 | } 189 | 190 | mediaSourceID := ctx.Query("mediasourceid") 191 | logging.Debugf("请求 ItemsServiceQueryItem:%s", mediaSourceID) 192 | itemResponse, err := jellyfinHandler.server.ItemsServiceQueryItem(mediaSourceID, 1, "Path,MediaSources") // 查询 item 需要去除前缀仅保留数字部分 193 | if err != nil { 194 | logging.Warning("请求 ItemsServiceQueryItem 失败:", err) 195 | jellyfinHandler.proxy.ServeHTTP(ctx.Writer, ctx.Request) 196 | return 197 | } 198 | 199 | item := itemResponse.Items[0] 200 | 201 | if !strings.HasSuffix(strings.ToLower(*item.Path), ".strm") { // 不是 Strm 文件 202 | logging.Debugf("播放本地视频:%s,不进行处理", *item.Path) 203 | jellyfinHandler.proxy.ServeHTTP(ctx.Writer, ctx.Request) 204 | return 205 | } 206 | 207 | strmFileType, opt := recgonizeStrmFileType(*item.Path) 208 | for _, mediasource := range item.MediaSources { 209 | if *mediasource.ID == mediaSourceID { // EmbyServer >= 4.9 返回的ID带有前缀mediasource_ 210 | switch strmFileType { 211 | case constants.HTTPStrm: 212 | if *mediasource.Protocol == jellyfin.HTTP { 213 | redirectURL := *mediasource.Path 214 | if config.HTTPStrm.FinalURL { 215 | logging.Debug("HTTPStrm 启用获取最终 URL,开始尝试获取最终 URL") 216 | if finalURL, err := getFinalURL(redirectURL, ctx.Request.UserAgent()); err != nil { 217 | logging.Warning("获取最终 URL 失败,使用原始 URL:", err) 218 | } else { 219 | redirectURL = finalURL 220 | } 221 | } else { 222 | logging.Debug("HTTPStrm 未启用获取最终 URL,直接使用原始 URL") 223 | } 224 | logging.Info("HTTPStrm 重定向至:", redirectURL) 225 | ctx.Redirect(http.StatusFound, redirectURL) 226 | } 227 | return 228 | case constants.AlistStrm: // 无需判断 *mediasource.Container 是否以Strm结尾,当 AlistStrm 存储的位置有对应的文件时,*mediasource.Container 会被设置为文件后缀 229 | alistServerAddr := opt.(string) 230 | alistServer, err := service.GetAlistServer(alistServerAddr) 231 | if err != nil { 232 | logging.Warning("获取 AlistServer 失败:", err) 233 | return 234 | } 235 | fsGetData, err := alistServer.FsGet(*mediasource.Path) 236 | if err != nil { 237 | logging.Warning("请求 FsGet 失败:", err) 238 | return 239 | } 240 | var redirectURL string 241 | if config.AlistStrm.RawURL { 242 | redirectURL = fsGetData.RawURL 243 | } else { 244 | redirectURL = fmt.Sprintf("%s/d%s", alistServerAddr, *mediasource.Path) 245 | if fsGetData.Sign != "" { 246 | redirectURL += "?sign=" + fsGetData.Sign 247 | } 248 | } 249 | logging.Infof("AlistStrm 重定向至:%s", redirectURL) 250 | ctx.Redirect(http.StatusFound, redirectURL) 251 | return 252 | case constants.UnknownStrm: 253 | jellyfinHandler.proxy.ServeHTTP(ctx.Writer, ctx.Request) 254 | return 255 | } 256 | } 257 | } 258 | } 259 | 260 | // 修改首页函数 261 | func (jellyfinHandler *JellyfinHandler) ModifyIndex(rw *http.Response) error { 262 | var ( 263 | htmlFilePath string = path.Join(config.CostomDir(), "index.html") 264 | htmlContent []byte 265 | addHEAD []byte 266 | err error 267 | ) 268 | 269 | defer rw.Body.Close() // 无论哪种情况,最终都要确保原 Body 被关闭,避免内存泄漏 270 | if config.Web.Index { // 从本地文件读取index.html 271 | if htmlContent, err = os.ReadFile(htmlFilePath); err != nil { 272 | logging.Warning("读取文件内容出错,错误信息:", err) 273 | return err 274 | } 275 | } else { // 从上游获取响应体 276 | if htmlContent, err = readBody(rw); err != nil { 277 | return err 278 | } 279 | } 280 | 281 | if config.Web.Head != "" { // 用户自定义HEAD 282 | addHEAD = append(addHEAD, []byte(config.Web.Head+"\n")...) 283 | } 284 | if config.Web.ExternalPlayerUrl { // 外部播放器 285 | addHEAD = append(addHEAD, []byte(``+"\n")...) 286 | } 287 | if config.Web.Crx { // crx 美化 288 | addHEAD = append(addHEAD, []byte(` 289 | 290 | 291 | 292 | `+"\n")...) 293 | } 294 | if config.Web.ActorPlus { // 过滤没有头像的演员和制作人员 295 | addHEAD = append(addHEAD, []byte(``+"\n")...) 296 | } 297 | if config.Web.FanartShow { // 显示同人图(fanart图) 298 | addHEAD = append(addHEAD, []byte(``+"\n")...) 299 | } 300 | if config.Web.Danmaku { // 弹幕 301 | addHEAD = append(addHEAD, []byte(``+"\n")...) 302 | } 303 | if config.Web.VideoTogether { // VideoTogether 304 | addHEAD = append(addHEAD, []byte(``+"\n")...) 305 | } 306 | htmlContent = bytes.Replace(htmlContent, []byte(""), append(addHEAD, []byte("")...), 1) // 将添加HEAD 307 | 308 | return updateBody(rw, htmlContent) 309 | } 310 | 311 | var _ MediaServerHandler = (*JellyfinHandler)(nil) // 确保 JellyfinHandler 实现 MediaServerHandler 接口 312 | -------------------------------------------------------------------------------- /internal/handler/rule.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // 正则表达式路由规则 10 | type RegexpRouteRule struct { 11 | Regexp *regexp.Regexp 12 | Handler gin.HandlerFunc 13 | } 14 | -------------------------------------------------------------------------------- /internal/handler/server.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "errors" 7 | "net/http" 8 | ) 9 | 10 | // 媒体服务器处理接口 11 | type MediaServerHandler interface { 12 | ReverseProxy(http.ResponseWriter, *http.Request) // 转发请求至上游服务器 13 | GetRegexpRouteRules() []RegexpRouteRule // 获取正则路由表 14 | } 15 | 16 | var mediaServerHandler MediaServerHandler 17 | var ErrInvalidMediaServerType = errors.New("错误的媒体服务器类型") 18 | 19 | // 初始化媒体服务器处理器 20 | func Init() error { 21 | var err error 22 | switch config.MediaServer.Type { 23 | case constants.EMBY: 24 | mediaServerHandler, err = NewEmbyServerHandler(config.MediaServer.ADDR, config.MediaServer.AUTH) 25 | case constants.JELLYFIN: 26 | mediaServerHandler, err = NewJellyfinHander(config.MediaServer.ADDR, config.MediaServer.AUTH) 27 | default: 28 | err = ErrInvalidMediaServerType 29 | } 30 | return err 31 | } 32 | 33 | // 获取媒体服务器接口 34 | func GetMediaServer() MediaServerHandler { 35 | return mediaServerHandler 36 | } 37 | -------------------------------------------------------------------------------- /internal/handler/utils.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/logging" 7 | "bytes" 8 | "compress/gzip" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "net/http/httputil" 14 | "net/url" 15 | "reflect" 16 | "runtime" 17 | "runtime/debug" 18 | "strconv" 19 | "strings" 20 | "time" 21 | 22 | "github.com/andybalholm/brotli" 23 | "github.com/gin-gonic/gin" 24 | ) 25 | 26 | // 响应修改创建器 27 | // 28 | // 将需要修改上游响应的处理器包装成一个 gin.HandlerFunc 处理器 29 | func responseModifyCreater(proxy *httputil.ReverseProxy, modifyResponseFN func(rw *http.Response) error) gin.HandlerFunc { 30 | funcPtr := reflect.ValueOf(modifyResponseFN).Pointer() 31 | funcName := strings.ReplaceAll(runtime.FuncForPC(funcPtr).Name(), "-fm", "") 32 | logging.Debugf("创建响应修改处理器:%s", funcName) 33 | 34 | proxy.ModifyResponse = func(rw *http.Response) error { 35 | defer func() { 36 | if r := recover(); r != nil { 37 | logging.Errorf("%s 发生 panic:%s\n%s", funcName, r, string(debug.Stack())) 38 | } 39 | }() 40 | return modifyResponseFN(rw) 41 | } 42 | 43 | return func(ctx *gin.Context) { 44 | proxy.ServeHTTP(ctx.Writer, ctx.Request) 45 | } 46 | } 47 | 48 | // 根据 Strm 文件路径识别 Strm 文件类型 49 | // 50 | // 返回 Strm 文件类型和一个可选配置 51 | func recgonizeStrmFileType(strmFilePath string) (constants.StrmFileType, any) { 52 | if config.HTTPStrm.Enable { 53 | for _, prefix := range config.HTTPStrm.PrefixList { 54 | if strings.HasPrefix(strmFilePath, prefix) { 55 | logging.Debugf("%s 成功匹配路径:%s,Strm 类型:%s", strmFilePath, prefix, constants.HTTPStrm) 56 | return constants.HTTPStrm, nil 57 | } 58 | } 59 | } 60 | if config.AlistStrm.Enable { 61 | for _, alistStrmConfig := range config.AlistStrm.List { 62 | for _, prefix := range alistStrmConfig.PrefixList { 63 | if strings.HasPrefix(strmFilePath, prefix) { 64 | logging.Debugf("%s 成功匹配路径:%s,Strm 类型:%s,AlistServer 地址:%s", strmFilePath, prefix, constants.AlistStrm, alistStrmConfig.ADDR) 65 | return constants.AlistStrm, alistStrmConfig.ADDR 66 | } 67 | } 68 | } 69 | } 70 | logging.Debugf("%s 未匹配任何路径,Strm 类型:%s", strmFilePath, constants.UnknownStrm) 71 | return constants.UnknownStrm, nil 72 | } 73 | 74 | // 读取响应体 75 | // 76 | // 读取响应体,解压缩 GZIP、Brotli 数据(若响应体被压缩) 77 | func readBody(rw *http.Response) ([]byte, error) { 78 | encoding := rw.Header.Get("Content-Encoding") 79 | 80 | var reader io.Reader 81 | switch encoding { 82 | case "gzip": 83 | logging.Debug("解码 GZIP 数据") 84 | gr, err := gzip.NewReader(rw.Body) 85 | if err != nil { 86 | return nil, fmt.Errorf("gzip reader error: %w", err) 87 | } 88 | defer gr.Close() 89 | reader = gr 90 | 91 | case "br": 92 | logging.Debug("解码 Brotli 数据") 93 | reader = brotli.NewReader(rw.Body) 94 | 95 | case "": // 无压缩 96 | logging.Debug("无压缩数据") 97 | reader = rw.Body 98 | 99 | default: 100 | return nil, fmt.Errorf("unsupported Content-Encoding: %s", encoding) 101 | } 102 | return io.ReadAll(reader) 103 | } 104 | 105 | // 更新响应体 106 | // 107 | // 修改响应体、更新Content-Length 108 | func updateBody(rw *http.Response, content []byte) error { 109 | encoding := rw.Header.Get("Content-Encoding") 110 | var ( 111 | compressed bytes.Buffer 112 | writer io.Writer 113 | ) 114 | 115 | // 根据原始编码选择压缩方式 116 | switch encoding { 117 | case "gzip": 118 | logging.Debug("使用 GZIP 重新编码数据") 119 | gw := gzip.NewWriter(&compressed) 120 | defer gw.Close() 121 | writer = gw 122 | 123 | case "br": 124 | logging.Debug("使用 Brotli 重新编码数据") 125 | bw := brotli.NewWriter(&compressed) 126 | defer bw.Close() 127 | writer = bw 128 | 129 | case "": // 无压缩 130 | logging.Debug("无压缩数据") 131 | writer = &compressed 132 | 133 | default: 134 | logging.Warningf("不支持的重新编码:%s,将不对数据进行压缩编码", encoding) 135 | rw.Header.Del("Content-Encoding") 136 | } 137 | 138 | if _, err := writer.Write(content); err != nil { 139 | return fmt.Errorf("compression write error: %w", err) 140 | } 141 | 142 | // Brotli 需要显式 Flush 143 | if bw, ok := writer.(*brotli.Writer); ok { 144 | if err := bw.Flush(); err != nil { 145 | return err 146 | } 147 | } 148 | 149 | // 设置新 Body 150 | rw.Body = io.NopCloser(bytes.NewReader(compressed.Bytes())) 151 | rw.ContentLength = int64(compressed.Len()) 152 | rw.Header.Set("Content-Length", strconv.Itoa(compressed.Len())) // 更新响应头 153 | 154 | return nil 155 | } 156 | 157 | const ( 158 | MaxRedirectAttempts = 10 // 最大重定向次数限制 159 | RedirectTimeout = 10 * time.Second // 最大超时时间 160 | 161 | ) 162 | 163 | var ( 164 | ErrInvalidLocationHeader = errors.New("重定向 Location 头无效") 165 | ErrMaxRedirectsExceeded = fmt.Errorf("超过最大重定向次数限制(%d)", MaxRedirectAttempts) 166 | ) 167 | 168 | // 获取URL的最终目标地址(自动跟踪重定向) 169 | func getFinalURL(rawURL string, ua string) (string, error) { 170 | startTime := time.Now() 171 | defer func() { 172 | logging.Debugf("获取 %s 最终URL耗时:%s", rawURL, time.Since(startTime)) 173 | }() 174 | 175 | parsedURL, err := url.Parse(rawURL) // 验证并解析输入URL 176 | if err != nil { 177 | return "", fmt.Errorf("非法 URL: %w", err) 178 | } 179 | if parsedURL.Scheme == "" { 180 | return "", fmt.Errorf("URL 缺少协议头: %s", parsedURL) 181 | } 182 | 183 | // 创建自定义HTTP客户端配置 184 | client := &http.Client{ 185 | Timeout: RedirectTimeout, 186 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 187 | // 禁止自动重定向,以便手动处理 188 | return http.ErrUseLastResponse 189 | }, 190 | } 191 | 192 | currentURL := parsedURL.String() 193 | visited := make(map[string]struct{}, MaxRedirectAttempts) 194 | redirectChain := make([]string, 0, MaxRedirectAttempts+1) 195 | 196 | // 跟踪重定向链 197 | for i := 0; i <= MaxRedirectAttempts; i++ { 198 | // 检测循环重定向 199 | if _, exists := visited[currentURL]; exists { 200 | return "", fmt.Errorf("检测到循环重定向,重定向链: %s", strings.Join(redirectChain, " -> ")) 201 | } 202 | visited[currentURL] = struct{}{} 203 | redirectChain = append(redirectChain, currentURL) 204 | 205 | req, err := http.NewRequest(http.MethodHead, currentURL, nil) // 创建 HEAD 请求(更高效,只获取头部信息) 206 | if err != nil { 207 | return "", fmt.Errorf("创建请求失败: %w", err) 208 | } 209 | req.Header.Set("User-Agent", ua) // 设置 User-Agent 头部 210 | 211 | resp, err := client.Do(req) 212 | if err != nil { 213 | return "", fmt.Errorf("发送 HTTP 请求失败:%w", err) 214 | } 215 | defer resp.Body.Close() 216 | 217 | // 检查是否需要重定向 (3xx 状态码) 218 | if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { 219 | location, err := resp.Location() 220 | if err != nil { 221 | return "", ErrInvalidLocationHeader 222 | } 223 | 224 | // 处理相对路径重定向 225 | currentURL = location.String() 226 | continue 227 | } 228 | 229 | // 返回最终的非重定向URL 230 | logging.Debug("重定向链:", strings.Join(redirectChain, " -> ")) 231 | return resp.Request.URL.String(), nil 232 | } 233 | 234 | return "", ErrMaxRedirectsExceeded 235 | } 236 | -------------------------------------------------------------------------------- /internal/logging/access.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "MediaWarp/internal/config" 5 | "MediaWarp/utils" 6 | "bytes" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type accessLoggerSetting struct{} 14 | 15 | // 实现Format方法 16 | func (s *accessLoggerSetting) Format(entry *logrus.Entry) ([]byte, error) { 17 | var b *bytes.Buffer 18 | if entry.Buffer == nil { 19 | b = &bytes.Buffer{} 20 | } else { 21 | b = entry.Buffer 22 | } 23 | 24 | fmt.Fprint( 25 | b, 26 | entry.Message+"\n", 27 | ) 28 | return b.Bytes(), nil 29 | } 30 | 31 | func (s *accessLoggerSetting) Levels() []logrus.Level { 32 | return logrus.AllLevels 33 | } 34 | 35 | // HOOK 36 | // 37 | // 将日志写入文件 38 | func (s *accessLoggerSetting) Fire(entry *logrus.Entry) error { 39 | if err := os.MkdirAll(config.LogDirWithDate(), os.ModePerm); err != nil { 40 | return err 41 | } 42 | 43 | accessLogFile, err := os.OpenFile(config.AccessLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 44 | if err != nil { 45 | return err 46 | } 47 | defer accessLogFile.Close() 48 | 49 | line, err := entry.String() 50 | if err != nil { 51 | return err 52 | } 53 | accessLogFile.WriteString(utils.RemoveColorCodes(line)) 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "MediaWarp/internal/config" 5 | "io" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | var ( 11 | accessLogger = logrus.New() // 访问日志 12 | serviceLogger = logrus.New() // 服务日志 13 | ) 14 | 15 | func Init() { 16 | var ( 17 | aLS = &accessLoggerSetting{} // 访问日志logrus相关设置 18 | sLS = &serviceLoggerSetting{} // 服务日志logrus相关设置 19 | ) 20 | 21 | serviceLogger.SetReportCaller(false) // 关闭报告调用方 22 | 23 | // 设置样式 24 | accessLogger.SetFormatter(aLS) 25 | serviceLogger.SetFormatter(sLS) 26 | 27 | if !config.Logger.AccessLogger.Console { // 访问日志不输出到终端 28 | accessLogger.Out = io.Discard 29 | } 30 | 31 | if !config.Logger.ServiceLogger.Console { // 服务日志不输出到终端 32 | serviceLogger.Out = io.Discard 33 | } 34 | 35 | if config.Logger.AccessLogger.File { 36 | accessLogger.AddHook(aLS) 37 | } 38 | 39 | if config.Logger.ServiceLogger.File { 40 | serviceLogger.AddHook(sLS) 41 | } 42 | 43 | } 44 | 45 | // 访问日志 46 | // 47 | // 默认日志级别为 Info 48 | func AccessLog(format string, args ...any) { 49 | accessLogger.Infof(format, args...) 50 | } 51 | 52 | // 服务日志 53 | // 54 | // Debug 级别日志 55 | func Debug(args ...any) { 56 | serviceLogger.Debug(args...) 57 | } 58 | 59 | func Debugf(format string, args ...any) { 60 | serviceLogger.Debugf(format, args...) 61 | } 62 | 63 | // 服务日志 64 | // 65 | // Info 级别日志 66 | func Info(args ...any) { 67 | serviceLogger.Info(args...) 68 | } 69 | 70 | func Infof(format string, args ...any) { 71 | serviceLogger.Infof(format, args...) 72 | } 73 | 74 | // 服务日志 75 | // 76 | // Warning 级别日志 77 | func Warning(args ...any) { 78 | serviceLogger.Warning(args...) 79 | } 80 | 81 | func Warningf(format string, args ...any) { 82 | serviceLogger.Warningf(format, args...) 83 | } 84 | 85 | // 服务日志 86 | // 87 | // Error 级别日志 88 | func Error(args ...any) { 89 | serviceLogger.Error(args...) 90 | } 91 | 92 | func Errorf(format string, args ...any) { 93 | serviceLogger.Errorf(format, args...) 94 | } 95 | 96 | // 服务日志 97 | // 98 | // 设置日志级别 99 | func SetLevel(level logrus.Level) { 100 | serviceLogger.SetLevel(level) 101 | } 102 | -------------------------------------------------------------------------------- /internal/logging/service.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/utils" 7 | "bytes" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type serviceLoggerSetting struct{} 16 | 17 | func (s *serviceLoggerSetting) Format(entry *logrus.Entry) ([]byte, error) { 18 | // 根据日志级别设置颜色 19 | var colorCode uint8 20 | switch entry.Level { 21 | case logrus.DebugLevel: 22 | colorCode = constants.ColorBlue 23 | case logrus.InfoLevel: 24 | colorCode = constants.ColorGreen 25 | case logrus.WarnLevel: 26 | colorCode = constants.ColorYellow 27 | case logrus.ErrorLevel: 28 | colorCode = constants.ColorRed 29 | default: 30 | colorCode = constants.ColorGray 31 | } 32 | 33 | // 设置文本Buffer 34 | var b *bytes.Buffer 35 | if entry.Buffer == nil { 36 | b = &bytes.Buffer{} 37 | } else { 38 | b = entry.Buffer 39 | } 40 | // 时间格式化 41 | formatTime := entry.Time.Format(constants.FORMATE_TIME) 42 | 43 | fmt.Fprintf( 44 | b, 45 | "\033[3%dm【%s】\033[0m\t%s | %s\n", // 长度需要算是上控制字符的长度 46 | colorCode, 47 | strings.ToUpper(entry.Level.String()), 48 | formatTime, 49 | entry.Message, 50 | ) 51 | return b.Bytes(), nil 52 | } 53 | 54 | func (s *serviceLoggerSetting) Levels() []logrus.Level { 55 | return []logrus.Level{logrus.ErrorLevel, logrus.WarnLevel} 56 | } 57 | 58 | // HOOK 59 | // 60 | // 将日志写入文件 61 | func (s *serviceLoggerSetting) Fire(entry *logrus.Entry) error { 62 | if err := os.MkdirAll(config.LogDirWithDate(), os.ModePerm); err != nil { 63 | return err 64 | } 65 | 66 | serviceLogFile, err := os.OpenFile(config.ServiceLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 67 | if err != nil { 68 | return err 69 | } 70 | defer serviceLogFile.Close() 71 | 72 | line, err := entry.String() 73 | if err != nil { 74 | return err 75 | } 76 | serviceLogFile.WriteString(utils.RemoveColorCodes(line)) 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /internal/middleware/case.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // 将请求查询参数的键转换为小写 10 | func QueryCaseInsensitive() gin.HandlerFunc { 11 | return func(ctx *gin.Context) { 12 | // 获取所有查询参数 13 | queryParams := ctx.Request.URL.Query() 14 | 15 | // 创建一个新的 map 来存储大小写不敏感的查询参数 16 | caseInsensitiveParams := make(map[string][]string) 17 | for key, values := range queryParams { 18 | // 将查询参数的键转换为小写,并存储值 19 | caseInsensitiveParams[strings.ToLower(key)] = values 20 | } 21 | 22 | // 清空原始查询参数并设置新的大小写不敏感的查询参数 23 | ctx.Request.URL.RawQuery = "" 24 | q := ctx.Request.URL.Query() 25 | for key, values := range caseInsensitiveParams { 26 | for _, value := range values { 27 | q.Add(key, value) 28 | } 29 | } 30 | ctx.Request.URL.RawQuery = q.Encode() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/middleware/fliter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/logging" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | // 客户端过滤器 14 | func ClientFilter() gin.HandlerFunc { 15 | return func(ctx *gin.Context) { 16 | userAgent := ctx.Request.UserAgent() 17 | var allowed bool 18 | if userAgent == "" { // 开启了客户端过滤器后禁止所有未提供User-Agent的链接 19 | allowed = false 20 | } else { 21 | switch config.ClientFilter.Mode { 22 | case constants.WHITELIST: // 白名单模式 23 | allowed = false 24 | for _, ua := range config.ClientFilter.ClientList { 25 | if strings.Contains(userAgent, ua) { 26 | allowed = true 27 | break 28 | } 29 | } 30 | case constants.BLACKLIST: // 黑名单模式 31 | allowed = true 32 | for _, ua := range config.ClientFilter.ClientList { 33 | if strings.Contains(userAgent, ua) { 34 | allowed = false 35 | break 36 | } 37 | } 38 | } 39 | } 40 | 41 | if !allowed { 42 | ctx.AbortWithStatus(http.StatusForbidden) // 禁止访问 43 | logging.Info("客户端过滤器拦截了请求,User-Agent: ", userAgent) 44 | return 45 | } 46 | logging.Debug("客户端过滤器放行了请求,User-Agent: ", userAgent) 47 | ctx.Next() 48 | 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/logging" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // 记录访问日志 13 | func Logger() gin.HandlerFunc { 14 | return func(ctx *gin.Context) { 15 | method := ctx.Request.Method 16 | path := ctx.Request.URL.Path 17 | query := ctx.Request.URL.RawQuery 18 | if query != "" { 19 | path = path + "?" + query 20 | } 21 | 22 | startTime := time.Now() 23 | ctx.Next() 24 | wasteTime := time.Since(startTime) 25 | 26 | clientIP := ctx.ClientIP() 27 | statusCode := ctx.Writer.Status() 28 | 29 | statusColor, methodColor := getColor(statusCode, method) 30 | 31 | logging.AccessLog( 32 | "【Access】 %s |\033[4%dm %d \033[0m| %-10s |\033[4%dm %-7s \033[0m| %s \"%s\"", 33 | startTime.Format(constants.FORMATE_TIME), 34 | statusColor, statusCode, 35 | wasteTime, 36 | methodColor, method, 37 | clientIP, 38 | path, 39 | ) 40 | } 41 | } 42 | 43 | // 根据Http状态码和Http请求方法获取颜色 44 | func getColor(statusCode int, method string) (uint8, uint8) { 45 | var statusColor, methodColor uint8 46 | switch { 47 | case statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices: 48 | statusColor = constants.StatusCode200Color 49 | case statusCode >= http.StatusMultipleChoices && statusCode < http.StatusBadRequest: 50 | statusColor = constants.StatusCode300Color 51 | case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError: 52 | statusColor = constants.StatusCode400Color 53 | case statusCode >= http.StatusInternalServerError: 54 | statusColor = constants.StatusCode500Color 55 | default: 56 | statusColor = constants.ColorBlack 57 | } 58 | switch method { 59 | case http.MethodGet: 60 | methodColor = constants.MethodGetColor 61 | case http.MethodPost: 62 | methodColor = constants.MethodPostColor 63 | case http.MethodPut: 64 | methodColor = constants.MethodPutColor 65 | case http.MethodPatch: 66 | methodColor = constants.MethodPatchColor 67 | case http.MethodDelete: 68 | methodColor = constants.MethodDeleteColor 69 | case http.MethodHead: 70 | methodColor = constants.MethodHeadColor 71 | case http.MethodOptions: 72 | methodColor = constants.MethodOptionsColor 73 | default: 74 | methodColor = constants.ColorBlack 75 | } 76 | return statusColor, methodColor 77 | } 78 | -------------------------------------------------------------------------------- /internal/middleware/recover.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "MediaWarp/internal/logging" 5 | "net/http" 6 | "runtime/debug" 7 | 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func Recovery() gin.HandlerFunc { 12 | return func(ctx *gin.Context) { 13 | defer func() { 14 | if r := recover(); r != nil && r != http.ErrAbortHandler { // 忽略 http.ErrAbortHandler 的 panic(httputil.ReverseProxy 的 panic) 15 | stack := debug.Stack() 16 | logging.Errorf("[Recovery] %s panic revocered: %v\n%s", ctx.Request.URL.Path, r, string(stack)) 17 | ctx.AbortWithStatusJSON( 18 | http.StatusInternalServerError, 19 | gin.H{ 20 | "error": "MediaWarp Internal Server Error", 21 | "msg": r, 22 | }, 23 | ) 24 | } 25 | }() 26 | ctx.Next() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/middleware/referer.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // 设置Referer策略 10 | func SetRefererPolicy(value constants.ReferrerPolicy) gin.HandlerFunc { 11 | return func(ctx *gin.Context) { 12 | ctx.Header("Referrer-Policy", string(value)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/handler" 7 | "MediaWarp/internal/logging" 8 | "MediaWarp/internal/middleware" 9 | "MediaWarp/static" 10 | "net/http" 11 | 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func InitRouter() *gin.Engine { 16 | ginR := gin.New() 17 | ginR.Use( 18 | middleware.Logger(), 19 | middleware.Recovery(), 20 | middleware.QueryCaseInsensitive(), 21 | middleware.SetRefererPolicy(constants.SameOrigin), 22 | ) 23 | 24 | if config.ClientFilter.Enable { 25 | ginR.Use(middleware.ClientFilter()) 26 | logging.Info("客户端过滤中间件已启用") 27 | } else { 28 | logging.Info("客户端过滤中间件未启用") 29 | } 30 | 31 | mediawarpRouter := ginR.Group("/MediaWarp") 32 | { 33 | mediawarpRouter.Any("/version", func(ctx *gin.Context) { 34 | ctx.JSON(http.StatusOK, config.Version()) 35 | }) 36 | if config.Web.Enable { // 启用 Web 页面修改相关设置 37 | mediawarpRouter.StaticFS("/static", http.FS(static.EmbeddedStaticAssets)) 38 | if config.Web.Custom { // 用户自定义静态资源目录 39 | mediawarpRouter.Static("/custom", config.CostomDir()) 40 | } 41 | } 42 | } 43 | 44 | ginR.NoRoute(RegexpRouterHandler) 45 | return ginR 46 | } 47 | 48 | // 正则表达式路由处理器 49 | // 50 | // 从媒体服务器处理结构体中获取正则路由规则 51 | // 依次匹配请求, 找到对应的处理器 52 | func RegexpRouterHandler(ctx *gin.Context) { 53 | mediaServerHandler := handler.GetMediaServer() 54 | 55 | for _, rule := range mediaServerHandler.GetRegexpRouteRules() { 56 | if rule.Regexp.MatchString(ctx.Request.URL.Path) { // 不带查询参数的字符串:/emby/Items/54/Images/Primary 57 | logging.Debugf("URL: %s 匹配成功 -> %s", ctx.Request.URL.Path, rule.Regexp.String()) 58 | rule.Handler(ctx) 59 | return 60 | } 61 | } 62 | 63 | // 未匹配路由 64 | mediaServerHandler.ReverseProxy(ctx.Writer, ctx.Request) 65 | } 66 | -------------------------------------------------------------------------------- /internal/service/alist.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "MediaWarp/internal/config" 5 | "MediaWarp/internal/service/alist" 6 | "MediaWarp/utils" 7 | "fmt" 8 | "sync" 9 | ) 10 | 11 | var ( 12 | alistSeverMap sync.Map 13 | ) 14 | 15 | // 初始化 Alist 服务器 16 | func InitAlistSerer() { 17 | if config.AlistStrm.Enable { 18 | for _, alist := range config.AlistStrm.List { 19 | registerAlistServer(alist.ADDR, alist.Username, alist.Password, alist.Token) 20 | } 21 | } 22 | } 23 | 24 | // 注册Alist服务器 25 | // 26 | // 将Alist服务器注册到全局Map中 27 | func registerAlistServer(addr string, username string, password string, token *string) { 28 | alistServer := alist.New(addr, username, password, token) 29 | alistSeverMap.Store(alistServer.GetEndpoint(), alistServer) 30 | } 31 | 32 | // 获取Alist服务器 33 | // 34 | // 从全局Map中获取Alist服务器 35 | // 若未找到则抛出panic 36 | func GetAlistServer(addr string) (*alist.AlistServer, error) { 37 | endpoint := utils.GetEndpoint(addr) 38 | if server, ok := alistSeverMap.Load(endpoint); ok { 39 | return server.(*alist.AlistServer), nil 40 | } 41 | return nil, fmt.Errorf("%s 未注册到 Alist 服务器列表中", endpoint) 42 | } 43 | -------------------------------------------------------------------------------- /internal/service/alist/alist.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | import ( 4 | "MediaWarp/utils" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type alistToken struct { 16 | value string // 令牌 Token 17 | expireAt time.Time // 令牌过期时间 18 | mutex sync.RWMutex // 令牌锁 19 | } 20 | type AlistServer struct { 21 | endpoint string // 服务器入口 URL 22 | username string // 用户名 23 | password string // 密码 24 | token alistToken 25 | } 26 | 27 | // 得到服务器入口 28 | // 29 | // 避免直接访问 endpoint 字段 30 | func (alistServer *AlistServer) GetEndpoint() string { 31 | return alistServer.endpoint 32 | } 33 | 34 | // 得到用户名 35 | // 36 | // 避免直接访问 username 字段 37 | func (alistServer *AlistServer) GetUsername() string { 38 | return alistServer.username 39 | } 40 | 41 | // 得到一个可用的 Token 42 | // 43 | // 先从缓存池中读取,若过期或者未找到则重新生成 44 | func (alistServer *AlistServer) getToken() (string, error) { 45 | var tokenDuration = 2*24*time.Hour - 5*time.Minute // Token 有效期为 2 天,提前 5 分钟刷新 46 | 47 | alistServer.token.mutex.RLock() 48 | if alistServer.token.value != "" && (alistServer.token.expireAt.IsZero() || time.Now().Before(alistServer.token.expireAt)) { 49 | // 零值表示永不过期 50 | defer alistServer.token.mutex.RUnlock() 51 | return alistServer.token.value, nil 52 | } 53 | 54 | token, err := alistServer.authLogin() // 重新生成一个token 55 | alistServer.token.mutex.RUnlock() 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | alistServer.token.mutex.Lock() 61 | defer alistServer.token.mutex.Unlock() 62 | alistServer.token.value = token 63 | alistServer.token.expireAt = time.Now().Add(tokenDuration) // Token 有效期为30分钟 64 | 65 | return token, nil 66 | } 67 | 68 | // ==========Alist API(v3) 相关操作========== 69 | 70 | // 登录Alist(获取一个新的Token) 71 | func (alistServer *AlistServer) authLogin() (string, error) { 72 | var ( 73 | funcInfo = "Alist登录" 74 | url = alistServer.GetEndpoint() + "/api/auth/login" 75 | method = http.MethodPost 76 | payload = strings.NewReader(fmt.Sprintf(`{"username": "%s","password": "%s"}`, alistServer.GetUsername(), alistServer.password)) 77 | authLoginResponse AlistResponse[AuthLoginData] 78 | ) 79 | 80 | client := &http.Client{} 81 | req, err := http.NewRequest(method, url, payload) 82 | if err != nil { 83 | err = fmt.Errorf("创建 %s 请求失败: %w", funcInfo, err) 84 | return "", err 85 | } 86 | req.Header.Add("Content-Type", "application/json") 87 | 88 | res, err := client.Do(req) 89 | if err != nil { 90 | err = fmt.Errorf("请求 %s 失败: %w", funcInfo, err) 91 | return "", err 92 | } 93 | 94 | defer res.Body.Close() 95 | body, err := io.ReadAll(res.Body) 96 | if err != nil { 97 | err = fmt.Errorf("读取 %s 响应体失败: %w", funcInfo, err) 98 | return "", err 99 | } 100 | 101 | err = json.Unmarshal(body, &authLoginResponse) 102 | if err != nil { 103 | err = fmt.Errorf("解析 %s 响应体失败: %w", funcInfo, err) 104 | return "", err 105 | } 106 | if authLoginResponse.Code != 200 { 107 | err = errors.New(authLoginResponse.Message) 108 | return "", err 109 | } 110 | 111 | return authLoginResponse.Data.Token, nil 112 | } 113 | 114 | // 获取某个文件/目录信息 115 | func (alistServer *AlistServer) FsGet(path string) (FsGetData, error) { 116 | var ( 117 | fsGetDataResponse AlistResponse[FsGetData] 118 | token string 119 | funcInfo = "Alist获取某个文件/目录信息" 120 | url = alistServer.GetEndpoint() + "/api/fs/get" 121 | method = "POST" 122 | payload = strings.NewReader(fmt.Sprintf(`{"path": "%s","password": "","page": 1,"per_page": 0,"refresh": false}`, path)) 123 | ) 124 | 125 | // 未从缓存池中读取到数据 126 | token, err := alistServer.getToken() 127 | if err != nil { 128 | return fsGetDataResponse.Data, nil 129 | } 130 | 131 | client := &http.Client{} 132 | req, err := http.NewRequest(method, url, payload) 133 | 134 | if err != nil { 135 | err = fmt.Errorf("创建 %s 请求失败: %w", funcInfo, err) 136 | return fsGetDataResponse.Data, err 137 | } 138 | req.Header.Add("Authorization", token) 139 | req.Header.Add("Content-Type", "application/json") 140 | 141 | res, err := client.Do(req) 142 | if err != nil { 143 | err = fmt.Errorf("请求 %s 信息失败: %w", funcInfo, err) 144 | return fsGetDataResponse.Data, err 145 | } 146 | 147 | defer res.Body.Close() 148 | body, err := io.ReadAll(res.Body) 149 | if err != nil { 150 | err = fmt.Errorf("读取 %s 响应体失败: %w", funcInfo, err) 151 | return fsGetDataResponse.Data, err 152 | } 153 | 154 | err = json.Unmarshal(body, &fsGetDataResponse) 155 | if err != nil { 156 | err = fmt.Errorf("解析 %s 响应体失败: %w", funcInfo, err) 157 | return fsGetDataResponse.Data, err 158 | } 159 | if fsGetDataResponse.Code != 200 { 160 | err = errors.New(fsGetDataResponse.Message) 161 | return fsGetDataResponse.Data, err 162 | } 163 | 164 | return fsGetDataResponse.Data, nil 165 | } 166 | 167 | // 获得AlistServer实例 168 | func New(addr string, username string, password string, token *string) *AlistServer { 169 | s := AlistServer{ 170 | endpoint: utils.GetEndpoint(addr), 171 | username: username, 172 | password: password, 173 | } 174 | if token != nil { 175 | s.token = alistToken{ 176 | value: *token, 177 | expireAt: time.Time{}, 178 | } 179 | } 180 | return &s 181 | } 182 | -------------------------------------------------------------------------------- /internal/service/alist/schema.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | type AlistResponse[T any] struct { 4 | Code int64 `json:"code"` // 状态码 5 | Data T `json:"data"` // data 6 | Message string `json:"message"` // 信息 7 | } 8 | 9 | type AuthLoginData struct { 10 | Token string `json:"token"` // token 11 | } 12 | 13 | type FsGetData struct { 14 | Created string `json:"created"` // 创建时间 15 | HashInfo interface{} `json:"hash_info"` 16 | Hashinfo string `json:"hashinfo"` 17 | Header string `json:"header"` 18 | IsDir bool `json:"is_dir"` // 是否是文件夹 19 | Modified string `json:"modified"` // 修改时间 20 | Name string `json:"name"` // 文件名 21 | Provider string `json:"provider"` 22 | RawURL string `json:"raw_url"` // 原始url 23 | Readme string `json:"readme"` // 说明 24 | Related interface{} `json:"related"` 25 | Sign string `json:"sign"` // 签名 26 | Size int64 `json:"size"` // 大小 27 | Thumb string `json:"thumb"` // 缩略图 28 | Type int64 `json:"type"` // 类型 29 | } 30 | -------------------------------------------------------------------------------- /internal/service/emby/emby.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/utils" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | ) 12 | 13 | type EmbyServer struct { 14 | endpoint string 15 | apiKey string // 认证方式:APIKey;获取方式:Emby控制台 -> 高级 -> API密钥 16 | } 17 | 18 | // 获取媒体服务器类型 19 | func (embyServer *EmbyServer) GetType() constants.MediaServerType { 20 | return constants.EMBY 21 | } 22 | 23 | // 获取EmbyServer连接地址 24 | // 25 | // 包含协议、服务器域名(IP)、端口号 26 | // 示例:return "http://emby.example.com:8096" 27 | func (embyServer *EmbyServer) GetEndpoint() string { 28 | return embyServer.endpoint 29 | } 30 | 31 | // 获取EmbyServer的API Key 32 | func (embyServer *EmbyServer) GetAPIKey() string { 33 | return embyServer.apiKey 34 | } 35 | 36 | // ItemsService 37 | // /Items 38 | func (embyServer *EmbyServer) ItemsServiceQueryItem(ids string, limit int, fields string) (*EmbyResponse, error) { 39 | var ( 40 | params = url.Values{} 41 | itemResponse = &EmbyResponse{} 42 | ) 43 | params.Add("Ids", ids) 44 | params.Add("Limit", strconv.Itoa(limit)) 45 | params.Add("Fields", fields) 46 | params.Add("Recursive","true") 47 | params.Add("api_key", embyServer.GetAPIKey()) 48 | api := embyServer.GetEndpoint() + "/Items?" + params.Encode() 49 | resp, err := http.Get(api) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | defer resp.Body.Close() 55 | body, err := io.ReadAll(resp.Body) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | err = json.Unmarshal(body, itemResponse) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return itemResponse, nil 65 | } 66 | 67 | // 获取index.html内容 API:/web/index.html 68 | func (embyServer *EmbyServer) GetIndexHtml() ([]byte, error) { 69 | resp, err := http.Get(embyServer.GetEndpoint() + "/web/index.html") 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | defer resp.Body.Close() 75 | htmlContent, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return htmlContent, nil 80 | } 81 | 82 | // 获取EmbyServer实例 83 | func New(addr string, apiKey string) *EmbyServer { 84 | emby := &EmbyServer{ 85 | endpoint: utils.GetEndpoint(addr), 86 | apiKey: apiKey, 87 | } 88 | return emby 89 | } 90 | -------------------------------------------------------------------------------- /internal/service/emby/schema.go: -------------------------------------------------------------------------------- 1 | package emby 2 | 3 | type EmbyResponse struct { 4 | Items []BaseItemDto `json:"Items,omitempty"` 5 | TotalRecordCount *int64 `json:"TotalRecordCount,omitempty"` 6 | } 7 | 8 | // /Items/:itemID/PlaybackInfo的响应 9 | type PlaybackInfoResponse struct { 10 | ErrorCode *PlaybackErrorCode `json:"ErrorCode,omitempty"` 11 | MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` 12 | PlaySessionID *string `json:"PlaySessionId,omitempty"` 13 | } 14 | 15 | // PlaybackErrorCode 16 | type PlaybackErrorCode string 17 | 18 | const ( 19 | NoCompatibleStream PlaybackErrorCode = "NoCompatibleStream" 20 | NotAllowed PlaybackErrorCode = "NotAllowed" 21 | RateLimitExceeded PlaybackErrorCode = "RateLimitExceeded" 22 | ) 23 | 24 | // BaseItemDto 25 | type BaseItemDto struct { 26 | AffiliateCallSign *string `json:"AffiliateCallSign,omitempty"` 27 | AirDays []DayOfWeek `json:"AirDays,omitempty"` 28 | Album *string `json:"Album,omitempty"` 29 | AlbumArtist *string `json:"AlbumArtist,omitempty"` 30 | AlbumArtists []NameIDPair `json:"AlbumArtists,omitempty"` 31 | AlbumCount *int64 `json:"AlbumCount"` 32 | AlbumID *string `json:"AlbumId,omitempty"` 33 | AlbumPrimaryImageTag *string `json:"AlbumPrimaryImageTag,omitempty"` 34 | Altitude *float64 `json:"Altitude"` 35 | Aperture *float64 `json:"Aperture"` 36 | ArtistItems []NameIDPair `json:"ArtistItems,omitempty"` 37 | Artists []string `json:"Artists,omitempty"` 38 | AsSeries *bool `json:"AsSeries"` 39 | BackdropImageTags []string `json:"BackdropImageTags,omitempty"` 40 | Bitrate *int64 `json:"Bitrate"` 41 | CameraMake *string `json:"CameraMake,omitempty"` 42 | CameraModel *string `json:"CameraModel,omitempty"` 43 | CanDelete *bool `json:"CanDelete"` 44 | CanDownload *bool `json:"CanDownload"` 45 | CanEditItems *bool `json:"CanEditItems"` 46 | CanLeaveContent *bool `json:"CanLeaveContent"` 47 | CanMakePublic *bool `json:"CanMakePublic"` 48 | CanManageAccess *bool `json:"CanManageAccess"` 49 | ChannelID *string `json:"ChannelId,omitempty"` 50 | ChannelName *string `json:"ChannelName,omitempty"` 51 | ChannelNumber *string `json:"ChannelNumber,omitempty"` 52 | ChannelPrimaryImageTag *string `json:"ChannelPrimaryImageTag,omitempty"` 53 | Chapters []ChapterInfo `json:"Chapters,omitempty"` 54 | ChildCount *int64 `json:"ChildCount"` 55 | CollectionType *string `json:"CollectionType,omitempty"` 56 | CommunityRating *float64 `json:"CommunityRating"` 57 | CompletionPercentage *float64 `json:"CompletionPercentage"` 58 | Composers []NameIDPair `json:"Composers,omitempty"` 59 | Container *string `json:"Container,omitempty"` 60 | CriticRating *float64 `json:"CriticRating"` 61 | CurrentProgram *BaseItemDto `json:"CurrentProgram,omitempty"` 62 | CustomRating *string `json:"CustomRating,omitempty"` 63 | DateCreated *string `json:"DateCreated"` 64 | Disabled *bool `json:"Disabled"` 65 | DisplayOrder *string `json:"DisplayOrder,omitempty"` 66 | DisplayPreferencesID *string `json:"DisplayPreferencesId,omitempty"` 67 | EndDate *string `json:"EndDate"` 68 | EpisodeTitle *string `json:"EpisodeTitle,omitempty"` 69 | Etag *string `json:"Etag,omitempty"` 70 | ExposureTime *float64 `json:"ExposureTime"` 71 | ExternalUrls []ExternalURL `json:"ExternalUrls,omitempty"` 72 | ExtraType *string `json:"ExtraType,omitempty"` 73 | FileName *string `json:"FileName,omitempty"` 74 | FocalLength *float64 `json:"FocalLength"` 75 | ForcedSortName *string `json:"ForcedSortName,omitempty"` 76 | GameSystem *string `json:"GameSystem,omitempty"` 77 | GameSystemID *int64 `json:"GameSystemId"` 78 | GenreItems []NameLongIDPair `json:"GenreItems,omitempty"` 79 | Genres []string `json:"Genres,omitempty"` 80 | GUID *string `json:"Guid,omitempty"` 81 | Height *int64 `json:"Height"` 82 | ID *string `json:"Id,omitempty"` 83 | ImageOrientation *DrawingImageOrientation `json:"ImageOrientation,omitempty"` 84 | ImageTags map[string]string `json:"ImageTags,omitempty"` 85 | IndexNumber *int64 `json:"IndexNumber"` 86 | IndexNumberEnd *int64 `json:"IndexNumberEnd"` 87 | IsFolder *bool `json:"IsFolder"` 88 | IsKids *bool `json:"IsKids"` 89 | IsLive *bool `json:"IsLive"` 90 | IsMovie *bool `json:"IsMovie"` 91 | IsNew *bool `json:"IsNew"` 92 | IsNews *bool `json:"IsNews"` 93 | ISOSpeedRating *int64 `json:"IsoSpeedRating"` 94 | IsPremiere *bool `json:"IsPremiere"` 95 | IsRepeat *bool `json:"IsRepeat"` 96 | IsSeries *bool `json:"IsSeries"` 97 | IsSports *bool `json:"IsSports"` 98 | Latitude *float64 `json:"Latitude"` 99 | ListingsChannelID *string `json:"ListingsChannelId,omitempty"` 100 | ListingsChannelName *string `json:"ListingsChannelName,omitempty"` 101 | ListingsChannelNumber *string `json:"ListingsChannelNumber,omitempty"` 102 | ListingsID *string `json:"ListingsId,omitempty"` 103 | ListingsPath *string `json:"ListingsPath,omitempty"` 104 | ListingsProviderID *string `json:"ListingsProviderId,omitempty"` 105 | LocalTrailerCount *int64 `json:"LocalTrailerCount"` 106 | LocationType *LocationType `json:"LocationType,omitempty"` 107 | LockData *bool `json:"LockData"` 108 | LockedFields []MetadataFields `json:"LockedFields,omitempty"` 109 | Longitude *float64 `json:"Longitude"` 110 | ManagementID *string `json:"ManagementId,omitempty"` 111 | MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` 112 | MediaStreams []MediaStream `json:"MediaStreams,omitempty"` 113 | MediaType *string `json:"MediaType,omitempty"` 114 | MovieCount *int64 `json:"MovieCount"` 115 | MusicVideoCount *int64 `json:"MusicVideoCount"` 116 | Name *string `json:"Name,omitempty"` 117 | Number *string `json:"Number,omitempty"` 118 | OfficialRating *string `json:"OfficialRating,omitempty"` 119 | OriginalTitle *string `json:"OriginalTitle,omitempty"` 120 | Overview *string `json:"Overview,omitempty"` 121 | ParentBackdropImageTags []string `json:"ParentBackdropImageTags,omitempty"` 122 | ParentBackdropItemID *string `json:"ParentBackdropItemId,omitempty"` 123 | ParentID *string `json:"ParentId,omitempty"` 124 | ParentIndexNumber *int64 `json:"ParentIndexNumber"` 125 | ParentLogoImageTag *string `json:"ParentLogoImageTag,omitempty"` 126 | ParentLogoItemID *string `json:"ParentLogoItemId,omitempty"` 127 | ParentThumbImageTag *string `json:"ParentThumbImageTag,omitempty"` 128 | ParentThumbItemID *string `json:"ParentThumbItemId,omitempty"` 129 | PartCount *int64 `json:"PartCount"` 130 | Path *string `json:"Path,omitempty"` 131 | People []BaseItemPerson `json:"People,omitempty"` 132 | PlaylistItemID *string `json:"PlaylistItemId,omitempty"` 133 | PreferredMetadataCountryCode *string `json:"PreferredMetadataCountryCode,omitempty"` 134 | PreferredMetadataLanguage *string `json:"PreferredMetadataLanguage,omitempty"` 135 | Prefix *string `json:"Prefix,omitempty"` 136 | PremiereDate *string `json:"PremiereDate"` 137 | PresentationUniqueKey *string `json:"PresentationUniqueKey,omitempty"` 138 | PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio"` 139 | PrimaryImageItemID *string `json:"PrimaryImageItemId,omitempty"` 140 | PrimaryImageTag *string `json:"PrimaryImageTag,omitempty"` 141 | ProductionLocations []string `json:"ProductionLocations,omitempty"` 142 | ProductionYear *int64 `json:"ProductionYear"` 143 | ProviderIDS map[string]string `json:"ProviderIds,omitempty"` 144 | RecursiveItemCount *int64 `json:"RecursiveItemCount"` 145 | RemoteTrailers []MediaURL `json:"RemoteTrailers,omitempty"` 146 | RunTimeTicks *int64 `json:"RunTimeTicks"` 147 | SeasonID *string `json:"SeasonId,omitempty"` 148 | SeasonName *string `json:"SeasonName,omitempty"` 149 | SeriesCount *int64 `json:"SeriesCount"` 150 | SeriesID *string `json:"SeriesId,omitempty"` 151 | SeriesName *string `json:"SeriesName,omitempty"` 152 | SeriesPrimaryImageTag *string `json:"SeriesPrimaryImageTag,omitempty"` 153 | SeriesStudio *string `json:"SeriesStudio,omitempty"` 154 | SeriesTimerID *string `json:"SeriesTimerId,omitempty"` 155 | ServerID *string `json:"ServerId,omitempty"` 156 | ShutterSpeed *float64 `json:"ShutterSpeed"` 157 | Size *int64 `json:"Size"` 158 | Software *string `json:"Software,omitempty"` 159 | SongCount *int64 `json:"SongCount"` 160 | SortIndexNumber *int64 `json:"SortIndexNumber"` 161 | SortName *string `json:"SortName,omitempty"` 162 | SortParentIndexNumber *int64 `json:"SortParentIndexNumber"` 163 | SpecialFeatureCount *int64 `json:"SpecialFeatureCount"` 164 | StartDate *string `json:"StartDate"` 165 | Status *string `json:"Status,omitempty"` 166 | Studios []NameLongIDPair `json:"Studios,omitempty"` 167 | Subviews []string `json:"Subviews,omitempty"` 168 | SupportsResume *bool `json:"SupportsResume"` 169 | SupportsSync *bool `json:"SupportsSync"` 170 | SyncStatus *SyncJobItemStatus `json:"SyncStatus,omitempty"` 171 | TagItems []NameLongIDPair `json:"TagItems,omitempty"` 172 | Taglines []string `json:"Taglines,omitempty"` 173 | Tags []string `json:"Tags,omitempty"` 174 | TimerID *string `json:"TimerId,omitempty"` 175 | TimerType *LiveTvTimerType `json:"TimerType,omitempty"` 176 | Type *string `json:"Type,omitempty"` 177 | UserData *UserItemDataDto `json:"UserData,omitempty"` 178 | Video3DFormat *Video3DFormat `json:"Video3DFormat,omitempty"` 179 | Width *int64 `json:"Width"` 180 | } 181 | 182 | // NameIdPair 183 | type NameIDPair struct { 184 | ID *string `json:"Id,omitempty"` 185 | Name *string `json:"Name,omitempty"` 186 | } 187 | 188 | // ChapterInfo 189 | type ChapterInfo struct { 190 | ChapterIndex *int64 `json:"ChapterIndex,omitempty"` 191 | ImageTag *string `json:"ImageTag,omitempty"` 192 | MarkerType *MarkerType `json:"MarkerType,omitempty"` 193 | Name *string `json:"Name,omitempty"` 194 | StartPositionTicks *int64 `json:"StartPositionTicks,omitempty"` 195 | } 196 | 197 | // ExternalUrl 198 | type ExternalURL struct { 199 | Name *string `json:"Name,omitempty"` 200 | URL *string `json:"Url,omitempty"` 201 | } 202 | 203 | // NameLongIdPair 204 | type NameLongIDPair struct { 205 | ID *int64 `json:"Id,omitempty"` 206 | Name *string `json:"Name,omitempty"` 207 | } 208 | 209 | // MediaSourceInfo 210 | type MediaSourceInfo struct { 211 | AddAPIKeyToDirectStreamURL *bool `json:"AddApiKeyToDirectStreamUrl,omitempty"` 212 | AnalyzeDurationMS *int64 `json:"AnalyzeDurationMs,omitempty"` 213 | Bitrate *int64 `json:"Bitrate,omitempty"` 214 | BufferMS *int64 `json:"BufferMs,omitempty"` 215 | Container *string `json:"Container,omitempty"` // AlistStrm 显示 strm,普通视频和 HTTPStrm 216 | ContainerStartTimeTicks *int64 `json:"ContainerStartTimeTicks,omitempty"` 217 | DefaultAudioStreamIndex *int64 `json:"DefaultAudioStreamIndex,omitempty"` 218 | DefaultSubtitleStreamIndex *int64 `json:"DefaultSubtitleStreamIndex,omitempty"` 219 | DirectStreamURL *string `json:"DirectStreamUrl,omitempty"` 220 | EncoderPath *string `json:"EncoderPath,omitempty"` 221 | EncoderProtocol *MediaProtocol `json:"EncoderProtocol,omitempty"` 222 | Formats []string `json:"Formats,omitempty"` 223 | HasMixedProtocols *bool `json:"HasMixedProtocols,omitempty"` 224 | ID *string `json:"Id,omitempty"` // mediasource_45 225 | IsInfiniteStream *bool `json:"IsInfiniteStream,omitempty"` 226 | IsRemote *bool `json:"IsRemote,omitempty"` // HTTPStrm 会被设置为 true 227 | ItemID *string `json:"ItemId,omitempty"` 228 | LiveStreamID *string `json:"LiveStreamId,omitempty"` 229 | MediaStreams []MediaStream `json:"MediaStreams"` // 避免当 MediaStreams 为空数组的情况移除该字段导致导致部分客户端(目前已知:Yamby)报错 230 | Name *string `json:"Name,omitempty"` 231 | OpenToken *string `json:"OpenToken,omitempty"` 232 | Path *string `json:"Path,omitempty"` // 本地视频文件则是正常的本地路径,Strm 则是 Strm 文件的内容 233 | ProbePath *string `json:"ProbePath,omitempty"` 234 | ProbeProtocol *MediaProtocol `json:"ProbeProtocol,omitempty"` 235 | Protocol *MediaProtocol `json:"Protocol,omitempty"` 236 | ReadAtNativeFramerate *bool `json:"ReadAtNativeFramerate,omitempty"` 237 | RequiredHTTPHeaders map[string]string `json:"RequiredHttpHeaders,omitempty"` 238 | RequiresClosing *bool `json:"RequiresClosing,omitempty"` 239 | RequiresLooping *bool `json:"RequiresLooping,omitempty"` 240 | RequiresOpening *bool `json:"RequiresOpening,omitempty"` 241 | RunTimeTicks *int64 `json:"RunTimeTicks,omitempty"` 242 | ServerID *string `json:"ServerId,omitempty"` 243 | Size *int64 `json:"Size,omitempty"` 244 | SortName *string `json:"SortName,omitempty"` 245 | SupportsDirectPlay *bool `json:"SupportsDirectPlay,omitempty"` 246 | SupportsDirectStream *bool `json:"SupportsDirectStream,omitempty"` 247 | SupportsProbing *bool `json:"SupportsProbing,omitempty"` 248 | SupportsTranscoding *bool `json:"SupportsTranscoding,omitempty"` 249 | Timestamp *TransportStreamTimestamp `json:"Timestamp,omitempty"` 250 | TrancodeLiveStartIndex *int64 `json:"TrancodeLiveStartIndex,omitempty"` 251 | TranscodingContainer *string `json:"TranscodingContainer,omitempty"` 252 | TranscodingSubProtocol *string `json:"TranscodingSubProtocol,omitempty"` 253 | TranscodingURL *string `json:"TranscodingUrl,omitempty"` 254 | Type *MediaSourceType `json:"Type,omitempty"` 255 | Video3DFormat *Video3DFormat `json:"Video3DFormat,omitempty"` 256 | WallClockStart *string `json:"WallClockStart,omitempty"` 257 | } 258 | 259 | // MediaStream 260 | type MediaStream struct { 261 | AspectRatio *string `json:"AspectRatio,omitempty"` 262 | AttachmentSize *int64 `json:"AttachmentSize"` 263 | AverageFrameRate *float64 `json:"AverageFrameRate"` 264 | BitDepth *int64 `json:"BitDepth"` 265 | BitRate *int64 `json:"BitRate"` 266 | ChannelLayout *string `json:"ChannelLayout,omitempty"` 267 | Channels *int64 `json:"Channels"` 268 | Codec *string `json:"Codec,omitempty"` 269 | CodecTag *string `json:"CodecTag,omitempty"` 270 | ColorPrimaries *string `json:"ColorPrimaries,omitempty"` 271 | ColorSpace *string `json:"ColorSpace,omitempty"` 272 | ColorTransfer *string `json:"ColorTransfer,omitempty"` 273 | Comment *string `json:"Comment,omitempty"` 274 | DeliveryMethod *SubtitleDeliveryMethod `json:"DeliveryMethod,omitempty"` 275 | DeliveryURL *string `json:"DeliveryUrl,omitempty"` 276 | DisplayLanguage *string `json:"DisplayLanguage,omitempty"` 277 | DisplayTitle *string `json:"DisplayTitle,omitempty"` 278 | ExtendedVideoSubType *ExtendedVideoSubTypes `json:"ExtendedVideoSubType,omitempty"` 279 | ExtendedVideoSubTypeDescription *string `json:"ExtendedVideoSubTypeDescription,omitempty"` 280 | ExtendedVideoType *ExtendedVideoTypes `json:"ExtendedVideoType,omitempty"` 281 | Extradata *string `json:"Extradata,omitempty"` 282 | Height *int64 `json:"Height"` 283 | Index *int64 `json:"Index,omitempty"` 284 | IsAnamorphic *bool `json:"IsAnamorphic"` 285 | IsAVC *bool `json:"IsAVC"` 286 | IsDefault *bool `json:"IsDefault,omitempty"` 287 | IsExternal *bool `json:"IsExternal,omitempty"` 288 | IsExternalURL *bool `json:"IsExternalUrl"` 289 | IsForced *bool `json:"IsForced,omitempty"` 290 | IsHearingImpaired *bool `json:"IsHearingImpaired,omitempty"` 291 | IsInterlaced *bool `json:"IsInterlaced,omitempty"` 292 | IsTextSubtitleStream *bool `json:"IsTextSubtitleStream,omitempty"` 293 | ItemID *string `json:"ItemId,omitempty"` 294 | Language *string `json:"Language,omitempty"` 295 | Level *float64 `json:"Level"` 296 | MIMEType *string `json:"MimeType,omitempty"` 297 | NalLengthSize *string `json:"NalLengthSize,omitempty"` 298 | Path *string `json:"Path,omitempty"` 299 | PixelFormat *string `json:"PixelFormat,omitempty"` 300 | Profile *string `json:"Profile,omitempty"` 301 | Protocol *MediaProtocol `json:"Protocol,omitempty"` 302 | RealFrameRate *float64 `json:"RealFrameRate"` 303 | RefFrames *int64 `json:"RefFrames"` 304 | Rotation *int64 `json:"Rotation"` 305 | SampleRate *int64 `json:"SampleRate"` 306 | ServerID *string `json:"ServerId,omitempty"` 307 | StreamStartTimeTicks *int64 `json:"StreamStartTimeTicks"` 308 | SubtitleLocationType *SubtitleLocationType `json:"SubtitleLocationType,omitempty"` 309 | SupportsExternalStream *bool `json:"SupportsExternalStream,omitempty"` 310 | TimeBase *string `json:"TimeBase,omitempty"` 311 | Title *string `json:"Title,omitempty"` 312 | Type *MediaStreamType `json:"Type,omitempty"` 313 | VideoRange *string `json:"VideoRange,omitempty"` 314 | Width *int64 `json:"Width"` 315 | } 316 | 317 | // BaseItemPerson 318 | type BaseItemPerson struct { 319 | ID *string `json:"Id,omitempty"` 320 | Name *string `json:"Name,omitempty"` 321 | PrimaryImageTag *string `json:"PrimaryImageTag,omitempty"` 322 | Role *string `json:"Role,omitempty"` 323 | Type *PersonType `json:"Type,omitempty"` 324 | } 325 | 326 | // MediaUrl 327 | type MediaURL struct { 328 | Name *string `json:"Name,omitempty"` 329 | URL *string `json:"Url,omitempty"` 330 | } 331 | 332 | // UserItemDataDto 333 | type UserItemDataDto struct { 334 | IsFavorite *bool `json:"IsFavorite,omitempty"` 335 | ItemID *string `json:"ItemId,omitempty"` 336 | Key *string `json:"Key,omitempty"` 337 | LastPlayedDate *string `json:"LastPlayedDate"` 338 | PlaybackPositionTicks *int64 `json:"PlaybackPositionTicks,omitempty"` 339 | PlayCount *int64 `json:"PlayCount"` 340 | Played *bool `json:"Played,omitempty"` 341 | PlayedPercentage *float64 `json:"PlayedPercentage"` 342 | Rating *float64 `json:"Rating"` 343 | ServerID *string `json:"ServerId,omitempty"` 344 | UnplayedItemCount *int64 `json:"UnplayedItemCount"` 345 | } 346 | 347 | // DayOfWeek 348 | type DayOfWeek string 349 | 350 | const ( 351 | Friday DayOfWeek = "Friday" 352 | Monday DayOfWeek = "Monday" 353 | Saturday DayOfWeek = "Saturday" 354 | Sunday DayOfWeek = "Sunday" 355 | Thursday DayOfWeek = "Thursday" 356 | Tuesday DayOfWeek = "Tuesday" 357 | Wednesday DayOfWeek = "Wednesday" 358 | ) 359 | 360 | // MarkerType 361 | type MarkerType string 362 | 363 | const ( 364 | Chapter MarkerType = "Chapter" 365 | CreditsStart MarkerType = "CreditsStart" 366 | IntroEnd MarkerType = "IntroEnd" 367 | IntroStart MarkerType = "IntroStart" 368 | ) 369 | 370 | // Drawing.ImageOrientation 371 | type DrawingImageOrientation string 372 | 373 | const ( 374 | BottomLeft DrawingImageOrientation = "BottomLeft" 375 | BottomRight DrawingImageOrientation = "BottomRight" 376 | LeftBottom DrawingImageOrientation = "LeftBottom" 377 | LeftTop DrawingImageOrientation = "LeftTop" 378 | RightBottom DrawingImageOrientation = "RightBottom" 379 | RightTop DrawingImageOrientation = "RightTop" 380 | TopLeft DrawingImageOrientation = "TopLeft" 381 | TopRight DrawingImageOrientation = "TopRight" 382 | ) 383 | 384 | // LocationType 385 | type LocationType string 386 | 387 | const ( 388 | FileSystem LocationType = "FileSystem" 389 | Virtual LocationType = "Virtual" 390 | ) 391 | 392 | // MetadataFields 393 | type MetadataFields string 394 | 395 | const ( 396 | Cast MetadataFields = "Cast" 397 | ChannelNumber MetadataFields = "ChannelNumber" 398 | Collections MetadataFields = "Collections" 399 | CommunityRating MetadataFields = "CommunityRating" 400 | CriticRating MetadataFields = "CriticRating" 401 | Genres MetadataFields = "Genres" 402 | Name MetadataFields = "Name" 403 | OfficialRating MetadataFields = "OfficialRating" 404 | OriginalTitle MetadataFields = "OriginalTitle" 405 | Overview MetadataFields = "Overview" 406 | ProductionLocations MetadataFields = "ProductionLocations" 407 | Runtime MetadataFields = "Runtime" 408 | SortIndexNumber MetadataFields = "SortIndexNumber" 409 | SortName MetadataFields = "SortName" 410 | SortParentIndexNumber MetadataFields = "SortParentIndexNumber" 411 | Studios MetadataFields = "Studios" 412 | Tagline MetadataFields = "Tagline" 413 | Tags MetadataFields = "Tags" 414 | ) 415 | 416 | // MediaProtocol 417 | type MediaProtocol string 418 | 419 | const ( 420 | FTP MediaProtocol = "Ftp" 421 | File MediaProtocol = "File" 422 | HTTP MediaProtocol = "Http" 423 | Mms MediaProtocol = "Mms" 424 | RTP MediaProtocol = "Rtp" 425 | RTSP MediaProtocol = "Rtsp" 426 | Rtmp MediaProtocol = "Rtmp" 427 | UDP MediaProtocol = "Udp" 428 | ) 429 | 430 | // SubtitleDeliveryMethod 431 | type SubtitleDeliveryMethod string 432 | 433 | const ( 434 | Embed SubtitleDeliveryMethod = "Embed" 435 | Encode SubtitleDeliveryMethod = "Encode" 436 | External SubtitleDeliveryMethod = "External" 437 | HLS SubtitleDeliveryMethod = "Hls" 438 | SubtitleDeliveryMethodVideoSideData SubtitleDeliveryMethod = "VideoSideData" 439 | ) 440 | 441 | // ExtendedVideoSubTypes 442 | type ExtendedVideoSubTypes string 443 | 444 | const ( 445 | DoviProfile02 ExtendedVideoSubTypes = "DoviProfile02" 446 | DoviProfile10 ExtendedVideoSubTypes = "DoviProfile10" 447 | DoviProfile22 ExtendedVideoSubTypes = "DoviProfile22" 448 | DoviProfile30 ExtendedVideoSubTypes = "DoviProfile30" 449 | DoviProfile42 ExtendedVideoSubTypes = "DoviProfile42" 450 | DoviProfile50 ExtendedVideoSubTypes = "DoviProfile50" 451 | DoviProfile61 ExtendedVideoSubTypes = "DoviProfile61" 452 | DoviProfile76 ExtendedVideoSubTypes = "DoviProfile76" 453 | DoviProfile81 ExtendedVideoSubTypes = "DoviProfile81" 454 | DoviProfile82 ExtendedVideoSubTypes = "DoviProfile82" 455 | DoviProfile83 ExtendedVideoSubTypes = "DoviProfile83" 456 | DoviProfile84 ExtendedVideoSubTypes = "DoviProfile84" 457 | DoviProfile85 ExtendedVideoSubTypes = "DoviProfile85" 458 | DoviProfile92 ExtendedVideoSubTypes = "DoviProfile92" 459 | ExtendedVideoSubTypesHdr10 ExtendedVideoSubTypes = "Hdr10" 460 | ExtendedVideoSubTypesHyperLogGamma ExtendedVideoSubTypes = "HyperLogGamma" 461 | ExtendedVideoSubTypesNone ExtendedVideoSubTypes = "None" 462 | Hdr10Plus0 ExtendedVideoSubTypes = "Hdr10Plus0" 463 | ) 464 | 465 | // ExtendedVideoTypes 466 | type ExtendedVideoTypes string 467 | 468 | const ( 469 | DolbyVision ExtendedVideoTypes = "DolbyVision" 470 | ExtendedVideoTypesHdr10 ExtendedVideoTypes = "Hdr10" 471 | ExtendedVideoTypesHyperLogGamma ExtendedVideoTypes = "HyperLogGamma" 472 | ExtendedVideoTypesNone ExtendedVideoTypes = "None" 473 | Hdr10Plus ExtendedVideoTypes = "Hdr10Plus" 474 | ) 475 | 476 | // SubtitleLocationType 477 | type SubtitleLocationType string 478 | 479 | const ( 480 | InternalStream SubtitleLocationType = "InternalStream" 481 | SubtitleLocationTypeVideoSideData SubtitleLocationType = "VideoSideData" 482 | ) 483 | 484 | // MediaStreamType 485 | type MediaStreamType string 486 | 487 | const ( 488 | Attachment MediaStreamType = "Attachment" 489 | Audio MediaStreamType = "Audio" 490 | Data MediaStreamType = "Data" 491 | EmbeddedImage MediaStreamType = "EmbeddedImage" 492 | Subtitle MediaStreamType = "Subtitle" 493 | Unknown MediaStreamType = "Unknown" 494 | Video MediaStreamType = "Video" 495 | ) 496 | 497 | // TransportStreamTimestamp 498 | type TransportStreamTimestamp string 499 | 500 | const ( 501 | TransportStreamTimestampNone TransportStreamTimestamp = "None" 502 | Valid TransportStreamTimestamp = "Valid" 503 | Zero TransportStreamTimestamp = "Zero" 504 | ) 505 | 506 | // MediaSourceType 507 | type MediaSourceType string 508 | 509 | const ( 510 | Default MediaSourceType = "Default" // 普通视频和 AlistStrm 显示 Default 511 | Grouping MediaSourceType = "Grouping" // HTTPStrm 显示 Grouping 512 | Placeholder MediaSourceType = "Placeholder" 513 | ) 514 | 515 | // Video3DFormat 516 | type Video3DFormat string 517 | 518 | const ( 519 | FullSideBySide Video3DFormat = "FullSideBySide" 520 | FullTopAndBottom Video3DFormat = "FullTopAndBottom" 521 | HalfSideBySide Video3DFormat = "HalfSideBySide" 522 | HalfTopAndBottom Video3DFormat = "HalfTopAndBottom" 523 | MVC Video3DFormat = "MVC" 524 | ) 525 | 526 | // PersonType 527 | type PersonType string 528 | 529 | const ( 530 | Actor PersonType = "Actor" 531 | Composer PersonType = "Composer" 532 | Conductor PersonType = "Conductor" 533 | Director PersonType = "Director" 534 | GuestStar PersonType = "GuestStar" 535 | Lyricist PersonType = "Lyricist" 536 | Producer PersonType = "Producer" 537 | Writer PersonType = "Writer" 538 | ) 539 | 540 | // SyncJobItemStatus 541 | type SyncJobItemStatus string 542 | 543 | const ( 544 | Converting SyncJobItemStatus = "Converting" 545 | Failed SyncJobItemStatus = "Failed" 546 | Queued SyncJobItemStatus = "Queued" 547 | ReadyToTransfer SyncJobItemStatus = "ReadyToTransfer" 548 | Synced SyncJobItemStatus = "Synced" 549 | Transferring SyncJobItemStatus = "Transferring" 550 | ) 551 | 552 | // LiveTv.TimerType 553 | type LiveTvTimerType string 554 | 555 | const ( 556 | DateTime LiveTvTimerType = "DateTime" 557 | Keyword LiveTvTimerType = "Keyword" 558 | Program LiveTvTimerType = "Program" 559 | ) 560 | -------------------------------------------------------------------------------- /internal/service/jellyfin/jellyfin.go: -------------------------------------------------------------------------------- 1 | package jellyfin 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/utils" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | ) 12 | 13 | type Jellyfin struct { 14 | endpoint string 15 | apiKey string // 认证方式:APIKey;获取方式:Jellyfin 控制台 -> 高级 -> API密钥 16 | } 17 | 18 | // 获取媒体服务器类型 19 | func (jellyfin *Jellyfin) GetType() constants.MediaServerType { 20 | return constants.JELLYFIN 21 | } 22 | 23 | // 获取 Jellyfin 连接地址 24 | // 25 | // 包含协议、服务器域名(IP)、端口号 26 | // 示例:return "http://jellyfin.example.com:8096" 27 | func (jellyfin *Jellyfin) GetEndpoint() string { 28 | return jellyfin.endpoint 29 | } 30 | 31 | // 获取 Jellyfin 的API Key 32 | func (jellyfin *Jellyfin) GetAPIKey() string { 33 | return jellyfin.apiKey 34 | } 35 | 36 | // ItemsService 37 | // /Items 38 | func (jellyfin *Jellyfin) ItemsServiceQueryItem(ids string, limit int, fields string) (*Response, error) { 39 | var ( 40 | params = url.Values{} 41 | itemResponse = &Response{} 42 | ) 43 | params.Add("Ids", ids) 44 | params.Add("Limit", strconv.Itoa(limit)) 45 | params.Add("Fields", fields) 46 | params.Add("api_key", jellyfin.GetAPIKey()) 47 | 48 | resp, err := http.Get(jellyfin.GetEndpoint() + "/Items?" + params.Encode()) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | defer resp.Body.Close() 54 | body, err := io.ReadAll(resp.Body) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if err = json.Unmarshal(body, itemResponse); err != nil { 60 | return nil, err 61 | } 62 | return itemResponse, nil 63 | } 64 | 65 | // 获取 Jellyfin 实例 66 | func New(addr string, apiKey string) *Jellyfin { 67 | jellyfin := &Jellyfin{ 68 | endpoint: utils.GetEndpoint(addr), 69 | apiKey: apiKey, 70 | } 71 | return jellyfin 72 | } 73 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Akimio521/MediaWarp/fcb0d595d69d46a3f3e13d1d9c7caff32039498f/logs/.gitkeep -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "MediaWarp/constants" 5 | "MediaWarp/internal/config" 6 | "MediaWarp/internal/handler" 7 | "MediaWarp/internal/logging" 8 | "MediaWarp/internal/router" 9 | "MediaWarp/internal/service" 10 | "MediaWarp/utils" 11 | "flag" 12 | "fmt" 13 | "os" 14 | "os/signal" 15 | "syscall" 16 | 17 | "encoding/json" 18 | 19 | "github.com/gin-gonic/gin" 20 | "github.com/sirupsen/logrus" 21 | ) 22 | 23 | var ( 24 | isDebug bool // 开启调试模式 25 | showVersion bool // 显示版本信息 26 | configPath string // 配置文件路径 27 | ) 28 | 29 | func init() { 30 | flag.BoolVar(&showVersion, "version", false, "显示版本信息") 31 | flag.BoolVar(&isDebug, "debug", false, "是否启用调试模式") 32 | flag.StringVar(&configPath, "config", "", "指定配置文件路径") 33 | flag.Parse() 34 | 35 | fmt.Print(constants.LOGO) 36 | fmt.Println(utils.Center(fmt.Sprintf(" MediaWarp %s ", config.Version().AppVersion), 71, "=")) 37 | } 38 | 39 | func main() { 40 | if showVersion { 41 | versionInfo, _ := json.MarshalIndent(config.Version(), "", " ") 42 | fmt.Println(string(versionInfo)) 43 | return 44 | } 45 | 46 | gin.SetMode(gin.ReleaseMode) 47 | 48 | if isDebug { 49 | logging.SetLevel(logrus.DebugLevel) 50 | fmt.Println("已启用调试模式") 51 | } 52 | 53 | signChan := make(chan os.Signal, 1) 54 | errChan := make(chan error, 1) 55 | signal.Notify(signChan, syscall.SIGINT, syscall.SIGTERM) 56 | defer func() { 57 | fmt.Println("MediaWarp 已退出") 58 | }() 59 | 60 | if err := config.Init(configPath); err != nil { // 初始化配置 61 | fmt.Println("配置初始化失败:", err) 62 | return 63 | } 64 | logging.Init() // 初始化日志 65 | logging.Infof("上游媒体服务器类型:%s,服务器地址:%s", config.MediaServer.Type, config.MediaServer.ADDR) // 日志打印 66 | service.InitAlistSerer() // 初始化Alist服务器 67 | if err := handler.Init(); err != nil { // 初始化媒体服务器处理器 68 | logging.Error("媒体服务器处理器初始化失败:", err) 69 | return 70 | } 71 | 72 | logging.Info("MediaWarp 监听端口:", config.Port) 73 | ginR := router.InitRouter() // 路由初始化 74 | logging.Info("MediaWarp 启动成功") 75 | go func() { 76 | if err := ginR.Run(config.ListenAddr()); err != nil { 77 | errChan <- err 78 | } 79 | }() 80 | 81 | select { 82 | case sig := <-signChan: 83 | logging.Info("MediaWarp 正在退出,信号:", sig) 84 | case err := <-errChan: 85 | logging.Error("MediaWarp 运行出错:", err) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /static/embed.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed embyExternalUrl/embyWebAddExternalUrl/embyLaunchPotplayer.js 8 | //go:embed dd-danmaku/ede.js 9 | //go:embed jellyfin-danmaku/ede.js 10 | //go:embed emby-web-mod/actorPlus/actorPlus.js 11 | //go:embed emby-web-mod/emby-swiper/emby-swiper.js 12 | //go:embed emby-web-mod/emby-tab/emby-tab.js 13 | //go:embed emby-web-mod/fanart_show/fanart_show.js 14 | //go:embed emby-web-mod/itemSortForNewDevice/itemSortForNewDevice.js 15 | //go:embed emby-web-mod/playbackRate/playbackRate.js 16 | //go:embed emby-web-mod/trailer/trailer.js 17 | //go:embed emby-crx/static/css/style.css 18 | //go:embed emby-crx/static/js/common-utils.js 19 | //go:embed emby-crx/static/js/jquery-3.6.0.min.js 20 | //go:embed emby-crx/static/js/md5.min.js 21 | //go:embed emby-crx/content/main.js 22 | //go:embed jellyfin-crx/static/css/style.css 23 | //go:embed jellyfin-crx/static/js/common-utils.js 24 | //go:embed jellyfin-crx/static/js/jquery-3.6.0.min.js 25 | //go:embed jellyfin-crx/static/js/md5.min.js 26 | //go:embed jellyfin-crx/content/main.js 27 | var EmbeddedStaticAssets embed.FS 28 | -------------------------------------------------------------------------------- /utils/fs.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | ) 8 | 9 | // 判断路径是否存在 10 | func PathExists(path string) (bool, error) { 11 | _, err := os.Stat(path) 12 | if err == nil { 13 | return true, nil 14 | } 15 | if os.IsNotExist(err) { // isnotexist来判断,是不是不存在的错误 16 | // 如果返回的错误类型使用os.isNotExist()判断为true,说明文件或者文件夹不存在 17 | return false, nil 18 | } 19 | return false, err // 如果有错误了,但是不是不存在的错误,所以把这个错误原封不动的返回 20 | } 21 | 22 | // 判断路径是否为文件夹 23 | func IsDir(path string) (bool, error) { 24 | fileInfo, err := os.Stat(path) 25 | if err != nil { 26 | return false, err 27 | } 28 | return fileInfo.IsDir(), nil 29 | } 30 | 31 | // 判断路径是否为文件 32 | func IsFile(path string) (bool, error) { 33 | fileInfo, err := os.Stat(path) 34 | if err != nil { 35 | return false, err 36 | } 37 | return !fileInfo.IsDir(), nil 38 | } 39 | 40 | // 读取文件内容 41 | func GetFileContent(filepath string) ([]byte, error) { 42 | isFile, err := IsFile(filepath) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if !isFile { 48 | return nil, errors.New(filepath + "不是文件") 49 | } 50 | 51 | file, err := os.OpenFile(filepath, os.O_RDONLY, 0666) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | defer file.Close() 57 | fileContent, err := io.ReadAll(file) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return fileContent, nil 62 | 63 | } 64 | -------------------------------------------------------------------------------- /utils/http.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // 代理访问上游服务器返回的响应体 11 | func GetRespBody(ctx *gin.Context, target string, api_key string) (body []byte, err error) { 12 | params := ctx.Request.URL.Query() 13 | if params.Get("api_key") == "" || params.Get("api_key") == "x-emby-token" { 14 | params.Set("api_key", api_key) 15 | } 16 | api := target + ctx.Request.URL.Path + "?" + params.Encode() 17 | client := http.Client{} 18 | req, err := http.NewRequest(ctx.Request.Method, api, ctx.Request.Body) 19 | if err != nil { 20 | return 21 | } 22 | 23 | req.Header.Add("Content-Type", "application/json;charset=utf-8") 24 | resp, err := client.Do(req) 25 | if err != nil { 26 | return 27 | } 28 | 29 | defer resp.Body.Close() 30 | body, err = io.ReadAll(resp.Body) 31 | if err != nil { 32 | return 33 | } 34 | 35 | // 清除所有响应头 36 | // for key := range ctx.Writer.Header() { 37 | // ctx.Writer.Header().Del(key) 38 | // } 39 | 40 | for key, values := range resp.Header { 41 | for _, value := range values { 42 | if key != "Content-Length" { 43 | ctx.Writer.Header().Set(key, value) 44 | } 45 | } 46 | } 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /utils/set.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | func sortSlice[T comparable](slice []T) { 9 | sort.Slice(slice, func(i, j int) bool { 10 | return fmt.Sprintf("%v", slice[i]) < fmt.Sprintf("%v", slice[j]) 11 | }) 12 | } 13 | 14 | type SetInterface[T comparable] interface { 15 | Add(value T) // 添加元素 16 | Adds(values ...T) // 添加多个元素 17 | Remove(value T) // 删除元素 18 | Contains(value T) bool // 判断元素是否存在 19 | Len() int // 计算集合元素数量 20 | Values() []T // 获取集合所有元素并排序 21 | Equal(other SetInterface[T]) bool // 判断两个集合是否相等 22 | } 23 | 24 | func NewSet[T comparable]() SetInterface[T] { 25 | return &Set[T]{elements: make(map[T]struct{})} 26 | } 27 | 28 | // 集合对象 29 | type Set[T comparable] struct { 30 | elements map[T]struct{} 31 | } 32 | 33 | func (s *Set[T]) Add(value T) { 34 | s.elements[value] = struct{}{} 35 | } 36 | 37 | func (s *Set[T]) Adds(values ...T) { 38 | for _, value := range values { 39 | s.Add(value) 40 | } 41 | } 42 | 43 | func (s *Set[T]) Remove(value T) { 44 | delete(s.elements, value) 45 | } 46 | 47 | func (s *Set[T]) Contains(value T) bool { 48 | _, ok := s.elements[value] 49 | return ok 50 | } 51 | 52 | func (s *Set[T]) Len() int { 53 | return len(s.elements) 54 | } 55 | 56 | func (s *Set[T]) Values() []T { 57 | values := make([]T, 0, len(s.elements)) 58 | for value := range s.elements { 59 | values = append(values, value) 60 | } 61 | sortSlice(values) 62 | return values 63 | } 64 | 65 | func (s *Set[T]) Equal(other SetInterface[T]) bool { 66 | if s.Len() != other.Len() { 67 | return false 68 | } 69 | for _, value := range s.Values() { 70 | if !other.Contains(value) { 71 | return false 72 | } 73 | } 74 | return true 75 | 76 | } 77 | -------------------------------------------------------------------------------- /utils/slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | // 检查切片中是否包含某个元素 4 | func Contains[T comparable](slice []T, element T) bool { 5 | for _, item := range slice { 6 | if item == element { 7 | return true 8 | } 9 | } 10 | return false 11 | } 12 | -------------------------------------------------------------------------------- /utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "net/url" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | var removeColorCodesRegexp = regexp.MustCompile(`\033\[[0-9]*m`) 14 | 15 | // RemoveColorCodes 移除字符串中的 ANSI 颜色代码 16 | func RemoveColorCodes(line string) string { 17 | return removeColorCodesRegexp.ReplaceAllString(line, "") 18 | } 19 | 20 | // 将字符串居中 21 | func Center(s string, width int, fill string) string { 22 | if len(s) >= width { 23 | return s 24 | } 25 | padding := width - len(s) 26 | leftPadding := padding / 2 27 | rightPadding := padding - leftPadding 28 | return strings.Repeat(fill, leftPadding) + s + strings.Repeat(fill, rightPadding) 29 | } 30 | 31 | // 分离主机域名(IP)和端口号 32 | // 33 | // 示例: 34 | // "example.com:8096" => "example.com", "8096" 35 | // "[240e:da8:a801:5a47::316]:8096" => "240e:da8:a801:5a47::316" "8096" 36 | // "192.168.1.1:8096" => "192.168.1.1" "8096" 37 | func SplitHostPort(hostPort string) (host string, port string) { 38 | host = hostPort 39 | 40 | colon := strings.LastIndexByte(host, ':') 41 | if colon != -1 && validOptionalPort(host[colon:]) { 42 | host, port = host[:colon], host[colon+1:] 43 | } 44 | 45 | // 对地址是IPv6地址进行处理 46 | if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { 47 | host = host[1 : len(host)-1] 48 | } 49 | 50 | return 51 | } 52 | 53 | func validOptionalPort(port string) bool { 54 | if port == "" { 55 | return true 56 | } 57 | if port[0] != ':' { 58 | return false 59 | } 60 | for _, b := range port[1:] { 61 | if b < '0' || b > '9' { 62 | return false 63 | } 64 | } 65 | return true 66 | } 67 | 68 | // MD5编码 69 | // 70 | // 对字符串进行MD5哈希运算, 返回十六进制 71 | func MD5Hash(raw string) string { 72 | hash := md5.New() 73 | hash.Write([]byte(raw)) 74 | return hex.EncodeToString(hash.Sum(nil)) 75 | } 76 | 77 | // 获取服务器入口 78 | // 79 | // 包含协议、主机域名(IP)、端口号(标准端口号可省略) 80 | // Example1: https://example1.com:8920 81 | // Example2: http://example2.com:5224 82 | func GetEndpoint(addr string) string { 83 | if !strings.HasPrefix(addr, "http") { 84 | addr = "http://" + addr 85 | } 86 | return strings.TrimSuffix(addr, "/") 87 | } 88 | 89 | var embyAPIKeys = []string{"api_key", "X-Emby-Token"} 90 | 91 | // 从 URL 中查询参数中解析 Emby 的 API 键值对 92 | // 93 | // 以 xxx=xxx 的字符串形式返回 94 | func ResolveEmbyAPIKVPairs(urlString string) (string, error) { 95 | url, err := url.Parse(urlString) 96 | if err != nil { 97 | return "", err 98 | } 99 | for quryKey, queryValue := range url.Query() { 100 | for _, key := range embyAPIKeys { 101 | if strings.EqualFold(quryKey, key) { 102 | return fmt.Sprintf("%s=%s", quryKey, queryValue[0]), nil 103 | } 104 | } 105 | } 106 | return "", nil 107 | } 108 | 109 | // 判断字符串是否为整型数字 110 | func isInt[T ~[]byte | ~[]rune | ~string](s T) bool { 111 | _, err := strconv.Atoi(string(s)) 112 | return err == nil 113 | } 114 | 115 | // 在 []string 中找到某个字符串的索引 116 | // 如果未找到,返回 -1 117 | // 118 | // slice: 目标切片 119 | // target: 目标字符串 120 | // caseInsensitive: 是否忽略大小写 121 | // trim: 是否去除空白字符 122 | func FindStringIndex(slice []string, target string, caseInsensitive bool, trim bool) int { 123 | if trim { 124 | target = strings.TrimSpace(target) 125 | } 126 | for i, str := range slice { 127 | if trim { 128 | str = strings.TrimSpace(str) 129 | } 130 | if caseInsensitive { 131 | if strings.EqualFold(str, target) { 132 | return i 133 | } 134 | } else { 135 | if str == target { 136 | return i 137 | } 138 | } 139 | } 140 | return -1 141 | } 142 | -------------------------------------------------------------------------------- /utils/string_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "MediaWarp/utils" 5 | "bytes" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestResolveEmbyAPIKVPairs(t *testing.T) { 11 | type TestCase struct { 12 | URI string 13 | Result string 14 | } 15 | testCases := map[string]TestCase{ 16 | "api_key": { 17 | "/emby/videos/41/master.m3u8?DeviceId=B5F997A7-B2EA-448A-A610-A21872B0B4DD&MediaSourceId=mediasource_41&PlaySessionId=d69e971d45fc45dda567cc60834813ab&api_key=e12acc0815f74e9da6a86c9e8c2d45d8&VideoCodec=hevc,h264,mpeg4&VideoBitrate=42000000&TranscodingMaxAudioChannels=6&SegmentContainer=ts&MinSegments=2&BreakOnNonKeyFrames=True&TranscodeReasons=ContainerNotSupported", 18 | "api_key=e12acc0815f74e9da6a86c9e8c2d45d8", 19 | }, 20 | "api_KEY": { 21 | "/emby/videos/41/master.m3u8?DeviceId=B5F997A7-B2EA-448A-A610-A21872B0B4DD&MediaSourceId=mediasource_41&PlaySessionId=d69e971d45fc45dda567cc60834813ab&api_KEY=e12acc0815f74e9da6a86c9e8c2d45d8&VideoCodec=hevc,h264,mpeg4&VideoBitrate=42000000&TranscodingMaxAudioChannels=6&SegmentContainer=ts&MinSegments=2&BreakOnNonKeyFrames=True&TranscodeReasons=ContainerNotSupported", 22 | "api_KEY=e12acc0815f74e9da6a86c9e8c2d45d8", 23 | }, 24 | "X-Emby-Token": { 25 | "/emby/Videos/8/stream.strm?mediasourceid=mediasource_31&playsessionid=8b1ce7461411479cbfbf14a9c63b41e6&static=true&X-Emby-Token=539c76dc33fc4935857e027698d685c7", 26 | "X-Emby-Token=539c76dc33fc4935857e027698d685c7", 27 | }, 28 | "x-emby-token": { 29 | "/emby/Videos/8/stream.strm?mediasourceid=mediasource_31&playsessionid=8b1ce7461411479cbfbf14a9c63b41e6&static=true&x-emby-token=539c76dc33fc4935857e027698d685c7", 30 | "x-emby-token=539c76dc33fc4935857e027698d685c7", 31 | }, 32 | "blank": { 33 | "/emby/Videos/8/stream.strm", 34 | "", 35 | }, 36 | } 37 | 38 | for caseName, testCase := range testCases { 39 | t.Run(caseName, func(t *testing.T) { 40 | result, err := utils.ResolveEmbyAPIKVPairs(testCase.URI) 41 | if err != nil { 42 | t.Errorf("%s 解析发生错误。错误: ", err) 43 | } 44 | if testCase.Result != result { 45 | t.Errorf("%s 解析错误。期望: %s, 实际: %s", caseName, testCase.Result, result) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | // goos: darwin 52 | // goarch: arm64 53 | // pkg: MediaWarp/utils 54 | // cpu: Apple M1 55 | // BenchmarkStringConcat 56 | // BenchmarkStringConcat-8 542830 25741 ns/op 275330 B/op 1 allocs/op 57 | // 测试字符串拼接 58 | func BenchmarkStringConcat(b *testing.B) { 59 | s := "" 60 | for i := 0; i < b.N; i++ { 61 | s += "a" 62 | } 63 | } 64 | 65 | // goos: darwin 66 | // goarch: arm64 67 | // pkg: MediaWarp/utils 68 | // cpu: Apple M1 69 | // BenchmarkByteAppend 70 | // BenchmarkByteAppend-8 819340761 1.597 ns/op 6 B/op 0 allocs/op 71 | // 测试 []byte 的 append 72 | func BenchmarkByteAppend(b *testing.B) { 73 | buf := make([]byte, 0) 74 | for i := 0; i < b.N; i++ { 75 | buf = append(buf, 'a') 76 | } 77 | _ = string(buf) 78 | } 79 | 80 | // goos: darwin 81 | // goarch: arm64 82 | // pkg: MediaWarp/utils 83 | // cpu: Apple M1 84 | // BenchmarkStringBuilder 85 | // BenchmarkStringBuilder-8 430995778 2.531 ns/op 5 B/op 0 allocs/op 86 | // PASS 87 | // ok MediaWarp/utils 1.829s 88 | // // 测试 strings.Builder 89 | func BenchmarkStringBuilder(b *testing.B) { 90 | var sb strings.Builder 91 | for i := 0; i < b.N; i++ { 92 | sb.WriteByte('a') 93 | } 94 | _ = sb.String() 95 | } 96 | 97 | // goos: darwin 98 | // goarch: arm64 99 | // pkg: MediaWarp/utils 100 | // cpu: Apple M1 101 | // BenchmarkMethod1 102 | // BenchmarkMethod1-8 11925727 86.93 ns/op 80 B/op 2 allocs/op 103 | func BenchmarkMethod1(b *testing.B) { 104 | header := "Header" 105 | style := []string{"Style1", "Style2"} 106 | for i := 0; i < b.N; i++ { 107 | var buf bytes.Buffer 108 | buf.WriteString(header + "\n") 109 | buf.WriteString(strings.Join(style, "\n") + "\n\n") 110 | buf.WriteString("Footer\n\n") 111 | } 112 | } 113 | 114 | // goos: darwin 115 | // goarch: arm64 116 | // pkg: MediaWarp/utils 117 | // cpu: Apple M1 118 | // BenchmarkMethod2 119 | // BenchmarkMethod2-8 14530182 82.06 ns/op 80 B/op 2 allocs/op 120 | func BenchmarkMethod2(b *testing.B) { 121 | header := "Header" 122 | style := []string{"Style1", "Style2"} 123 | newLine := []byte{'\n'} 124 | for i := 0; i < b.N; i++ { 125 | var buf bytes.Buffer 126 | buf.WriteString(header) 127 | buf.Write(newLine) 128 | buf.WriteString(strings.Join(style, "\n")) 129 | buf.Write(newLine) 130 | buf.Write(newLine) 131 | buf.WriteString("Footer") 132 | buf.Write(newLine) 133 | buf.Write(newLine) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /utils/subtitle.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | defaultRegularBodyWeight = 400 // 默认正常体字重 13 | defaultBoldBodyWeight = 700 // 默认加粗体字重 14 | ASSHeader1 = `[Script Info] 15 | ; This is an Advanced Sub Station Alpha v4+ script. 16 | Title: 17 | ScriptType: v4.00+ 18 | Collisions: Normal 19 | PlayDepth: 0 20 | 21 | [V4+ Styles]` 22 | ASSHeader2 = `[Events] 23 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text` 24 | ) 25 | 26 | var ( 27 | srtSubtitlesPattern = regexp.MustCompile(`@\d+@\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}@`) // 用于文本是否为 SRT 字幕的正则表达式 28 | srtTimePattern = regexp.MustCompile(`-?\d\d:\d\d:\d\d`) 29 | timeFormatPattern = regexp.MustCompile(`\d(\d:\d{2}:\d{2}),(\d{2})\d`) 30 | arrowPattern = regexp.MustCompile(`\s+-->\s+`) 31 | styleTagStartPattern = regexp.MustCompile(`<([ubi])>`) 32 | styleTagEndPattern = regexp.MustCompile(``) 33 | fontColorTagStartPattern = regexp.MustCompile(``) 34 | fontColorTagEndPattern = regexp.MustCompile(``) 35 | ) 36 | 37 | // 判断字幕是否为 SRT 格式 38 | func IsSRT(content []byte) bool { 39 | content = bytes.ReplaceAll(content, []byte{'\r'}, []byte{}) // 去除 \r 保证多系统兼容 40 | content = bytes.ReplaceAll(content, []byte{'\n'}, []byte{'@'}) // 将 \n 替换为 @ 41 | return srtSubtitlesPattern.Match(content) // 查找第一个匹配项 42 | } 43 | 44 | // 将 SRT 字幕转换成 ASS 字幕 45 | // 46 | // srtText: SRT 格式字幕文本 47 | // style: ASS 字幕样式 48 | func SRT2ASS(srtText []byte, style []string) []byte { 49 | // 预定义常量 50 | var ( 51 | newLine = []byte("\n") 52 | dialogueStart = []byte("Dialogue: 0,") 53 | dialogueSuffix = []byte(",Default,,0,0,0,,") 54 | ) 55 | 56 | srtText = bytes.ReplaceAll(srtText, []byte("\r"), []byte("")) 57 | var lines [][]byte 58 | for _, line := range bytes.Split(srtText, []byte("\n")) { 59 | line = bytes.TrimSpace(line) 60 | if len(line) != 0 { 61 | lines = append(lines, line) 62 | } 63 | } 64 | var ( 65 | subtitleBuffer bytes.Buffer // 字幕缓存区(某一行字幕未完成先存取到此处) 66 | currentSubtitleContent uint8 = 0 // 一个时间下字幕的行数(2表示这一时间有2行字幕) 67 | subtitleContent bytes.Buffer // 字幕内容(预分配 16K 大小) 68 | result bytes.Buffer 69 | ) 70 | subtitleBuffer.Grow(1024) 71 | subtitleContent.Grow(16 * 1024) 72 | result.Grow(16 * 1024) 73 | 74 | for index, line := range lines { 75 | if isInt(line) && srtTimePattern.Match(lines[index+1]) { // 这一行是 SRT 字幕的序列数且下一行是时间 76 | if subtitleBuffer.Len() > 0 { 77 | subtitleContent.Write(subtitleBuffer.Bytes()) 78 | subtitleContent.Write(newLine) 79 | subtitleBuffer.Reset() // 清空缓存区 80 | } 81 | currentSubtitleContent = 0 82 | continue 83 | } 84 | 85 | if srtTimePattern.Match(line) { // 这一行是时间行 86 | subtitleBuffer.Write(dialogueStart) 87 | subtitleBuffer.Write(bytes.ReplaceAll(line, []byte("-0"), []byte("0"))) // 替换时间中的负号 88 | subtitleBuffer.Write(dialogueSuffix) 89 | } else { 90 | if currentSubtitleContent == 0 { 91 | subtitleBuffer.Write(newLine) // 同一时间多行字幕需要在一行中使用字面量 \n 表示换行 92 | } 93 | subtitleBuffer.Write(line) 94 | currentSubtitleContent += 1 95 | } 96 | } 97 | // 最后一行字幕 98 | subtitleContent.Write(subtitleBuffer.Bytes()) 99 | subtitleContent.Write(newLine) 100 | 101 | content := subtitleContent.Bytes() 102 | content = timeFormatPattern.ReplaceAll(content, []byte("$1.$2")) // 替换时间格式 103 | content = arrowPattern.ReplaceAll(content, []byte(",")) // 替换箭头符号 104 | content = styleTagStartPattern.ReplaceAll(content, []byte(`{\\$11}`)) // 替换样式标签 105 | content = styleTagEndPattern.ReplaceAll(content, []byte(`{\\$10}`)) // 替换字体颜色标签 106 | content = fontColorTagStartPattern.ReplaceAll(content, []byte(`{\\c&H$3$2$1&}`)) // 替换字体颜色标签 107 | content = fontColorTagEndPattern.ReplaceAll(content, []byte("")) // 删除字体结束标签 108 | 109 | result.WriteString(ASSHeader1 + "\n") 110 | result.Write(newLine) 111 | result.WriteString(strings.Join(style, "\n")) 112 | result.Write(newLine) 113 | result.Write(newLine) 114 | result.WriteString(ASSHeader2) 115 | result.Write(newLine) 116 | result.Write(newLine) 117 | result.Write(content) 118 | return result.Bytes() 119 | } 120 | 121 | type ASSFontStyle struct { 122 | Name string // 字体名字 123 | Weight uint16 // 字重 124 | Italic bool // 是否为意大利体(斜体) 125 | } 126 | 127 | // 分析 ASS 字幕中有哪些“字”,用于字体子集化 128 | func AnalyseASS(assText string) (map[ASSFontStyle]SetInterface[rune], error) { 129 | var ( 130 | state uint8 = 0 // 文本状态控制字 131 | assStyleNameIndex int8 = -1 132 | assFontNameIndex int8 = -1 133 | assBodyIndex int8 = -1 134 | assItalicIndex int8 = -1 135 | fontStyles map[string]ASSFontStyle = make(map[string]ASSFontStyle, 1) 136 | allFontStyleName []string = []string{} 137 | firstFontStyleName string = "" 138 | assEventTextIndex int8 = -1 139 | assEventStyleIndex int8 = -1 140 | subFontSets map[ASSFontStyle]SetInterface[rune] = make(map[ASSFontStyle]SetInterface[rune], 1) 141 | ) 142 | assText = strings.ReplaceAll(assText, "\r", "") 143 | for _, line := range strings.Split(assText, "\n") { 144 | if line == "" { 145 | continue 146 | } else if state == 0 && strings.HasPrefix(line, "[V4+ Styles]") { 147 | state = 1 148 | } else if state == 1 { // 这一行是字体格式 149 | if !strings.HasPrefix(line, "Format:") { 150 | return nil, fmt.Errorf("解析 ASS 字体 Styles 格式失败:%s", line) 151 | } 152 | stylesFormat := strings.Split(strings.TrimSpace(strings.Replace(line, "Format:", "", 1)), ",") 153 | assStyleNameIndex = int8(FindStringIndex(stylesFormat, "Name", true, true)) // ASS 中字体样式的名字 154 | assFontNameIndex = int8(FindStringIndex(stylesFormat, "Fontname", true, true)) // ASS 中样式使用字体的名字 155 | if assStyleNameIndex == -1 { 156 | return nil, fmt.Errorf("未找字体格式中的 Name :%s", stylesFormat) 157 | } 158 | if assFontNameIndex == -1 { 159 | return nil, fmt.Errorf("未找字体格式中的 Fontname:%s", stylesFormat) 160 | } 161 | assBodyIndex = int8(FindStringIndex(stylesFormat, "Bold", true, true)) 162 | assItalicIndex = int8(FindStringIndex(stylesFormat, "Italic", true, true)) 163 | state = 2 164 | } else if state == 2 { // 这一行开始是字体样式 165 | if strings.HasPrefix(line, "Style:") { 166 | styleData := strings.Split(strings.TrimSpace(strings.Replace(line, "Style:", "", 1)), ",") 167 | styleName := strings.ReplaceAll(strings.TrimSpace(styleData[assStyleNameIndex]), "*", "") 168 | fontName := strings.ReplaceAll(strings.TrimSpace(styleData[assFontNameIndex]), "@", "") 169 | var ( 170 | fontWeight uint16 = defaultRegularBodyWeight 171 | italic bool = false 172 | ) // 字重默认 400 173 | if assBodyIndex != -1 && strings.TrimSpace(styleData[assBodyIndex]) == "1" { // 当该 ASS 字幕格式存在 Bold 属性且该样式属性设置为 "1" 时,将字重设置为 700 174 | fontWeight = defaultBoldBodyWeight 175 | } 176 | if assItalicIndex != -1 && strings.TrimSpace(styleData[assItalicIndex]) == "1" { 177 | italic = true 178 | } 179 | fontStyles[styleName] = ASSFontStyle{fontName, fontWeight, italic} // 将该样式存入 map 中 180 | allFontStyleName = append(allFontStyleName, styleName) 181 | if firstFontStyleName == "" { 182 | firstFontStyleName = styleName 183 | } 184 | } else if strings.HasPrefix(line, "[Events]") { 185 | state = 3 // 字体样式已结束 186 | } 187 | } else if state == 3 { // 开始解析 Event 188 | if !strings.HasPrefix(line, "Format:") { 189 | return nil, fmt.Errorf("解析字幕事件格式失败:%s", line) 190 | } 191 | eventFormat := strings.Split(strings.ReplaceAll(strings.Replace(line, "Format:", "", 1), " ", ""), ",") 192 | assEventTextIndex = int8(FindStringIndex(eventFormat, "Text", true, true)) 193 | assEventStyleIndex = int8(FindStringIndex(eventFormat, "Style", true, true)) 194 | if assEventTextIndex == -1 || assEventStyleIndex == -1 { 195 | return nil, fmt.Errorf("字幕事件格式中未找到 Text 或 Style:%s", line) 196 | } 197 | if int(assEventTextIndex) != len(eventFormat)-1 { 198 | return nil, fmt.Errorf("字幕事件格式中 Text 不是最后一个元素:%s", line) 199 | } 200 | state = 4 201 | } else if state == 4 { // 开始解析字幕具体内容 202 | if strings.HasPrefix(line, "Dialogue:") { 203 | parts := strings.Split(line, ",") 204 | text := strings.Join(parts[assEventTextIndex:], ",") // 获取字幕文本,需要考虑字幕中带有英文逗号的可能性 205 | defaultStyleName := strings.ReplaceAll(parts[assEventStyleIndex], "*", "") // 当前行默认样式 206 | if !Contains(allFontStyleName, defaultStyleName) { 207 | defaultStyleName = firstFontStyleName // 未找到对应样式,使用第一个样式 208 | } 209 | defaultStyle := fontStyles[defaultStyleName] // 当前样式 210 | currentStyle := defaultStyle // 当前样式 211 | parseTag := func(tag []rune) error { 212 | length := len(tag) 213 | if len(tag) == 0 { 214 | return nil 215 | } 216 | if length == 1 && tag[0] == 'r' { 217 | currentStyle = defaultStyle 218 | } else if length > 2 && tag[0] == 'f' && tag[1] == 'n' { 219 | // fmt.Println("Tag:", string(tag), "字体名:", strings.ReplaceAll(string(tag[2:]), "@", "")) 220 | currentStyle.Name = strings.ReplaceAll(string(tag[2:]), "@", "") 221 | } else if length == 2 && tag[0] == 'i' { 222 | if tag[1] == '1' { 223 | currentStyle.Italic = true 224 | } else if tag[1] == '0' { 225 | currentStyle.Italic = false 226 | } else { 227 | return fmt.Errorf("未知斜体状态:%s", string(tag)) 228 | } 229 | } else if tag[0] == 'b' { 230 | if length == 2 { 231 | if tag[1] == '0' { 232 | currentStyle.Weight = defaultRegularBodyWeight 233 | } else if tag[1] == '1' { 234 | currentStyle.Weight = defaultBoldBodyWeight 235 | } else { 236 | return fmt.Errorf("未知加粗状态:%s", string(tag)) 237 | } 238 | } else { 239 | for i := range tag[1:] { 240 | if tag[i] < '0' || tag[i] > '9' { 241 | return nil // b 后面不全是数字,不是加粗标签,忽略 242 | } 243 | } 244 | num, err := strconv.Atoi(string(tag[1:])) 245 | if err != nil { 246 | return fmt.Errorf("解析加粗数值失败:%s", string(tag)) 247 | } 248 | currentStyle.Weight = uint16(num) 249 | } 250 | } 251 | return nil 252 | } 253 | 254 | var buffer []rune = make([]rune, 0, len(line)) 255 | for _, char := range text { 256 | if char == '{' { // 可能是特殊样式的开始 257 | if subFontSets[currentStyle] == nil { 258 | subFontSets[currentStyle] = NewSet[rune]() 259 | } 260 | subFontSets[currentStyle].Adds(buffer...) 261 | } else if char == '}' && len(buffer) > 1 && buffer[0] == '{' && buffer[1] == '\\' { 262 | var tagStartIndex uint16 = 2 263 | for i, c := range buffer[2:] { 264 | if c == '\\' { 265 | err := parseTag(buffer[tagStartIndex : 2+i]) 266 | if err != nil { 267 | return nil, err 268 | } 269 | tagStartIndex = uint16(2 + i + 1) 270 | } 271 | } 272 | err := parseTag(buffer[tagStartIndex:]) 273 | if err != nil { 274 | return nil, err 275 | } 276 | } else { 277 | buffer = append(buffer, char) 278 | } 279 | } 280 | if len(buffer) != 0 { 281 | if subFontSets[currentStyle] == nil { 282 | subFontSets[currentStyle] = NewSet[rune]() 283 | } 284 | subFontSets[currentStyle].Adds(buffer...) 285 | } 286 | 287 | } 288 | } 289 | 290 | } 291 | return subFontSets, nil 292 | } 293 | --------------------------------------------------------------------------------