Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d654368290 | |||
| ab67f6b409 | |||
| 3eb7f13eca | |||
| 3b279e4a05 | |||
| ed03781692 | |||
| fb9dd15186 | |||
| 115572c513 | |||
| 1aa2bcd511 | |||
| 248237d593 | |||
| 99281d89f9 | |||
| aa24ef5966 | |||
| e94b91f2f7 | |||
| fc99be56df | |||
| 34bbe42600 | |||
| dfcdf6a6a5 | |||
| f395d49a10 | |||
| f6a228f318 | |||
| ba958b9f44 | |||
| 19e49e89b9 | |||
| 3f889da389 | |||
| ab669f2e74 | |||
| 5386bfc8a2 | |||
| aa998d9a3a | |||
| dde75321a7 | |||
| d23f594b88 | |||
| b1cbb74f32 | |||
| f01cae6554 | |||
| 74b67101ae | |||
| 3279f40541 | |||
| 62331e5c7b | |||
| d9c3d8ae1d | |||
| c34812841e | |||
| 28e2c58366 | |||
| a9e3ed6a22 | |||
| 48aabc9a7d | |||
| b72b39ad7e | |||
| f04989e973 | |||
| e5bcd7a224 | |||
| fbf31d706c | |||
| b782329ae2 | |||
| 65e20638ff | |||
| 19048f6ead | |||
| b29a5f1748 | |||
| 8783d949ba | |||
| bee6bd1cbd | |||
| bdea15ace9 | |||
| 72acd2ca94 | |||
| ba5f389dbe | |||
| aaa5ee69d2 |
@@ -0,0 +1,3 @@
|
||||
# Shell scripts MUST keep LF endings — CRLF from Windows checkouts breaks
|
||||
# /bin/sh inside Linux containers (`/bin/sh^M: bad interpreter`).
|
||||
*.sh text eol=lf
|
||||
@@ -2,6 +2,16 @@ name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Image tag to publish under (e.g. 3.5.0 to rebuild a released version, or 3.5.0-fix.1 for a side-by-side). Leave blank for dev-<run_id>."
|
||||
required: false
|
||||
type: string
|
||||
push_latest:
|
||||
description: "Also tag the resulting image as `latest` (use when rebuilding a stable release)."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -9,6 +19,11 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Docker Hub mirror — same image, different registry. Credentials
|
||||
# come from the DOCKERHUB_USERNAME / DOCKERHUB_TOKEN secrets (token,
|
||||
# not account password). If either secret is empty, the Docker Hub
|
||||
# login step is skipped and only GHCR gets pushed.
|
||||
DOCKERHUB_IMAGE: caorushizi/mediago
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
@@ -35,17 +50,65 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# GitHub Actions forbids `secrets.*` inside `if:` expressions
|
||||
# directly — it fails with `Unrecognized named-value: 'secrets'`.
|
||||
# The blessed workaround: pull the secret through `env:` (which
|
||||
# IS allowed) inside a single check step, write a boolean to
|
||||
# step outputs, and have every downstream step guard on the
|
||||
# output instead of the raw secret.
|
||||
- name: Detect Docker Hub credentials
|
||||
id: dockerhub
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Only log in (and later push) to Docker Hub when both secrets are
|
||||
# configured. Lets forks / trial runs without Docker Hub
|
||||
# credentials still build-and-push to GHCR.
|
||||
- name: Login to Docker Hub
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Compose the list of target images based on which registries we
|
||||
# actually have credentials for. Without this, metadata-action
|
||||
# would always generate Docker Hub tags and build-push-action
|
||||
# would then fail with 401 on forks that haven't set the secrets.
|
||||
- name: Resolve image targets
|
||||
id: targets
|
||||
run: |
|
||||
{
|
||||
echo "images<<EOF"
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
if [ "${{ steps.dockerhub.outputs.enabled }}" = "true" ]; then
|
||||
echo "${{ env.DOCKERHUB_IMAGE }}"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: ${{ steps.targets.outputs.images }}
|
||||
tags: |
|
||||
# For version tags: v1.0.0 → 1.0.0 + latest (non-beta only)
|
||||
# 1. Manual rebuild with an explicit tag (e.g. `3.5.0`, `3.5.0-fix.1`)
|
||||
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
|
||||
# 2. Tag push: v1.0.0 → 1.0.0
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, 'beta') }}
|
||||
# For manual triggers: dev-{run_id}
|
||||
type=raw,value=dev-${{ github.run_id }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
# 3. `latest`: on stable semver tag pushes, or when the manual
|
||||
# rebuild explicitly asks for it
|
||||
type=raw,value=latest,enable=${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, 'beta')) || inputs.push_latest }}
|
||||
# 4. Fallback for manual triggers with no custom tag
|
||||
type=raw,value=dev-${{ github.run_id }},enable=${{ !startsWith(github.ref, 'refs/tags/') && inputs.tag == '' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -54,6 +117,11 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
# `metadata-action` already emits the standard OCI labels
|
||||
# (image.title / description / source / licenses / version /
|
||||
# revision / created). `revision` uses github.sha, so two
|
||||
# builds that re-use a tag are distinguishable via
|
||||
# `docker inspect`.
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -62,8 +130,14 @@ jobs:
|
||||
run: |
|
||||
echo "### 🎉 Docker Image Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Image:** \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Registries:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.dockerhub.outputs.enabled }}" = "true" ]; then
|
||||
echo "- \`docker.io/${{ env.DOCKERHUB_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "**Platforms:** linux/amd64, linux/arm64" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** \`${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" | while read tag; do
|
||||
@@ -72,5 +146,8 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Pull:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.dockerhub.outputs.enabled }}" = "true" ]; then
|
||||
echo "docker pull ${{ env.DOCKERHUB_IMAGE }}:${{ steps.meta.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -15,3 +15,8 @@ apps/electron/bin/
|
||||
apps/main/bin/
|
||||
.deps/
|
||||
|
||||
tmp/
|
||||
|
||||
# Local planning notes that should not be published with the open-source repo.
|
||||
docs/.vitepress/seo-keyword-map*.md
|
||||
docs/.vitepress/private/
|
||||
|
||||
@@ -7,8 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
MediaGo is a cross-platform video downloader supporting m3u8/HLS streams. The codebase is a pnpm monorepo with three products:
|
||||
|
||||
1. **Desktop app** (`apps/electron` + `apps/ui`) — Electron wrapper that launches Go Core as a subprocess
|
||||
2. **Web server** (`apps/server` + `apps/ui`) — Koa 3 server that launches Go Core as a subprocess
|
||||
3. **Video player** (`apps/player` + `apps/player-ui`) — Standalone Go server with embedded React UI for video playback
|
||||
2. **Web server** (`apps/server` + `apps/ui`) — Node.js launcher that spawns Go Core as a subprocess
|
||||
3. **Video player** (`apps/core` + `apps/player-ui`) — Player UI embedded in Go Core for video playback
|
||||
|
||||
All three products share the Go Core backend (`apps/core`) for download orchestration.
|
||||
|
||||
@@ -17,14 +17,13 @@ All three products share the Go Core backend (`apps/core`) for download orchestr
|
||||
```bash
|
||||
pnpm install # Install all dependencies (run once per clone)
|
||||
pnpm dev:electron # Start Electron desktop dev environment (HMR)
|
||||
pnpm dev:server # Start Koa web server dev environment (HMR)
|
||||
pnpm dev:server # Start server dev environment (HMR)
|
||||
pnpm build:electron # Production build for Electron
|
||||
pnpm build:server # Production build for server
|
||||
pnpm build:web # Build UI only (server mode)
|
||||
pnpm core:dev # Start Go Core dev server (port 9900)
|
||||
pnpm core:build # Compile Go Core binary
|
||||
pnpm player:dev # Start Player dev (Go backend + UI)
|
||||
pnpm player:build # Build Player (UI embedded in Go binary)
|
||||
pnpm player:dev # Start Player dev (alias for core:dev)
|
||||
pnpm player:build # Build Player (alias for core:build)
|
||||
pnpm deps:download # Download third-party tools (ffmpeg, BBDown, etc.)
|
||||
pnpm deps:download:all # Download tools for all platforms
|
||||
pnpm lint # Lint with oxlint
|
||||
@@ -34,7 +33,6 @@ pnpm format:check # Check formatting without modifying
|
||||
pnpm check # Full check: lint + format + type check
|
||||
pnpm type:check # TypeScript type checking via Turborepo
|
||||
pnpm pack:electron # Build + package Electron distributable
|
||||
pnpm pack:server # Build + package server distributable
|
||||
```
|
||||
|
||||
Commits use Conventional Commits format (e.g. `feat(electron): add queue UI`).
|
||||
@@ -47,10 +45,9 @@ Commits use Conventional Commits format (e.g. `feat(electron): add queue UI`).
|
||||
|
||||
- **`apps/core/`** — Go (Gin) REST API backend for download orchestration. Runs on port 9900. Uses SQLite (GORM), SSE for real-time events, PTY for capturing download tool output. Built with Gulp + Go cross-compilation.
|
||||
- **`apps/electron/`** — Electron main process (tsdown build, inversify DI). Launches Go Core via `@mediago/service-runner`.
|
||||
- **`apps/server/`** — Koa 3 web server backend (tsdown build, inversify DI, socket.io). Launches Go Core via `@mediago/service-runner`.
|
||||
- **`apps/server/`** — Node.js launcher (tsdown build). Spawns Go Core via `@mediago/service-runner`.
|
||||
- **`apps/ui/`** — Shared React 19 frontend (Vite 8, Ant Design 6, Zustand, TailwindCSS 4, i18next). Used by both Electron and server targets.
|
||||
- **`apps/player/`** — Go (Gin) video player server. Embeds React UI via `//go:embed`. Built with Gulp + Go.
|
||||
- **`apps/player-ui/`** — React 19 frontend for player (Vite 8, shadcn/ui, video.js, TailwindCSS 4).
|
||||
- **`apps/player-ui/`** — React 19 frontend for player (Vite 8, shadcn/ui, video.js, TailwindCSS 4). Built assets are embedded into Go Core via `//go:embed`.
|
||||
|
||||
**Packages:**
|
||||
|
||||
@@ -67,23 +64,23 @@ The `APP_TARGET` env var (`electron` | `server`) controls which backend the UI b
|
||||
- **Electron**: IPC bridge (preload) + Go Core direct (via `@mediago/core-sdk`)
|
||||
- **Server/Web**: HTTP/WebSocket + Go Core direct (via `@mediago/core-sdk`)
|
||||
|
||||
The UI adapter layer (`apps/ui/src/hooks/adapters/`) abstracts this: `go-adapter.ts` calls Go Core SDK directly, `electron/` adapter uses IPC, and a Proxy-based `apiAdapter` routes to whichever is available.
|
||||
The UI adapter layer (`apps/ui/src/hooks/adapters/`) abstracts this: `electron.ts` provides IPC bridge in desktop mode, `platform-stubs.ts` provides no-op stubs in web mode, and `index.ts` exports `platformApi` which selects the appropriate adapter.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Go Core as subprocess**: Both Electron and server apps launch Go Core via `@mediago/service-runner`, which manages the process lifecycle and port allocation
|
||||
- **Dependency Injection**: inversify with `@inversifyjs/binding-decorators` in Electron and server backends
|
||||
- **Dependency Injection**: inversify with `@inversifyjs/binding-decorators` in Electron backend
|
||||
- **State Management**: Zustand in the UI
|
||||
- **Real-time events**: Go Core emits SSE events (`/api/events`); the UI's `go-event-bridge.ts` subscribes and dispatches to React via a listener pattern
|
||||
- **Real-time events**: Go Core emits SSE events (`/api/events`); the UI's `api/events.ts` subscribes and dispatches to React via a listener pattern
|
||||
- **TypeScript**: Strict mode with experimental decorators and decorator metadata enabled
|
||||
- **Module format**: ES Modules everywhere except server output (CommonJS)
|
||||
- **Module format**: ES Modules everywhere
|
||||
|
||||
## Tooling
|
||||
|
||||
- **Package manager**: pnpm 10.15.0 (enforced via `packageManager` field)
|
||||
- **Build orchestration**: Turborepo
|
||||
- **App bundling**: tsdown for Node/Electron, Vite 8 for UI apps
|
||||
- **Go builds**: Gulp orchestrating `go build` / `go run` in `apps/core` and `apps/player`
|
||||
- **Go builds**: Gulp orchestrating `go build` / `go run` in `apps/core`
|
||||
- **Linter**: oxlint (config in `.oxlintrc.json`)
|
||||
- **Formatter**: oxfmt (config in `.oxfmtrc.json`)
|
||||
- **Pre-commit**: husky + lint-staged (runs oxlint --fix + oxfmt --write on staged files)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Contributing to MediaGo
|
||||
|
||||
Thanks for your interest in hacking on MediaGo! This doc covers everything
|
||||
you need to get a local dev build running. For user-facing usage, see the
|
||||
main [README](./README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** ≥ 20 — install from [nodejs.org](https://nodejs.org/)
|
||||
- **pnpm** ≥ 10 — `npm i -g pnpm`
|
||||
- **Go** ≥ 1.22 — only needed if you're working on the Go Core backend
|
||||
(`apps/core/`). Install from [go.dev](https://go.dev/dl/).
|
||||
|
||||
## Clone & install
|
||||
|
||||
```shell
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
cd mediago
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
MediaGo is a pnpm + Turborepo monorepo with three products that share the
|
||||
same Go Core backend:
|
||||
|
||||
```
|
||||
apps/
|
||||
core/ Go backend (download orchestration, SSE, REST API)
|
||||
electron/ Electron desktop main process
|
||||
server/ Node.js launcher for the self-hosted web build
|
||||
ui/ Shared React 19 frontend (Electron + Web)
|
||||
player-ui/ React frontend embedded inside Go Core for playback
|
||||
packages/
|
||||
shared/common/ Cross-platform types, constants, i18n resources
|
||||
core-sdk/ TypeScript SDK for the Go Core REST API
|
||||
electron-preload/
|
||||
mediago-extension/ Browser extension (Chrome / Edge)
|
||||
docs/ VitePress site (zh / en / jp)
|
||||
extra/ Vendored binaries (e.g. aria2)
|
||||
scripts/ Dep downloaders, extension packager, etc.
|
||||
```
|
||||
|
||||
Deeper architecture notes live in [`CLAUDE.md`](./CLAUDE.md).
|
||||
|
||||
## Everyday commands
|
||||
|
||||
```shell
|
||||
# Download third-party binaries (ffmpeg, yt-dlp, N_m3u8DL-RE, BBDown,
|
||||
# aria2, mediago-core) for the current platform — run once per clone
|
||||
pnpm deps:download
|
||||
|
||||
# Run the Electron desktop app in dev mode (HMR)
|
||||
pnpm dev:electron
|
||||
|
||||
# Run the self-hosted web server in dev mode
|
||||
pnpm dev:server
|
||||
|
||||
# Build an unpacked Electron directory (fast, for smoke-testing layout)
|
||||
pnpm pack:electron
|
||||
|
||||
# Build full Electron installers for distribution (.exe / .dmg / .deb)
|
||||
pnpm release:electron
|
||||
|
||||
# Lint + format + type-check (what CI runs)
|
||||
pnpm check
|
||||
```
|
||||
|
||||
The self-hosted web server doesn't have a dedicated packaging script — it
|
||||
ships via the Docker image published to GHCR, or you can run the build
|
||||
output (`pnpm -F @mediago/server build`) directly under Node.
|
||||
|
||||
## Commit style
|
||||
|
||||
This repo uses [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
Typical shapes:
|
||||
|
||||
```
|
||||
feat(ui): add dark-mode toggle to settings page
|
||||
fix(core): resume m3u8 downloads after process restart
|
||||
refactor(extension): split options hook per card
|
||||
chore(deps): bump axios from 1.14.0 to 1.15.0
|
||||
```
|
||||
|
||||
Commits are lint-staged on commit (oxlint --fix + oxfmt --write on
|
||||
staged files). Type checks run via `turbo type:check`.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Open PRs against the `master` branch.
|
||||
- Keep each PR focused — one feature / fix per PR if possible.
|
||||
- Include a short "why" in the description; the "what" is in the diff.
|
||||
- If the change is user-visible, a line in the PR description that would
|
||||
fit in a release note is appreciated.
|
||||
|
||||
Thanks for contributing! 🚀
|
||||
+13
-13
@@ -34,6 +34,12 @@ COPY apps/player-ui/ apps/player-ui/
|
||||
COPY apps/electron/app/package.json apps/electron/app/package.json
|
||||
COPY scripts/ scripts/
|
||||
|
||||
# Vendored binaries (aria2 in particular). `scripts/download-deps.ts`
|
||||
# treats `source: "local"` entries by copying from `extra/<tool>/<os>/<arch>/`
|
||||
# into `.deps/`; without this COPY they silently get skipped and the
|
||||
# resulting image ships with no aria2c.
|
||||
COPY extra/ extra/
|
||||
|
||||
# Build player-ui (will be embedded in Go core binary)
|
||||
RUN pnpm --filter @mediago/player-ui run build
|
||||
|
||||
@@ -98,20 +104,14 @@ RUN mkdir -p /app/deps && \
|
||||
|
||||
RUN mkdir -p /app/mediago/data /app/mediago/logs /app/mediago/downloads
|
||||
|
||||
# Entrypoint script — isolates the invocation flags from the Dockerfile
|
||||
# so editing the default args doesn't require rebuilding the full image
|
||||
# layer and so callers can still append overrides via `docker run`.
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8899
|
||||
|
||||
VOLUME ["/app/mediago"]
|
||||
|
||||
CMD ["mediago-core", \
|
||||
"--port=8899", \
|
||||
"--static-dir=/app/static", \
|
||||
"--enable-auth", \
|
||||
"--db-path=/app/mediago/data/mediago.db", \
|
||||
"--config-dir=/app/mediago/data", \
|
||||
"--log-dir=/app/mediago/logs", \
|
||||
"--local-dir=/app/mediago/downloads", \
|
||||
"--m3u8-bin=/app/deps/N_m3u8DL-RE", \
|
||||
"--bilibili-bin=/app/deps/BBDown", \
|
||||
"--direct-bin=/app/deps/gopeed", \
|
||||
"--mediago-bin=/app/deps/mediago", \
|
||||
"--ffmpeg-bin=/app/deps/ffmpeg"]
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<div align="center">
|
||||
<h1>MediaGo</h1>
|
||||
<a href="https://downloader.caorushizi.cn/it/guides.html?form=github">Avvio rapido</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/it?form=github">Sito web</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/it/documents.html?form=github">Documentazione</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/discussions">Discussioni</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://www.reddit.com/r/MediaGo_Studio/">Reddit</a>
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.md">English</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.zh.md">中文</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.jp.md">日本語</a>
|
||||
<br>
|
||||
|
||||
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/mediago/total">
|
||||
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/mediago/latest/total">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/mediago">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/caorushizi/mediago">
|
||||
<img alt="GitCode" src="https://gitcode.com/caorushizi/mediago/star/badge.svg">
|
||||
<br>
|
||||
|
||||
<a href="https://trendshift.io/repositories/11083" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/11083" alt="caorushizi%2Fmediago | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
Un downloader video multipiattaforma con sniffing integrato: apri una
|
||||
pagina, scegli la risorsa che ti interessa e salvala. Nessuna cattura dei
|
||||
pacchetti, nessuna configurazione complicata di estensioni browser, nessuno
|
||||
strumento da riga di comando da gestire.
|
||||
|
||||
L'interfaccia dell'app include attualmente inglese, cinese semplificato e
|
||||
italiano.
|
||||
|
||||
## ✨ Cosa include
|
||||
|
||||
### 🌐 Estensione browser per Chrome / Edge
|
||||
|
||||
Trovi un video interessante su un sito qualsiasi → clicchi l'estensione →
|
||||
lo invii a MediaGo con un clic. Rileva automaticamente le risorse video,
|
||||
mostra il numero di elementi trovati nel badge della toolbar e funziona con
|
||||
le principali piattaforme video, incluse YouTube, Bilibili e molte altre.
|
||||
L'estensione è inclusa nell'app desktop: apri **Impostazioni → Altre
|
||||
impostazioni → Directory estensione browser** per trovare la cartella di
|
||||
installazione.
|
||||
|
||||
### 🎬 YouTube e oltre 1000 siti
|
||||
|
||||
Basato su yt-dlp. Supporta YouTube, Twitter/X, Instagram, Reddit e
|
||||
[oltre mille altri siti video](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
### 🦞 Gli assistenti AI possono scaricare per te — OpenClaw Skill
|
||||
|
||||
Usi Claude Code, Cursor o un altro assistente AI per programmare? Installa
|
||||
la skill MediaGo e scrivi semplicemente _"please download this video:
|
||||
<url>"_. L'assistente gestisce il resto.
|
||||
|
||||
```shell
|
||||
npx clawhub@latest install mediago
|
||||
```
|
||||
|
||||
### 🔌 Funziona con altri strumenti
|
||||
|
||||
MediaGo espone una API HTTP completa: script, automazioni e app di terze
|
||||
parti possono creare attività di download, consultare l'avanzamento e
|
||||
gestire la lista. L'estensione browser usa la stessa API per parlare con
|
||||
l'app desktop, e puoi integrarla anche nei tuoi workflow.
|
||||
|
||||
### 🎞️ Conversione formato integrata
|
||||
|
||||
Dopo il download puoi convertire il file in un altro formato o qualità
|
||||
direttamente da MediaGo. Non serve aprire uno strumento ffmpeg separato.
|
||||
|
||||
### 🐳 Deploy Docker con un solo comando
|
||||
|
||||
Installazione headless sul tuo server, poi accesso alla UI web da qualsiasi
|
||||
dispositivo nella stessa rete:
|
||||
|
||||
```shell
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
Disponibile su [Docker Hub](https://hub.docker.com/r/caorushizi/mediago)
|
||||
e GHCR (`ghcr.io/caorushizi/mediago`): la stessa immagine, scegli il
|
||||
registry più veloce per te. Supporta Intel / AMD (amd64) e ARM (arm64).
|
||||
Nella build desktop, MediaGo ascolta sia su `127.0.0.1` sia sull'IP LAN,
|
||||
così telefoni e tablet sulla stessa rete Wi-Fi possono aprire direttamente
|
||||
la UI web.
|
||||
|
||||
## 📷 Screenshot
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 📥 Download
|
||||
|
||||
### v3.5.0 (stabile)
|
||||
|
||||
- [Windows — installer](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [Windows — portable](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [macOS — Apple Silicon (arm64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [macOS — Intel (x64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [Linux (deb)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago): `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
Per le versioni precedenti, consulta la [pagina GitHub Releases](https://github.com/caorushizi/mediago/releases).
|
||||
|
||||
### 🪄 Deploy Docker con un clic tramite BT Panel
|
||||
|
||||
1. Installa [BT Panel](https://www.bt.cn/new/download.html?r=dk_mediago)
|
||||
usando lo script ufficiale.
|
||||
2. Accedi al pannello, clicca **Docker** nella barra laterale e completa la
|
||||
configurazione del servizio Docker seguendo le istruzioni.
|
||||
3. Trova **MediaGo** nello store delle app, clicca **Install**, configura il
|
||||
dominio e hai finito.
|
||||
|
||||
## 📝 Novità in v3.5.0
|
||||
|
||||
- **🌐 Estensione browser** — sniffing video su qualsiasi sito e invio a
|
||||
MediaGo con un clic
|
||||
- **🎬 YouTube + 1000+ siti** — integrazione con yt-dlp
|
||||
- **🦞 OpenClaw Skill** — scarica video tramite assistenti AI per programmare
|
||||
- **🔌 API HTTP** — integrazione con script, automazioni e strumenti di terze parti
|
||||
- **🎞️ Conversione formato in app** — scegli formato e qualità di output
|
||||
- **🐳 Deploy Docker più semplice** — monta una sola cartella, immagini multi-arch su GHCR
|
||||
- **⚡ Avvio più rapido** — backend riscritto, minore consumo di memoria, player video integrato
|
||||
|
||||
## 🛠️ Tecnologie
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.electronjs.org)
|
||||
[](https://vitejs.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com)
|
||||
[](https://ui.shadcn.com/)
|
||||
[](https://go.dev/)
|
||||
[](https://ant.design)
|
||||
|
||||
## 🙏 Ringraziamenti
|
||||
|
||||
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE)
|
||||
- [BBDown](https://github.com/nilaoda/BBDown)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [aria2](https://aria2.github.io/)
|
||||
- [mediago-core](https://github.com/caorushizi/mediago-core)
|
||||
|
||||
## ⚖️ Disclaimer
|
||||
|
||||
> **Questo progetto è destinato esclusivamente a scopi educativi e di ricerca. Non usarlo per finalità commerciali o illegali.**
|
||||
>
|
||||
> 1. Tutto il codice e tutte le funzionalità fornite da questo progetto sono pensati solo come riferimento per lo studio delle tecnologie di streaming. Gli utenti devono rispettare le leggi e i regolamenti della propria giurisdizione.
|
||||
> 2. Qualsiasi contenuto scaricato tramite questo progetto resta di proprietà dei rispettivi titolari dei diritti. Gli utenti devono eliminare i contenuti scaricati entro 24 ore o ottenere un'autorizzazione adeguata.
|
||||
> 3. Gli sviluppatori del progetto non sono responsabili delle azioni degli utenti, incluso il download di contenuti protetti da copyright o l'impatto su piattaforme di terze parti.
|
||||
> 4. È vietato usare questo progetto per scraping massivo, interruzione dei servizi delle piattaforme o qualsiasi attività che violi diritti legittimi altrui.
|
||||
> 5. Usando questo progetto confermi di aver letto e accettato questo disclaimer. Se non lo accetti, interrompi subito l'uso del progetto ed eliminalo.
|
||||
|
||||
---
|
||||
|
||||
> Vuoi compilare da sorgente? Consulta [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
>
|
||||
> Vuoi tradurre MediaGo? Consulta [TRANSLATION.md](./TRANSLATION.md).
|
||||
+97
-98
@@ -1,30 +1,32 @@
|
||||
<div align="center">
|
||||
<h1>MediaGo</h1>
|
||||
<a href="https://downloader.caorushizi.cn/jp/guides.html?form=github">早く始めます</a>
|
||||
<a href="https://downloader.caorushizi.cn/jp/guides.html?form=github">クイックスタート</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/jp?form=github">公式サイトです</a>
|
||||
<a href="https://downloader.caorushizi.cn/jp?form=github">公式サイト</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/jp/documents.html?form=github">にやすりをかける</a>
|
||||
<a href="https://downloader.caorushizi.cn/jp/documents.html?form=github">ドキュメント</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://www.reddit.com/r/MediaGo_Studio/">Reddit</a>
|
||||
<br>
|
||||
<!-- MediaGo Pro -->
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/✨_新登場-MediaGo_Pro-ff6b6b?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyTDMgN2wzIDMgNi00IDYgNCAzLTMtOS01eiIvPjxwYXRoIGQ9Ik0zIDE3bDkgNSA5LTUtMy0zLTYgNC02LTQtMyAzeiIvPjwvc3ZnPg==" alt="MediaGo Pro" />
|
||||
</a>
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/🚀_今すぐ試す-オンライン版_インストール不要-2a82f6?style=for-the-badge" alt="Try Now" />
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.md">English</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.zh.md">中文</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.it.md">Italiano</a>
|
||||
<br>
|
||||
|
||||
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/mediago/total">
|
||||
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/mediago/latest/total">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/mediago">
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/caorushizi/mediago">
|
||||
<img alt="GitCode" src="https://gitcode.com/caorushizi/mediago/star/badge.svg">
|
||||
<br>
|
||||
<br>
|
||||
|
||||
|
||||
<a href="https://trendshift.io/repositories/11083" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/11083" alt="caorushizi%2Fmediago | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
@@ -32,120 +34,117 @@
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## Intro
|
||||
ビルトインのスニッフィング機能を備えたクロスプラットフォームの動画ダウンローダー —— ページを開いて、欲しいリソースを選んで、保存するだけ。パケットキャプチャ不要、ブラウザ拡張の設定不要、コマンドラインの操作も不要です。
|
||||
|
||||
本プロジェクトはm3u8ビデオ抽出ツール、ストリーミングダウンロード、m3u8ダウンロードをサポートしています。
|
||||
アプリ UI は現在、英語・簡体中国語・イタリア語に対応しています。
|
||||
|
||||
- **✅ パケットキャプチャ不要**: ソフトウェアに内蔵されたブラウザを使用して、ウェブページ内のビデオリソースを簡単に検出し、検出したリソースリストからダウンロードしたいリソースを選択することで、シンプルかつ迅速にダウンロードできます。
|
||||
- **📱 モバイル再生**: PCとモバイルデバイス間で簡単にシームレスに切り替えが可能で、ダウンロードが完了した後はスマートフォンでビデオを視聴できます。
|
||||
- **⚡️ バッチダウンロード**: 複数のビデオやライブストリームリソースを同時にダウンロードでき、高速帯域幅を無駄にしません。
|
||||
- **🎉 Dockerデプロイメントサポート**: WebエンドをDockerでデプロイすることができ、簡単かつ便利です。
|
||||
## ✨ 主な機能
|
||||
|
||||
## Quickstart
|
||||
### 🌐 ブラウザ拡張機能(Chrome / Edge)
|
||||
|
||||
コードを実行するには、Node.jsとpnpmが必要です。Node.jsは公式ウェブサイトからダウンロードしてインストールし、pnpmは`npm i -g pnpm`コマンドでインストールできます。
|
||||
ウェブを閲覧中に気になる動画を見つけたら → 拡張機能のアイコンをクリック → ワンクリックで MediaGo に送信。ページ内のダウンロード可能なリソースを自動検出し、ツールバーアイコンのバッジに件数を表示します。YouTube、Bilibili をはじめ主要な動画サイトに対応。拡張機能はデスクトップ版インストーラーに同梱されているので、**設定 → その他の設定 → ブラウザ拡張ディレクトリ** から直接インストールフォルダを開けます。
|
||||
|
||||
## コードの実行
|
||||
### 🎬 YouTube と 1000+ サイト対応
|
||||
|
||||
内部では yt-dlp を使用。YouTube、Twitter/X、Instagram、Reddit など [1000 以上の動画サイト](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) をサポートします。
|
||||
|
||||
### 🦞 AI アシスタントで動画をダウンロード — OpenClaw Skill
|
||||
|
||||
Claude Code や Cursor などの AI コーディングアシスタントを使っていますか?MediaGo Skill をインストールすれば、AI に「この動画をダウンロードして:<URL>」と言うだけでダウンロードが始まります。
|
||||
|
||||
```shell
|
||||
# コードのダウンロードです
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
|
||||
# インストール依存症です
|
||||
pnpm i
|
||||
|
||||
# 開発環境です
|
||||
pnpm dev
|
||||
|
||||
# 梱包して運行します
|
||||
pnpm release
|
||||
|
||||
# dockerミラーリングを構築します
|
||||
docker buildx build -t caorushizi/mediago:latest .
|
||||
|
||||
# docker启动
|
||||
docker run -d --name mediago -p 8899:8899 -v /root/mediago:/root/mediago registry.cn-beijing.aliyuncs.com/caorushizi/mediago
|
||||
|
||||
npx clawhub@latest install mediago
|
||||
```
|
||||
|
||||
## Releases
|
||||
### 🔌 他のツールと連携
|
||||
|
||||
### v3.0.0 (2024.10.7 発売)
|
||||
MediaGo は完全な HTTP API を提供します。スクリプト、自動化ツール、他のアプリから直接ダウンロードタスクの作成、進捗の取得、リスト管理が可能です。ブラウザ拡張機能はこの API を介してデスクトップアプリと通信しており、自分のワークフローに組み込むこともできます。
|
||||
|
||||
#### ソフトウェアダウンロード
|
||||
### 🎞️ 内蔵フォーマット変換
|
||||
|
||||
- [【mediago】 windows(インストーラー版) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 windows(ポータブル版) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 macos arm64(Appleチップ) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
|
||||
- [【mediago】 macos x64(Intelチップ) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-x64-3.0.0.dmg)
|
||||
- [【mediago】 linux v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-linux-amd64-3.0.0.deb)
|
||||
- 【mediago】 docker v3.0 `docker run -d --name mediago -p 8899:8899 -v /root/mediago:/root/mediago registry.cn-beijing.aliyuncs.com/caorushizi/mediago:v3.0.0`
|
||||
ダウンロード完了後、MediaGo 内で他のフォーマットや画質に変換できます。ffmpeg を別途起動する必要はありません。
|
||||
|
||||
#### 国内ダウンロード
|
||||
### 🐳 Docker でワンライン展開
|
||||
|
||||
- [【mediago】 windows(インストーラー版) v3.0.0](https://static.ziying.site/mediago/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 windows(ポータブル版) v3.0.0](https://static.ziying.site/mediago/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 macos arm64(Appleチップ) v3.0.0](https://static.ziying.site/mediago/mediago-setup-darwin-arm64-3.0.0.dmg)
|
||||
- [【mediago】 macos x64(Intelチップ) v3.0.0](https://static.ziying.site/mediago/mediago-setup-darwin-x64-3.0.0-beta.5.dmg)
|
||||
- [【mediago】 linux v3.0.0](https://static.ziying.site/mediago/mediago-setup-linux-amd64-3.0.0.deb)
|
||||
- 【mediago】 docker v3.0 `docker run -d --name mediago -p 8899:8899 -v /root/mediago:/root/mediago registry.cn-beijing.aliyuncs.com/caorushizi/mediago:v3.0.0`
|
||||
サーバーにヘッドレスでインストールし、同じネットワーク内のどこからでも Web UI にアクセスできます:
|
||||
|
||||
### docker 宝塔パネルワンクリックデプロイ(推奨)
|
||||
```shell
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
1. 宝塔パネルをインストールし、[宝塔パネル](https://www.bt.cn/new/download.html?r=dk_mediago) の公式サイトから正式版のスクリプトを選択してインストールします。
|
||||
[Docker Hub](https://hub.docker.com/r/caorushizi/mediago) と GHCR(`ghcr.io/caorushizi/mediago`)の両方で配信しています。同じイメージなのでお好みのレジストリを。Intel / AMD (amd64) と ARM (arm64) の両方に対応。デスクトップ版は `127.0.0.1` と LAN IP の両方で待ち受けるため、同じ Wi-Fi のスマートフォンやタブレットからも Web UI を開けます。
|
||||
|
||||
2. インストール後、宝塔パネルにログインし、メニューから `Docker` をクリックします。初めてアクセスすると、`Docker` サービスをインストールするように指示されるので、「今すぐインストール」をクリックし、指示に従ってインストールを完了します。
|
||||
## 📷 スクリーンショット
|
||||
|
||||
3. インストールが完了したら、アプリストアで「MediaGo」を見つけ、インストールをクリックし、ドメイン名などの基本情報を設定してインストールを完了します。
|
||||

|
||||
|
||||
### ソフトウェアスクリーンショット
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
### 重要な更新
|
||||

|
||||
|
||||
- Web端のdockerデプロイをサポート
|
||||
- デスクトップ端のUIを更新
|
||||
## 📥 ダウンロード
|
||||
|
||||
### 更新ログ
|
||||
### v3.5.0(安定版)
|
||||
|
||||
- デスクトップ端のUIを更新
|
||||
- Web端のdockerデプロイをサポート
|
||||
- 新たにビデオ再生機能を追加、デスクトップとモバイル端両方で再生可能
|
||||
- Macでの画面表示ができない問題を修正
|
||||
- バッチダウンロードのインタラクションを最適化
|
||||
- Windowsのポータブル版(インストール不要)を追加
|
||||
- ダウンロードリストの最適化、ページ内の複数のビデオリソースを嗅ぎ取る機能を追加
|
||||
- 手動でお気に入りリストのインポートとエクスポートをサポート
|
||||
- ホームページのダウンロードリストエクスポートをサポート
|
||||
- 「新規ダウンロード」フォームのインタラクションロジックを最適化
|
||||
- UrlSchemeでアプリを開き、ダウンロードタスクを追加する機能をサポート
|
||||
- バグの修正とユーザー体験の向上
|
||||
- [Windows — インストーラー版](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [Windows — ポータブル版](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [macOS — Apple Silicon (arm64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [macOS — Intel (x64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [Linux (deb)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago):`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**:`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
## ソフトウェアスクリーンショット
|
||||
過去のバージョンは [GitHub Releases ページ](https://github.com/caorushizi/mediago/releases) をご覧ください。
|
||||
|
||||

|
||||
### 🪄 宝塔パネルでワンクリック Docker デプロイ
|
||||
|
||||

|
||||
1. [宝塔パネル公式サイト](https://www.bt.cn/new/download.html?r=dk_mediago) から正式版のスクリプトをダウンロードしてインストール
|
||||
2. 宝塔パネルにログイン、メニューから **Docker** をクリック。初回アクセス時に Docker サービスのインストールを求められるので、「今すぐインストール」をクリックして完了
|
||||
3. アプリストアで **MediaGo** を見つけて、インストールをクリック、ドメインなどの基本情報を設定すれば完了
|
||||
|
||||

|
||||
## 📝 v3.5.0 の新機能
|
||||
|
||||

|
||||
- **🌐 ブラウザ拡張機能** — 任意のサイトで動画をスニッフィング、ワンクリックで MediaGo に送信
|
||||
- **🎬 YouTube + 1000+ サイト** — yt-dlp による対応
|
||||
- **🦞 OpenClaw Skill** — AI コーディングアシスタント経由でダウンロード
|
||||
- **🔌 HTTP API** — スクリプト、自動化、サードパーティツールとの統合
|
||||
- **🎞️ アプリ内フォーマット変換** — 出力形式と画質を選択
|
||||
- **🐳 Docker デプロイの簡素化** — 単一ディレクトリをマウント、GHCR のマルチアーキテクチャイメージ
|
||||
- **⚡ 起動の高速化** — バックエンド書き換え、メモリ使用量の削減、内蔵動画プレーヤー
|
||||
|
||||
## 技術スタック
|
||||
## 🛠️ 技術スタック
|
||||
|
||||
- react <https://react.dev/>
|
||||
- electron <https://www.electronjs.org>
|
||||
- koa <https://koajs.com>
|
||||
- vite <https://cn.vitejs.dev>
|
||||
- antd <https://ant.design>
|
||||
- tailwindcss <https://tailwindcss.com>
|
||||
- shadcn <https://ui.shadcn.com/>
|
||||
- inversify <https://inversify.io>
|
||||
- typeorm <https://typeorm.io>
|
||||
[](https://react.dev/)
|
||||
[](https://www.electronjs.org)
|
||||
[](https://vitejs.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com)
|
||||
[](https://ui.shadcn.com/)
|
||||
[](https://go.dev/)
|
||||
[](https://ant.design)
|
||||
|
||||
## 感謝
|
||||
## 🙏 謝辞
|
||||
|
||||
- N_m2u8DL-CLI は <https://github.com/nilaoda/N_m3u8DL-CLI> から来ています
|
||||
- N_m3u8DL-RE は <https://github.com/nilaoda/N_m3u8DL-RE> から来ています
|
||||
- mediago は <https://github.com/caorushizi/hls-downloader> から来ています
|
||||
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE)
|
||||
- [BBDown](https://github.com/nilaoda/BBDown)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [aria2](https://aria2.github.io/)
|
||||
- [mediago-core](https://github.com/caorushizi/mediago-core)
|
||||
|
||||
## ⚖️ 免責事項
|
||||
|
||||
> **本プロジェクトは学習および研究目的にのみ提供されるものであり、商用または違法な目的での使用はご遠慮ください。**
|
||||
>
|
||||
> 1. 本プロジェクトが提供するすべてのコードおよび機能は、ストリーミング技術の学習のための参考資料としてのみ使用されます。利用者は所在地域の法令を遵守してください。
|
||||
> 2. 本プロジェクトを使用してダウンロードされたコンテンツの著作権は、原コンテンツの所有者に帰属します。利用者はダウンロード後 24 時間以内にコンテンツを削除するか、著作権者の許可を取得する必要があります。
|
||||
> 3. 本プロジェクトの開発者は、著作権で保護されたコンテンツのダウンロードや第三者プラットフォームへの影響を含め、利用者の行動に対して一切の責任を負いません。
|
||||
> 4. 大規模なスクレイピング、プラットフォームサービスの妨害、その他他者の合法的権利を侵害する行為に本プロジェクトを使用することは禁止されています。
|
||||
> 5. 本プロジェクトを使用することにより、あなたはこの免責事項を読み、同意したものとみなされます。同意しない場合は、直ちに本プロジェクトの使用を停止し、削除してください。
|
||||
|
||||
---
|
||||
|
||||
> ソースからビルドする場合は [CONTRIBUTING.md](./CONTRIBUTING.md)(英語)を参照してください。
|
||||
>
|
||||
> MediaGo の翻訳をご検討中の方は [TRANSLATION.md](./TRANSLATION.md)(英語)をご参照ください。
|
||||
|
||||
@@ -7,22 +7,19 @@
|
||||
<a href="https://downloader.caorushizi.cn/en/documents.html?form=github">Docs</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://www.reddit.com/r/MediaGo_Studio/">Reddit</a>
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.zh.md">中文</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.jp.md">日本語</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.it.md">Italiano</a>
|
||||
<br>
|
||||
|
||||
<!-- MediaGo Pro -->
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/✨_New_Release-MediaGo_Pro-ff6b6b?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyTDMgN2wzIDMgNi00IDYgNCAzLTMtOS01eiIvPjxwYXRoIGQ9Ik0zIDE3bDkgNSA5LTUtMy0zLTYgNC02LTQtMyAzeiIvPjwvc3ZnPg==" alt="MediaGo Pro" />
|
||||
</a>
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/🚀_Try_Now-Online_Version_No_Install-2a82f6?style=for-the-badge" alt="Try Now" />
|
||||
</a>
|
||||
<br>
|
||||
|
||||
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/mediago/total">
|
||||
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/mediago/latest/total">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/mediago">
|
||||
@@ -37,120 +34,126 @@
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## What is MediaGo?
|
||||
A cross-platform video downloader with built-in sniffing — point it at a
|
||||
page, pick what you want, and save. No packet capture, no browser
|
||||
extensions to configure, no fiddling with command-line tools.
|
||||
|
||||
A cross-platform streaming media downloader with built-in browser sniffing — grab m3u8, HLS, and more with zero packet-capture hassle.
|
||||
The app UI currently ships with English, Simplified Chinese, and Italian.
|
||||
|
||||
- **✅ No packet capture needed** — The built-in browser automatically detects video resources on any page. Just pick what you want from the detected list and download.
|
||||
- **📱 Watch on mobile** — Seamlessly switch between PC and mobile. Once a video is downloaded, scan a QR code to watch it on your phone.
|
||||
- **⚡️ Batch downloads** — Download multiple videos and live streams at the same time — no wasted bandwidth.
|
||||
- **🎉 Docker support** — Deploy the web UI via Docker for quick, headless operation.
|
||||
- **🦞 OpenClaw Skill** — Download videos with natural language through AI coding assistants (OpenClaw, Claude Code, etc.). Install with `npx clawhub@latest install mediago`.
|
||||
## ✨ What's inside
|
||||
|
||||
## Quick Start
|
||||
### 🌐 Browser extension for Chrome / Edge
|
||||
|
||||
You need **Node.js** and **pnpm**. Install Node.js from the [official site](https://nodejs.org/), then install pnpm:
|
||||
See something you want on any site → click the extension → send it to
|
||||
MediaGo. Detects video resources automatically, shows the count on the
|
||||
toolbar badge, works with most mainstream video platforms including
|
||||
YouTube, Bilibili and more. Ships bundled with the Desktop app — open
|
||||
**Settings → More Settings → Browser extension directory** to find the
|
||||
install folder.
|
||||
|
||||
### 🎬 YouTube and 1000+ sites
|
||||
|
||||
Powered by yt-dlp under the hood. Supports YouTube, Twitter/X, Instagram,
|
||||
Reddit and [over a thousand more video sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
### 🦞 AI assistants can download for you — OpenClaw Skill
|
||||
|
||||
Using Claude Code, Cursor or another AI coding assistant? Install the
|
||||
MediaGo skill and just say _"please download this video: <url>"_.
|
||||
The AI handles the rest.
|
||||
|
||||
```shell
|
||||
npm i -g pnpm
|
||||
npx clawhub@latest install mediago
|
||||
```
|
||||
|
||||
## Running locally
|
||||
### 🔌 Works with other tools
|
||||
|
||||
MediaGo exposes a full HTTP API — scripts, automation tools and other
|
||||
apps can create download tasks, query progress and manage the list
|
||||
directly. The browser extension uses this same API to talk to the desktop
|
||||
app; anyone else can tap in too.
|
||||
|
||||
### 🎞️ Built-in format conversion
|
||||
|
||||
After a download finishes, convert it to another format or quality
|
||||
without leaving MediaGo. No more opening a separate tool for ffmpeg.
|
||||
|
||||
### 🐳 One-line Docker deployment
|
||||
|
||||
Headless install on your server, then access the web UI from anywhere on
|
||||
the same network:
|
||||
|
||||
```shell
|
||||
# Clone the repo
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the Electron desktop app (dev mode)
|
||||
pnpm dev:electron
|
||||
|
||||
# — or — start the web server (dev mode)
|
||||
pnpm dev:server
|
||||
|
||||
# Package the Electron app for distribution
|
||||
pnpm pack:electron
|
||||
|
||||
# Package the web server for distribution
|
||||
pnpm pack:server
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
## Releases
|
||||
Available on [Docker Hub](https://hub.docker.com/r/caorushizi/mediago) and GHCR (`ghcr.io/caorushizi/mediago`) — same image, pick whichever registry is faster for you. Supports both Intel / AMD (amd64) and ARM (arm64). On the desktop build,
|
||||
MediaGo listens on both `127.0.0.1` and your LAN IP out of the box, so
|
||||
phones and tablets on the same Wi-Fi can open the web UI too.
|
||||
|
||||
### v3.5.0-beta.0 (Apr 3, 2026)
|
||||
## 📷 Screenshots
|
||||
|
||||
#### Downloads
|
||||

|
||||
|
||||
- [Windows (installer) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-win32-x64-3.5.0-beta.0.exe)
|
||||
- [Windows (portable) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-portable-win32-x64-3.5.0-beta.0.exe)
|
||||
- [macOS ARM64 (Apple Silicon) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-darwin-arm64-3.5.0-beta.0.dmg)
|
||||
- [macOS x64 (Intel) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-darwin-x64-3.5.0-beta.0.dmg)
|
||||
- [Linux v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-linux-amd64-3.5.0-beta.0.deb)
|
||||
- Docker v3.5.0-beta.0: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0-beta.0`
|
||||

|
||||
|
||||
### v3.0.0 (Oct 7, 2024)
|
||||

|
||||
|
||||
#### Downloads
|
||||

|
||||
|
||||
- [Windows (installer) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [Windows (portable) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [macOS ARM64 (Apple Silicon) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
|
||||
- [macOS x64 (Intel) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-x64-3.0.0.dmg)
|
||||
- [Linux v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-linux-amd64-3.0.0.deb)
|
||||
- Docker: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:latest`
|
||||
## 📥 Download
|
||||
|
||||
### One-click Docker deployment via BT Panel
|
||||
### v3.5.0 (stable)
|
||||
|
||||
- [Windows — installer](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [Windows — portable](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [macOS — Apple Silicon (arm64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [macOS — Intel (x64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [Linux (deb)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago): `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
Browsing older releases? See the [GitHub Releases page](https://github.com/caorushizi/mediago/releases).
|
||||
|
||||
### 🪄 One-click Docker deployment via BT Panel
|
||||
|
||||
1. Install [BT Panel](https://www.bt.cn/new/download.html?r=dk_mediago) using the official script.
|
||||
2. Log in to the panel, click **Docker** in the sidebar, and follow the prompts to install the Docker service.
|
||||
3. Find **MediaGo** in the app store, click **Install**, configure your domain, and you're done.
|
||||
2. Log in to the panel, click **Docker** in the sidebar and finish the
|
||||
Docker service setup (just follow the prompts).
|
||||
3. Find **MediaGo** in the app store, click **Install**, configure your
|
||||
domain, and you're done.
|
||||
|
||||
## Screenshots
|
||||
## 📝 What's new in v3.5.0
|
||||
|
||||

|
||||
- **🌐 Browser extension** — sniff videos on any site, send to MediaGo
|
||||
in one click
|
||||
- **🎬 YouTube + 1000+ sites** — powered by yt-dlp
|
||||
- **🦞 OpenClaw Skill** — download videos via AI coding assistants
|
||||
- **🔌 HTTP API** — integrate with scripts, automation and third-party tools
|
||||
- **🎞️ In-app format conversion** — choose output format and quality
|
||||
- **🐳 Simpler Docker deployment** — mount a single folder, multi-arch images on GHCR
|
||||
- **⚡ Faster startup** — backend rewrite, lower memory footprint, built-in video player
|
||||
|
||||

|
||||
## 🛠️ Built with
|
||||
|
||||

|
||||
[](https://react.dev/)
|
||||
[](https://www.electronjs.org)
|
||||
[](https://vitejs.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com)
|
||||
[](https://ui.shadcn.com/)
|
||||
[](https://go.dev/)
|
||||
[](https://ant.design)
|
||||
|
||||

|
||||
|
||||
## Changelog (v3.0.0)
|
||||
|
||||
- Docker deployment for the web UI
|
||||
- Redesigned desktop UI
|
||||
- Video playback on desktop and mobile
|
||||
- Fixed blank window on macOS launch
|
||||
- Improved batch download UX
|
||||
- Added Windows portable build (no install required)
|
||||
- Enhanced resource sniffing — detect multiple videos per page
|
||||
- Import / export favorites
|
||||
- Export the download list from the home page
|
||||
- Improved "New download" form flow
|
||||
- Open the app and add downloads via URL scheme
|
||||
- Various bug fixes and UX improvements
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [React](https://react.dev/)
|
||||
- [Electron](https://www.electronjs.org)
|
||||
- [Koa](https://koajs.com)
|
||||
- [Vite](https://vitejs.dev)
|
||||
- [Ant Design](https://ant.design)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [Inversify](https://inversify.io)
|
||||
|
||||
## Acknowledgements
|
||||
## 🙏 Acknowledgements
|
||||
|
||||
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE)
|
||||
- [BBDown](https://github.com/nilaoda/BBDown)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [aria2](https://aria2.github.io/)
|
||||
- [mediago-core](https://github.com/caorushizi/mediago-core)
|
||||
|
||||
## Disclaimer
|
||||
## ⚖️ Disclaimer
|
||||
|
||||
> **This project is for educational and research purposes only. Do not use it for any commercial or illegal purposes.**
|
||||
>
|
||||
@@ -159,3 +162,9 @@ pnpm pack:server
|
||||
> 3. The developers of this project are not responsible for any actions taken by users, including but not limited to downloading copyrighted content or impacting third-party platforms.
|
||||
> 4. Using this project for mass scraping, disrupting platform services, or any activity that infringes upon the legitimate rights of others is strictly prohibited.
|
||||
> 5. By using this project you acknowledge that you have read and agree to this disclaimer. If you do not agree, stop using the project and delete it immediately.
|
||||
|
||||
---
|
||||
|
||||
> Building from source? See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
>
|
||||
> Translating MediaGo? See [TRANSLATION.md](./TRANSLATION.md).
|
||||
|
||||
+87
-106
@@ -7,22 +7,19 @@
|
||||
<a href="https://downloader.caorushizi.cn/documents.html?form=github">文档</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://www.reddit.com/r/MediaGo_Studio/">Reddit</a>
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.md">English</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.jp.md">日本語</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/caorushizi/mediago/blob/master/README.it.md">Italiano</a>
|
||||
<br>
|
||||
|
||||
<!-- MediaGo Pro 推广 -->
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/✨_全新发布-MediaGo_Pro-ff6b6b?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyTDMgN2wzIDMgNi00IDYgNCAzLTMtOS01eiIvPjxwYXRoIGQ9Ik0zIDE3bDkgNSA5LTUtMy0zLTYgNC02LTQtMyAzeiIvPjwvc3ZnPg==" alt="MediaGo Pro" />
|
||||
</a>
|
||||
<a href="https://mediago.torchstellar.com/?from=github">
|
||||
<img src="https://img.shields.io/badge/🚀_立即体验-在线版本_无需安装-2a82f6?style=for-the-badge" alt="Try Now" />
|
||||
</a>
|
||||
<br>
|
||||
|
||||
<img alt="GitHub Downloads (all assets, all releases)" src="https://img.shields.io/github/downloads/caorushizi/mediago/total">
|
||||
<img alt="GitHub Downloads (all assets, latest release)" src="https://img.shields.io/github/downloads/caorushizi/mediago/latest/total">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/caorushizi/mediago">
|
||||
@@ -37,131 +34,106 @@
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## Intro
|
||||
跨平台视频下载器,内置嗅探 —— 打开网页、选一下想要的资源、保存完事。不用抓包、不用折腾浏览器插件、不用面对命令行。
|
||||
|
||||
本项目支持 m3u8 视频在线提取工具 流媒体下载 m3u8 下载。
|
||||
应用界面目前内置中文、英文和意大利语。
|
||||
|
||||
- **✅ 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
|
||||
- **📱 移动播放**: 可以轻松无缝的在 PC 和移动设备之前切换,下载完成后即可使用手机观看视频。
|
||||
- **⚡️ 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
|
||||
- **🎉 支持 docker 部署**: 支持 docker 部署 web 端,方便快捷。
|
||||
- **🦞 OpenClaw Skill**: 支持通过 AI 编程助手(Openclaw、Claude Code 等)用自然语言下载视频,`npx clawhub@latest install mediago` 一键安装。
|
||||
## ✨ 主打功能
|
||||
|
||||
## Quickstart
|
||||
### 🌐 浏览器扩展(Chrome / Edge)
|
||||
|
||||
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g pnpm`安装。
|
||||
浏览网页时遇到想下的视频 → 点扩展图标 → 一键发到 MediaGo。自动识别页面里的可下载资源,工具栏图标显示检测到的数量,主流视频网站(包括 YouTube、Bilibili 等)都能覆盖。扩展随桌面端安装包一起打包,在 **设置 → 更多设置 → 浏览器扩展目录** 就能找到安装文件夹。
|
||||
|
||||
## 运行代码
|
||||
### 🎬 支持 YouTube 和 1000+ 站点
|
||||
|
||||
底层用的是 yt-dlp。支持 YouTube、Twitter/X、Instagram、Reddit 等 [一千多个视频站点](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)。
|
||||
|
||||
### 🦞 让 AI 助手帮你下载 —— OpenClaw Skill
|
||||
|
||||
在用 Claude Code、Cursor 等 AI 编程助手?装上 MediaGo Skill 后直接跟 AI 说"帮我下载这个视频:<链接>"就行,剩下的交给 AI。
|
||||
|
||||
```shell
|
||||
# 代码下载
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 首次安装需要 rebuild 一下
|
||||
pnpm rebuild:workspace
|
||||
|
||||
# electron 开发环境
|
||||
pnpm dev:electron
|
||||
|
||||
# electron 打包运行
|
||||
pnpm release:electron
|
||||
|
||||
# server 开发环境
|
||||
pnpm dev:server
|
||||
|
||||
# server 打包运行
|
||||
pnpm release:server
|
||||
|
||||
npx clawhub@latest install mediago
|
||||
```
|
||||
|
||||
## Releases
|
||||
### 🔌 可以和其他工具联动
|
||||
|
||||
### v3.5.0-beta.0 (2026.4.3 发布)
|
||||
MediaGo 提供一整套 HTTP 接口 —— 脚本、自动化工具、其他 App 都能直接调用 MediaGo 创建下载任务、查询进度、管理列表。浏览器扩展就是通过这套接口和桌面端对话的,你也可以接入自己的工作流。
|
||||
|
||||
### 软件下载
|
||||
### 🎞️ 内置格式转换
|
||||
|
||||
- [【mediago】 windows(安装版) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-win32-x64-3.5.0-beta.0.exe)
|
||||
- [【mediago】 windows(便携版) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-portable-win32-x64-3.5.0-beta.0.exe)
|
||||
- [【mediago】 macos arm64(apple 芯片) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-darwin-arm64-3.5.0-beta.0.dmg)
|
||||
- [【mediago】 macos x64(intel 芯片) v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-darwin-x64-3.5.0-beta.0.dmg)
|
||||
- [【mediago】 linux v3.5.0-beta.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0-beta.0/mediago-community-setup-linux-amd64-3.5.0-beta.0.deb)
|
||||
- 【mediago】 docker v3.0 `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0-beta.0`
|
||||
下载完成后可以直接在 MediaGo 里转换格式、选画质,不用再打开别的软件。
|
||||
|
||||
### v3.0.0 (2024.10.7 发布)
|
||||
### 🐳 Docker 一键部署
|
||||
|
||||
#### 软件下载
|
||||
服务器端一条命令部署,局域网内任意设备都能打开 Web 界面:
|
||||
|
||||
- [【mediago】 windows(安装版) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 windows(便携版) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 macos arm64(apple 芯片) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
|
||||
- [【mediago】 macos x64(intel 芯片) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-x64-3.0.0.dmg)
|
||||
- [【mediago】 linux v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-linux-amd64-3.0.0.deb)
|
||||
- 【mediago】 docker `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:latest`
|
||||
```shell
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
### docker 宝塔面板一键部署(推荐)
|
||||
在 [Docker Hub](https://hub.docker.com/r/caorushizi/mediago) 和 GHCR(`ghcr.io/caorushizi/mediago`)上同步发布 —— 同一份镜像,哪个源更快用哪个。支持 Intel / AMD (amd64) 和 ARM (arm64) 两种架构。桌面版同时监听 `127.0.0.1` 和局域网 IP,同一个 Wi-Fi 下的手机、平板可以直接打开 Web 界面。
|
||||
|
||||
1. 安装宝塔面板,前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_mediago) 官网,选择正式版的脚本下载安装
|
||||
|
||||
2. 安装后登录宝塔面板,在菜单栏中点击 `Docker`,首次进入会提示安装`Docker`服务,点击立即安装,按提示完成安装
|
||||
|
||||
3. 安装完成后在应用商店中找到`MediaGo`,点击安装,配置域名等基本信息即可完成安装
|
||||
|
||||
### 软件截图
|
||||
## 📷 软件截图
|
||||
|
||||

|
||||
|
||||
### 重要更新
|
||||

|
||||
|
||||
- 支持 docker 部署 web 端
|
||||
- 更新桌面端 UI
|
||||
|
||||
### 更新日志
|
||||
|
||||
- 更新桌面端 UI
|
||||
- 支持 docker 部署 web 端
|
||||
- 新增视频播放,支持桌面端和移动端播放
|
||||
- 修复 mac 打开无法展示界面的问题
|
||||
- 优化了批量下载的交互
|
||||
- 添加了 windows 的便携版(免安装哦)
|
||||
- 优化了下载列表,支持页面中多个视频的嗅探
|
||||
- 支持收藏列表手动导入导出
|
||||
- 支持首页下载列表导出
|
||||
- 优化了【新建下载】表单的交互逻辑
|
||||
- 支持 UrlScheme 打开应用,并添加下载任务
|
||||
- 修复了一些 bug 并提升用户体验
|
||||
|
||||
## 软件截图
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## 技术栈
|
||||
## 📥 下载
|
||||
|
||||
- react <https://react.dev/>
|
||||
- electron <https://www.electronjs.org>
|
||||
- koa <https://koajs.com>
|
||||
- vite <https://cn.vitejs.dev>
|
||||
- antd <https://ant.design>
|
||||
- tailwindcss <https://tailwindcss.com>
|
||||
- shadcn <https://ui.shadcn.com/>
|
||||
- inversify <https://inversify.io>
|
||||
- typeorm <https://typeorm.io>
|
||||
### v3.5.0(正式版)
|
||||
|
||||
## 鸣谢
|
||||
- [Windows — 安装版](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [Windows — 便携版](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [macOS — Apple Silicon (arm64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [macOS — Intel (x64)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [Linux (deb)](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago):`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**:`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
- N_m3u8DL-RE 来自于 <https://github.com/nilaoda/N_m3u8DL-RE>
|
||||
- BBDown 来自于 <https://github.com/nilaoda/BBDown>
|
||||
- mediago 来自于 <https://github.com/caorushizi/mediago-core>
|
||||
查看历史版本请移步 [GitHub Releases](https://github.com/caorushizi/mediago/releases)。
|
||||
|
||||
## 免责声明
|
||||
### 🪄 宝塔面板一键部署 Docker
|
||||
|
||||
1. 安装宝塔面板,前往 [宝塔面板官网](https://www.bt.cn/new/download.html?r=dk_mediago) 选择正式版的脚本下载安装
|
||||
2. 登录宝塔面板,在菜单栏中点击 **Docker**,首次进入会提示安装 Docker 服务,点击立即安装并按提示完成
|
||||
3. 在应用商店中找到 **MediaGo**,点击安装,配置域名等基本信息即可
|
||||
|
||||
## 📝 v3.5.0 更新要点
|
||||
|
||||
- **🌐 浏览器扩展**:任意网站一键嗅探视频、一键发到 MediaGo
|
||||
- **🎬 YouTube + 1000+ 站点**:集成 yt-dlp
|
||||
- **🦞 OpenClaw Skill**:通过 AI 编程助手下载视频
|
||||
- **🔌 开放 HTTP 接口**:接入脚本、自动化工具和其他应用
|
||||
- **🎞️ 内置格式转换**:选输出格式和画质
|
||||
- **🐳 Docker 部署简化**:挂载一个目录即可,多架构镜像已迁至 GHCR
|
||||
- **⚡ 启动更快**:后端重写,资源占用更低,内置视频播放器
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.electronjs.org)
|
||||
[](https://vitejs.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com)
|
||||
[](https://ui.shadcn.com/)
|
||||
[](https://go.dev/)
|
||||
[](https://ant.design)
|
||||
|
||||
## 🙏 鸣谢
|
||||
|
||||
- [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE)
|
||||
- [BBDown](https://github.com/nilaoda/BBDown)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [aria2](https://aria2.github.io/)
|
||||
- [mediago-core](https://github.com/caorushizi/mediago-core)
|
||||
|
||||
## ⚖️ 免责声明
|
||||
|
||||
> **本项目仅供学习和研究使用,请勿用于任何商业或非法用途。**
|
||||
>
|
||||
@@ -170,3 +142,12 @@ pnpm release:server
|
||||
> 3. 本项目开发者不对使用者的任何行为承担责任,包括但不限于:下载受版权保护的内容、对第三方平台造成的影响等。
|
||||
> 4. 禁止将本项目用于大规模抓取、破坏平台服务或任何侵犯他人合法权益的行为。
|
||||
> 5. 使用本项目即表示您已阅读并同意本免责声明。如不同意,请立即停止使用并删除本项目。
|
||||
|
||||
---
|
||||
|
||||
> 想从源码构建?见 [CONTRIBUTING.md](./CONTRIBUTING.md)(英文)。
|
||||
>
|
||||
> 想为 MediaGo 做翻译?见 [TRANSLATION.md](./TRANSLATION.md)(英文)。
|
||||
|
||||
尾注: 感谢[吾爱破解论坛](https://www.52pojie.cn/)
|
||||
lp_Zain@www.52pojie.cn
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
# Contributing translations
|
||||
|
||||
Thanks for helping translate MediaGo! This guide walks you through adding a
|
||||
new language from scratch, with a live-preview workflow so you can iterate
|
||||
without rebuilding the app.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```shell
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
cd mediago
|
||||
pnpm install
|
||||
pnpm deps:download
|
||||
pnpm dev:electron
|
||||
```
|
||||
|
||||
1. Copy `packages/shared/common/src/i18n/resources/en.ts` to `<lang>.ts`,
|
||||
translate every value.
|
||||
2. Register the new locale in the shared resources, resolver, UI dropdown,
|
||||
and browser extension (see below).
|
||||
3. Open **Settings → Language**, pick your locale, and iterate. Vite HMR
|
||||
reflects edits in the running app within a second.
|
||||
4. Open a PR — we review and merge.
|
||||
|
||||
## Where strings live
|
||||
|
||||
- **Main app UI** (desktop + self-hosted web):
|
||||
`packages/shared/common/src/i18n/resources/{en,it,zh}.ts`
|
||||
- **Browser extension** (separate, smaller catalog):
|
||||
`packages/mediago-extension/src/i18n/resources/{en,it,zh}.ts`
|
||||
|
||||
Resources are plain TypeScript modules — each file exports a flat object of
|
||||
`key: "translation"` pairs. Keys are shared across languages; only values
|
||||
change.
|
||||
|
||||
## Adding a new language end-to-end
|
||||
|
||||
Example: French (`fr`). Adjust the code to whichever language you're adding.
|
||||
|
||||
### 1. Create the resource file
|
||||
|
||||
Copy `en.ts` to `fr.ts` in the same directory and translate every **value**.
|
||||
Leave all keys untouched:
|
||||
|
||||
```ts
|
||||
// packages/shared/common/src/i18n/resources/fr.ts
|
||||
export const fr = {
|
||||
// ...translated values...
|
||||
followSystem: "Système",
|
||||
chinese: "中文",
|
||||
english: "English",
|
||||
french: "Français", // add your language's own name
|
||||
displayLanguage: "Langue",
|
||||
// ...
|
||||
} as const;
|
||||
```
|
||||
|
||||
Don't forget to add the new `french: "Français"` key to **every** resource
|
||||
file (`en.ts`, `it.ts`, `zh.ts`, and your new `fr.ts`) so the Settings
|
||||
dropdown can render it in each language.
|
||||
|
||||
### 2. Register the resource
|
||||
|
||||
Core app registration:
|
||||
|
||||
**`packages/shared/common/src/i18n/resources/index.ts`** — import the new
|
||||
locale and add it to both exports:
|
||||
|
||||
```ts
|
||||
import { fr } from "./fr";
|
||||
// ...
|
||||
export const i18nResources = { en, it, zh, fr } as const;
|
||||
export const SUPPORTED_LANGUAGES = ["en", "it", "zh", "fr"] as const;
|
||||
export { en, it, zh, fr };
|
||||
```
|
||||
|
||||
**`packages/shared/common/src/i18n/config.ts`** — widen the
|
||||
`resolveAppLanguage` return type and the check inside:
|
||||
|
||||
```ts
|
||||
export type ResolvedAppLanguage = "zh" | "en" | "it" | "fr";
|
||||
|
||||
export function resolveAppLanguage(
|
||||
language: string | undefined,
|
||||
systemLocale: string | undefined,
|
||||
): ResolvedAppLanguage {
|
||||
if (
|
||||
language === "zh" ||
|
||||
language === "en" ||
|
||||
language === "it" ||
|
||||
language === "fr"
|
||||
) {
|
||||
return language;
|
||||
}
|
||||
// ...existing fallback...
|
||||
}
|
||||
```
|
||||
|
||||
If the new locale should follow the OS/browser locale automatically, add the
|
||||
matching `systemLocale` prefix check in the same function.
|
||||
|
||||
**`packages/shared/common/src/types/index.ts`** — extend the `AppLanguage`
|
||||
enum:
|
||||
|
||||
```ts
|
||||
export enum AppLanguage {
|
||||
System = "system",
|
||||
ZH = "zh",
|
||||
EN = "en",
|
||||
FR = "fr",
|
||||
}
|
||||
```
|
||||
|
||||
**`apps/ui/src/App.tsx`** — if Ant Design ships a locale for your language,
|
||||
import it and include it in `getAntdLocale`. Otherwise, explicitly fall back
|
||||
to `enUS` for Ant Design components while your app strings still use your
|
||||
translated resource file.
|
||||
|
||||
### 3. Add the Settings dropdown option
|
||||
|
||||
**`apps/ui/src/pages/setting-page/index.tsx`** — inside the **Language**
|
||||
`<Select>`, add one line:
|
||||
|
||||
```tsx
|
||||
options={[
|
||||
{ label: t("followSystem"), value: AppLanguage.System },
|
||||
{ label: t("chinese"), value: AppLanguage.ZH },
|
||||
{ label: t("english"), value: AppLanguage.EN },
|
||||
{ label: t("french"), value: AppLanguage.FR }, // new
|
||||
]}
|
||||
```
|
||||
|
||||
### 4. (Optional) Translate the browser extension
|
||||
|
||||
Same pattern, under `packages/mediago-extension/src/i18n/resources/`. It's a
|
||||
much smaller catalog and lives in its own `index.ts`. Also update:
|
||||
|
||||
- `packages/mediago-extension/src/i18n/index.ts` for language resolution.
|
||||
- `packages/mediago-extension/src/shared/types.ts` for the persisted language
|
||||
union.
|
||||
- `packages/mediago-extension/src/options/components/LanguageCard.tsx` for
|
||||
the options-page selector.
|
||||
- `packages/mediago-extension/public/_locales/<lang>/messages.json` for the
|
||||
Chrome extension name, description, and action tooltip.
|
||||
|
||||
## Live preview workflow
|
||||
|
||||
The dev server uses **Vite HMR** — edits to any resource file are reflected
|
||||
in the running app almost instantly, no restart required.
|
||||
|
||||
```shell
|
||||
pnpm install
|
||||
pnpm deps:download # fetch ffmpeg / BBDown (first clone only)
|
||||
pnpm dev:electron # starts Electron with HMR
|
||||
```
|
||||
|
||||
Once the window is up, open **Settings → Language** and switch to your new
|
||||
locale. Then edit `fr.ts` in your editor — save, and the UI updates live.
|
||||
|
||||
Use this to catch overflowing strings, awkward wrapping, and untranslated
|
||||
values before opening the PR.
|
||||
|
||||
## Submitting the PR
|
||||
|
||||
- **Branch**: `i18n/add-<lang>` — e.g. `i18n/add-fr`.
|
||||
- **Commit**: follow [Conventional Commits](https://www.conventionalcommits.org/),
|
||||
e.g. `feat(i18n): add French translation`.
|
||||
- In the PR description, please include:
|
||||
- A screenshot of **Settings → Language** with your new locale selected.
|
||||
- A screenshot of at least one main screen (e.g. the download list) in
|
||||
the new language.
|
||||
- Confirmation that `pnpm check` passes locally.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Keep placeholders intact.** Tokens like `{{count}}` or `{name}` are
|
||||
interpolated at runtime — copy them verbatim into your translation.
|
||||
- **Natural phrasing beats literal translation.** The English source is a
|
||||
guide, not a cage. Idiomatic phrasing in your language is always better.
|
||||
- **Unsure how a string is used?** Grep the key across the repo (e.g.
|
||||
`rg '"displayLanguage"'`) — you'll find the component that renders it,
|
||||
which gives you the UI context.
|
||||
|
||||
Questions? Comment on [issue #638](https://github.com/caorushizi/mediago/issues/638)
|
||||
or open a new discussion. Thanks for contributing! 🌍
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
# 只有直接运行 Go 程序时才需要手动设置这些路径
|
||||
# MEDIAGO_M3U8_BIN=.bin/win32/x64/N_m3u8DL-RE.exe
|
||||
# MEDIAGO_BILIBILI_BIN=.bin/win32/x64/BBDown.exe
|
||||
# MEDIAGO_DIRECT_BIN=.bin/win32/x64/gopeed.exe
|
||||
# MEDIAGO_DIRECT_BIN=.bin/win32/x64/aria2c.exe
|
||||
|
||||
# 日志配置
|
||||
# 日志级别: debug, info, warn, error (默认: info)
|
||||
|
||||
+3
-3
@@ -108,7 +108,7 @@ swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
||||
|
||||
1. **M3U8** - HLS 流媒体下载
|
||||
2. **Bilibili** - B站视频下载
|
||||
3. **Direct** - 直接文件下载(gopeed)
|
||||
3. **Direct** - 直接文件下载(aria2c)
|
||||
|
||||
---
|
||||
|
||||
@@ -270,7 +270,7 @@ npx @mediago/core
|
||||
|
||||
- `./bin/N_m3u8DL-RE` (或 `.exe`)
|
||||
- `./bin/BBDown` (或 `.exe`)
|
||||
- `./bin/gopeed` (或 `.exe`)
|
||||
- `./bin/aria2c` (或 `.exe`)
|
||||
|
||||
用户无需配置任何环境变量即可运行!
|
||||
|
||||
@@ -389,7 +389,7 @@ A: 编辑 `scripts/` 目录下的 TypeScript 文件。所有任务都是模块
|
||||
A:
|
||||
|
||||
- **Core 包**:包含 mediago-core 的二进制文件和配置
|
||||
- **Deps 包**:包含依赖的下载工具(N_m3u8DL-RE、BBDown、gopeed)
|
||||
- **Deps 包**:包含依赖的下载工具(N_m3u8DL-RE、BBDown、aria2c、yt-dlp)
|
||||
- 分开发布可以单独更新,减少不必要的下载
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"caorushizi.cn/mediago/internal/app"
|
||||
"caorushizi.cn/mediago/internal/core"
|
||||
"caorushizi.cn/mediago/internal/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := newRootCommand().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCommand() *cobra.Command {
|
||||
cfg := app.DefaultConfig()
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "mediago-cli",
|
||||
Short: "MediaGo command-line interface",
|
||||
Long: "MediaGo CLI directly uses the core downloader runtime without going through the HTTP API server.",
|
||||
}
|
||||
addConfigFlags(rootCmd.PersistentFlags(), cfg)
|
||||
rootCmd.AddCommand(newDownloadCommand(cfg))
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func newDownloadCommand(cfg *app.AppConfig) *cobra.Command {
|
||||
var id string
|
||||
var typ string
|
||||
var name string
|
||||
var folder string
|
||||
var headers []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "download <url>",
|
||||
Short: "Download a URL directly with MediaGo core",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg.ApplyEnvAndDefaults()
|
||||
if err := app.InitLogger(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
if id == "" {
|
||||
id = uuid.New().String()
|
||||
}
|
||||
if name == "" {
|
||||
name = "download-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
rt, err := app.NewRuntime(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rt.Close()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
params := core.DownloadParams{
|
||||
ID: core.TaskID(id),
|
||||
Type: core.DownloadType(typ),
|
||||
URL: args[0],
|
||||
Name: core.SanitizeFilename(name),
|
||||
Folder: folder,
|
||||
Headers: headers,
|
||||
}
|
||||
|
||||
lastPercent := -1
|
||||
fmt.Printf("Starting %s download: %s\n", params.Type, params.Name)
|
||||
err = rt.Downloader.Download(ctx, params, core.Callbacks{
|
||||
OnProgress: func(e core.ProgressEvent) {
|
||||
percent := int(e.Percent)
|
||||
if percent != lastPercent {
|
||||
lastPercent = percent
|
||||
if e.Speed != "" {
|
||||
fmt.Printf("\r%3d%% %s", percent, e.Speed)
|
||||
} else {
|
||||
fmt.Printf("\r%3d%%", percent)
|
||||
}
|
||||
}
|
||||
},
|
||||
OnMessage: func(e core.MessageEvent) {
|
||||
if strings.TrimSpace(e.Message) != "" {
|
||||
logger.Debugf("[%s] %s", e.ID, e.Message)
|
||||
}
|
||||
},
|
||||
})
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Download completed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&id, "id", "", "Download task id")
|
||||
cmd.Flags().StringVarP(&typ, "type", "t", string(core.TypeM3U8), "Download type (m3u8, bilibili, direct, mediago, youtube)")
|
||||
cmd.Flags().StringVarP(&name, "name", "n", "", "Output file name")
|
||||
cmd.Flags().StringVar(&folder, "folder", "", "Subdirectory under the download directory")
|
||||
cmd.Flags().StringArrayVarP(&headers, "header", "H", nil, "HTTP header, can be repeated")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addConfigFlags(flags *pflag.FlagSet, cfg *app.AppConfig) {
|
||||
flags.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)")
|
||||
flags.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory")
|
||||
flags.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries")
|
||||
flags.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json")
|
||||
flags.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory")
|
||||
flags.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download")
|
||||
flags.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader")
|
||||
flags.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy")
|
||||
flags.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners")
|
||||
flags.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file")
|
||||
flags.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file")
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
// AppStore holds all user-facing configuration options.
|
||||
// Field names and defaults match the TypeScript AppStore interface
|
||||
// in @mediago/shared-common.
|
||||
type AppStore struct {
|
||||
Local string `json:"local"`
|
||||
PromptTone bool `json:"promptTone"`
|
||||
Proxy string `json:"proxy"`
|
||||
UseProxy bool `json:"useProxy"`
|
||||
DeleteSegments bool `json:"deleteSegments"`
|
||||
OpenInNewWindow bool `json:"openInNewWindow"`
|
||||
BlockAds bool `json:"blockAds"`
|
||||
Theme string `json:"theme"`
|
||||
UseExtension bool `json:"useExtension"`
|
||||
IsMobile bool `json:"isMobile"`
|
||||
MaxRunner int `json:"maxRunner"`
|
||||
Language string `json:"language"`
|
||||
ShowTerminal bool `json:"showTerminal"`
|
||||
Privacy bool `json:"privacy"`
|
||||
MachineId string `json:"machineId"`
|
||||
DownloadProxySwitch bool `json:"downloadProxySwitch"`
|
||||
AutoUpgrade bool `json:"autoUpgrade"`
|
||||
AllowBeta bool `json:"allowBeta"`
|
||||
CloseMainWindow bool `json:"closeMainWindow"`
|
||||
AudioMuted bool `json:"audioMuted"`
|
||||
EnableDocker bool `json:"enableDocker"`
|
||||
DockerUrl string `json:"dockerUrl"`
|
||||
EnableMobilePlayer bool `json:"enableMobilePlayer"`
|
||||
ApiKey string `json:"apiKey"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
}
|
||||
|
||||
// defaultAppStore returns default config values matching the TS appStoreDefaults.
|
||||
func defaultAppStore() AppStore {
|
||||
return AppStore{
|
||||
Local: "",
|
||||
PromptTone: true,
|
||||
Proxy: "",
|
||||
UseProxy: false,
|
||||
DeleteSegments: true,
|
||||
OpenInNewWindow: false,
|
||||
BlockAds: true,
|
||||
Theme: "system",
|
||||
UseExtension: false,
|
||||
IsMobile: false,
|
||||
MaxRunner: 2,
|
||||
Language: "system",
|
||||
ShowTerminal: false,
|
||||
Privacy: false,
|
||||
MachineId: "",
|
||||
DownloadProxySwitch: false,
|
||||
AutoUpgrade: true,
|
||||
AllowBeta: false,
|
||||
CloseMainWindow: false,
|
||||
AudioMuted: true,
|
||||
EnableDocker: false,
|
||||
DockerUrl: "",
|
||||
EnableMobilePlayer: false,
|
||||
ApiKey: "",
|
||||
PasswordHash: "",
|
||||
}
|
||||
}
|
||||
+47
-352
@@ -3,21 +3,15 @@ package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"caorushizi.cn/mediago/internal/api"
|
||||
"caorushizi.cn/mediago/internal/api/handler"
|
||||
"caorushizi.cn/mediago/internal/core"
|
||||
"caorushizi.cn/mediago/internal/core/runner"
|
||||
"caorushizi.cn/mediago/internal/core/schema"
|
||||
"caorushizi.cn/mediago/internal/db"
|
||||
"caorushizi.cn/mediago/internal/app"
|
||||
"caorushizi.cn/mediago/internal/logger"
|
||||
"caorushizi.cn/mediago/internal/tasklog"
|
||||
"caorushizi.cn/mediago/pkg/conf"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -48,227 +42,64 @@ import (
|
||||
// @tag.name Events
|
||||
// @tag.description Real-time event streaming endpoints
|
||||
|
||||
// AppConfig stores all startup configuration options (passed via command-line flags or environment variables).
|
||||
type AppConfig struct {
|
||||
GinMode string `json:"gin_mode"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
LogLevel string `json:"log_level"`
|
||||
LogDir string `json:"log_dir"`
|
||||
SchemaPath string `json:"schema_path"`
|
||||
DepsDir string `json:"deps_dir"`
|
||||
MaxRunner int `json:"max_runner"`
|
||||
LocalDir string `json:"local_dir"`
|
||||
DeleteSegments bool `json:"delete_segments"`
|
||||
Proxy string `json:"proxy"`
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
DBPath string `json:"db_path"`
|
||||
ConfigDir string `json:"config_dir"`
|
||||
EnableAuth bool `json:"enable_auth"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetLocalDir() string {
|
||||
return c.LocalDir
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetDeleteSegments() bool {
|
||||
return c.DeleteSegments
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetProxy() string {
|
||||
return c.Proxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetUseProxy() bool {
|
||||
return c.UseProxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetLocalDir(dir string) {
|
||||
c.LocalDir = dir
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetDeleteSegments(del bool) {
|
||||
c.DeleteSegments = del
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetProxy(proxy string) {
|
||||
c.Proxy = proxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetUseProxy(useProxy bool) {
|
||||
c.UseProxy = useProxy
|
||||
}
|
||||
|
||||
// getSystemDownloadsDir returns the system downloads directory.
|
||||
// Prefers $HOME/Downloads; falls back to $HOME if it does not exist.
|
||||
func getSystemDownloadsDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
downloads := filepath.Join(home, "Downloads")
|
||||
if info, err := os.Stat(downloads); err == nil && info.IsDir() {
|
||||
return downloads
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 1. Initialize the logger with default config first so it is available during config parsing
|
||||
// if err := logger.Init(logger.DefaultConfig()); err != nil {
|
||||
// panic("Failed to initialize logger: " + err.Error())
|
||||
// }
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Initialize and parse configuration
|
||||
cfg := initConfig()
|
||||
func run(args []string) error {
|
||||
cfg := app.DefaultConfig()
|
||||
fs := flag.NewFlagSet("mediago-core server", flag.ContinueOnError)
|
||||
registerConfigFlags(fs, cfg)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
cfg.ApplyEnvAndDefaults()
|
||||
|
||||
// 3. Re-initialize the logger using the resolved configuration
|
||||
logCfg := logger.DefaultConfig()
|
||||
logCfg.Level = cfg.LogLevel
|
||||
logCfg.LogDir = cfg.LogDir
|
||||
|
||||
if err := logger.Init(logCfg); err != nil {
|
||||
logger.Fatalf("Failed to reinitialize logger with config: %v", err)
|
||||
if err := app.InitLogger(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
logger.Info("MediaGo Downloader Service Starting...")
|
||||
logger.Infof("Final Config: %+v", cfg)
|
||||
|
||||
// 4. Initialize AppStore configuration (user-level persistent config)
|
||||
appStore, err := conf.New(conf.Options[AppStore]{
|
||||
ConfigName: "config",
|
||||
CWD: cfg.ConfigDir,
|
||||
Defaults: defaultAppStore(),
|
||||
})
|
||||
rt, err := app.NewRuntime(cfg)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize app store: %v", err)
|
||||
return err
|
||||
}
|
||||
logger.Infof("App store initialized at: %s", appStore.Path())
|
||||
defer rt.Close()
|
||||
|
||||
// Ensure machineId is set (generate on first run)
|
||||
if appStore.Store().MachineId == "" {
|
||||
newId := uuid.New().String()
|
||||
_ = appStore.Set("machineId", newId)
|
||||
logger.Infof("Generated new machineId: %s", newId)
|
||||
}
|
||||
return runServer(rt)
|
||||
}
|
||||
|
||||
// Sync appStore values back into cfg (so cfg reflects the final state of persistent config).
|
||||
// Note: CLI arguments are no longer written into appStore to avoid overwriting settings
|
||||
// saved by the user in the UI on every startup. CLI args serve only as initial defaults;
|
||||
// appStore (user config) takes higher priority.
|
||||
syncAppStoreToCfg(appStore, cfg)
|
||||
func registerConfigFlags(fs *flag.FlagSet, cfg *app.AppConfig) {
|
||||
fs.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)")
|
||||
fs.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory")
|
||||
fs.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries")
|
||||
fs.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json")
|
||||
fs.StringVar(&cfg.Port, "port", cfg.Port, "Server port")
|
||||
fs.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory")
|
||||
fs.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download")
|
||||
fs.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader")
|
||||
fs.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy")
|
||||
fs.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners")
|
||||
fs.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file")
|
||||
fs.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file")
|
||||
fs.BoolVar(&cfg.EnableAuth, "enable-auth", cfg.EnableAuth, "Enable API key authentication")
|
||||
fs.StringVar(&cfg.StaticDir, "static-dir", cfg.StaticDir, "Directory to serve static files from (SPA mode)")
|
||||
}
|
||||
|
||||
// If the download directory is empty, is the default value, or does not exist, use the system downloads directory
|
||||
{
|
||||
needDefault := cfg.LocalDir == "" || cfg.LocalDir == "./downloads"
|
||||
if !needDefault {
|
||||
if info, err := os.Stat(cfg.LocalDir); err != nil || !info.IsDir() {
|
||||
needDefault = true
|
||||
}
|
||||
}
|
||||
if needDefault {
|
||||
sysDownloads := getSystemDownloadsDir()
|
||||
logger.Infof("Download dir %q unavailable, using system default: %s", cfg.LocalDir, sysDownloads)
|
||||
cfg.LocalDir = sysDownloads
|
||||
}
|
||||
// Ensure appStore has the download directory (so UI can read it via /api/config)
|
||||
if appStore.Store().Local == "" {
|
||||
_ = appStore.Set("local", cfg.LocalDir)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Load JSON Schema configuration
|
||||
logger.Infof("Loading schemas from: %s", cfg.SchemaPath)
|
||||
schemas, err := schema.LoadSchemasFromJSON(cfg.SchemaPath)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to load schemas: %v", err)
|
||||
}
|
||||
logger.Infof("Loaded %d download schemas", len(schemas.Schemas))
|
||||
|
||||
// 6. Configure downloader binary paths
|
||||
binMap := getBinaryMap(cfg)
|
||||
for dt, binPath := range binMap {
|
||||
logger.Infof("%s downloader: %s", dt, binPath)
|
||||
if binPath == "" {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(binPath); err != nil {
|
||||
logger.Warnf("%s binary not found: %v", dt, err)
|
||||
} else if info.Mode()&0o111 == 0 {
|
||||
logger.Warnf("%s binary is not executable: %s", dt, binPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Create core components
|
||||
r := runner.NewPTYRunner()
|
||||
downloader := core.NewDownloader(binMap, r, schemas, cfg)
|
||||
queue := core.NewTaskQueue(downloader, cfg.MaxRunner)
|
||||
taskLogs := tasklog.NewManager(filepath.Join(cfg.LogDir, "tasks"))
|
||||
|
||||
logger.Infof("Task queue initialized (maxRunner=%d)", cfg.MaxRunner)
|
||||
logger.Infof("Task logs will be stored in %s", filepath.Join(cfg.LogDir, "tasks"))
|
||||
|
||||
// 8. Watch appStore changes and sync them to the downloader
|
||||
appStore.OnDidChange("maxRunner", func(newVal, oldVal any) {
|
||||
if v, ok := toInt(newVal); ok {
|
||||
queue.SetMaxRunner(v)
|
||||
logger.Infof("maxRunner updated to %d via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("proxy", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(string); ok {
|
||||
cfg.SetProxy(v)
|
||||
logger.Infof("proxy updated to %q via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("useProxy", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(bool); ok {
|
||||
cfg.SetUseProxy(v)
|
||||
logger.Infof("useProxy updated to %v via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("deleteSegments", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(bool); ok {
|
||||
cfg.SetDeleteSegments(v)
|
||||
logger.Infof("deleteSegments updated to %v via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("local", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(string); ok {
|
||||
cfg.SetLocalDir(v)
|
||||
logger.Infof("localDir updated to %q via config change", v)
|
||||
}
|
||||
})
|
||||
|
||||
// 9. Initialize database (optional)
|
||||
var database *db.Database
|
||||
if cfg.DBPath != "" {
|
||||
// Ensure the database parent directory exists
|
||||
if dir := filepath.Dir(cfg.DBPath); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
logger.Fatalf("Failed to create database directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
var dbErr error
|
||||
database, dbErr = db.New(cfg.DBPath)
|
||||
if dbErr != nil {
|
||||
logger.Fatalf("Failed to open database: %v", dbErr)
|
||||
}
|
||||
defer database.Close()
|
||||
logger.Infof("Database opened: %s", cfg.DBPath)
|
||||
} else {
|
||||
logger.Info("No database path provided, running without persistence")
|
||||
}
|
||||
|
||||
// 10. Start HTTP server
|
||||
confStore := handler.WrapConfStore[AppStore](appStore)
|
||||
func runServer(rt *app.Runtime) error {
|
||||
cfg := rt.Config
|
||||
confStore := handler.WrapConfStore[app.AppStore](rt.AppStore)
|
||||
execPath, _ := os.Executable()
|
||||
server := api.NewServer(queue, taskLogs, database, confStore, api.ServerOptions{
|
||||
server := api.NewServer(rt.Queue, rt.TaskLogs, rt.Database, confStore, api.ServerOptions{
|
||||
EnableAuth: cfg.EnableAuth,
|
||||
StaticDir: cfg.StaticDir,
|
||||
FFmpegBin: getFFmpegBin(cfg),
|
||||
FFmpegBin: app.FFmpegBinPath(cfg),
|
||||
VideoRoot: cfg.LocalDir,
|
||||
EnvPaths: handler.EnvPaths{
|
||||
ConfigDir: cfg.ConfigDir,
|
||||
@@ -280,141 +111,5 @@ func main() {
|
||||
gin.SetMode(cfg.GinMode)
|
||||
logger.Infof("Starting HTTP server on %s", addr)
|
||||
|
||||
if err := server.Run(addr); err != nil {
|
||||
logger.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// syncAppStoreToCfg reads appStore values back into cfg.
|
||||
// CLI flags take priority: if --local-dir was explicitly set (not the default),
|
||||
// the CLI value is kept and written back to appStore.
|
||||
func syncAppStoreToCfg(store *conf.Conf[AppStore], cfg *AppConfig) {
|
||||
s := store.Store()
|
||||
cliLocalDir := cfg.LocalDir
|
||||
cliExplicit := cliLocalDir != "" && cliLocalDir != "./downloads"
|
||||
|
||||
if cliExplicit {
|
||||
// CLI explicitly set --local-dir, use it and update appStore
|
||||
_ = store.Set("local", cliLocalDir)
|
||||
} else if s.Local != "" {
|
||||
cfg.LocalDir = s.Local
|
||||
}
|
||||
cfg.Proxy = s.Proxy
|
||||
cfg.UseProxy = s.UseProxy
|
||||
cfg.DeleteSegments = s.DeleteSegments
|
||||
if s.MaxRunner > 0 {
|
||||
cfg.MaxRunner = s.MaxRunner
|
||||
}
|
||||
}
|
||||
|
||||
// initConfig initializes configuration following priority order: CLI flags > environment variables > JSON string > defaults.
|
||||
func initConfig() *AppConfig {
|
||||
// Default configuration
|
||||
cfg := &AppConfig{
|
||||
GinMode: "release",
|
||||
Host: "0.0.0.0",
|
||||
Port: "8080",
|
||||
LogLevel: "info",
|
||||
LogDir: "./logs",
|
||||
SchemaPath: "", // computed later
|
||||
DepsDir: "",
|
||||
MaxRunner: 2,
|
||||
LocalDir: "./downloads",
|
||||
DeleteSegments: true,
|
||||
Proxy: "",
|
||||
UseProxy: false,
|
||||
ConfigDir: "",
|
||||
}
|
||||
|
||||
// 1. Define command-line flags
|
||||
flag.StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (debug/info/warn/error)")
|
||||
flag.StringVar(&cfg.LogDir, "log-dir", cfg.LogDir, "Log directory")
|
||||
flag.StringVar(&cfg.DepsDir, "deps-dir", cfg.DepsDir, "Directory containing downloader tool binaries")
|
||||
flag.StringVar(&cfg.SchemaPath, "schema-path", cfg.SchemaPath, "Path to the download schema config.json")
|
||||
flag.StringVar(&cfg.Port, "port", cfg.Port, "Server port")
|
||||
flag.StringVar(&cfg.LocalDir, "local-dir", cfg.LocalDir, "Default download directory")
|
||||
flag.BoolVar(&cfg.DeleteSegments, "delete-segments", cfg.DeleteSegments, "Delete segments after download")
|
||||
flag.StringVar(&cfg.Proxy, "proxy", cfg.Proxy, "Proxy for downloader")
|
||||
flag.BoolVar(&cfg.UseProxy, "use-proxy", cfg.UseProxy, "Enable proxy")
|
||||
flag.IntVar(&cfg.MaxRunner, "max-runner", cfg.MaxRunner, "Maximum concurrent download runners")
|
||||
flag.StringVar(&cfg.DBPath, "db-path", cfg.DBPath, "Path to SQLite database file")
|
||||
flag.StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "Directory for persistent config file")
|
||||
flag.BoolVar(&cfg.EnableAuth, "enable-auth", cfg.EnableAuth, "Enable API key authentication")
|
||||
flag.StringVar(&cfg.StaticDir, "static-dir", cfg.StaticDir, "Directory to serve static files from (SPA mode)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// 3. Load from environment variables (overrides JSON and defaults)
|
||||
cfg.GinMode = getEnv("GIN_MODE", cfg.GinMode)
|
||||
cfg.Host = getEnv("HOST", cfg.Host)
|
||||
cfg.Port = getEnv("PORT", cfg.Port)
|
||||
cfg.DBPath = getEnv("DB_PATH", cfg.DBPath)
|
||||
|
||||
// If SchemaPath is still empty, compute its default value
|
||||
if cfg.SchemaPath == "" {
|
||||
cfg.SchemaPath = getDefaultSchemaPath()
|
||||
}
|
||||
|
||||
// If ConfigDir is empty, default to the LogDir path
|
||||
if cfg.ConfigDir == "" {
|
||||
cfg.ConfigDir = cfg.LogDir
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getDefaultSchemaPath returns the default path for the schema config file.
|
||||
func getDefaultSchemaPath() string {
|
||||
// Default path: prefer config.json in the same directory as the executable
|
||||
execPath, _ := os.Executable()
|
||||
execDir := filepath.Dir(execPath)
|
||||
localConfig := filepath.Join(execDir, "config.json")
|
||||
if _, err := os.Stat(localConfig); err == nil {
|
||||
return localConfig
|
||||
}
|
||||
// Fall back to the config file path inside the repository
|
||||
return "configs/config.json"
|
||||
}
|
||||
|
||||
// exeExt returns ".exe" on Windows, empty string otherwise.
|
||||
func exeExt() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ".exe"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getBinaryMap builds the downloader binary path map from a single deps directory.
|
||||
func getBinaryMap(cfg *AppConfig) map[core.DownloadType]string {
|
||||
ext := exeExt()
|
||||
m := make(map[core.DownloadType]string, len(core.BinaryNames))
|
||||
for dt, name := range core.BinaryNames {
|
||||
m[dt] = filepath.Join(cfg.DepsDir, name+ext)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// getFFmpegBin returns the ffmpeg binary path derived from the deps directory.
|
||||
func getFFmpegBin(cfg *AppConfig) string {
|
||||
return filepath.Join(cfg.DepsDir, core.FFmpegBinaryName+exeExt())
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// toInt converts a JSON-decoded number (float64) or int to int.
|
||||
func toInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
return server.Run(addr)
|
||||
}
|
||||
|
||||
+4
-1
@@ -9,10 +9,13 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/text v0.30.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -43,6 +46,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -61,7 +65,6 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
|
||||
@@ -10,6 +10,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -20,6 +21,7 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -72,6 +74,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -106,6 +110,11 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package dto
|
||||
|
||||
// AddFavoriteReq Add favorite request.
|
||||
// Title is optional; when empty the server falls back to the URL so the
|
||||
// entry still has a human-visible label.
|
||||
type AddFavoriteReq struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Icon *string `json:"icon"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"caorushizi.cn/mediago/internal/api/dto"
|
||||
"caorushizi.cn/mediago/internal/api/sse"
|
||||
"caorushizi.cn/mediago/internal/i18n"
|
||||
"caorushizi.cn/mediago/internal/logger"
|
||||
"caorushizi.cn/mediago/internal/service"
|
||||
@@ -16,11 +17,12 @@ import (
|
||||
type DownloadHandler struct {
|
||||
svc *service.DownloadTaskService
|
||||
conf ConfigStore
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
// NewDownloadHandler creates a DownloadHandler.
|
||||
func NewDownloadHandler(svc *service.DownloadTaskService, conf ConfigStore) *DownloadHandler {
|
||||
return &DownloadHandler{svc: svc, conf: conf}
|
||||
func NewDownloadHandler(svc *service.DownloadTaskService, conf ConfigStore, hub *sse.Hub) *DownloadHandler {
|
||||
return &DownloadHandler{svc: svc, conf: conf, hub: hub}
|
||||
}
|
||||
|
||||
// Create adds download tasks (supports batch creation).
|
||||
@@ -60,6 +62,20 @@ func (h *DownloadHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast creation so every connected renderer (main window, overlay
|
||||
// dialog, external clients) can update its own badge/state without
|
||||
// relying on cross-WebContents IPC.
|
||||
if h.hub != nil && len(videos) > 0 {
|
||||
ids := make([]int64, 0, len(videos))
|
||||
for _, v := range videos {
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
h.hub.Broadcast("download-create", map[string]interface{}{
|
||||
"ids": ids,
|
||||
"count": len(ids),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.SuccessResponse{Success: true, Code: http.StatusOK, Message: i18n.T(c, i18n.MsgOK), Data: videos})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -42,12 +43,26 @@ func (h *FavoriteHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
if title == "" {
|
||||
title = req.URL
|
||||
}
|
||||
fav, err := h.svc.AddFavorite(&service.AddFavoriteInput{
|
||||
Title: req.Title,
|
||||
Title: title,
|
||||
URL: req.URL,
|
||||
Icon: req.Icon,
|
||||
})
|
||||
if err != nil {
|
||||
// Duplicate URL is a normal user-facing condition, not a server
|
||||
// fault — surface it as 409 with a translated message.
|
||||
if errors.Is(err, service.ErrURLAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, dto.ErrorResponse{
|
||||
Success: false,
|
||||
Code: http.StatusConflict,
|
||||
Message: i18n.T(c, i18n.MsgURLAlreadyExists),
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Error("Failed to add favorite", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Success: false, Code: http.StatusInternalServerError, Message: err.Error()})
|
||||
return
|
||||
|
||||
@@ -108,7 +108,7 @@ func New(queue *core.TaskQueue, logs *tasklog.Manager, database *db.Database, co
|
||||
converter := service.NewConverter(opt.FFmpegBin)
|
||||
conversionSvc := service.NewConversionService(conversionRepo, converter, hub)
|
||||
|
||||
srv.downloadHandler = handler.NewDownloadHandler(downloadSvc, confStore)
|
||||
srv.downloadHandler = handler.NewDownloadHandler(downloadSvc, confStore, hub)
|
||||
srv.favoriteHandler = handler.NewFavoriteHandler(favoriteSvc)
|
||||
srv.conversionHandler = handler.NewConversionHandler(conversionSvc)
|
||||
srv.downloadService = downloadSvc
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
// AppStore holds all user-facing configuration options.
|
||||
// Field names and defaults match the TypeScript AppStore interface
|
||||
// in @mediago/shared-common.
|
||||
type AppStore struct {
|
||||
Local string `json:"local"`
|
||||
PromptTone bool `json:"promptTone"`
|
||||
Proxy string `json:"proxy"`
|
||||
UseProxy bool `json:"useProxy"`
|
||||
DeleteSegments bool `json:"deleteSegments"`
|
||||
OpenInNewWindow bool `json:"openInNewWindow"`
|
||||
BlockAds bool `json:"blockAds"`
|
||||
Theme string `json:"theme"`
|
||||
UseExtension bool `json:"useExtension"`
|
||||
IsMobile bool `json:"isMobile"`
|
||||
MaxRunner int `json:"maxRunner"`
|
||||
Language string `json:"language"`
|
||||
ShowTerminal bool `json:"showTerminal"`
|
||||
Privacy bool `json:"privacy"`
|
||||
MachineId string `json:"machineId"`
|
||||
DownloadProxySwitch bool `json:"downloadProxySwitch"`
|
||||
AutoUpgrade bool `json:"autoUpgrade"`
|
||||
AllowBeta bool `json:"allowBeta"`
|
||||
CloseMainWindow bool `json:"closeMainWindow"`
|
||||
AudioMuted bool `json:"audioMuted"`
|
||||
EnableDocker bool `json:"enableDocker"`
|
||||
DockerUrl string `json:"dockerUrl"`
|
||||
EnableMobilePlayer bool `json:"enableMobilePlayer"`
|
||||
ApiKey string `json:"apiKey"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
}
|
||||
|
||||
// DefaultAppStore returns default config values matching the TS appStoreDefaults.
|
||||
func DefaultAppStore() AppStore {
|
||||
return AppStore{
|
||||
Local: "",
|
||||
PromptTone: true,
|
||||
Proxy: "",
|
||||
UseProxy: false,
|
||||
DeleteSegments: true,
|
||||
OpenInNewWindow: false,
|
||||
BlockAds: true,
|
||||
Theme: "system",
|
||||
UseExtension: false,
|
||||
IsMobile: false,
|
||||
MaxRunner: 2,
|
||||
Language: "system",
|
||||
ShowTerminal: false,
|
||||
Privacy: false,
|
||||
MachineId: "",
|
||||
DownloadProxySwitch: false,
|
||||
AutoUpgrade: true,
|
||||
AllowBeta: false,
|
||||
CloseMainWindow: false,
|
||||
AudioMuted: true,
|
||||
EnableDocker: false,
|
||||
DockerUrl: "",
|
||||
EnableMobilePlayer: false,
|
||||
ApiKey: "",
|
||||
PasswordHash: "",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// AppConfig stores startup configuration options passed by flags or environment.
|
||||
type AppConfig struct {
|
||||
GinMode string `json:"gin_mode"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
LogLevel string `json:"log_level"`
|
||||
LogDir string `json:"log_dir"`
|
||||
SchemaPath string `json:"schema_path"`
|
||||
DepsDir string `json:"deps_dir"`
|
||||
MaxRunner int `json:"max_runner"`
|
||||
LocalDir string `json:"local_dir"`
|
||||
DeleteSegments bool `json:"delete_segments"`
|
||||
Proxy string `json:"proxy"`
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
DBPath string `json:"db_path"`
|
||||
ConfigDir string `json:"config_dir"`
|
||||
EnableAuth bool `json:"enable_auth"`
|
||||
StaticDir string `json:"static_dir"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
GinMode: "release",
|
||||
Host: "0.0.0.0",
|
||||
Port: "8080",
|
||||
LogLevel: "info",
|
||||
LogDir: "./logs",
|
||||
SchemaPath: "",
|
||||
DepsDir: "",
|
||||
MaxRunner: 2,
|
||||
LocalDir: "./downloads",
|
||||
DeleteSegments: true,
|
||||
Proxy: "",
|
||||
UseProxy: false,
|
||||
ConfigDir: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AppConfig) ApplyEnvAndDefaults() {
|
||||
c.GinMode = getEnv("GIN_MODE", c.GinMode)
|
||||
c.Host = getEnv("HOST", c.Host)
|
||||
c.Port = getEnv("PORT", c.Port)
|
||||
c.DBPath = getEnv("DB_PATH", c.DBPath)
|
||||
|
||||
if c.SchemaPath == "" {
|
||||
c.SchemaPath = getDefaultSchemaPath()
|
||||
}
|
||||
if c.ConfigDir == "" {
|
||||
c.ConfigDir = c.LogDir
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetLocalDir() string {
|
||||
return c.LocalDir
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetDeleteSegments() bool {
|
||||
return c.DeleteSegments
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetProxy() string {
|
||||
return c.Proxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetUseProxy() bool {
|
||||
return c.UseProxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetLocalDir(dir string) {
|
||||
c.LocalDir = dir
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetDeleteSegments(del bool) {
|
||||
c.DeleteSegments = del
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetProxy(proxy string) {
|
||||
c.Proxy = proxy
|
||||
}
|
||||
|
||||
func (c *AppConfig) SetUseProxy(useProxy bool) {
|
||||
c.UseProxy = useProxy
|
||||
}
|
||||
|
||||
func getDefaultSchemaPath() string {
|
||||
execPath, _ := os.Executable()
|
||||
execDir := filepath.Dir(execPath)
|
||||
localConfig := filepath.Join(execDir, "config.json")
|
||||
if _, err := os.Stat(localConfig); err == nil {
|
||||
return localConfig
|
||||
}
|
||||
return "configs/config.json"
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"caorushizi.cn/mediago/internal/core"
|
||||
"caorushizi.cn/mediago/internal/core/runner"
|
||||
"caorushizi.cn/mediago/internal/core/schema"
|
||||
"caorushizi.cn/mediago/internal/db"
|
||||
"caorushizi.cn/mediago/internal/logger"
|
||||
"caorushizi.cn/mediago/internal/tasklog"
|
||||
"caorushizi.cn/mediago/pkg/conf"
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
Config *AppConfig
|
||||
AppStore *conf.Conf[AppStore]
|
||||
Downloader *core.DownloaderSvc
|
||||
Queue *core.TaskQueue
|
||||
TaskLogs *tasklog.Manager
|
||||
Database *db.Database
|
||||
}
|
||||
|
||||
func InitLogger(cfg *AppConfig) error {
|
||||
logCfg := logger.DefaultConfig()
|
||||
logCfg.Level = cfg.LogLevel
|
||||
logCfg.LogDir = cfg.LogDir
|
||||
|
||||
if err := logger.Init(logCfg); err != nil {
|
||||
return fmt.Errorf("failed to initialize logger: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewRuntime(cfg *AppConfig) (*Runtime, error) {
|
||||
logger.Info("MediaGo Downloader Core Starting...")
|
||||
logger.Infof("Final Config: %+v", cfg)
|
||||
|
||||
appStore, err := conf.New(conf.Options[AppStore]{
|
||||
ConfigName: "config",
|
||||
CWD: cfg.ConfigDir,
|
||||
Defaults: DefaultAppStore(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize app store: %w", err)
|
||||
}
|
||||
logger.Infof("App store initialized at: %s", appStore.Path())
|
||||
|
||||
if appStore.Store().MachineId == "" {
|
||||
newId := uuid.New().String()
|
||||
_ = appStore.Set("machineId", newId)
|
||||
logger.Infof("Generated new machineId: %s", newId)
|
||||
}
|
||||
|
||||
syncAppStoreToCfg(appStore, cfg)
|
||||
ensureDownloadDir(appStore, cfg)
|
||||
|
||||
logger.Infof("Loading schemas from: %s", cfg.SchemaPath)
|
||||
schemas, err := schema.LoadSchemasFromJSON(cfg.SchemaPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load schemas: %w", err)
|
||||
}
|
||||
logger.Infof("Loaded %d download schemas", len(schemas.Schemas))
|
||||
|
||||
binMap := getBinaryMap(cfg)
|
||||
for dt, binPath := range binMap {
|
||||
logger.Infof("%s downloader: %s", dt, binPath)
|
||||
if binPath == "" {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(binPath); err != nil {
|
||||
logger.Warnf("%s binary not found: %v", dt, err)
|
||||
} else if info.Mode()&0o111 == 0 {
|
||||
logger.Warnf("%s binary is not executable: %s", dt, binPath)
|
||||
}
|
||||
}
|
||||
|
||||
r := runner.NewPTYRunner()
|
||||
downloader := core.NewDownloader(binMap, r, schemas, cfg)
|
||||
queue := core.NewTaskQueue(downloader, cfg.MaxRunner)
|
||||
taskLogs := tasklog.NewManager(filepath.Join(cfg.LogDir, "tasks"))
|
||||
|
||||
logger.Infof("Task queue initialized (maxRunner=%d)", cfg.MaxRunner)
|
||||
logger.Infof("Task logs will be stored in %s", filepath.Join(cfg.LogDir, "tasks"))
|
||||
|
||||
appStore.OnDidChange("maxRunner", func(newVal, oldVal any) {
|
||||
if v, ok := toInt(newVal); ok {
|
||||
queue.SetMaxRunner(v)
|
||||
logger.Infof("maxRunner updated to %d via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("proxy", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(string); ok {
|
||||
cfg.SetProxy(v)
|
||||
logger.Infof("proxy updated to %q via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("useProxy", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(bool); ok {
|
||||
cfg.SetUseProxy(v)
|
||||
logger.Infof("useProxy updated to %v via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("deleteSegments", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(bool); ok {
|
||||
cfg.SetDeleteSegments(v)
|
||||
logger.Infof("deleteSegments updated to %v via config change", v)
|
||||
}
|
||||
})
|
||||
appStore.OnDidChange("local", func(newVal, oldVal any) {
|
||||
if v, ok := newVal.(string); ok {
|
||||
cfg.SetLocalDir(v)
|
||||
logger.Infof("localDir updated to %q via config change", v)
|
||||
}
|
||||
})
|
||||
|
||||
var database *db.Database
|
||||
if cfg.DBPath != "" {
|
||||
if dir := filepath.Dir(cfg.DBPath); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
var dbErr error
|
||||
database, dbErr = db.New(cfg.DBPath)
|
||||
if dbErr != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", dbErr)
|
||||
}
|
||||
logger.Infof("Database opened: %s", cfg.DBPath)
|
||||
} else {
|
||||
logger.Info("No database path provided, running without persistence")
|
||||
}
|
||||
|
||||
return &Runtime{
|
||||
Config: cfg,
|
||||
AppStore: appStore,
|
||||
Downloader: downloader,
|
||||
Queue: queue,
|
||||
TaskLogs: taskLogs,
|
||||
Database: database,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rt *Runtime) Close() {
|
||||
if rt.Database != nil {
|
||||
_ = rt.Database.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func syncAppStoreToCfg(store *conf.Conf[AppStore], cfg *AppConfig) {
|
||||
s := store.Store()
|
||||
cliLocalDir := cfg.LocalDir
|
||||
cliExplicit := cliLocalDir != "" && cliLocalDir != "./downloads"
|
||||
|
||||
if cliExplicit {
|
||||
_ = store.Set("local", cliLocalDir)
|
||||
} else if s.Local != "" {
|
||||
cfg.LocalDir = s.Local
|
||||
}
|
||||
cfg.Proxy = s.Proxy
|
||||
cfg.UseProxy = s.UseProxy
|
||||
cfg.DeleteSegments = s.DeleteSegments
|
||||
if s.MaxRunner > 0 {
|
||||
cfg.MaxRunner = s.MaxRunner
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDownloadDir(store *conf.Conf[AppStore], cfg *AppConfig) {
|
||||
needDefault := cfg.LocalDir == "" || cfg.LocalDir == "./downloads"
|
||||
if !needDefault {
|
||||
if info, err := os.Stat(cfg.LocalDir); err != nil || !info.IsDir() {
|
||||
needDefault = true
|
||||
}
|
||||
}
|
||||
if needDefault {
|
||||
sysDownloads := getSystemDownloadsDir()
|
||||
logger.Infof("Download dir %q unavailable, using system default: %s", cfg.LocalDir, sysDownloads)
|
||||
cfg.LocalDir = sysDownloads
|
||||
}
|
||||
if store.Store().Local == "" {
|
||||
_ = store.Set("local", cfg.LocalDir)
|
||||
}
|
||||
}
|
||||
|
||||
func getSystemDownloadsDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
downloads := filepath.Join(home, "Downloads")
|
||||
if info, err := os.Stat(downloads); err == nil && info.IsDir() {
|
||||
return downloads
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func exeExt() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ".exe"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getBinaryMap(cfg *AppConfig) map[core.DownloadType]string {
|
||||
ext := exeExt()
|
||||
m := make(map[core.DownloadType]string, len(core.BinaryNames))
|
||||
for dt, name := range core.BinaryNames {
|
||||
m[dt] = filepath.Join(cfg.DepsDir, name+ext)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func FFmpegBinPath(cfg *AppConfig) string {
|
||||
return filepath.Join(cfg.DepsDir, core.FFmpegBinaryName+exeExt())
|
||||
}
|
||||
|
||||
func toInt(v any) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -73,8 +73,12 @@ func (d *DownloaderSvc) buildArgs(p DownloadParams, s schema.Schema) []string {
|
||||
pushKV(spec.ArgsName, final)
|
||||
|
||||
case "name":
|
||||
// file name argument: handle postfix
|
||||
name := p.Name
|
||||
// File-name argument. The task-creation service already
|
||||
// runs `SanitizeFilename` before persisting `p.Name`, so
|
||||
// this path sees a filesystem-safe value. We sanitize
|
||||
// again defensively — cheap, and guards against any future
|
||||
// code path that bypasses the service layer.
|
||||
name := SanitizeFilename(p.Name)
|
||||
if spec.Postfix == "@@AUTO@@" {
|
||||
// automatically infer the file extension
|
||||
name = name + "." + guessExtFromURL(p.URL)
|
||||
@@ -118,6 +122,49 @@ func (d *DownloaderSvc) buildArgs(p DownloadParams, s schema.Schema) []string {
|
||||
}
|
||||
|
||||
|
||||
// SanitizeFilename strips or replaces characters that filesystems — chiefly
|
||||
// Windows — reject or misinterpret when they appear in a filename. It is
|
||||
// intentionally aggressive enough to be safe across Windows, macOS, and
|
||||
// Linux:
|
||||
//
|
||||
// - Reserved path / wildcard characters: \ / : * ? " < > |
|
||||
// - ASCII control characters (0x00–0x1F)
|
||||
// - Trailing dots and spaces (Windows strips them silently at create time,
|
||||
// producing a name that doesn't match what was requested)
|
||||
//
|
||||
// Illegal characters collapse to a single underscore. The result is never
|
||||
// empty — if every character was illegal we fall back to "download".
|
||||
//
|
||||
// Exported so the task-creation service can sanitize once at persist time;
|
||||
// buildArgs then sees an already-safe value and the downloader command-line,
|
||||
// the DB row, and the post-download "file exists?" check all agree on the
|
||||
// same filename.
|
||||
func SanitizeFilename(name string) string {
|
||||
if name == "" {
|
||||
return "download"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20:
|
||||
// control char → drop
|
||||
case r == '\\' || r == '/' || r == ':' || r == '*' || r == '?' ||
|
||||
r == '"' || r == '<' || r == '>' || r == '|':
|
||||
b.WriteRune('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
cleaned := strings.TrimRight(b.String(), ". ")
|
||||
if cleaned == "" {
|
||||
return "download"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// guessExtFromURL infers the file extension from a URL
|
||||
func guessExtFromURL(u string) string {
|
||||
l := strings.ToLower(u)
|
||||
|
||||
@@ -86,19 +86,42 @@ func DefaultSchemas() SchemaList {
|
||||
},
|
||||
},
|
||||
{
|
||||
// Direct downloads are handled by aria2c (static build vendored
|
||||
// at extra/aria2/<os>/<arch>/). The -x/-s/-k flags carry over
|
||||
// verbatim from the previous gopeed config — aria2 uses the
|
||||
// same short flags — and the remaining four flags tune aria2's
|
||||
// own console readout so the regex below can pick up progress.
|
||||
Type: "direct",
|
||||
Args: map[string]ArgSpec{
|
||||
"localDir": {ArgsName: []string{"-D"}},
|
||||
"name": {ArgsName: []string{"-N"}, Postfix: "@@AUTO@@"},
|
||||
"url": {ArgsName: []string{}},
|
||||
"__common__": {ArgsName: []string{"-x", "16", "-s", "16", "-k", "1M"}},
|
||||
"localDir": {ArgsName: []string{"-d"}}, // download directory
|
||||
"name": {ArgsName: []string{"-o"}, Postfix: "@@AUTO@@"}, // output filename
|
||||
"url": {ArgsName: []string{}},
|
||||
"__common__": {ArgsName: []string{
|
||||
"-x", "16", // max-connection-per-server
|
||||
"-s", "16", // split
|
||||
"-k", "1M", // min-split-size
|
||||
"--console-log-level=notice", // keep readout line above the noise
|
||||
"--summary-interval=1", // emit a progress line every second
|
||||
"--allow-overwrite=true", // re-runs overwrite the previous file
|
||||
"--auto-file-renaming=false", // never silently rename when -o conflicts
|
||||
// Workaround for SChannel TLS-handshake issues on the
|
||||
// aria2 1.19.0 Windows build (SEC_I_MESSAGE_FRAGMENT /
|
||||
// 0x80090318) against modern CDNs like twimg.com.
|
||||
// NOTE: the handshake failure occurs BEFORE cert
|
||||
// validation, so this flag may not always rescue it —
|
||||
// the proper fix is upgrading aria2 to a build that
|
||||
// links against OpenSSL (e.g. 1.37.0).
|
||||
"--check-certificate=false",
|
||||
}},
|
||||
},
|
||||
ConsoleReg: ConsoleReg{
|
||||
Percent: `([\d.]+)%`,
|
||||
Speed: `([\d.]+[GMK]B/s)`,
|
||||
Error: "fail",
|
||||
Start: "downloading...",
|
||||
IsLive: "检测到直播流",
|
||||
// aria2 summary line looks like:
|
||||
// [#abc 1.5MiB/1.8MiB(83%) CN:2 DL:512KiB ETA:2s]
|
||||
Percent: `\((\d+)%\)`,
|
||||
Speed: `DL:(\S+)`, // e.g. "DL:512KiB"; shown as-is in the UI
|
||||
Error: `errorCode=\d+|exception`,
|
||||
Start: `Download (started|Results:)`,
|
||||
IsLive: ``, // direct downloads never have a live-stream phase
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
var BinaryNames = map[DownloadType]string{
|
||||
TypeM3U8: "N_m3u8DL-RE",
|
||||
TypeBilibili: "BBDown",
|
||||
TypeDirect: "gopeed",
|
||||
TypeDirect: "aria2c",
|
||||
TypeMediago: "mediago",
|
||||
TypeYoutube: "yt-dlp",
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ func (s *DownloadTaskService) AddDownloadTask(input *AddDownloadTaskInput) (*db.
|
||||
title = fmt.Sprintf("untitled-%s", RandomName())
|
||||
}
|
||||
|
||||
// Sanitize BEFORE the de-duplication lookup so the value we check
|
||||
// against the DB is the same filesystem-safe form we'll later hand
|
||||
// to the downloader and use for post-download file-existence
|
||||
// checks. Without this a title like "(2) 主页 / X" slips a '/'
|
||||
// into the filename, aria2 reads it as a path separator, and the
|
||||
// saved file ends up at a different path than the DB row.
|
||||
title = core.SanitizeFilename(title)
|
||||
|
||||
existing, err := s.repo.FindByName(title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,6 +97,10 @@ func (s *DownloadTaskService) AddDownloadTasks(inputs []*AddDownloadTaskInput) (
|
||||
title = fmt.Sprintf("untitled-%s", RandomName())
|
||||
}
|
||||
|
||||
// Sanitize BEFORE the de-duplication lookup (same rationale
|
||||
// as AddDownloadTask above).
|
||||
title = core.SanitizeFilename(title)
|
||||
|
||||
existing, err := s.repo.FindByName(title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getExeExt, mkdir, runCommand, copyFile, rmrf } from "./utils";
|
||||
* Start the development server
|
||||
*/
|
||||
export async function dev() {
|
||||
console.log("🚀 启动开发服务器...");
|
||||
console.log("🚀 Starting development server...");
|
||||
const args = [
|
||||
"run",
|
||||
"-tags",
|
||||
@@ -32,7 +32,7 @@ export async function dev() {
|
||||
* Build player-ui and copy dist to core assets for embedding
|
||||
*/
|
||||
export async function buildPlayerUI() {
|
||||
console.log("🎬 构建 Player UI...");
|
||||
console.log("🎬 Building Player UI...");
|
||||
const playerUiDist = join(config.PLAYER_UI_DIR, "dist");
|
||||
|
||||
// Build player-ui
|
||||
@@ -48,14 +48,14 @@ export async function buildPlayerUI() {
|
||||
rmrf(config.PLAYER_ASSETS_DIR);
|
||||
copyFile(playerUiDist, config.PLAYER_ASSETS_DIR);
|
||||
|
||||
console.log(`✅ Player UI 已复制到 ${config.PLAYER_ASSETS_DIR}`);
|
||||
console.log(`✅ Player UI copied to ${config.PLAYER_ASSETS_DIR}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the development build for the current platform
|
||||
*/
|
||||
export async function devBuild() {
|
||||
console.log("🔨 编译开发版本...");
|
||||
console.log("🔨 Compiling development build...");
|
||||
|
||||
// Build and embed player-ui first
|
||||
await buildPlayerUI();
|
||||
@@ -80,5 +80,5 @@ export async function devBuild() {
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(output, 0o755);
|
||||
}
|
||||
console.log(`✅ 开发版本编译成功 -> ${output}`);
|
||||
console.log(`✅ Development build compiled -> ${output}`);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,11 @@ async function buildBinary(cfg: BuildConfig) {
|
||||
* Build binaries for all platforms
|
||||
*/
|
||||
export async function releaseBuild() {
|
||||
console.log("🔨 构建所有平台二进制文件...");
|
||||
console.log("🔨 Building binaries for all platforms...");
|
||||
mkdir(config.BIN_DIR);
|
||||
|
||||
await Promise.all(BUILD_PLATFORMS.map(buildBinary));
|
||||
console.log("✅ 全平台二进制文件编译完成");
|
||||
console.log("✅ All-platform binaries compiled");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,24 +125,22 @@ async function packagePlatform(cfg: BuildConfig) {
|
||||
* Package the complete release bundles for all platforms
|
||||
*/
|
||||
async function releasePackage() {
|
||||
console.log("📦 打包所有平台发布包...");
|
||||
console.log("📦 Packaging release bundles for all platforms...");
|
||||
|
||||
await Promise.all(PACKAGE_PLATFORMS.map(packagePlatform));
|
||||
|
||||
console.log("✅ 所有平台发布包打包完成");
|
||||
console.log(
|
||||
`📦 发布包位置: ${resolveReleasePath(releaseConfig.packagesDir)}/`,
|
||||
);
|
||||
console.log("✅ All-platform release bundles packed");
|
||||
console.log(`📦 Output: ${resolveReleasePath(releaseConfig.packagesDir)}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean all release artifacts
|
||||
*/
|
||||
export async function releaseClean() {
|
||||
console.log("🧹 清理发布产物...");
|
||||
console.log("🧹 Cleaning release artifacts...");
|
||||
rmrf(config.BIN_DIR);
|
||||
rmrf(config.RELEASE_DIR);
|
||||
console.log("✅ 发布产物清理完成");
|
||||
console.log("✅ Release artifacts cleaned");
|
||||
}
|
||||
export const releasePackageFull = series(
|
||||
releaseClean,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mediago",
|
||||
"version": "3.5.0-beta.1",
|
||||
"name": "mediago-community",
|
||||
"version": "3.5.0",
|
||||
"description": "A powerful and easy-to-use online video downloader",
|
||||
"main": "main/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
; ============================================================================
|
||||
; Custom NSIS overrides injected by electron-builder.
|
||||
;
|
||||
; electron-builder splices `!insertmacro customHeader` into the top of its
|
||||
; generated installer script (see
|
||||
; node_modules/app-builder-lib/templates/nsis/installer.nsi around line 45),
|
||||
; which is AFTER `common.nsh` is included. That makes this macro the right
|
||||
; place to override directives like `Name`, `Caption`, `BrandingText`.
|
||||
;
|
||||
; Note on FileDescription: we intentionally do NOT override it here.
|
||||
; electron-builder's `NsisTarget.computeVersionKey()` (app-builder-lib ->
|
||||
; out/targets/nsis/NsisTarget.js) unconditionally emits
|
||||
; VIAddVersionKey /LANG=1033 "FileDescription" "${appInfo.description}"
|
||||
; from `apps/electron/app/package.json`'s `description`. Any customHeader
|
||||
; VIAddVersionKey targeting the same LANG+key triggers a hard NSIS error
|
||||
; ("already defined!") that `-WX` does not gate. Different LANG (e.g. 0)
|
||||
; produces a `warning 9100: without standard key FileVersion` which IS
|
||||
; gated by -WX and still fails the build.
|
||||
;
|
||||
; The installer's FileDescription is rewritten post-build via `app-builder
|
||||
; rcedit` in `afterAllArtifactBuild` (see apps/electron/scripts/build.ts).
|
||||
; That sidesteps the NSIS constraint and lets the installer and app
|
||||
; binary carry distinct descriptions (like VS Code / Chrome's Inno Setup
|
||||
; default of "{AppName} Setup").
|
||||
;
|
||||
; Wired up through `nsis.include` in apps/electron/scripts/build.ts.
|
||||
; ============================================================================
|
||||
|
||||
!macro customHeader
|
||||
; ---------------------------------------------------------------------
|
||||
; Surface the version in the installer's title bar.
|
||||
;
|
||||
; electron-builder's common.nsh sets `Name "${PRODUCT_NAME}"`, from
|
||||
; which NSIS derives the default `$(^SetupCaption)` (e.g. "Setup -
|
||||
; mediago-community"). That caption omits the version — users had to
|
||||
; look at the gray BrandingText at the bottom-left to see which
|
||||
; release they were installing.
|
||||
;
|
||||
; Why `Caption` and not `Name`: re-setting `Name` emits
|
||||
; `warning 6029: Name: specified multiple times`, and electron-builder
|
||||
; compiles NSIS with `-WX` (warnings-as-errors), so the build fails.
|
||||
;
|
||||
; Why a plain literal and not `$(^SetupCaption)`: the localized lang
|
||||
; string is evaluated at runtime via the MUI plugin. electron-builder
|
||||
; compiles an intermediate installer with `-DBUILD_UNINSTALLER` and
|
||||
; executes it silently (NsisTarget.js line ~370, `execWine(installerPath,
|
||||
; ..., __COMPAT_LAYER=RunAsInvoker)`) just to extract the uninstaller.
|
||||
; Evaluating a language string in the `Caption` directive during that
|
||||
; silent pass crashes with STATUS_STACK_BUFFER_OVERRUN (exit 3221225725)
|
||||
; and aborts the whole packaging. A plain literal sidesteps the MUI
|
||||
; runtime call entirely, so the intermediate pass finishes cleanly.
|
||||
; Trade-off: title bar reads "Setup - mediago-community 3.5.0" in every
|
||||
; locale instead of the translated "Installazione di ..." — acceptable.
|
||||
; ---------------------------------------------------------------------
|
||||
Caption "Setup - ${PRODUCT_NAME} ${VERSION}"
|
||||
!macroend
|
||||
@@ -1,7 +1,10 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path, { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import dotenvFlow from "dotenv-flow";
|
||||
import { type Configuration, build } from "electron-builder";
|
||||
|
||||
@@ -14,6 +17,22 @@ const projectRoot = path.resolve(__dirname, "../../..");
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
|
||||
// Resolve electron-builder's bundled `app-builder` native helper
|
||||
// (wraps rcedit, ships inside `app-builder-bin`). We use it from
|
||||
// `afterAllArtifactBuild` below to rewrite the NSIS installer's
|
||||
// FileDescription — see the hook for why. `app-builder-bin` is
|
||||
// a transitive dep of electron-builder, not a direct one, so we
|
||||
// resolve it via nested `createRequire` scoped to electron-builder's
|
||||
// own location.
|
||||
const execFileAsync = promisify(execFile);
|
||||
const localRequire = createRequire(import.meta.url);
|
||||
const electronBuilderRequire = createRequire(
|
||||
localRequire.resolve("electron-builder/package.json"),
|
||||
);
|
||||
const { appBuilderPath } = electronBuilderRequire("app-builder-bin") as {
|
||||
appBuilderPath: string;
|
||||
};
|
||||
|
||||
dotenvFlow.config({
|
||||
path: projectRoot,
|
||||
});
|
||||
@@ -28,6 +47,18 @@ function getReleaseConfig(): Configuration {
|
||||
buildVersion: pkg.version,
|
||||
appId: process.env.APP_ID,
|
||||
copyright: process.env.APP_COPYRIGHT,
|
||||
// Register the custom scheme at install/first-run time.
|
||||
// • macOS: electron-builder merges this into CFBundleURLTypes.
|
||||
// • Windows: runtime `app.setAsDefaultProtocolClient` in src/index.ts
|
||||
// handles registry entries on first launch — electron-builder
|
||||
// doesn't write these directly.
|
||||
// • Linux: runtime handler writes the .desktop file at first launch.
|
||||
protocols: [
|
||||
{
|
||||
name: "MediaGo URL Scheme",
|
||||
schemes: [process.env.APP_NAME as string],
|
||||
},
|
||||
],
|
||||
artifactName:
|
||||
"${productName}-setup-${platform}-${arch}-${buildVersion}.${ext}",
|
||||
npmRebuild: true,
|
||||
@@ -66,6 +97,15 @@ function getReleaseConfig(): Configuration {
|
||||
from: "./app/build/deps",
|
||||
to: "deps",
|
||||
},
|
||||
{
|
||||
// MediaGo browser extension (Manifest V3). Shipped unpacked
|
||||
// inside the installer's `resources/extension/` so users can
|
||||
// "Load unpacked" it from Chrome / Edge via the Settings page's
|
||||
// "Browser extension directory" button. The runtime path is
|
||||
// resolved by `resolveExtensionDir()` in binaryResolver.ts.
|
||||
from: "./app/build/extension",
|
||||
to: "extension",
|
||||
},
|
||||
],
|
||||
win: {
|
||||
icon: "../assets/icon.ico",
|
||||
@@ -111,8 +151,11 @@ function getReleaseConfig(): Configuration {
|
||||
extendInfo: {
|
||||
CFBundleURLTypes: [
|
||||
{
|
||||
CFBundleURLName: "Mediago URL Scheme",
|
||||
CFBundleURLSchemes: ["mediagoaaa"],
|
||||
CFBundleURLName: "MediaGo URL Scheme",
|
||||
// Must match `process.env.APP_NAME` in .env — the same
|
||||
// scheme used by src/index.ts's setAsDefaultProtocolClient
|
||||
// and by the browser extension's MEDIAGO_SCHEME.
|
||||
CFBundleURLSchemes: [process.env.APP_NAME as string],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -132,6 +175,52 @@ function getReleaseConfig(): Configuration {
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
// Inject our customHeader macro to add the version to the
|
||||
// installer title bar. See comments in
|
||||
// `apps/electron/installer/installer.nsh`.
|
||||
include: "./installer/installer.nsh",
|
||||
},
|
||||
// Rewrite the NSIS installer's `FileDescription` after the fact.
|
||||
//
|
||||
// Why this isn't done in the .nsh header: electron-builder's
|
||||
// `NsisTarget.computeVersionKey()` unconditionally emits
|
||||
// VIAddVersionKey /LANG=1033 "FileDescription" "${appInfo.description}"
|
||||
// into the generated .nsi — binding the installer's FileDescription
|
||||
// to the app binary's (both drawn from `app/package.json:description`).
|
||||
// Any customHeader `VIAddVersionKey` with the same LANG+key triggers
|
||||
// a hard NSIS "already defined!" error that `-WX` does not gate, and
|
||||
// a different LANG (e.g. 0) triggers `warning 9100: without standard
|
||||
// key FileVersion` which IS gated by `-WX`. There is no in-NSIS way
|
||||
// to override this cleanly.
|
||||
//
|
||||
// Inno Setup (used by VS Code, Chrome) gets this for free via a
|
||||
// default `VersionInfoDescription = "{AppName} Setup"`. NSIS has no
|
||||
// such default, so we post-process the artifact with the same
|
||||
// `app-builder rcedit` call electron-builder itself uses on
|
||||
// `mediago.exe` (see winPackager.js around line 185).
|
||||
afterAllArtifactBuild: async ({ artifactPaths }) => {
|
||||
// rcedit crashes when executed through Wine (per electron-builder's
|
||||
// own note in winPackager.js:183); skip on Linux. Windows installer
|
||||
// artifacts aren't produced on Linux builds anyway.
|
||||
if (process.platform !== "win32" && process.platform !== "darwin") {
|
||||
return [];
|
||||
}
|
||||
const installers = artifactPaths.filter((p) =>
|
||||
/-setup-win32-.*\.exe$/i.test(path.basename(p)),
|
||||
);
|
||||
for (const installer of installers) {
|
||||
await execFileAsync(appBuilderPath, [
|
||||
"rcedit",
|
||||
"--args",
|
||||
JSON.stringify([
|
||||
installer,
|
||||
"--set-version-string",
|
||||
"FileDescription",
|
||||
`${process.env.APP_NAME} installer`,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -153,6 +242,14 @@ const electronTask = [
|
||||
src: "packages/browser-extension/build",
|
||||
dest: "app/build/plugin",
|
||||
},
|
||||
{
|
||||
// MediaGo browser extension dist — produced upstream by
|
||||
// `turbo run build -F @mediago/extension` (wired into
|
||||
// `pnpm build:electron` at the repo root). Gets zipped into the
|
||||
// installer via the `extraResources` entry above.
|
||||
src: "packages/mediago-extension/dist",
|
||||
dest: "app/build/extension",
|
||||
},
|
||||
];
|
||||
|
||||
for (const task of electronTask) {
|
||||
@@ -181,7 +278,7 @@ await fs.copyFile(
|
||||
path.join(binDir, `mediago-core${ext}`),
|
||||
);
|
||||
|
||||
// Copy dependency binaries (ffmpeg, N_m3u8DL-RE, BBDown, gopeed)
|
||||
// Copy dependency binaries (ffmpeg, N_m3u8DL-RE, BBDown, aria2c, yt-dlp)
|
||||
const platformKey = `${os.platform()}-${os.arch()}`;
|
||||
const localDepsDir = path.resolve(projectRoot, ".deps", platformKey);
|
||||
try {
|
||||
|
||||
@@ -26,11 +26,13 @@ import BrowserWindowService from "./windows/browser.window";
|
||||
import MainWindow from "./windows/main.window";
|
||||
import "./controller";
|
||||
import ElectronLogger from "./vendor/ElectronLogger";
|
||||
import { AppTheme } from "@mediago/shared-common";
|
||||
import { AppTheme, resolveAppLanguage } from "@mediago/shared-common";
|
||||
|
||||
@injectable()
|
||||
@provide()
|
||||
export default class ElectronApp {
|
||||
private tray?: Tray;
|
||||
|
||||
constructor(
|
||||
@inject(MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
@@ -95,7 +97,7 @@ export default class ElectronApp {
|
||||
|
||||
// 4. Apply initial config
|
||||
nativeTheme.themeSource = (config.theme || "system") as AppTheme;
|
||||
i18n.changeLanguage(config.language);
|
||||
i18n.changeLanguage(resolveAppLanguage(config.language, app.getLocale()));
|
||||
} catch (err) {
|
||||
this.logger.error("[ElectronApp] Failed to start Go core service:", err);
|
||||
}
|
||||
@@ -131,7 +133,9 @@ export default class ElectronApp {
|
||||
this.webviewService.setDefaultSession(v);
|
||||
},
|
||||
language: (v) => {
|
||||
i18n.changeLanguage(v);
|
||||
i18n.changeLanguage(
|
||||
resolveAppLanguage(v as string, app.getLocale()),
|
||||
);
|
||||
},
|
||||
allowBeta: (v) => {
|
||||
this.updater.changeAllowBeta(v);
|
||||
@@ -160,6 +164,16 @@ export default class ElectronApp {
|
||||
tray.addListener("click", () => {
|
||||
this.mainWindow.init();
|
||||
});
|
||||
this.tray = tray;
|
||||
this.refreshTrayMenu();
|
||||
|
||||
// Rebuild the tray menu whenever the app language changes so the
|
||||
// labels stay in sync with user settings and the OS locale.
|
||||
i18n.on("languageChanged", () => this.refreshTrayMenu());
|
||||
}
|
||||
|
||||
private refreshTrayMenu() {
|
||||
if (!this.tray) return;
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: i18n.t("showMainWindow"),
|
||||
@@ -170,7 +184,7 @@ export default class ElectronApp {
|
||||
role: "quit",
|
||||
},
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
this.tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
secondInstance = (event: Event, commandLine: string[]) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TYPES } from "../types/symbols";
|
||||
import { type IpcMainEvent } from "electron";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { exePath, workspace } from "../utils";
|
||||
import { resolveExtensionDir } from "../utils/binaryResolver";
|
||||
import ElectronUpdater from "../vendor/ElectronUpdater";
|
||||
import BrowserWindow from "../windows/browser.window";
|
||||
import MainWindow from "../windows/main.window";
|
||||
@@ -42,6 +43,21 @@ export default class HomeController implements Controller {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the bundled MediaGo browser-extension directory.
|
||||
*
|
||||
* The UI pairs this with the generic `shell.open()` IPC — no
|
||||
* dedicated "open extension dir" action is needed, we just return
|
||||
* the path and let the renderer treat it like any other folder
|
||||
* shortcut (configDir / binDir / localDir). In dev this resolves to
|
||||
* `packages/mediago-extension/dist/` inside the monorepo; in prod
|
||||
* it's `resources/extension/` inside the installer.
|
||||
*/
|
||||
@handle(IPC.app.getExtensionDir)
|
||||
async getExtensionDir(): Promise<string> {
|
||||
return resolveExtensionDir().extensionDir;
|
||||
}
|
||||
|
||||
@handle(IPC.app.showBrowserWindow)
|
||||
async showBrowserWindow() {
|
||||
this.browserWindow.showWindow();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import i18n, { type InitOptions, type Resource } from "i18next";
|
||||
import { BASE_I18N_OPTIONS, i18nResources } from "@mediago/shared-common";
|
||||
import {
|
||||
BASE_I18N_OPTIONS,
|
||||
i18nResources,
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from "@mediago/shared-common";
|
||||
|
||||
const nodeResources: Resource = {
|
||||
en: { translation: i18nResources.en },
|
||||
zh: { translation: i18nResources.zh },
|
||||
};
|
||||
const nodeResources: Resource = SUPPORTED_LANGUAGES.reduce<Resource>(
|
||||
(resources, language) => {
|
||||
resources[language] = { translation: i18nResources[language] };
|
||||
return resources;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const nodeI18nOptions: InitOptions = {
|
||||
...BASE_I18N_OPTIONS,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import EventEmitter from "node:events";
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import { DownloadType } from "@mediago/shared-common";
|
||||
import {
|
||||
DownloadType,
|
||||
matchPageUrl,
|
||||
matchRequestUrl,
|
||||
} from "@mediago/shared-common";
|
||||
import { type OnSendHeadersListenerDetails, session } from "electron";
|
||||
import { inject, injectable } from "inversify";
|
||||
import {
|
||||
@@ -19,46 +23,11 @@ export interface SourceParams {
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
export interface SourceFilter {
|
||||
hosts?: RegExp[];
|
||||
matches?: RegExp[];
|
||||
type: DownloadType;
|
||||
schema?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PageInfo {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const filterList: SourceFilter[] = [
|
||||
{
|
||||
matches: [/\.m3u8/],
|
||||
type: DownloadType.m3u8,
|
||||
},
|
||||
{
|
||||
// TODO: Collections, lists, favorites
|
||||
hosts: [/^https?:\/\/(www\.)?bilibili.com\/video/],
|
||||
type: DownloadType.bilibili,
|
||||
schema: {
|
||||
name: "title",
|
||||
},
|
||||
},
|
||||
{
|
||||
hosts: [/^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//],
|
||||
type: DownloadType.youtube,
|
||||
schema: {
|
||||
name: "title",
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: [
|
||||
/\.(mp4|flv|mov|avi|mkv|wmv|m4a|ogg|m4b|m4p|m4r|m4b|m4p|m4r)(?![a-zA-Z])/,
|
||||
],
|
||||
type: DownloadType.direct,
|
||||
},
|
||||
];
|
||||
|
||||
@injectable()
|
||||
@provide()
|
||||
export class SniffingHelper extends EventEmitter {
|
||||
@@ -99,22 +68,14 @@ export class SniffingHelper extends EventEmitter {
|
||||
|
||||
this.checkTimer = setTimeout(() => {
|
||||
this.checkTimer = null;
|
||||
listLoop: for (const filter of filterList) {
|
||||
if (filter.hosts) {
|
||||
for (const host of filter.hosts) {
|
||||
if (!host.test(pageInfo.url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.send({
|
||||
url: pageInfo.url,
|
||||
documentURL: pageInfo.url,
|
||||
name: pageInfo.title,
|
||||
type: filter.type,
|
||||
});
|
||||
break listLoop;
|
||||
}
|
||||
}
|
||||
const filter = matchPageUrl(pageInfo.url);
|
||||
if (filter) {
|
||||
this.send({
|
||||
url: pageInfo.url,
|
||||
documentURL: pageInfo.url,
|
||||
name: pageInfo.title,
|
||||
type: filter.type,
|
||||
});
|
||||
}
|
||||
}, this.prepareDelay);
|
||||
}
|
||||
@@ -142,24 +103,15 @@ export class SniffingHelper extends EventEmitter {
|
||||
const { url, requestHeaders } = details;
|
||||
const { title, url: documentURL } = this.pageInfo;
|
||||
|
||||
listLoop: for (const filter of filterList) {
|
||||
if (filter.matches) {
|
||||
for (const match of filter.matches) {
|
||||
const u = new URL(url);
|
||||
if (!match.test(u.pathname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.send({
|
||||
url,
|
||||
documentURL,
|
||||
name: title,
|
||||
type: filter.type,
|
||||
headers: formatHeaders(requestHeaders),
|
||||
});
|
||||
break listLoop;
|
||||
}
|
||||
}
|
||||
const filter = matchRequestUrl(url);
|
||||
if (filter) {
|
||||
this.send({
|
||||
url,
|
||||
documentURL,
|
||||
name: title,
|
||||
type: filter.type,
|
||||
headers: formatHeaders(requestHeaders),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,3 +94,38 @@ export function resolvePlayerBinary(): { playerBin: string } {
|
||||
playerBin: path.join(process.resourcesPath, "bin", `mediago-player${ext}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the bundled browser-extension directory.
|
||||
*
|
||||
* Development: packages/mediago-extension/dist (produced by
|
||||
* `pnpm -F @mediago/extension build`)
|
||||
* Production: extraResources/extension (copied by electron-builder
|
||||
* via the scripts/build.ts `extraResources` declaration)
|
||||
*
|
||||
* Override with MEDIAGO_EXTENSION_DIR env var.
|
||||
*
|
||||
* Consumed by the `shell.openExtensionDir` IPC — the Settings page
|
||||
* exposes a "Browser extension directory" button so users can locate
|
||||
* the unpacked extension to load into Chrome / Edge.
|
||||
*/
|
||||
export function resolveExtensionDir(): { extensionDir: string } {
|
||||
if (process.env.MEDIAGO_EXTENSION_DIR) {
|
||||
return { extensionDir: process.env.MEDIAGO_EXTENSION_DIR };
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
return {
|
||||
extensionDir: path.join(
|
||||
getMonorepoRoot(),
|
||||
"packages",
|
||||
"mediago-extension",
|
||||
"dist",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
extensionDir: path.join(process.resourcesPath, "extension"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "5.0.0-rc.2"
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
|
||||
+20
-15
@@ -2,9 +2,11 @@ import { App as AntdApp, ConfigProvider, theme as antdTheme } from "antd";
|
||||
import { type FC, lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import "dayjs/locale/it";
|
||||
import { useAsyncEffect, useMemoizedFn } from "ahooks";
|
||||
import zhCN from "antd/es/locale/zh_CN";
|
||||
import enUS from "antd/es/locale/en_US";
|
||||
import itIT from "antd/es/locale/it_IT";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import Loading from "./components/loading";
|
||||
import { PAGE_LOAD } from "./const";
|
||||
@@ -19,14 +21,14 @@ import {
|
||||
updateSelector,
|
||||
useSessionStore,
|
||||
} from "./store/session";
|
||||
import { getBrowserLang, isWeb, tdApp } from "./utils";
|
||||
import { isWeb, resolveAppLanguage, tdApp } from "./utils";
|
||||
import { usePlatform } from "./hooks/use-platform";
|
||||
import { setupHttp } from "./utils/http";
|
||||
import { getConfig } from "./api/config";
|
||||
import { initGoEvents, onConfigChanged } from "./api/events";
|
||||
import { AppLanguage, DownloadFilter } from "@mediago/shared-common";
|
||||
import { DownloadFilter } from "@mediago/shared-common";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { Locale } from "antd/es/locale";
|
||||
import type { Locale } from "antd/es/locale";
|
||||
|
||||
const AppLayout = lazy(() => import("./layout/app-layout"));
|
||||
const HomePage = lazy(() => import("./pages/home-page"));
|
||||
@@ -42,6 +44,20 @@ function getAlgorithm(appTheme: "dark" | "light") {
|
||||
: antdTheme.defaultAlgorithm;
|
||||
}
|
||||
|
||||
function getAntdLocale(
|
||||
language: ReturnType<typeof resolveAppLanguage>,
|
||||
): Locale {
|
||||
switch (language) {
|
||||
case "zh":
|
||||
return zhCN;
|
||||
case "it":
|
||||
return itIT;
|
||||
case "en":
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
|
||||
const App: FC = () => {
|
||||
useAuth();
|
||||
const { on, off } = usePlatform();
|
||||
@@ -56,18 +72,7 @@ const App: FC = () => {
|
||||
const [adapterReady, setAdapterReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (language === AppLanguage.ZH) {
|
||||
setAppLocale(zhCN);
|
||||
} else if (language === AppLanguage.EN) {
|
||||
setAppLocale(enUS);
|
||||
} else {
|
||||
const lang = getBrowserLang();
|
||||
if (lang.startsWith("zh")) {
|
||||
setAppLocale(zhCN);
|
||||
} else {
|
||||
setAppLocale(enUS);
|
||||
}
|
||||
}
|
||||
setAppLocale(getAntdLocale(resolveAppLanguage(language)));
|
||||
}, [language]);
|
||||
|
||||
const themeChange = useMemoizedFn((event: MediaQueryListEvent) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// DOWNLOAD_EVENT_NAME is used as the channel name for dispatching download events
|
||||
import { http } from "@/utils";
|
||||
import { useDownloadStore } from "@/store/download";
|
||||
|
||||
type Callback = (...args: unknown[]) => void;
|
||||
|
||||
@@ -21,6 +22,36 @@ export function initGoEvents(coreUrl: string) {
|
||||
|
||||
es = new EventSource(`${coreUrl}/api/events`);
|
||||
|
||||
// Task creation is broadcast from Go Core's download.Create handler.
|
||||
// Driving the sidebar badge from SSE (instead of the local `increase()`
|
||||
// call in the form/panel handlers) keeps the count correct across
|
||||
// WebContents — e.g. the source-extract overlay dialog has its own
|
||||
// Zustand instance and its local store updates never reach the main
|
||||
// window.
|
||||
es.addEventListener("download-create", (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data);
|
||||
const count =
|
||||
typeof payload?.count === "number" && payload.count > 0
|
||||
? payload.count
|
||||
: 1;
|
||||
const ids: number[] = Array.isArray(payload?.ids)
|
||||
? payload.ids.map((id: unknown) => Number(id))
|
||||
: [];
|
||||
const { increase } = useDownloadStore.getState();
|
||||
for (let i = 0; i < count; i++) increase();
|
||||
|
||||
// Also fan out to download-event listeners so `useTasks` can
|
||||
// revalidate its list — otherwise tasks imported externally
|
||||
// (browser extension's HTTP mode, Docker clients) only bump
|
||||
// the sidebar badge and the list stays stale until a manual
|
||||
// refresh.
|
||||
dispatchDownload({ type: "created", data: { ids, count } });
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("download-start", (e) => {
|
||||
const payload = JSON.parse(e.data);
|
||||
dispatchDownload({ type: "start", data: { id: Number(payload.id) } });
|
||||
|
||||
@@ -23,7 +23,6 @@ import { createDownloadTasks, getDownloadFolders } from "@/api/download-task";
|
||||
import { useDockerApi } from "@/hooks/use-docker-api";
|
||||
import { appStoreSelector, useAppStore } from "@/store/app";
|
||||
import { downloadFormSelector, useConfigStore } from "@/store/config";
|
||||
import { downloadStoreSelector, useDownloadStore } from "@/store/download";
|
||||
import { tdApp } from "@/utils";
|
||||
import { DownloadTask, DownloadType } from "@mediago/shared-common";
|
||||
import { BatchUrlTextarea } from "./batchurl-textarea";
|
||||
@@ -82,7 +81,6 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
const [videoFolders, setVideoFolders] = useState<string[]>([]);
|
||||
const { contextMenu } = usePlatform();
|
||||
const { addVideosToDocker } = useDockerApi();
|
||||
const { increase } = useDownloadStore(useShallow(downloadStoreSelector));
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (modalOpen) {
|
||||
@@ -149,7 +147,9 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
try {
|
||||
const tasks = await getFormItems();
|
||||
await createDownloadTasks(tasks);
|
||||
increase();
|
||||
// Badge increments via the "download-create" SSE event
|
||||
// (apps/ui/src/api/events.ts); drives both the main window and
|
||||
// the overlay-dialog WebContents from a single source.
|
||||
setModalOpen(false);
|
||||
onConfirm?.(form.getFieldsValue());
|
||||
tdApp.onEvent(ADD_TO_LIST, { id });
|
||||
@@ -184,7 +184,8 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
try {
|
||||
const tasks = await getFormItems();
|
||||
await createDownloadTasks(tasks, true);
|
||||
increase();
|
||||
// Badge increments via the "download-create" SSE event; see
|
||||
// handleSave comment.
|
||||
setModalOpen(false);
|
||||
onConfirm?.(form.getFieldsValue());
|
||||
tdApp.onEvent(DOWNLOAD_NOW, { id });
|
||||
|
||||
@@ -35,6 +35,28 @@ const Terminal: FC<TerminalProps> = ({ className, id, header }) => {
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Copy-on-shortcut. xterm doesn't bind Ctrl+C / Cmd+C to "copy
|
||||
// selection" by default (it passes them through as control chars).
|
||||
// Since stdin is disabled for this read-only log view, hijacking
|
||||
// those keys is safe — when there's a selection we copy it and tell
|
||||
// xterm to stop handling the event; otherwise we let it through.
|
||||
terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type !== "keydown") return true;
|
||||
const isCopy =
|
||||
(ev.ctrlKey || ev.metaKey) &&
|
||||
!ev.altKey &&
|
||||
!ev.shiftKey &&
|
||||
ev.key.toLowerCase() === "c";
|
||||
if (!isCopy) return true;
|
||||
const sel = terminal.getSelection();
|
||||
if (!sel) return true;
|
||||
void navigator.clipboard.writeText(sel).catch(() => {
|
||||
/* clipboard API may be unavailable in non-secure contexts; ignore */
|
||||
});
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
if (data?.log) {
|
||||
terminal.write(data.log);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export const webPlatformStubs: PlatformApi = {
|
||||
playerUrl: "",
|
||||
coreUrl: "",
|
||||
}),
|
||||
// Web/server mode has no local filesystem — the browser extension
|
||||
// is an Electron-only concept, and the Settings UI hides the
|
||||
// "Browser extension directory" button behind `isWeb`. The stub
|
||||
// just returns an empty string so type-wise the shared contract
|
||||
// holds.
|
||||
getExtensionDir: async () => "",
|
||||
getSharedState: async () => ({}),
|
||||
setSharedState: noop,
|
||||
showBrowserWindow: noop,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DownloadFilter,
|
||||
DownloadStatus,
|
||||
type DownloadCreatedEvent,
|
||||
type DownloadEvent,
|
||||
type DownloadFailedEvent,
|
||||
type DownloadProgress,
|
||||
@@ -32,6 +33,8 @@ const isFailedEvent = (obj: DownloadEvent): obj is DownloadFailedEvent =>
|
||||
obj.type === "failed";
|
||||
const isStoppedEvent = (obj: DownloadEvent): obj is DownloadStoppedEvent =>
|
||||
obj.type === "stopped";
|
||||
const isCreatedEvent = (obj: DownloadEvent): obj is DownloadCreatedEvent =>
|
||||
obj.type === "created";
|
||||
const isProgressEvent = (
|
||||
obj: DownloadEvent,
|
||||
): obj is DownloadEvent<DownloadProgress[]> => obj.type === "progress";
|
||||
@@ -95,6 +98,14 @@ export function useTasks(filter: DownloadFilter = DownloadFilter.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tasks created externally (browser extension, Docker clients)
|
||||
// arrive here — refetch so the new rows show up immediately
|
||||
// without a manual refresh.
|
||||
if (isCreatedEvent(eventData)) {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProgressEvent(eventData)) {
|
||||
const events = eventData.data.map((item) => ({
|
||||
percent: item.percent,
|
||||
|
||||
@@ -20,9 +20,17 @@ interface Props {
|
||||
|
||||
export function TerminalDialog({ trigger, title, id, asChild }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const handleContextMenu = useMemoizedFn(
|
||||
|
||||
// Radix Dialog portals the DOM to document.body, but React synthetic
|
||||
// events still bubble up through the React tree — that means a
|
||||
// right-click inside this dialog would otherwise propagate to the
|
||||
// underlying DownloadTaskItem's `onContextMenu` and pop its "select /
|
||||
// download / refresh / delete" menu instead of the native "Copy"
|
||||
// menu. Stop React-level bubbling so the item handler is isolated;
|
||||
// don't preventDefault, so Chromium's built-in context menu still
|
||||
// fires for the xterm selection.
|
||||
const stopContextPropagation = useMemoizedFn(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
);
|
||||
@@ -30,7 +38,10 @@ export function TerminalDialog({ trigger, title, id, asChild }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild={asChild}>{trigger}</DialogTrigger>
|
||||
<DialogContent onContextMenu={handleContextMenu} className="min-w-fit">
|
||||
<DialogContent
|
||||
className="min-w-fit"
|
||||
onContextMenu={stopContextPropagation}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("downloadLog")}</DialogTitle>
|
||||
<DialogDescription>{title}</DialogDescription>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
type FormInstance,
|
||||
Input,
|
||||
@@ -20,10 +21,8 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tabs,
|
||||
type TabsProps,
|
||||
} from "antd";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import PageContainer from "@/components/page-container";
|
||||
@@ -47,7 +46,7 @@ import { AppLanguage, AppStore, AppTheme } from "@mediago/shared-common";
|
||||
const version = import.meta.env.APP_VERSION;
|
||||
|
||||
const SettingPage: React.FC = () => {
|
||||
const { dialog, shell, browser, contextMenu, update, on, off } =
|
||||
const { dialog, shell, browser, contextMenu, update, on, off, app } =
|
||||
usePlatform();
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<FormInstance<AppStore>>(null);
|
||||
@@ -62,10 +61,35 @@ const SettingPage: React.FC = () => {
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [updateDownloaded, setUpdateDownloaded] = useState(false);
|
||||
|
||||
// Mounting all 6 cards + ~25 Form.Items in one render produces a
|
||||
// ~300ms long task on navigation (dominated by Ant Design Form.Item
|
||||
// registration + cssinjs). Render one card per animation frame so the
|
||||
// longest task is just a single card (~50ms) — no individual frame is
|
||||
// long enough to feel janky.
|
||||
const [visibleCount, setVisibleCount] = useState(1);
|
||||
|
||||
const isFirstSync = useRef(true);
|
||||
useEffect(() => {
|
||||
// initialValues already seeds the form on mount; the extra
|
||||
// setFieldsValue here would force Ant Form to diff every Form.Item
|
||||
// again right after mount, which is a meaningful slice of the jank.
|
||||
if (isFirstSync.current) {
|
||||
isFirstSync.current = false;
|
||||
return;
|
||||
}
|
||||
formRef.current?.setFieldsValue(settings);
|
||||
}, [settings]);
|
||||
|
||||
// Mount remaining cards one per animation frame, so each task stays
|
||||
// short enough to avoid a visible freeze.
|
||||
useEffect(() => {
|
||||
if (visibleCount >= 6) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
setVisibleCount((c) => c + 1);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [visibleCount]);
|
||||
|
||||
const onSelectDir = useMemoizedFn(async () => {
|
||||
const paths = await dialog.open({ type: "directory" });
|
||||
const local = paths?.[0];
|
||||
@@ -182,370 +206,432 @@ const SettingPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const tabItems: TabsProps["items"] = [
|
||||
{
|
||||
key: "1",
|
||||
label: t("basicSetting"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="local" label={renderButtonLabel()}>
|
||||
<Input
|
||||
width="xl"
|
||||
disabled
|
||||
placeholder={t("pleaseSelectDownloadDir")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item hidden={isWeb} name="theme" label={t("downloaderTheme")}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: t("followSystem"), value: AppTheme.System },
|
||||
{ label: t("dark"), value: AppTheme.Dark },
|
||||
{ label: t("light"), value: AppTheme.Light },
|
||||
]}
|
||||
placeholder={t("pleaseSelectTheme")}
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label={t("displayLanguage")}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: t("followSystem"), value: AppLanguage.System },
|
||||
{ label: t("chinese"), value: AppLanguage.ZH },
|
||||
{ label: t("english"), value: AppLanguage.EN },
|
||||
]}
|
||||
placeholder={t("pleaseSelectLanguage")}
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("downloadPrompt")}
|
||||
name="promptTone"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("showTerminal")} name="showTerminal">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("autoUpgrade")}
|
||||
tooltip={t("autoUpgradeTooltip")}
|
||||
name="autoUpgrade"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("allowBetaVersion")}
|
||||
name="allowBeta"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("closeMainWindow")}
|
||||
name="closeMainWindow"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value={true}>{t("close")}</Radio>
|
||||
<Radio value={false}>{t("minimizeToTray")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("enableMobilePlayer")}
|
||||
name="enableMobilePlayer"
|
||||
hidden={isWeb}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
disabled: isWeb,
|
||||
label: t("browserSetting"),
|
||||
children: !isWeb && (
|
||||
<>
|
||||
<Form.Item label={t("audioMuted")} name="audioMuted">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("openInNewWindow")} name="openInNewWindow">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxy" label={t("proxySetting")}>
|
||||
<Input
|
||||
width="xl"
|
||||
placeholder={t("pleaseEnterProxy")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="useProxy"
|
||||
label={t("proxySwitch")}
|
||||
rules={[
|
||||
{
|
||||
validator(rules, value) {
|
||||
if (value && formRef.current?.getFieldValue("proxy") === "") {
|
||||
return Promise.reject(t("pleaseEnterProxyFirst"));
|
||||
const cardSections: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
hidden?: boolean;
|
||||
children: React.ReactNode;
|
||||
}> = useMemo<
|
||||
Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
hidden?: boolean;
|
||||
children: React.ReactNode;
|
||||
}>
|
||||
>(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: "1",
|
||||
title: t("basicSetting"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="local" label={renderButtonLabel()}>
|
||||
<Input disabled placeholder={t("pleaseSelectDownloadDir")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
name="theme"
|
||||
label={t("downloaderTheme")}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: t("followSystem"), value: AppTheme.System },
|
||||
{ label: t("dark"), value: AppTheme.Dark },
|
||||
{ label: t("light"), value: AppTheme.Light },
|
||||
]}
|
||||
placeholder={t("pleaseSelectTheme")}
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label={t("displayLanguage")}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: t("followSystem"), value: AppLanguage.System },
|
||||
{ label: t("chinese"), value: AppLanguage.ZH },
|
||||
{ label: t("english"), value: AppLanguage.EN },
|
||||
{ label: t("italian"), value: AppLanguage.IT },
|
||||
]}
|
||||
placeholder={t("pleaseSelectLanguage")}
|
||||
allowClear={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("downloadPrompt")}
|
||||
name="promptTone"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("showTerminal")} name="showTerminal">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("autoUpgrade")}
|
||||
tooltip={t("autoUpgradeTooltip")}
|
||||
name="autoUpgrade"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("allowBetaVersion")}
|
||||
name="allowBeta"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
hidden={isWeb}
|
||||
label={t("closeMainWindow")}
|
||||
name="closeMainWindow"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value={true}>{t("close")}</Radio>
|
||||
<Radio value={false}>{t("minimizeToTray")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("enableMobilePlayer")}
|
||||
name="enableMobilePlayer"
|
||||
hidden={isWeb}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
hidden: isWeb,
|
||||
title: t("browserSetting"),
|
||||
children: !isWeb && (
|
||||
<>
|
||||
<Form.Item label={t("audioMuted")} name="audioMuted">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("openInNewWindow")} name="openInNewWindow">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxy" label={t("proxySetting")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterProxy")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("blockAds")} name="blockAds">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("enterMobileMode")} name="isMobile">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("useImmersiveSniffing")}
|
||||
tooltip={t("immersiveSniffingDescription")}
|
||||
name="useExtension"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("privacy")}
|
||||
tooltip={t("privacyTooltip")}
|
||||
name="privacy"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("moreAction")}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleClearWebviewCache}
|
||||
icon={<ClearOutlined />}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="useProxy"
|
||||
label={t("proxySwitch")}
|
||||
rules={[
|
||||
{
|
||||
validator(rules, value) {
|
||||
if (
|
||||
value &&
|
||||
formRef.current?.getFieldValue("proxy") === ""
|
||||
) {
|
||||
return Promise.reject(t("pleaseEnterProxyFirst"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t("clearCache")}
|
||||
</Button>
|
||||
<Button onClick={handleExportFavorite}>
|
||||
<DownloadOutlined />
|
||||
{t("exportFavorite")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onMenuClick}>
|
||||
<UploadOutlined />
|
||||
{t("importFavorite")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
label: t("downloadSetting"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item hidden={!isWeb} name="proxy" label={t("proxySetting")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterProxy")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="downloadProxySwitch"
|
||||
label={t("downloadProxySwitch")}
|
||||
rules={[
|
||||
{
|
||||
validator(rules, value) {
|
||||
if (value && formRef.current?.getFieldValue("proxy") === "") {
|
||||
return Promise.reject(t("pleaseEnterProxyFirst"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("deleteSegments")} name="deleteSegments">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("maxRunner")}
|
||||
tooltip={t("maxRunnerDescription")}
|
||||
name="maxRunner"
|
||||
>
|
||||
<InputNumber min={1} max={50} precision={0} />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
label: t("dockerSetting"),
|
||||
disabled: isWeb,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="apiKey" label={t("apiKey")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterApiKey")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dockerUrl" label={t("dockerUrl")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterDockerUrl")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("enableDocker")} name="enableDocker">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
label: t("skillsSetting"),
|
||||
children: (() => {
|
||||
const coreUrl = envPath?.playerUrl
|
||||
? envPath.playerUrl.replace(/\/player\/$/, "")
|
||||
: "";
|
||||
const apiKey = settings.apiKey || "";
|
||||
let setupCmd: string;
|
||||
if (isWeb) {
|
||||
// Server/Docker mode: need both URL and API key
|
||||
const url = coreUrl || "http://localhost:8899";
|
||||
setupCmd = apiKey
|
||||
? `Set mediago url to ${url}, api key to ${apiKey}`
|
||||
: `Set mediago url to ${url}`;
|
||||
} else {
|
||||
// Electron mode: only need URL
|
||||
setupCmd = coreUrl
|
||||
? `Set mediago url to ${coreUrl}`
|
||||
: "Set mediago url to http://localhost:39719";
|
||||
}
|
||||
const installCmd = t("skillsInstallCmd");
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t("skillsInstall")}
|
||||
tooltip={t("skillsInstallTooltip")}
|
||||
>
|
||||
<Space.Compact className="w-full">
|
||||
<Input value={installCmd} readOnly className="font-mono" />
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(installCmd);
|
||||
message.success(t("skillsCopied"));
|
||||
}}
|
||||
>
|
||||
{t("skillsCopy")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("skillsInit")} tooltip={t("skillsInitTooltip")}>
|
||||
<Space.Compact className="w-full">
|
||||
<Input value={setupCmd} readOnly className="font-mono" />
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(setupCmd);
|
||||
message.success(t("skillsCopied"));
|
||||
}}
|
||||
>
|
||||
{t("skillsCopy")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
})(),
|
||||
},
|
||||
{
|
||||
key: "6",
|
||||
label: t("moreSettings"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item hidden={!isWeb} name="apiKey" label={t("apiKey")}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item hidden={isWeb} label={t("moreAction")}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() =>
|
||||
envPath?.configDir && shell.open(envPath.configDir)
|
||||
}
|
||||
icon={<FolderOpenOutlined />}
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("blockAds")} name="blockAds">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("enterMobileMode")} name="isMobile">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("useImmersiveSniffing")}
|
||||
tooltip={t("immersiveSniffingDescription")}
|
||||
name="useExtension"
|
||||
>
|
||||
{t("configDir")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => envPath?.binDir && shell.open(envPath.binDir)}
|
||||
icon={<FolderOpenOutlined />}
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("privacy")}
|
||||
tooltip={t("privacyTooltip")}
|
||||
name="privacy"
|
||||
>
|
||||
{t("binPath")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => settings.local && shell.open(settings.local)}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
{t("localDir")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("currentVersion")}>
|
||||
<Space>
|
||||
<div>{version}</div>
|
||||
{!isWeb && (
|
||||
<Badge dot={updateAvailable}>
|
||||
<Button type="text" onClick={handleCheckUpdate}>
|
||||
{t("checkUpdate")}
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("moreAction")}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={handleClearWebviewCache}
|
||||
icon={<ClearOutlined />}
|
||||
>
|
||||
{t("clearCache")}
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
].filter((item) => !item.disabled);
|
||||
<Button onClick={handleExportFavorite}>
|
||||
<DownloadOutlined />
|
||||
{t("exportFavorite")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onMenuClick}>
|
||||
<UploadOutlined />
|
||||
{t("importFavorite")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
title: t("downloadSetting"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item hidden={!isWeb} name="proxy" label={t("proxySetting")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterProxy")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="downloadProxySwitch"
|
||||
label={t("downloadProxySwitch")}
|
||||
rules={[
|
||||
{
|
||||
validator(rules, value) {
|
||||
if (
|
||||
value &&
|
||||
formRef.current?.getFieldValue("proxy") === ""
|
||||
) {
|
||||
return Promise.reject(t("pleaseEnterProxyFirst"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("deleteSegments")} name="deleteSegments">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("maxRunner")}
|
||||
tooltip={t("maxRunnerDescription")}
|
||||
name="maxRunner"
|
||||
>
|
||||
<InputNumber min={1} max={50} precision={0} />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
title: t("dockerSetting"),
|
||||
hidden: isWeb,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="apiKey" label={t("apiKey")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterApiKey")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dockerUrl" label={t("dockerUrl")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterDockerUrl")}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("enableDocker")} name="enableDocker">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
title: t("skillsSetting"),
|
||||
children: (() => {
|
||||
const coreUrl = envPath?.playerUrl
|
||||
? envPath.playerUrl.replace(/\/player\/$/, "")
|
||||
: "";
|
||||
const apiKey = settings.apiKey || "";
|
||||
let setupCmd: string;
|
||||
if (isWeb) {
|
||||
// Server/Docker mode: need both URL and API key
|
||||
const url = coreUrl || "http://localhost:8899";
|
||||
setupCmd = apiKey
|
||||
? `Set mediago url to ${url}, api key to ${apiKey}`
|
||||
: `Set mediago url to ${url}`;
|
||||
} else {
|
||||
// Electron mode: only need URL
|
||||
setupCmd = coreUrl
|
||||
? `Set mediago url to ${coreUrl}`
|
||||
: "Set mediago url to http://localhost:39719";
|
||||
}
|
||||
const installCmd = t("skillsInstallCmd");
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t("skillsInstall")}
|
||||
tooltip={t("skillsInstallTooltip")}
|
||||
>
|
||||
<Space.Compact className="w-full">
|
||||
<Input value={installCmd} readOnly className="font-mono" />
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(installCmd);
|
||||
message.success(t("skillsCopied"));
|
||||
}}
|
||||
>
|
||||
{t("skillsCopy")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("skillsInit")}
|
||||
tooltip={t("skillsInitTooltip")}
|
||||
>
|
||||
<Space.Compact className="w-full">
|
||||
<Input value={setupCmd} readOnly className="font-mono" />
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(setupCmd);
|
||||
message.success(t("skillsCopied"));
|
||||
}}
|
||||
>
|
||||
{t("skillsCopy")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
})(),
|
||||
},
|
||||
{
|
||||
key: "6",
|
||||
title: t("moreSettings"),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item hidden={!isWeb} name="apiKey" label={t("apiKey")}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item hidden={isWeb} label={t("moreAction")}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={() =>
|
||||
envPath?.configDir && shell.open(envPath.configDir)
|
||||
}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
{t("configDir")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
envPath?.binDir && shell.open(envPath.binDir)
|
||||
}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
{t("binPath")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => settings.local && shell.open(settings.local)}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
{t("localDir")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const dir = await app.getExtensionDir();
|
||||
if (dir) shell.open(dir);
|
||||
}}
|
||||
icon={<FolderOpenOutlined />}
|
||||
>
|
||||
{t("extensionDir")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("currentVersion")}>
|
||||
<Space wrap>
|
||||
<div>{version}</div>
|
||||
{!isWeb && (
|
||||
<Badge dot={updateAvailable}>
|
||||
<Button type="text" onClick={handleCheckUpdate}>
|
||||
{t("checkUpdate")}
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
].filter((item) => !item.hidden),
|
||||
[
|
||||
// Form.Items subscribe to the form store by name, so we don't need
|
||||
// the full settings object here — only the two fields that are read
|
||||
// directly inside the JSX (skillsSetting IIFE and the "open local"
|
||||
// button). Keeping the dep list narrow lets the memo survive most
|
||||
// SSE config-changed updates.
|
||||
t,
|
||||
settings.apiKey,
|
||||
settings.local,
|
||||
envPath,
|
||||
updateAvailable,
|
||||
renderButtonLabel,
|
||||
onMenuClick,
|
||||
handleExportFavorite,
|
||||
handleClearWebviewCache,
|
||||
handleCheckUpdate,
|
||||
contextMenu,
|
||||
shell,
|
||||
message,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer title={t("setting")}>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-[#1F2024] h-full">
|
||||
<div className="h-full overflow-auto px-1 py-2">
|
||||
<Form<AppStore>
|
||||
ref={formRef}
|
||||
layout="horizontal"
|
||||
labelAlign={"left"}
|
||||
labelAlign="left"
|
||||
labelCol={{ flex: "140px" }}
|
||||
wrapperCol={{ flex: "1 1 auto" }}
|
||||
colon={false}
|
||||
initialValues={settings}
|
||||
onValuesChange={onFormValueChange}
|
||||
className="flex flex-col gap-2"
|
||||
// labelCol={{ span: 5 }}
|
||||
wrapperCol={{ span: 10 }}
|
||||
>
|
||||
<Tabs defaultActiveKey="1" items={tabItems} onChange={() => {}} />
|
||||
<div className="gap-4 md:columns-2">
|
||||
{cardSections.slice(0, visibleCount).map((section) => (
|
||||
<div key={section.key} className="mb-4 block break-inside-avoid">
|
||||
<Card title={section.title} size="small" variant="borderless">
|
||||
{section.children}
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ export function BrowserViewPanel() {
|
||||
folder: "",
|
||||
};
|
||||
await createDownloadTasks([downloadTask], true);
|
||||
// Badge increments via the "download-create" SSE event (see
|
||||
// apps/ui/src/api/events.ts), so no local increase() call needed.
|
||||
} catch (e) {
|
||||
message.error((e as Error).message);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FavItem } from "./fav-item";
|
||||
import { useFavorites } from "@/hooks/use-favorites";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { useBrowserActions } from "@/hooks/use-browser-actions";
|
||||
import { getPageTitle } from "@/api/util";
|
||||
|
||||
export function FavoriteList() {
|
||||
const {
|
||||
@@ -42,7 +43,7 @@ export function FavoriteList() {
|
||||
const handleOk = useMemoizedFn(async () => {
|
||||
try {
|
||||
const values = await favoriteAddForm.validateFields();
|
||||
const icon = await getFavIcon(values.url);
|
||||
const icon = getFavIcon(values.url);
|
||||
await addFavorite({
|
||||
url: values.url,
|
||||
title: values.title,
|
||||
@@ -60,6 +61,23 @@ export function FavoriteList() {
|
||||
setIsModalOpen(false);
|
||||
});
|
||||
|
||||
// Auto-fill the title from the page's <title> tag when the user leaves
|
||||
// the URL field, unless they already typed a title themselves.
|
||||
const handleUrlBlur = useMemoizedFn(async () => {
|
||||
const url: string = favoriteAddForm.getFieldValue("url");
|
||||
if (!url || !/^https?:\/\/.+/.test(url)) return;
|
||||
if (favoriteAddForm.getFieldValue("title")) return;
|
||||
try {
|
||||
const { data: title } = await getPageTitle(url);
|
||||
// Re-check: the user may have typed something while we were fetching.
|
||||
if (!favoriteAddForm.getFieldValue("title") && title) {
|
||||
favoriteAddForm.setFieldValue("title", title);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: leave title blank if fetch fails; server falls back to URL.
|
||||
}
|
||||
});
|
||||
|
||||
const handleContextMenu = useMemoizedFn(async (item: Favorite) => {
|
||||
const action = await contextMenu.show([
|
||||
{ key: "open", label: t("open") },
|
||||
@@ -121,18 +139,6 @@ export function FavoriteList() {
|
||||
>
|
||||
<div className="flex min-h-36 flex-col justify-center">
|
||||
<Form<Favorite> form={favoriteAddForm} autoFocus>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t("siteName")}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("pleaseEnterSiteName"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t("pleaseEnterSiteName")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="url"
|
||||
label={t("siteUrl")}
|
||||
@@ -147,7 +153,13 @@ export function FavoriteList() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t("pleaseEnterSiteUrl")} />
|
||||
<Input
|
||||
placeholder={t("pleaseEnterSiteUrl")}
|
||||
onBlur={handleUrlBlur}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label={t("siteName")}>
|
||||
<Input placeholder={t("pleaseEnterSiteName")} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ToolBar({ page }: Props) {
|
||||
if (curIsFavorite) {
|
||||
await removeFavorite(curIsFavorite.id);
|
||||
} else {
|
||||
const icon = await getFavIcon(store.url);
|
||||
const icon = getFavIcon(store.url);
|
||||
await addFavorite({
|
||||
url: store.url,
|
||||
title: store.title || store.url,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import i18n from "../i18n";
|
||||
import { resolveAppLanguage } from "../utils";
|
||||
import { AppLanguage, AppStore, AppTheme } from "@mediago/shared-common";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
@@ -44,7 +45,7 @@ export const useAppStore = create<AppStore & Actions>()(
|
||||
set((state) => {
|
||||
const { language } = values;
|
||||
if (language) {
|
||||
i18n.changeLanguage(language);
|
||||
i18n.changeLanguage(resolveAppLanguage(language));
|
||||
}
|
||||
|
||||
Object.entries(values).forEach(([key, val]) => {
|
||||
|
||||
@@ -47,6 +47,13 @@ http.interceptors.response.use(
|
||||
useAppStore.getState().setAppStore({ apiKey: "" });
|
||||
window.location.pathname = "/signin";
|
||||
}
|
||||
// Go Core's error responses follow { success: false, message } too;
|
||||
// surface the translated server message instead of Axios's default
|
||||
// "Request failed with status code XXX".
|
||||
const serverMessage = resp?.data?.message;
|
||||
if (typeof serverMessage === "string" && serverMessage.length > 0) {
|
||||
return Promise.reject(new Error(serverMessage));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import { type ClassValue, clsx } from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { isUrl } from "./url";
|
||||
import { DownloadType } from "@mediago/shared-common";
|
||||
import {
|
||||
DownloadType,
|
||||
resolveAppLanguage as sharedResolveAppLanguage,
|
||||
} from "@mediago/shared-common";
|
||||
|
||||
export { http, setupHttp } from "./http";
|
||||
export { tdApp } from "./tdapp";
|
||||
@@ -25,17 +28,19 @@ export const requestImage = (url: string, timeout = 1000): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getFavIcon = async (url: string) => {
|
||||
let iconUrl = "";
|
||||
export const getFavIcon = (url: string): string => {
|
||||
// Return the canonical /favicon.ico URL without probing via <img> first.
|
||||
// The probe used a 1s timeout and dropped any URL that wasn't instantly
|
||||
// loadable (CORS, redirects, slow TLS, sites serving favicons from other
|
||||
// paths), which left most favorites with an empty icon. The consumer
|
||||
// (<Avatar src> in fav-item.tsx) already falls back to a link icon when
|
||||
// the image fails to load, so a best-effort URL is safe here.
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
const fetchUrl = urlObject.origin ? `${urlObject.origin}/favicon.ico` : "";
|
||||
await requestImage(fetchUrl);
|
||||
iconUrl = fetchUrl;
|
||||
return urlObject.origin ? `${urlObject.origin}/favicon.ico` : "";
|
||||
} catch {
|
||||
// empty
|
||||
return "";
|
||||
}
|
||||
return iconUrl;
|
||||
};
|
||||
|
||||
export const generateUrl = (url: string) => {
|
||||
@@ -105,3 +110,9 @@ export function getBrowserLang(): string {
|
||||
|
||||
return lang.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveAppLanguage(
|
||||
language: string | undefined,
|
||||
): ReturnType<typeof sharedResolveAppLanguage> {
|
||||
return sharedResolveAppLanguage(language, getBrowserLang());
|
||||
}
|
||||
|
||||
+372
-42
@@ -4,6 +4,7 @@ import { defineConfig, type HeadConfig } from "vitepress";
|
||||
import { baiduAnalytics, googleAnalytics } from "./plugins";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const siteUrl = "https://downloader.caorushizi.cn";
|
||||
|
||||
const head: HeadConfig[] = [
|
||||
["link", { rel: "shortcut icon", href: "/favicon.svg" }],
|
||||
@@ -12,12 +13,243 @@ if (!isDev) {
|
||||
head.push(...baiduAnalytics(), ...googleAnalytics());
|
||||
}
|
||||
|
||||
const translatedBlogAlternates: Record<string, Record<string, string>> = {
|
||||
"blog/video-downloader-review/index": {
|
||||
"zh-CN": `${siteUrl}/blog/video-downloader-review/`,
|
||||
en: `${siteUrl}/en/blog/video-downloader-review/`,
|
||||
"x-default": `${siteUrl}/blog/video-downloader-review/`,
|
||||
},
|
||||
"en/blog/video-downloader-review/index": {
|
||||
"zh-CN": `${siteUrl}/blog/video-downloader-review/`,
|
||||
en: `${siteUrl}/en/blog/video-downloader-review/`,
|
||||
"x-default": `${siteUrl}/blog/video-downloader-review/`,
|
||||
},
|
||||
};
|
||||
|
||||
function getPageUrl(page: string) {
|
||||
const normalized = page.replace(/\.md$/, "");
|
||||
if (normalized === "index") {
|
||||
return `${siteUrl}/`;
|
||||
}
|
||||
if (normalized.endsWith("/index")) {
|
||||
return `${siteUrl}/${normalized.slice(0, -"/index".length)}/`;
|
||||
}
|
||||
return `${siteUrl}/${normalized}.html`;
|
||||
}
|
||||
|
||||
function getPageLanguage(page: string) {
|
||||
if (page.startsWith("en/")) {
|
||||
return "en";
|
||||
}
|
||||
if (page.startsWith("jp/")) {
|
||||
return "ja";
|
||||
}
|
||||
if (page.startsWith("it/")) {
|
||||
return "it";
|
||||
}
|
||||
return "zh-CN";
|
||||
}
|
||||
|
||||
function isBlogPagePath(page: string) {
|
||||
return page.startsWith("blog/") || /^[a-z]{2}\/blog\//.test(page);
|
||||
}
|
||||
|
||||
function isBlogIndexPage(page: string) {
|
||||
return page === "blog/index.md" || /^[a-z]{2}\/blog\/index\.md$/.test(page);
|
||||
}
|
||||
|
||||
function getAbsoluteUrl(url: string) {
|
||||
if (/^https?:\/\//.test(url)) {
|
||||
return url;
|
||||
}
|
||||
return `${siteUrl}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
}
|
||||
|
||||
function getStringArray(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(String);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getBreadcrumbItems(
|
||||
pageUrl: string,
|
||||
pageTitle: string,
|
||||
isBlogPage: boolean,
|
||||
language: string,
|
||||
) {
|
||||
const isEnglish = language === "en";
|
||||
const localePrefix = isEnglish ? "/en" : "";
|
||||
const items = [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: isEnglish ? "Home" : "首页",
|
||||
item: `${siteUrl}${localePrefix}/`,
|
||||
},
|
||||
];
|
||||
|
||||
if (isBlogPage) {
|
||||
items.push({
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: isEnglish ? "Blog" : "博客",
|
||||
item: `${siteUrl}${localePrefix}/blog/`,
|
||||
});
|
||||
|
||||
if (pageUrl !== `${siteUrl}${localePrefix}/blog/`) {
|
||||
items.push({
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: pageTitle,
|
||||
item: pageUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "MediaGo",
|
||||
description: "简单易用,快速下载",
|
||||
lastUpdated: true,
|
||||
head,
|
||||
sitemap: {
|
||||
hostname: siteUrl,
|
||||
},
|
||||
transformHead({ page, pageData, title, description }) {
|
||||
const normalizedPage = page.replace(/\.md$/, "");
|
||||
const pageUrl = getPageUrl(page);
|
||||
const frontmatter = pageData.frontmatter;
|
||||
const language = getPageLanguage(page);
|
||||
const isBlogPage = isBlogPagePath(page);
|
||||
const pageTitle = String(frontmatter.title || title || "MediaGo");
|
||||
const pageDescription = String(
|
||||
frontmatter.description || description || "简单易用,快速下载",
|
||||
);
|
||||
const pageTags = getStringArray(frontmatter.tags);
|
||||
const pageImage =
|
||||
typeof frontmatter.image === "string"
|
||||
? getAbsoluteUrl(frontmatter.image)
|
||||
: undefined;
|
||||
const entries: HeadConfig[] = [
|
||||
["link", { rel: "canonical", href: pageUrl }],
|
||||
["meta", { property: "og:url", content: pageUrl }],
|
||||
["meta", { property: "og:title", content: pageTitle }],
|
||||
["meta", { property: "og:description", content: pageDescription }],
|
||||
["meta", { property: "og:site_name", content: "MediaGo" }],
|
||||
[
|
||||
"meta",
|
||||
{ property: "og:type", content: isBlogPage ? "article" : "website" },
|
||||
],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:title", content: pageTitle }],
|
||||
["meta", { name: "twitter:description", content: pageDescription }],
|
||||
[
|
||||
"script",
|
||||
{ type: "application/ld+json" },
|
||||
JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: getBreadcrumbItems(
|
||||
pageUrl,
|
||||
pageTitle,
|
||||
isBlogPage,
|
||||
language,
|
||||
),
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
const alternates = translatedBlogAlternates[normalizedPage];
|
||||
if (alternates) {
|
||||
for (const [hreflang, href] of Object.entries(alternates)) {
|
||||
entries.push(["link", { rel: "alternate", hreflang, href }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (pageTags.length > 0) {
|
||||
entries.push(["meta", { name: "keywords", content: pageTags.join(",") }]);
|
||||
}
|
||||
|
||||
if (pageImage) {
|
||||
entries.push(
|
||||
["meta", { property: "og:image", content: pageImage }],
|
||||
["meta", { name: "twitter:image", content: pageImage }],
|
||||
);
|
||||
}
|
||||
|
||||
if (isBlogPage && !isBlogIndexPage(page)) {
|
||||
const article: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: pageTitle,
|
||||
description: pageDescription,
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
name: String(frontmatter.author || "MediaGo"),
|
||||
url: siteUrl,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "MediaGo",
|
||||
url: siteUrl,
|
||||
},
|
||||
datePublished: frontmatter.date,
|
||||
dateModified: frontmatter.updated || frontmatter.date,
|
||||
mainEntityOfPage: pageUrl,
|
||||
inLanguage: language,
|
||||
};
|
||||
|
||||
if (pageTags.length > 0) {
|
||||
article.keywords = pageTags;
|
||||
}
|
||||
|
||||
if (pageImage) {
|
||||
article.image = [pageImage];
|
||||
}
|
||||
|
||||
entries.push([
|
||||
"script",
|
||||
{ type: "application/ld+json" },
|
||||
JSON.stringify(article),
|
||||
]);
|
||||
|
||||
if (frontmatter.date) {
|
||||
entries.push([
|
||||
"meta",
|
||||
{
|
||||
property: "article:published_time",
|
||||
content: String(frontmatter.date),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (frontmatter.updated || frontmatter.date) {
|
||||
entries.push([
|
||||
"meta",
|
||||
{
|
||||
property: "article:modified_time",
|
||||
content: String(frontmatter.updated || frontmatter.date),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
for (const tag of pageTags) {
|
||||
entries.push(["meta", { property: "article:tag", content: tag }]);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
},
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
@@ -25,28 +257,57 @@ export default defineConfig({
|
||||
{ text: "更新日志", link: "/changelog" },
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "开始",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guides" },
|
||||
{ text: "使用说明", link: "/documents" },
|
||||
{ text: "更新日志", link: "/changelog" },
|
||||
{ text: "通过宝塔面板部署", link: "/bt-install" },
|
||||
{ text: "配合猫爪下载视频", link: "/catcatch" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Q&A",
|
||||
items: [
|
||||
{ text: "常见问题", link: "/qa" },
|
||||
{ text: "windows7支持(64位)", link: "/history" },
|
||||
{ text: "意见收集", link: "/proposal" },
|
||||
{ text: "支持列表", link: "/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
sidebar: {
|
||||
"/blog/": [
|
||||
{
|
||||
text: "博客",
|
||||
items: [
|
||||
{ text: "博客首页", link: "/blog/" },
|
||||
{
|
||||
text: "视频下载器推荐",
|
||||
link: "/blog/video-downloader-recommendation/",
|
||||
},
|
||||
{ text: "视频下载器评测", link: "/blog/video-downloader-review/" },
|
||||
{ text: "网页视频下载指南", link: "/blog/video-download/" },
|
||||
{ text: "M3U8 / HLS 下载指南", link: "/blog/m3u8-hls-download/" },
|
||||
{ text: "网页视频嗅探指南", link: "/blog/video-sniffer/" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "产品文档",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guides" },
|
||||
{ text: "使用说明", link: "/documents" },
|
||||
{ text: "浏览器扩展", link: "/extension" },
|
||||
{ text: "下载接口", link: "/api" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"/": [
|
||||
{
|
||||
text: "开始",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guides" },
|
||||
{ text: "使用说明", link: "/documents" },
|
||||
{ text: "下载接口", link: "/api" },
|
||||
{ text: "更新日志", link: "/changelog" },
|
||||
{ text: "通过宝塔面板部署", link: "/bt-install" },
|
||||
{ text: "浏览器扩展", link: "/extension" },
|
||||
{ text: "配合猫爪下载视频", link: "/catcatch" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Q&A",
|
||||
items: [
|
||||
{ text: "常见问题", link: "/qa" },
|
||||
{ text: "windows7支持(64位)", link: "/history" },
|
||||
{ text: "意见收集", link: "/proposal" },
|
||||
{ text: "支持列表", link: "/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/caorushizi/m3u8-downloader" },
|
||||
@@ -65,29 +326,55 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: "Home", link: "/en" },
|
||||
{ text: "Guides", link: "/en/guides" },
|
||||
{ text: "Blog", link: "/en/blog/" },
|
||||
{ text: "Changelog", link: "/en/changelog" },
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "Quick start",
|
||||
items: [
|
||||
{ text: "Quick start", link: "/en/guides" },
|
||||
{ text: "Baota Panel", link: "/en/bt-install" },
|
||||
{ text: "Documents", link: "/en/documents" },
|
||||
{ text: "Changelog", link: "/en/changelog" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/en/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Q&A",
|
||||
items: [
|
||||
{ text: "History", link: "/en/history" },
|
||||
{ text: "Proposal", link: "/en/proposal" },
|
||||
{ text: "Support list", link: "/en/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
sidebar: {
|
||||
"/en/blog/": [
|
||||
{
|
||||
text: "Blog",
|
||||
items: [
|
||||
{ text: "Blog Home", link: "/en/blog/" },
|
||||
{
|
||||
text: "Video Downloader Review",
|
||||
link: "/en/blog/video-downloader-review/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Product Docs",
|
||||
items: [
|
||||
{ text: "Quick Start", link: "/en/guides" },
|
||||
{ text: "User Guide", link: "/en/documents" },
|
||||
{ text: "Browser Extension", link: "/en/extension" },
|
||||
{ text: "Download API", link: "/en/api" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"/en/": [
|
||||
{
|
||||
text: "Quick start",
|
||||
items: [
|
||||
{ text: "Quick start", link: "/en/guides" },
|
||||
{ text: "Baota Panel", link: "/en/bt-install" },
|
||||
{ text: "Documents", link: "/en/documents" },
|
||||
{ text: "Download API", link: "/en/api" },
|
||||
{ text: "Changelog", link: "/en/changelog" },
|
||||
{ text: "Browser extension", link: "/en/extension" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/en/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Q&A",
|
||||
items: [
|
||||
{ text: "History", link: "/en/history" },
|
||||
{ text: "Proposal", link: "/en/proposal" },
|
||||
{ text: "Support list", link: "/en/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{
|
||||
@@ -114,7 +401,9 @@ export default defineConfig({
|
||||
{ text: "早く始めます", link: "/jp/guides" },
|
||||
{ text: "塔のパネル配置です", link: "/jp/bt-install" },
|
||||
{ text: "使用説明書です", link: "/jp/documents" },
|
||||
{ text: "ダウンロード API", link: "/jp/api" },
|
||||
{ text: "ログを更新します。", link: "/jp/changelog" },
|
||||
{ text: "ブラウザ拡張機能", link: "/jp/extension" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/jp/skills" },
|
||||
],
|
||||
},
|
||||
@@ -128,6 +417,47 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{
|
||||
icon: "github",
|
||||
link: "https://github.com/caorushizi/mediago",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
it: {
|
||||
label: "Italiano",
|
||||
lang: "it",
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Home", link: "/it" },
|
||||
{ text: "Guide", link: "/it/guides" },
|
||||
{ text: "Changelog", link: "/it/changelog" },
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "Avvio rapido",
|
||||
items: [
|
||||
{ text: "Avvio rapido", link: "/it/guides" },
|
||||
{ text: "BT Panel", link: "/it/bt-install" },
|
||||
{ text: "Guida utente", link: "/it/documents" },
|
||||
{ text: "API di download", link: "/it/api" },
|
||||
{ text: "Changelog", link: "/it/changelog" },
|
||||
{ text: "Estensione browser", link: "/it/extension" },
|
||||
{ text: "🦞 OpenClaw Skill", link: "/it/skills" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Q&A",
|
||||
items: [
|
||||
{ text: "Versioni precedenti", link: "/it/history" },
|
||||
{ text: "Feedback", link: "/it/proposal" },
|
||||
{ text: "Siti supportati", link: "/it/list" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{
|
||||
icon: "github",
|
||||
|
||||
@@ -1,21 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { useData, inBrowser } from "vitepress";
|
||||
import { watchEffect } from "vue";
|
||||
import { useData, inBrowser, useRoute } from "vitepress";
|
||||
import { computed, watchEffect } from "vue";
|
||||
import Comments from "./components/Comments.vue";
|
||||
import Footer from "./components/Footer.vue";
|
||||
import QrCode from "./components/QrCode.vue";
|
||||
import TopBanner from "./components/TopBanner.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AdBanner from "./components/AdBanner.vue";
|
||||
|
||||
const { lang } = useData();
|
||||
const route = useRoute();
|
||||
const { locale } = useI18n();
|
||||
|
||||
const translatedBlogPaths = new Set([
|
||||
"/blog/video-downloader-review/",
|
||||
"/en/blog/video-downloader-review/",
|
||||
]);
|
||||
|
||||
const translatedBlogLinks: Record<
|
||||
string,
|
||||
{ href: string; label: string; note: string }
|
||||
> = {
|
||||
"/blog/video-downloader-review/": {
|
||||
href: "/en/blog/video-downloader-review/",
|
||||
label: "English",
|
||||
note: "这篇文章已有英文版",
|
||||
},
|
||||
"/en/blog/video-downloader-review/": {
|
||||
href: "/blog/video-downloader-review/",
|
||||
label: "简体中文",
|
||||
note: "This article is also available in Chinese",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeRoutePath(path: string) {
|
||||
return path.replace(/index\.html$/, "").replace(/\.html$/, "/");
|
||||
}
|
||||
|
||||
const normalizedPath = computed(() => normalizeRoutePath(route.path));
|
||||
const blogTranslationLink = computed(
|
||||
() => translatedBlogLinks[normalizedPath.value],
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
locale.value = lang.value;
|
||||
|
||||
if (inBrowser) {
|
||||
locale.value = lang.value;
|
||||
const isBlogPage =
|
||||
normalizedPath.value.startsWith("/blog/") ||
|
||||
normalizedPath.value.startsWith("/en/blog/") ||
|
||||
normalizedPath.value.startsWith("/jp/blog/") ||
|
||||
normalizedPath.value.startsWith("/it/blog/");
|
||||
|
||||
document.cookie = `nf_lang=${lang.value}; expires=Mon, 1 Jan 2030 00:00:00 UTC; path=/`;
|
||||
document.documentElement.classList.toggle("is-blog-page", isBlogPage);
|
||||
document.documentElement.classList.toggle(
|
||||
"has-blog-translation",
|
||||
translatedBlogPaths.has(normalizedPath.value),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,14 +64,15 @@ const { Layout } = DefaultTheme;
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #layout-top>
|
||||
<TopBanner />
|
||||
</template>
|
||||
<!-- <template #sidebar-nav-before>
|
||||
<AdBanner />
|
||||
</template> -->
|
||||
<template #doc-footer-before>
|
||||
<QrCode />
|
||||
<template #doc-before>
|
||||
<div
|
||||
v-if="blogTranslationLink"
|
||||
class="blog-language-switch"
|
||||
aria-label="Article language switch"
|
||||
>
|
||||
<span>{{ blogTranslationLink.note }}</span>
|
||||
<a :href="blogTranslationLink.href">{{ blogTranslationLink.label }}</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #doc-after>
|
||||
<Comments />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 MiB |
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="ad-container">
|
||||
<img src="/images/ad.png" alt="ad" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped>
|
||||
.ad-container {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -17,18 +17,7 @@
|
||||
<div class="text-sm text-[var(--vp-c-text-2)]">{{ t("slogan") }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧区域 -->
|
||||
<div class="flex flex-col md:flex-row gap-8 md:gap-12">
|
||||
<!-- 二维码部分 -->
|
||||
<div class="px-3 flex-shrink-0 mt-8 md:mt-0">
|
||||
<!-- <div class="font-semibold mb-2 text-[var(--vp-c-text-1)]">{{ t('contact') }}</div> -->
|
||||
<img
|
||||
src="../assets/wx.png"
|
||||
alt="WeChat QR Code"
|
||||
class="w-[280px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 帮助链接部分 -->
|
||||
<div class="px-3 text-sm flex-shrink-0 min-w-[150px] mt-8 md:mt-0">
|
||||
<div class="font-semibold mb-2 text-[var(--vp-c-text-1)]">
|
||||
@@ -37,29 +26,10 @@
|
||||
<ul class="list-none m-0 p-0">
|
||||
<li class="mb-1">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://mediago.pro/"
|
||||
href="/blog/"
|
||||
class="text-[var(--vp-c-text-2)] no-underline transition-colors hover:text-[var(--vp-c-text-1)] duration-300"
|
||||
>
|
||||
{{ t("search") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 友情链接部分 -->
|
||||
<div class="px-3 text-sm flex-shrink-0 min-w-[150px] mt-8 md:mt-0">
|
||||
<div class="font-semibold mb-2 text-[var(--vp-c-text-1)]">
|
||||
{{ t("links") }}
|
||||
</div>
|
||||
<ul class="list-none m-0 p-0">
|
||||
<li class="mb-1">
|
||||
<a
|
||||
href="https://www.jiexi.im/player/"
|
||||
class="text-[var(--vp-c-text-2)] no-underline transition-colors hover:text-[var(--vp-c-text-1)] duration-300"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t("jiexi.im") }}
|
||||
{{ t("blog") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-12 py-6 text-center border-t border-[var(--vp-c-divider)]">
|
||||
<img
|
||||
src="../assets/wx.png"
|
||||
alt="WeChat QR Code"
|
||||
class="w-[300px] object-contain mx-auto"
|
||||
/>
|
||||
<p class="mt-3 text-sm text-[var(--vp-c-text-2)]">
|
||||
扫码关注,获取更多精彩内容
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,245 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
const isVisible = ref(true);
|
||||
|
||||
const closeBanner = () => {
|
||||
isVisible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="top-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-left">
|
||||
<span class="banner-badge">{{ t("banner.new") }}</span>
|
||||
<span class="banner-text">
|
||||
<span class="banner-highlight">{{ t("banner.title") }}</span>
|
||||
{{ t("banner.desc") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<a
|
||||
href="https://mediago.torchstellar.com/?from=banner"
|
||||
target="_blank"
|
||||
class="banner-button"
|
||||
>
|
||||
{{ t("banner.action") }}
|
||||
<svg
|
||||
class="banner-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
class="banner-close"
|
||||
@click="closeBanner"
|
||||
:aria-label="t('banner.close')"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-shimmer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: var(--vp-layout-top-height, 60px);
|
||||
background: linear-gradient(135deg, #5e9ef3 0%, #2a82f6 50%, #1a6dd6 100%);
|
||||
padding: 16px 20px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding: 0 36px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px 5px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.banner-highlight {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banner-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.banner-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #2a82f6;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px 18px;
|
||||
border-radius: 25px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.banner-button:hover {
|
||||
background: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.banner-button:hover .banner-icon {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.banner-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.banner-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.banner-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.banner-shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.15),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.top-banner {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-left {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner-button {
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.banner-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,49 +13,22 @@ const i18n = createI18n({
|
||||
slogan: "Easy to use, fast download",
|
||||
articles: "Articles",
|
||||
help: "Help",
|
||||
search: "intelligent search",
|
||||
blog: "Blog",
|
||||
privacy: "Privacy Policy",
|
||||
links: "Links",
|
||||
"jiexi.im": "Smart Video Analysis",
|
||||
banner: {
|
||||
new: "NEW",
|
||||
title: "MediaGo Pro is Live!",
|
||||
desc: "- Experience faster downloads & exclusive features",
|
||||
action: "Try Now",
|
||||
close: "Close",
|
||||
},
|
||||
},
|
||||
jp: {
|
||||
slogan: "使いやすく、ダウンロードも速い",
|
||||
articles: "記事",
|
||||
help: "ヘルプ",
|
||||
search: "スマート検索",
|
||||
blog: "ブログ",
|
||||
privacy: "プライバシーポリシー",
|
||||
links: "友情リンク",
|
||||
"jiexi.im": "スマートビデオ解析",
|
||||
banner: {
|
||||
new: "新着",
|
||||
title: "MediaGo Pro がリリースされました!",
|
||||
desc: "- より高速なダウンロードと限定機能をお楽しみください",
|
||||
action: "今すぐ試す",
|
||||
close: "閉じる",
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
slogan: "简单易用,快速下载",
|
||||
articles: "文章",
|
||||
help: "帮助",
|
||||
search: "智能搜索",
|
||||
blog: "博客",
|
||||
privacy: "隐私政策",
|
||||
links: "友情链接",
|
||||
"jiexi.im": "智能视频解析",
|
||||
banner: {
|
||||
new: "全新上线",
|
||||
title: "MediaGo Pro 正式发布!",
|
||||
desc: "- 更快的下载速度,更多专属功能",
|
||||
action: "立即体验",
|
||||
close: "关闭",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Top Banner - 使用 VitePress 官方变量 */
|
||||
:root {
|
||||
--vp-layout-top-height: 60px;
|
||||
/* Keep blog tables inside the article column. */
|
||||
.vp-doc[class*="_blog_"] table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 移动端横幅高度 */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--vp-layout-top-height: 80px;
|
||||
.vp-doc[class*="_blog_"] th,
|
||||
.vp-doc[class*="_blog_"] td {
|
||||
min-width: 120px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.vp-doc[class*="_blog_"] th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Blog translations are handled by an explicit article-level link to avoid untranslated locale URLs. */
|
||||
body:has(.vp-doc[class*="_blog_"]) .VPNavBarTranslations,
|
||||
body:has(.vp-doc[class*="_blog_"]) .VPNavBarExtra .translations,
|
||||
body:has(.vp-doc[class*="_blog_"]) .VPNavScreen .translations {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.blog-language-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 0 24px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.blog-language-switch a {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.blog-language-switch a:hover {
|
||||
background: var(--vp-c-brand-2);
|
||||
color: var(--vp-c-white);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.blog-language-switch {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 下载接口
|
||||
|
||||
MediaGo 把下载核心暴露成一个 HTTP 服务。桌面端在 `39719` 端口,Docker 部署在 `9900` 端口。
|
||||
|
||||
你可以用任何支持 HTTP 的工具(curl / Python / Node.js / Postman 等)直接调用接口,新建下载任务、启动、停止、查询进度 —— MediaGo 自己的浏览器扩展、AI Skill 都是这套接口的消费者。
|
||||
|
||||
## 基础信息
|
||||
|
||||
### 接口地址
|
||||
|
||||
| 部署方式 | Base URL |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| 桌面端 | `http://localhost:39719` |
|
||||
| Docker | `http://<服务器地址>:9900`(按实际 `-p` 端口映射调整) |
|
||||
|
||||
所有接口都在 `/api` 前缀下。下文示例默认用桌面端的 `39719` 端口,Docker 部署请自行替换。
|
||||
|
||||
### 响应格式
|
||||
|
||||
所有 `/api/*` 接口都返回统一的 JSON 包裹结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------ | --------------------------- |
|
||||
| `success` | bool | 业务是否成功 |
|
||||
| `code` | number | 业务错误码,`0` 表示成功 |
|
||||
| `message` | string | 人类可读的提示 |
|
||||
| `data` | any | 实际响应载荷,结构因接口而异 |
|
||||
|
||||
下文示例中的"响应"只展示 `data` 字段的内容。
|
||||
|
||||
### 认证
|
||||
|
||||
- **桌面端**:默认**无需认证**,直接请求 `localhost:39719` 即可
|
||||
- **Docker 部署**:启用认证时,在 MediaGo **设置页面**中获取 API Key,之后的请求带 `Authorization: Bearer <key>` 头
|
||||
|
||||
## 快速上手
|
||||
|
||||
下面这三条命令串起"新建 → 开始下载 → 完成通知"的完整流程。
|
||||
|
||||
### 1. 新建下载任务
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:39719/api/downloads \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tasks": [
|
||||
{
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"name": "我的视频"
|
||||
}
|
||||
],
|
||||
"startDownload": true
|
||||
}'
|
||||
```
|
||||
|
||||
- `type`:下载类型,可选 `m3u8` / `bilibili` / `direct` / `youtube` / `mediago`
|
||||
- `url`:视频链接
|
||||
- `name`:任务名称(会作为保存文件名)
|
||||
- `startDownload`:创建后是否立即开始下载
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"name": "我的视频",
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"status": "waiting",
|
||||
"createdDate": "2026-04-23T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
记下返回的 `id`,后续接口会用到。
|
||||
|
||||
### 2. 订阅下载事件(SSE)
|
||||
|
||||
```bash
|
||||
curl -N http://localhost:39719/api/events
|
||||
```
|
||||
|
||||
这是一条长连接,服务端推什么、你收什么:
|
||||
|
||||
```text
|
||||
event: download-start
|
||||
data: {"id": "123"}
|
||||
|
||||
event: download-success
|
||||
data: {"id": "123"}
|
||||
```
|
||||
|
||||
浏览器 / Node.js 里:
|
||||
|
||||
```javascript
|
||||
const es = new EventSource("http://localhost:39719/api/events");
|
||||
es.addEventListener("download-success", (e) => {
|
||||
const { id } = JSON.parse(e.data);
|
||||
console.log("任务完成:", id);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 查询状态 / 手动控制
|
||||
|
||||
```bash
|
||||
# 列出所有下载任务(分页)
|
||||
curl "http://localhost:39719/api/downloads?current=1&pageSize=20"
|
||||
|
||||
# 查单个任务
|
||||
curl http://localhost:39719/api/downloads/123
|
||||
|
||||
# 启动已存在的任务
|
||||
curl -X POST http://localhost:39719/api/downloads/123/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"localPath": "/Downloads/MediaGo", "deleteSegments": true}'
|
||||
|
||||
# 停止任务
|
||||
curl -X POST http://localhost:39719/api/downloads/123/stop
|
||||
|
||||
# 查下载日志
|
||||
curl http://localhost:39719/api/downloads/123/logs
|
||||
```
|
||||
|
||||
## 下载事件
|
||||
|
||||
`GET /api/events` 是 Server-Sent Events 流,下载相关的事件:
|
||||
|
||||
| 事件名 | 载荷 | 说明 |
|
||||
| ------------------ | -------------------------------- | ------------ |
|
||||
| `download-create` | `{ids: number[], count: number}` | 批量创建任务 |
|
||||
| `download-start` | `{id: string}` | 下载开始 |
|
||||
| `download-success` | `{id: string}` | 下载成功 |
|
||||
| `download-failed` | `{id: string, error: string}` | 下载失败 |
|
||||
| `download-stop` | `{id: string}` | 下载手动停止 |
|
||||
|
||||
## 接口参考
|
||||
|
||||
### 列表 / 查询
|
||||
|
||||
#### `GET /api/downloads` — 分页列出下载任务
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
- `current` (number, 默认 1):页码
|
||||
- `pageSize` (number, 默认 20):每页条数
|
||||
- `filter` (string, 可选):按状态筛选,如 `downloading` / `success` / `failed`
|
||||
- `localPath` (string, 可选):按保存路径筛选
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 42,
|
||||
"list": [
|
||||
/* DownloadTask[] */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/downloads/active` — 列出活动中的任务
|
||||
|
||||
返回所有 `waiting` / `downloading` 状态的任务。
|
||||
|
||||
#### `GET /api/downloads/:id` — 查单个任务
|
||||
|
||||
**响应**(`DownloadTask` 结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "我的视频",
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"folder": "my-folder",
|
||||
"headers": "User-Agent: ...",
|
||||
"isLive": false,
|
||||
"status": "success",
|
||||
"file": "/path/to/saved.mp4",
|
||||
"createdDate": "2026-04-23T10:00:00Z",
|
||||
"updatedDate": "2026-04-23T10:05:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/downloads/folders` — 列出所有不重复的保存目录
|
||||
|
||||
**响应:** `string[]`
|
||||
|
||||
#### `GET /api/downloads/export` — 导出下载列表
|
||||
|
||||
返回纯文本,每行一个 URL。
|
||||
|
||||
#### `GET /api/downloads/:id/logs` — 查下载日志
|
||||
|
||||
**响应:** `{ id, log: string }`
|
||||
|
||||
### 创建 / 删除
|
||||
|
||||
#### `POST /api/downloads` — 批量新建下载
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"type": "m3u8 | bilibili | direct | youtube | mediago",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"name": "任务名",
|
||||
"folder": "可选子目录",
|
||||
"headers": "可选,多行 HTTP 头"
|
||||
}
|
||||
],
|
||||
"startDownload": true
|
||||
}
|
||||
```
|
||||
|
||||
**响应:** `DownloadTask[]`
|
||||
|
||||
#### `DELETE /api/downloads/:id` — 删除任务
|
||||
|
||||
**响应:** `{}`
|
||||
|
||||
### 编辑 / 状态
|
||||
|
||||
#### `PUT /api/downloads/:id` — 编辑任务
|
||||
|
||||
**请求体**(字段都可选):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "新名字",
|
||||
"url": "新 URL",
|
||||
"headers": "新的 headers",
|
||||
"folder": "新的子目录"
|
||||
}
|
||||
```
|
||||
|
||||
#### `PUT /api/downloads/:id/live` — 标记 / 取消直播流
|
||||
|
||||
**请求体:** `{ "isLive": true }`
|
||||
|
||||
#### `PUT /api/downloads/status` — 批量修改任务状态
|
||||
|
||||
**请求体:** `{ "ids": number[], "status": "waiting | downloading | success | failed | stopped" }`
|
||||
|
||||
### 启动 / 停止
|
||||
|
||||
#### `POST /api/downloads/:id/start` — 启动下载
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"localPath": "/Users/me/Downloads/MediaGo",
|
||||
"deleteSegments": true
|
||||
}
|
||||
```
|
||||
|
||||
- `localPath`:保存到哪里(绝对路径)
|
||||
- `deleteSegments`:m3u8 下载完成后是否删除分段 `.ts` 文件
|
||||
|
||||
#### `POST /api/downloads/:id/stop` — 停止下载
|
||||
|
||||
**响应:** `{}`
|
||||
|
||||
## 枚举值
|
||||
|
||||
### 下载类型 `type`
|
||||
|
||||
| 值 | 说明 |
|
||||
| ---------- | ----------------------------------- |
|
||||
| `m3u8` | HLS 流媒体(底层 N_m3u8DL-RE) |
|
||||
| `bilibili` | B 站视频(底层 BBDown) |
|
||||
| `direct` | 直接 HTTP 下载(底层 aria2) |
|
||||
| `youtube` | YouTube 及 yt-dlp 支持的 1000+ 站点 |
|
||||
| `mediago` | MediaGo 内部类型 |
|
||||
|
||||
### 任务状态 `status`
|
||||
|
||||
| 值 | 说明 |
|
||||
| ------------- | ---------- |
|
||||
| `waiting` | 等待开始 |
|
||||
| `downloading` | 下载中 |
|
||||
| `success` | 已完成 |
|
||||
| `failed` | 失败 |
|
||||
| `stopped` | 已手动停止 |
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: MediaGo 博客
|
||||
description: MediaGo 博客围绕网页视频下载、M3U8/HLS、视频嗅探、NAS/Docker 部署和下载器评测,提供面向搜索和 AI 摘要的专题指南。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [MediaGo, 视频下载器, m3u8, HLS, 视频嗅探]
|
||||
---
|
||||
|
||||
# MediaGo 博客
|
||||
|
||||
这里是 MediaGo 的学习中心,内容围绕网页视频下载、M3U8/HLS、视频嗅探、NAS/Docker 部署、下载器评测和自动化工作流展开。
|
||||
|
||||
如果你刚开始了解 MediaGo,建议先看 [快速开始](/guides);如果你正在比较工具,优先阅读 [2026 年视频下载器推荐](/blog/video-downloader-recommendation/),再进入完整评测报告。
|
||||
|
||||
## 推荐阅读路径
|
||||
|
||||
| 主题 | 适合问题 | 推荐入口 |
|
||||
| ------------ | ------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| 工具推荐 | 哪个视频下载器更适合我?免费、电脑端、m3u8、IDM、猫抓怎么选? | [视频下载器推荐](/blog/video-downloader-recommendation/) |
|
||||
| 深度评测 | MediaGo 和 yt-dlp、4K、NAS 工具有什么区别? | [视频下载器评测](/blog/video-downloader-review/) |
|
||||
| 网页视频下载 | 网页里的视频怎么下载?B站视频怎么保存?复制链接无效怎么办? | [网页视频下载指南](/blog/video-download/) |
|
||||
| M3U8 / HLS | m3u8 视频怎么下载?下载失败、转 MP4、NAS 工具怎么选? | [M3U8 / HLS 下载指南](/blog/m3u8-hls-download/) |
|
||||
| 视频嗅探 | 视频嗅探器浏览器哪个好用?插件、内置浏览器、网页工具怎么选? | [网页视频嗅探指南](/blog/video-sniffer/) |
|
||||
|
||||
## 支柱专题
|
||||
|
||||
### [2026 年视频下载器推荐](/blog/video-downloader-recommendation/)
|
||||
|
||||
面向国内搜索里的“视频下载器推荐”“视频下载软件哪个好”“m3u8 下载器”“IDM 下载器”“猫抓下载器”等需求,先给出按场景选择工具的结论。
|
||||
|
||||
配套文章:
|
||||
|
||||
- [IDM 下载器和 MediaGo 怎么选?](/blog/video-downloader-review/idm-vs-mediago/)
|
||||
- [m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?](/blog/video-downloader-review/m3u8-downloader-recommendation/)
|
||||
- [猫抓下载器和 MediaGo 有什么区别?](/blog/video-downloader-review/cat-catch-vs-mediago/)
|
||||
- [MediaGo 和 yt-dlp 怎么选?](/blog/video-downloader-review/mediago-vs-ytdlp/)
|
||||
|
||||
### [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
|
||||
从站点覆盖、HLS/M3U8、浏览器嗅探、NAS/Docker、API、AI 后处理等维度,对比 MediaGo、yt-dlp、4K Video Downloader、Video DownloadHelper、MeTube、Tube Archivist 等工具。
|
||||
|
||||
子文章:
|
||||
|
||||
- [IDM 下载器和 MediaGo 怎么选?](/blog/video-downloader-review/idm-vs-mediago/)
|
||||
- [m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?](/blog/video-downloader-review/m3u8-downloader-recommendation/)
|
||||
- [猫抓下载器和 MediaGo 有什么区别?](/blog/video-downloader-review/cat-catch-vs-mediago/)
|
||||
- [MediaGo 和 yt-dlp 怎么选?](/blog/video-downloader-review/mediago-vs-ytdlp/)
|
||||
- [MediaGo 和 4K Video Downloader 怎么选?](/blog/video-downloader-review/mediago-vs-4k-video-downloader/)
|
||||
- [NAS 视频下载器怎么选?](/blog/video-downloader-review/nas-video-downloader-tools/)
|
||||
|
||||
### [网页视频下载完整指南](/blog/video-download/)
|
||||
|
||||
解释网页视频下载的常见入口、使用场景、操作流程和工具选择,适合作为普通用户的入门专题。
|
||||
|
||||
子文章:
|
||||
|
||||
- [网页视频怎么下载?](/blog/video-download/download-web-video/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
- [B站视频怎么下载?](/blog/video-download/bilibili-video-download/)
|
||||
- [B站视频怎么转文字稿、提取字幕和音频?](/blog/video-download/bilibili-video-to-text-audio/)
|
||||
- [课程回看视频怎么下载?](/blog/video-download/download-course-video/)
|
||||
- [视频下载工作流怎么搭?](/blog/video-download/video-download-workflow/)
|
||||
|
||||
### [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
|
||||
围绕 m3u8、HLS、DASH、直播流和分段视频,说明协议差异、下载流程和常见失败原因。
|
||||
|
||||
子文章:
|
||||
|
||||
- [M3U8 是什么?](/blog/m3u8-hls-download/what-is-m3u8/)
|
||||
- [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/)
|
||||
- [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)
|
||||
- [m3u8 下载后怎么转 MP4?](/blog/m3u8-hls-download/m3u8-to-mp4/)
|
||||
|
||||
### [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
|
||||
解释浏览器扩展、内置浏览器和桌面端嗅探的区别,帮助用户选择更稳定的网页视频识别方式。
|
||||
|
||||
子文章:
|
||||
|
||||
- [Chrome 视频嗅探怎么用?](/blog/video-sniffer/chrome-video-sniffer/)
|
||||
- [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/)
|
||||
- [为什么网页视频嗅探到很多资源?](/blog/video-sniffer/detect-video-resources/)
|
||||
|
||||
## 相关产品文档
|
||||
|
||||
- [快速开始](/guides)
|
||||
- [使用说明](/documents)
|
||||
- [浏览器扩展](/extension)
|
||||
- [下载接口](/api)
|
||||
- [Docker / 宝塔面板部署](/bt-install)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: m3u8 视频怎么下载?MediaGo 下载 HLS 视频教程
|
||||
description: 介绍 m3u8/HLS 视频的下载步骤,包括网页嗅探、选择资源、下载分片、合并保存和常见注意事项。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8 下载, HLS 下载, 视频下载, MediaGo]
|
||||
---
|
||||
|
||||
# m3u8 视频怎么下载?
|
||||
|
||||
一句话答案:可以用 MediaGo 打开视频页面,自动识别 HLS/M3U8 资源,然后选择目标清晰度加入下载队列,由下载器完成分片下载和合并保存。
|
||||
|
||||
## 快速步骤
|
||||
|
||||
1. 打开 MediaGo。
|
||||
2. 使用内置浏览器访问目标视频页面。
|
||||
3. 播放视频并等待资源识别。
|
||||
4. 在资源列表中选择 m3u8/HLS 项。
|
||||
5. 添加下载任务。
|
||||
6. 等待分片下载、合并和保存完成。
|
||||
|
||||
## 下载前检查
|
||||
|
||||
- 视频页面是否可以正常播放;
|
||||
- 是否需要登录或授权访问;
|
||||
- 目标资源是否有多个清晰度;
|
||||
- 下载路径是否有足够空间;
|
||||
- 是否需要保留字幕、音频或封面。
|
||||
|
||||
## 为什么推荐内置浏览器
|
||||
|
||||
内置浏览器可以保留页面播放过程中的上下文信息,比如 Referer、Cookie、User-Agent 和临时请求。对于复杂的 HLS 页面,它比手动复制 m3u8 更稳定。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/) 的子文章,用于承接 “m3u8 视频怎么下载” 这个核心长尾词。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [M3U8 是什么?](/blog/m3u8-hls-download/what-is-m3u8/)
|
||||
- [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: m3u8 下载失败怎么办?HLS 视频常见问题排查
|
||||
description: 总结 m3u8/HLS 视频下载失败的常见原因,包括链接过期、请求头缺失、分片失败、音视频分离和直播流停止条件。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8 下载失败, HLS, 视频下载问题, MediaGo]
|
||||
---
|
||||
|
||||
# m3u8 下载失败怎么办?
|
||||
|
||||
一句话答案:m3u8 下载失败通常和链接过期、请求头缺失、分片无法访问、音视频分离或直播流规则有关。优先用 MediaGo 内置浏览器重新识别资源,再确认页面是否仍能正常播放。
|
||||
|
||||
## 常见原因
|
||||
|
||||
| 问题 | 可能原因 | 处理建议 |
|
||||
| -------------------- | ---------------------------------- | ---------------------- |
|
||||
| 任务一开始就失败 | m3u8 地址过期 | 重新打开页面并识别资源 |
|
||||
| 部分分片失败 | 网络波动或分片地址失效 | 重试任务或降低并发 |
|
||||
| 播放器能看但下载失败 | 缺少 Referer、Cookie 或 User-Agent | 使用内置浏览器识别 |
|
||||
| 只有视频没有声音 | 音视频分离 | 检查是否需要混流 |
|
||||
| 直播流无法结束 | 没有固定总时长 | 设置停止条件或手动停止 |
|
||||
|
||||
## 排查顺序
|
||||
|
||||
1. 确认网页还能正常播放;
|
||||
2. 重新用内置浏览器打开页面;
|
||||
3. 重新选择资源并创建任务;
|
||||
4. 检查网络和保存路径;
|
||||
5. 如果是直播流,确认停止条件;
|
||||
6. 如果仍失败,再考虑手动工具或日志排查。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/) 的子文章,用于承接下载失败和问题排查场景。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [M3U8 是什么?](/blog/m3u8-hls-download/what-is-m3u8/)
|
||||
- [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: M3U8 / HLS 视频下载完整指南
|
||||
description: 解释 M3U8、HLS、DASH、直播流和分段视频的关系,并介绍如何使用 MediaGo 下载和处理网页中的流媒体资源。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8, HLS, DASH, 直播流, MediaGo]
|
||||
---
|
||||
|
||||
# M3U8 / HLS 视频下载完整指南
|
||||
|
||||
一句话答案:m3u8 是 HLS 流媒体常见的播放列表格式,里面通常记录了一组视频分片地址。MediaGo 可以识别网页中的 HLS/M3U8 资源,并调用内置下载能力完成分片下载、合并和保存。
|
||||
|
||||
这篇支柱页覆盖“m3u8下载”“m3u8下载器”“m3u8下载器电脑版”“m3u8下载转mp4工具”“HLS下载”等搜索意图。概念、教程、排错和工具选型会分到不同子文章里。
|
||||
|
||||
## M3U8、HLS 和 DASH 是什么
|
||||
|
||||
| 名称 | 说明 | 常见场景 |
|
||||
| ------------- | ------------------------------------------------------ | ---------------------------- |
|
||||
| M3U8 | HLS 播放列表文件,通常包含清晰度、分片地址或子播放列表 | 网页视频、课程回看、直播回放 |
|
||||
| HLS | Apple 推出的 HTTP Live Streaming 协议 | 移动端播放、直播、长视频平台 |
|
||||
| DASH / MPD | 另一类自适应流媒体协议 | YouTube、部分国际视频平台 |
|
||||
| TS / M4S 分片 | 实际承载视频和音频的数据片段 | HLS/DASH 下载和合并 |
|
||||
|
||||
## 为什么 m3u8 下载容易失败
|
||||
|
||||
- 播放地址有时效性,过期后无法继续访问;
|
||||
- 视频分片需要 Referer、Cookie、User-Agent 等请求头;
|
||||
- 音频和视频可能分离,需要下载后重新混流;
|
||||
- 直播流没有固定结束点,需要按任务规则停止;
|
||||
- 部分页面只暴露播放器地址,不直接暴露 m3u8 地址。
|
||||
|
||||
## MediaGo 的处理方式
|
||||
|
||||
MediaGo 的价值在于把“识别资源”和“下载处理”连起来。用户不需要手动抓包、复制 m3u8、拼接 ts 分片或反复调整命令行参数。
|
||||
|
||||
推荐流程:
|
||||
|
||||
1. 用 MediaGo 内置浏览器打开视频页面。
|
||||
2. 等待资源列表识别出 HLS/M3U8 或直播流。
|
||||
3. 选择目标清晰度和视频资源。
|
||||
4. 添加下载任务。
|
||||
5. 下载完成后按需进行格式转换或移动端播放。
|
||||
|
||||
## 什么时候用专业命令行工具
|
||||
|
||||
如果你已经拿到了稳定的 m3u8、mpd 或直播流地址,并且需要精细控制 headers、分片并发、混流、字幕、断点续传等参数,N_m3u8DL-RE、FFmpeg 和 Streamlink 仍然适合高级场景。
|
||||
|
||||
如果你希望把这些能力放到图形界面、浏览器嗅探、下载队列和 NAS/Docker 工作流里,MediaGo 会更省心。
|
||||
|
||||
## 按搜索问题快速进入
|
||||
|
||||
| 你搜索的问题 | 应该先看 |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| m3u8 是什么、HLS 是什么 | [M3U8 是什么?](/blog/m3u8-hls-download/what-is-m3u8/) |
|
||||
| m3u8 视频怎么下载 | [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/) |
|
||||
| m3u8 下载失败、key 请求失败 | [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/) |
|
||||
| m3u8 下载转 MP4 工具 | [m3u8 下载后怎么转 MP4?](/blog/m3u8-hls-download/m3u8-to-mp4/) |
|
||||
| 好用的 m3u8 下载器、电脑版、NAS | [m3u8 下载器推荐](/blog/video-downloader-review/m3u8-downloader-recommendation/) |
|
||||
|
||||
## 专题文章
|
||||
|
||||
| 文章 | 解决的问题 | 目标搜索意图 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | --------------------------- |
|
||||
| [M3U8 是什么?](/blog/m3u8-hls-download/what-is-m3u8/) | 解释 M3U8、HLS、TS 分片关系 | m3u8 是什么、HLS 是什么 |
|
||||
| [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/) | 具体下载步骤和工具流程 | m3u8 视频怎么下载、HLS 下载 |
|
||||
| [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/) | 排查链接过期、请求头、分片失败 | m3u8 下载失败、HLS 下载失败 |
|
||||
| [m3u8 下载后怎么转 MP4?](/blog/m3u8-hls-download/m3u8-to-mp4/) | 分片合并、转码、离线播放 | m3u8 下载转 mp4 工具 |
|
||||
| [m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?](/blog/video-downloader-review/m3u8-downloader-recommendation/) | 工具选型、桌面端、NAS 场景 | m3u8 下载器、HLS 下载工具 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### m3u8 文件就是视频文件吗?
|
||||
|
||||
不是。m3u8 通常是播放列表,真正的视频数据在它引用的分片里。下载工具需要读取播放列表,再逐个获取分片并合并。
|
||||
|
||||
### 为什么播放器能看,下载器不能下?
|
||||
|
||||
播放器可能携带了页面上下文、Cookie、Referer 或临时签名。下载器如果没有这些请求信息,就可能访问失败。
|
||||
|
||||
### 直播流可以下载吗?
|
||||
|
||||
可以,但直播流和普通点播不同。它通常没有固定总时长,需要下载器持续读取新分片,并按用户设置的时间或手动停止条件结束任务。
|
||||
|
||||
### m3u8 下载后一定要转 MP4 吗?
|
||||
|
||||
不一定。如果播放器能直接读取本地 HLS 文件夹,可以不转。但对普通用户来说,MP4 更适合离线播放、移动设备传输和长期归档。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: m3u8 下载后怎么转 MP4?自动合并、转码和离线播放完整说明
|
||||
description: 解释 m3u8 下载转 MP4 的流程,说明为什么下载后只有几 KB、为什么需要合并 TS/M4S 分片,以及如何用 MediaGo、FFmpeg 等工具完成转码。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8下载转mp4工具, m3u8下载, HLS下载, m3u8转MP4, 视频格式转换]
|
||||
---
|
||||
|
||||
# m3u8 下载后怎么转 MP4?自动合并、转码和离线播放完整说明
|
||||
|
||||
一句话答案:m3u8 本身通常不是视频文件,而是 HLS 播放列表。要转成 MP4,需要先读取播放列表,下载里面的 TS/M4S 分片,再合并或转码成一个本地 MP4 文件。MediaGo 适合普通用户完成识别、下载、合并和后续格式处理;FFmpeg 更适合高级参数和脚本化处理。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料和个人素材归档。
|
||||
|
||||
## m3u8、TS 分片和 MP4 的区别
|
||||
|
||||
| 名称 | 它是什么 | 能不能直接当视频播放 |
|
||||
| -------- | ------------------------------ | -------------------------------- |
|
||||
| m3u8 | 播放列表,记录分片地址和清晰度 | 通常不能,它只是索引文件 |
|
||||
| TS / M4S | 真实的视频或音频分片 | 单个分片通常不适合独立观看 |
|
||||
| MP4 | 常见本地视频容器 | 可以,适合离线播放和跨设备保存 |
|
||||
| HLS | 使用 m3u8 和分片的流媒体协议 | 需要播放器或下载器按顺序读取分片 |
|
||||
|
||||
如果你下载后只看到一个几 KB 的 m3u8 文件,说明你保存的是播放列表,不是完整视频。
|
||||
|
||||
## m3u8 转 MP4 的完整流程
|
||||
|
||||
1. 获取有效的 m3u8 播放列表。
|
||||
2. 读取播放列表里的分片地址。
|
||||
3. 下载所有 TS/M4S 分片。
|
||||
4. 如果音频和视频分离,需要分别下载后混流。
|
||||
5. 合并分片,必要时转码为 MP4。
|
||||
6. 检查时长、画面、声音和字幕是否正常。
|
||||
|
||||
普通用户不建议手动复制分片地址。分片数量可能很多,而且地址经常带临时签名,复制后很快过期。
|
||||
|
||||
## 用 MediaGo 处理 m3u8 转 MP4
|
||||
|
||||
MediaGo 更适合不想写命令的用户。推荐方式是:
|
||||
|
||||
1. 用 MediaGo 内置浏览器打开视频页面。
|
||||
2. 等待资源列表识别出 HLS/M3U8。
|
||||
3. 选择目标清晰度或主播放列表。
|
||||
4. 添加下载任务。
|
||||
5. 下载完成后按需进行格式转换或移动端播放。
|
||||
|
||||
这种方式的关键是保留页面上下文。很多 m3u8 资源需要 Referer、Cookie、User-Agent 或临时签名,直接复制链接可能会失败。
|
||||
|
||||
## 用 FFmpeg 转 MP4 适合什么情况
|
||||
|
||||
如果你已经拿到了稳定、有效、可访问的 m3u8 地址,并且熟悉命令行,可以使用 FFmpeg 处理。
|
||||
|
||||
常见场景包括:
|
||||
|
||||
- 已有本地 m3u8 和分片文件;
|
||||
- 需要批量转码;
|
||||
- 需要指定编码、码率、字幕或音轨;
|
||||
- 需要在服务器或自动化脚本里处理。
|
||||
|
||||
但如果 m3u8 地址依赖浏览器登录态、临时签名或请求头,单独拿 FFmpeg 处理可能会遇到 403、key 请求失败或分片下载失败。这时先用 MediaGo 重新识别资源更稳。
|
||||
|
||||
## 下载后还用联网吗
|
||||
|
||||
如果已经完整下载并合并为 MP4,本地播放通常不需要联网。如果你只保存了 m3u8 播放列表,播放时仍然需要访问原始分片地址;一旦分片过期或服务器不可访问,就无法播放。
|
||||
|
||||
所以判断是否真正离线保存,可以看这三点:
|
||||
|
||||
1. 文件是不是一个完整 MP4,而不是几 KB 的 m3u8;
|
||||
2. 断网后是否能播放;
|
||||
3. 视频时长、声音和画面是否完整。
|
||||
|
||||
## 转 MP4 后没有画面或没有声音怎么办
|
||||
|
||||
| 现象 | 可能原因 | 处理方式 |
|
||||
| ------------ | -------------------------- | ------------------------ |
|
||||
| 有声音没画面 | 视频轨没有合并或编码不兼容 | 重新下载完整视频轨并转码 |
|
||||
| 有画面没声音 | 音频轨分离或缺失 | 检查是否有单独音频资源 |
|
||||
| 时长不完整 | 分片缺失或下载中断 | 重新识别资源后重试 |
|
||||
| 文件无法播放 | 容器或编码不兼容 | 转为通用 MP4 编码 |
|
||||
|
||||
如果问题发生在下载阶段,先看:[m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### m3u8 下载转 MP4 工具怎么选?
|
||||
|
||||
普通用户优先选择能自动识别、下载、合并和转换的图形界面工具,例如 MediaGo。开发者和高级用户可以选择 FFmpeg、N_m3u8DL-RE 等命令行工具。
|
||||
|
||||
### m3u8 下载器电脑版一定比浏览器插件好吗?
|
||||
|
||||
不一定。插件适合发现资源,桌面端适合下载、合并、转码和失败重试。复杂网页通常需要两者配合,或直接使用内置浏览器。
|
||||
|
||||
### m3u8 转 MP4 会降低画质吗?
|
||||
|
||||
如果只是无损封装或合并,画质通常不会明显变化。如果重新编码、压缩码率或转换格式,画质可能下降。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/)
|
||||
- [m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?](/blog/video-downloader-review/m3u8-downloader-recommendation/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: M3U8 是什么?HLS 播放列表、TS 分片和网页视频的关系
|
||||
description: 用通俗方式解释 M3U8、HLS、TS 分片和网页视频播放的关系,帮助用户理解为什么 m3u8 不是普通视频文件。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8, HLS, TS 分片, 流媒体]
|
||||
---
|
||||
|
||||
# M3U8 是什么?
|
||||
|
||||
一句话答案:M3U8 通常不是视频文件本身,而是 HLS 流媒体的播放列表,里面记录了不同清晰度、音视频轨道或视频分片地址。
|
||||
|
||||
## M3U8 的作用
|
||||
|
||||
播放器拿到 m3u8 后,会继续读取里面的分片地址,再按顺序下载并播放这些分片。用户看到的是连续视频,底层可能是一系列 ts 或 m4s 小文件。
|
||||
|
||||
## 常见结构
|
||||
|
||||
| 内容 | 说明 |
|
||||
| ------------- | ------------------------------- |
|
||||
| 主播放列表 | 可能包含多个清晰度或码率 |
|
||||
| 子播放列表 | 指向具体分片列表 |
|
||||
| TS / M4S 分片 | 实际承载视频或音频数据 |
|
||||
| Key 信息 | 某些 HLS 流可能包含加密相关信息 |
|
||||
|
||||
## 为什么理解 M3U8 很重要
|
||||
|
||||
理解 m3u8 之后,就能知道为什么普通复制链接经常失败:你复制到的可能只是页面地址,真正需要处理的是播放列表、分片、请求头和有效期。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/) 的子文章,负责解释基础概念。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/)
|
||||
- [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: B站视频怎么下载?保存到电脑、手机本地和后续处理方法
|
||||
description: 面向“b站视频怎么下载”“b站视频怎么下载到本地”“b站视频如何下载到电脑/手机”的合规教程,说明公开内容、自有稿件和课程回看的保存思路。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags:
|
||||
[b站视频怎么下载, b站视频下载, b站视频下载到本地, b站视频下载工具, Bilibili]
|
||||
---
|
||||
|
||||
# B站视频怎么下载?保存到电脑、手机本地和后续处理方法
|
||||
|
||||
一句话答案:B站视频下载要先确认你是否有权保存该内容。对公开可访问内容、自有稿件、已授权素材、课程回看或企业内部资料,可以用 MediaGo 这类工具在电脑端识别真实视频资源,再保存到本地、NAS 或后续处理流程中。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料和个人素材归档,不讨论去水印、绕过权限、破解会员内容或下载受保护内容。
|
||||
|
||||
## 适合哪些场景
|
||||
|
||||
| 场景 | 是否适合写入下载流程 | 说明 |
|
||||
| ---------------------- | -------------------- | ------------------------ |
|
||||
| 自己发布的稿件 | 适合 | 用于备份、剪辑、归档 |
|
||||
| 已获授权的视频素材 | 适合 | 适合团队协作和素材管理 |
|
||||
| 课程回看或学习资料 | 视授权而定 | 只处理有保存权限的内容 |
|
||||
| 公开素材或公开视频 | 视平台规则而定 | 需要遵守平台和原作者要求 |
|
||||
| 会员、付费、受保护内容 | 不建议 | 不讨论绕过限制或访问控制 |
|
||||
|
||||
## B站视频下载到电脑怎么做
|
||||
|
||||
推荐先用电脑端处理,因为电脑端更适合识别、下载、合并、转码和长期管理。
|
||||
|
||||
1. 安装 MediaGo 桌面端或部署 Docker/NAS 版本。
|
||||
2. 打开需要保存的 B站视频页面。
|
||||
3. 用 MediaGo 内置浏览器或浏览器扩展识别视频资源。
|
||||
4. 在资源列表里选择目标视频、清晰度或音视频资源。
|
||||
5. 添加到下载队列,等待下载、合并和格式处理完成。
|
||||
6. 按需移动到素材库、NAS、课程资料文件夹或后续处理流程。
|
||||
|
||||
如果复制页面链接后不能下载,通常是因为页面地址不是视频真实地址。可以看:[网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)。
|
||||
|
||||
## B站视频怎么下载到手机本地
|
||||
|
||||
如果你搜索的是“b站视频如何下载到手机”“b站视频怎么保存在手机本地”,要先区分两件事:
|
||||
|
||||
- B站客户端缓存:适合在 App 内离线观看,但通常不是通用本地视频文件;
|
||||
- 本地视频文件:适合跨设备播放、剪辑、归档或转文字,需要先完整保存为可播放文件。
|
||||
|
||||
MediaGo 当前更适合电脑端、浏览器扩展、Docker/NAS 和局域网访问。更稳的流程是先在电脑或 NAS 上完成识别和下载,再通过局域网、移动硬盘、网盘或媒体库在手机上访问。
|
||||
|
||||
## B站视频链接在哪里找
|
||||
|
||||
用户常问“B站视频的链接在哪里找到”,通常有三类链接:
|
||||
|
||||
| 链接类型 | 说明 | 下载时是否足够 |
|
||||
| ------------ | ---------------------------- | ------------------------ |
|
||||
| 页面链接 | 浏览器地址栏里的视频页面地址 | 不一定,常只是播放器页面 |
|
||||
| 分享链接 | App 或网页分享出来的短链接 | 不一定,需要还原到页面 |
|
||||
| 真实视频资源 | 播放过程中请求的视频或音频流 | 下载器真正需要识别的资源 |
|
||||
|
||||
普通用户不建议手动从开发者工具里找资源。B站页面可能存在音视频分离、清晰度选择、临时签名和分段请求,手动复制很容易选错或过期。
|
||||
|
||||
## B站视频下载工具怎么选
|
||||
|
||||
| 需求 | 更适合的方式 |
|
||||
| ---------------------- | ------------------------------ |
|
||||
| 不想写命令 | MediaGo 桌面端或内置浏览器 |
|
||||
| 边浏览边识别 | MediaGo 浏览器扩展 |
|
||||
| 批量、脚本化 | 命令行工具或 API 工作流 |
|
||||
| 长期保存和多端访问 | Docker/NAS、局域网和私有媒体库 |
|
||||
| 下载后转文字或提取音频 | 下载后处理流程 |
|
||||
|
||||
如果你的重点是工具对比,可以先看:[2026 年视频下载器推荐](/blog/video-downloader-recommendation/)。
|
||||
|
||||
## 下载后可以做什么
|
||||
|
||||
B站视频下载到本地后,常见后续需求包括:
|
||||
|
||||
- 转为 MP4 便于本地播放;
|
||||
- 提取音频或转 MP3;
|
||||
- 提取字幕或转文字稿;
|
||||
- 生成 AI 总结;
|
||||
- 归档到 NAS 或课程资料库;
|
||||
- 按项目、课程、作者或日期整理。
|
||||
|
||||
如果你的目标是“b站视频转文字稿、提取字幕、提取音频 mp3”,可以看:[B站视频怎么转文字稿、提取字幕和音频?](/blog/video-download/bilibili-video-to-text-audio/)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### B站视频缓存等于下载到本地吗?
|
||||
|
||||
不完全等于。缓存通常服务于 App 内离线观看,不一定是可自由管理的本地 MP4 文件。如果你需要剪辑、转文字或归档,需要保存为可处理的视频文件。
|
||||
|
||||
### B站视频可以批量下载吗?
|
||||
|
||||
自有稿件、授权课程或公开资料可以考虑批量整理,但要遵守平台规则和内容授权。批量下载更适合放到后续工作流或 NAS 自动化中,不建议把它写成绕过限制的教程。
|
||||
|
||||
### B站视频去水印、无水印下载要不要做?
|
||||
|
||||
不建议。去水印和无水印下载容易涉及平台规则和作者权益。MediaGo 内容应该围绕有权保存和处理的场景,不主攻这类词。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
- [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/)
|
||||
- [B站视频怎么转文字稿、提取字幕和音频?](/blog/video-download/bilibili-video-to-text-audio/)
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: B站视频怎么转文字稿、提取字幕和音频?
|
||||
description: 面向“b站视频怎么转文字”“b站视频提取字幕”“b站视频提取音频 mp3”“b站视频 AI 总结”的后处理指南,说明下载后转写、字幕和音频处理流程。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags:
|
||||
[b站视频转文字, b站视频提取字幕, b站视频提取音频, b站视频转mp3, b站视频AI总结]
|
||||
---
|
||||
|
||||
# B站视频怎么转文字稿、提取字幕和音频?
|
||||
|
||||
一句话答案:B站视频转文字稿、提取字幕或音频,通常分两步:先在有授权的前提下保存视频或音频文件,再使用转写、字幕提取、音频提取或 AI 总结工具处理。MediaGo 适合作为“识别和保存视频资源”的入口,后续可以接转写和音频处理流程。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料和个人素材归档,不讨论去水印、绕过权限或处理无授权内容。
|
||||
|
||||
## 常见后处理需求
|
||||
|
||||
| 需求 | 目标结果 | 适合场景 |
|
||||
| --------------- | -------------------- | ------------------------ |
|
||||
| B站视频转文字稿 | 一份可编辑文本 | 课程笔记、会议资料、复盘 |
|
||||
| B站视频提取字幕 | srt、vtt 或文本字幕 | 学习、剪辑、资料整理 |
|
||||
| B站视频提取音频 | 音频文件 | 听课、播客化、语音资料 |
|
||||
| B站视频转 mp3 | 更通用的音频格式 | 手机收听、离线整理 |
|
||||
| B站视频 AI 总结 | 摘要、要点、章节大纲 | 快速浏览长视频内容 |
|
||||
|
||||
## 推荐处理流程
|
||||
|
||||
1. 确认内容是否允许保存和二次处理。
|
||||
2. 使用 MediaGo 保存公开视频、自有稿件或授权课程回看。
|
||||
3. 检查下载文件是否完整,确认画面、声音和时长正常。
|
||||
4. 如果只需要音频,先提取音轨或转 MP3。
|
||||
5. 如果需要文字稿,使用语音转文字工具生成初稿。
|
||||
6. 对照原视频校对术语、时间点、人名和专有名词。
|
||||
7. 按用途整理成字幕、笔记、摘要或知识库内容。
|
||||
|
||||
## 转文字稿和提取字幕有什么区别
|
||||
|
||||
| 类型 | 来源 | 优点 | 局限 |
|
||||
| -------- | ------------------------ | -------------------------- | -------------------- |
|
||||
| 提取字幕 | 视频自带字幕或外挂字幕 | 准确度通常更高,时间轴完整 | 前提是原视频有字幕 |
|
||||
| 语音转写 | 从音频识别出文字 | 没有字幕也能生成文本 | 需要校对,受音质影响 |
|
||||
| AI 总结 | 基于文字稿或字幕生成摘要 | 适合快速提炼要点 | 不能替代原文校对 |
|
||||
|
||||
如果视频没有字幕,先提取音频再做语音转文字会更稳。音质差、多人说话、背景音乐明显的视频,需要人工校对。
|
||||
|
||||
## B站视频提取音频 mp3 怎么做
|
||||
|
||||
如果你有权处理该视频,可以先保存完整视频,再从视频文件中提取音频或转为 MP3。
|
||||
|
||||
适合提取音频的场景:
|
||||
|
||||
- 自己上传的视频备份;
|
||||
- 已授权课程或培训内容;
|
||||
- 公开演讲、公开资料的个人学习归档;
|
||||
- 企业内部视频资料整理。
|
||||
|
||||
不建议把“转 MP3”写成规避平台播放规则或无授权搬运的教程。SEO 页面可以承接这个搜索意图,但正文必须清楚说明使用边界。
|
||||
|
||||
## AI 总结适合放在哪一步
|
||||
|
||||
AI 总结最好放在转文字之后,而不是直接跳过文字稿。推荐顺序是:
|
||||
|
||||
1. 下载或保存有权处理的视频;
|
||||
2. 提取音频或字幕;
|
||||
3. 生成文字稿;
|
||||
4. 清理口癖、错字和重复段落;
|
||||
5. 再生成摘要、章节、关键词和待办事项。
|
||||
|
||||
这样得到的总结更稳定,也更适合被搜索和 AI 摘要理解。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### B站视频提取字幕一定能成功吗?
|
||||
|
||||
不一定。只有视频本身带字幕或可识别的字幕轨时,才可能直接提取。没有字幕的视频,需要用语音转文字生成。
|
||||
|
||||
### B站视频 AI 总结可以直接替代看视频吗?
|
||||
|
||||
不建议。AI 总结适合快速了解结构和重点,但关键信息、数据、引用和结论仍然需要回到原视频或文字稿核对。
|
||||
|
||||
### B站视频去水印、无水印和提取文字是一回事吗?
|
||||
|
||||
不是。提取文字和字幕是内容理解与资料整理;去水印和无水印下载涉及平台规则和作者权益,不建议作为 MediaGo 的内容方向。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [B站视频怎么下载?](/blog/video-download/bilibili-video-download/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [视频下载工作流怎么搭?](/blog/video-download/video-download-workflow/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: 课程回看视频怎么下载?公开课程与自有学习资料保存思路
|
||||
description: 介绍课程回看、培训视频和自有学习资料的保存流程,以及如何使用 MediaGo 识别网页视频资源并管理下载任务。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [课程回看, 网页视频下载, 学习资料, MediaGo]
|
||||
---
|
||||
|
||||
# 课程回看视频怎么下载?
|
||||
|
||||
一句话答案:课程回看通常嵌在网页播放器里,可能使用 MP4、M3U8/HLS 或分段流。MediaGo 可以在授权访问的前提下,通过内置浏览器识别播放过程中的视频资源,并加入下载队列保存。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 公开课程回看;
|
||||
- 个人购买或授权访问的学习资料;
|
||||
- 企业内部培训资料;
|
||||
- 自己上传或制作的视频素材;
|
||||
- 需要长期归档的学习视频。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 确认你有保存该视频的权限。
|
||||
2. 使用 MediaGo 内置浏览器打开课程页面。
|
||||
3. 播放视频,等待资源识别。
|
||||
4. 选择目标资源并添加下载任务。
|
||||
5. 使用文件夹、命名规则或 NAS 路径整理资料。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么课程视频比普通网页视频更难下载?
|
||||
|
||||
课程平台常常依赖登录态、临时链接、请求头和分段播放。使用内置浏览器能更好地保留播放上下文。
|
||||
|
||||
### 下载后怎么管理?
|
||||
|
||||
可以按课程、章节、日期或主题建立目录。后续如果使用 NAS 形态,可以进一步做媒体库和跨端播放。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频下载完整指南](/blog/video-download/) 的子文章,用于承接课程回看和学习资料保存场景。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [NAS 视频下载器怎么选?](/blog/video-downloader-review/nas-video-downloader-tools/)
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 网页视频怎么下载?MediaGo 保存网页视频的基础流程
|
||||
description: 介绍网页视频下载的基本流程,包括打开页面、识别真实资源、选择清晰度、添加下载任务和处理常见失败原因。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [网页视频下载, 视频下载, MediaGo, 视频资源]
|
||||
---
|
||||
|
||||
# 网页视频怎么下载?
|
||||
|
||||
一句话答案:网页视频通常需要先识别真实媒体资源,再添加到下载器。MediaGo 可以通过内置浏览器或浏览器扩展识别网页中的视频资源,然后把目标资源加入下载队列。
|
||||
|
||||
## 快速步骤
|
||||
|
||||
1. 打开 MediaGo。
|
||||
2. 使用内置浏览器打开目标视频页面。
|
||||
3. 等待 MediaGo 自动识别视频资源。
|
||||
4. 选择目标清晰度或目标视频流。
|
||||
5. 添加到下载队列。
|
||||
6. 下载完成后按需转换格式或在局域网播放。
|
||||
|
||||
## 为什么网页地址不等于视频地址
|
||||
|
||||
很多网页只是播放器页面,真正的视频地址可能来自接口、m3u8 播放列表、mpd 清单、ts 分片或临时签名链接。只复制浏览器地址栏里的 URL,下载器不一定能直接处理。
|
||||
|
||||
## 推荐入口
|
||||
|
||||
如果你只是偶尔下载,可以先用内置浏览器。日常浏览视频较多时,可以安装 [浏览器扩展](/extension),检测到资源后直接发送到 MediaGo。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频下载完整指南](/blog/video-download/) 的子文章,用于承接 “网页视频怎么下载” 这个基础搜索问题。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: 网页视频下载完整指南
|
||||
description: 介绍网页视频下载的常见方式、适用场景、工具选择,以及如何使用 MediaGo 通过内置浏览器、浏览器扩展和下载队列保存视频资源。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [网页视频下载, 视频下载器, MediaGo, 浏览器扩展]
|
||||
---
|
||||
|
||||
# 网页视频下载完整指南
|
||||
|
||||
一句话答案:网页视频下载的关键不是“复制一个页面地址”,而是识别页面背后的真实视频资源。MediaGo 可以通过内置浏览器、Chrome/Edge 扩展和下载队列,把网页视频识别、选择、下载和格式转换放在同一个工作流里。
|
||||
|
||||
这篇支柱页覆盖“网页视频下载”“网页视频怎么下载”“网页视频下载工具”“网页视频下载插件”“网页视频下载不了”“b站视频怎么下载”等搜索意图。具体教程、平台场景和排错会放到子文章里,避免把所有问题都堆在一个页面里。
|
||||
|
||||
## 适合谁阅读
|
||||
|
||||
- 想下载公开网页视频、课程回看、自有素材或企业内部资料的用户;
|
||||
- 不想学习 yt-dlp、FFmpeg、N_m3u8DL-RE 命令行参数的普通用户;
|
||||
- 需要同时覆盖普通 MP4、HLS/M3U8、DASH、直播流和 Bilibili、YouTube、Twitter/X 等站点的用户;
|
||||
- 希望把下载任务放到 Docker、NAS 或局域网环境中长期运行的用户。
|
||||
|
||||
## 常见下载方式
|
||||
|
||||
| 方式 | 优点 | 局限 | 适合场景 |
|
||||
| ---------------- | -------------------- | ------------------------------ | ---------------------- |
|
||||
| 复制链接到下载器 | 简单直接 | 页面地址不一定等于真实视频地址 | 平台链接、公开视频 |
|
||||
| 浏览器扩展嗅探 | 入口自然,边看边识别 | 容易受浏览器限制和站点策略影响 | Chrome/Edge 日常浏览 |
|
||||
| 内置浏览器嗅探 | 识别和下载链路更完整 | 需要在客户端里打开页面 | 复杂网页视频、课程回看 |
|
||||
| 命令行工具 | 参数灵活,适合批处理 | 学习成本高 | 开发者、脚本化任务 |
|
||||
| NAS / Docker | 可长期运行,多端访问 | 初始部署成本更高 | 家庭服务器、私有媒体库 |
|
||||
|
||||
## MediaGo 的推荐流程
|
||||
|
||||
1. 安装 MediaGo 桌面端或部署 Docker 版本。
|
||||
2. 打开需要下载的视频页面。
|
||||
3. 使用内置浏览器或 Chrome/Edge 扩展识别视频资源。
|
||||
4. 在资源列表里选择目标清晰度、格式或视频流。
|
||||
5. 添加到下载队列,等待下载和格式处理完成。
|
||||
6. 如需自动化,使用 [HTTP 下载接口](/api) 或 [OpenClaw Skill](/skills) 创建任务。
|
||||
|
||||
## 和其他工具怎么选
|
||||
|
||||
如果你只需要命令行批处理,可以直接使用 yt-dlp、FFmpeg 或 N_m3u8DL-RE。如果你想要图形界面、网页嗅探、批量队列、NAS/Docker 和 API 工作流,MediaGo 更适合作为入口工具。
|
||||
|
||||
更完整的横向对比可以阅读:[2026 年视频下载器评测:MediaGo、yt-dlp、4K、NAS 工具与浏览器插件对比](/blog/video-downloader-review/)。
|
||||
|
||||
## 按搜索问题快速进入
|
||||
|
||||
| 你搜索的问题 | 应该先看 |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 网页视频怎么下载、下载方法 | [网页视频怎么下载?](/blog/video-download/download-web-video/) |
|
||||
| 网页视频下载不了、复制链接不能下载 | [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/) |
|
||||
| 网页视频下载插件、Edge 插件 | [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/) |
|
||||
| B站视频怎么下载、保存到本地 | [B站视频怎么下载?](/blog/video-download/bilibili-video-download/) |
|
||||
| B站视频转文字、提取字幕或音频 | [B站视频怎么转文字稿、提取字幕和音频?](/blog/video-download/bilibili-video-to-text-audio/) |
|
||||
| m3u8 视频怎么下载 | [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/) |
|
||||
| 网页视频下载到电脑后怎么管理 | [视频下载工作流怎么搭?](/blog/video-download/video-download-workflow/) |
|
||||
|
||||
## 专题文章
|
||||
|
||||
| 文章 | 解决的问题 | 目标搜索意图 |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------- | -------------------------- |
|
||||
| [网页视频怎么下载?](/blog/video-download/download-web-video/) | 基础网页视频保存流程 | 网页视频怎么下载 |
|
||||
| [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/) | 复制链接无效、插件识别失败 | 网页视频下载不了 |
|
||||
| [B站视频怎么下载?](/blog/video-download/bilibili-video-download/) | B站平台场景、电脑和手机访问 | b站视频怎么下载 |
|
||||
| [B站视频怎么转文字稿、提取字幕和音频?](/blog/video-download/bilibili-video-to-text-audio/) | 下载后的字幕、音频、AI 总结 | b站视频转文字、提取字幕 |
|
||||
| [课程回看视频怎么下载?](/blog/video-download/download-course-video/) | 课程、培训、自有学习资料保存 | 课程回看下载、学习资料保存 |
|
||||
| [视频下载工作流怎么搭?](/blog/video-download/video-download-workflow/) | 从网页识别到 NAS 归档的流程 | 视频下载工作流、NAS 归档 |
|
||||
| [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/) | 浏览器插件、桌面端入口选择 | 网页视频下载插件、视频嗅探 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 网页视频下载一定需要浏览器插件吗?
|
||||
|
||||
不一定。浏览器插件适合日常浏览时快速发送任务;MediaGo 桌面端也提供内置浏览器,可以直接在客户端里打开页面并自动识别资源。
|
||||
|
||||
### 为什么有些网页复制链接后不能下载?
|
||||
|
||||
很多网页地址只是播放器页面,真实视频资源可能是 m3u8、mpd、ts 分片、临时签名地址或平台接口返回的数据。遇到这种情况,需要通过嗅探或平台解析能力识别真实资源。
|
||||
|
||||
### 网页视频下载工具和网页视频下载插件怎么选?
|
||||
|
||||
插件适合发现资源,下载工具适合下载、合并、重试、转码和管理文件。普通网页可以先用插件;复杂页面、m3u8/HLS 或失败排查更适合 MediaGo 内置浏览器。
|
||||
|
||||
### B站视频怎么下载到本地?
|
||||
|
||||
先确认你有权保存该内容。自有稿件、授权内容、课程回看或公开素材可以作为合规场景处理;具体流程见:[B站视频怎么下载?](/blog/video-download/bilibili-video-download/)。
|
||||
|
||||
### MediaGo 和 yt-dlp 是替代关系吗?
|
||||
|
||||
不是单纯替代关系。yt-dlp 更像底层下载生态,适合命令行和脚本;MediaGo 更像产品化入口,把底层工具能力整合为图形界面、浏览器入口、API、Docker/NAS 和队列管理。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [MediaGo 快速开始](/guides)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: 视频下载工作流怎么搭?从网页识别到 NAS 归档的完整流程
|
||||
description: 介绍从网页视频识别、下载队列、格式转换、命名整理到 NAS 或局域网播放的视频下载工作流设计。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [视频下载工作流, NAS, Docker, MediaGo, 自动化]
|
||||
---
|
||||
|
||||
# 视频下载工作流怎么搭?
|
||||
|
||||
一句话答案:稳定的视频下载工作流应该包含资源识别、任务队列、格式处理、文件命名、归档路径和多端访问。MediaGo 可以把内置浏览器、浏览器扩展、下载队列、API 和 Docker/NAS 连接起来。
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 浏览器或内置浏览器识别视频资源;
|
||||
2. 添加到 MediaGo 下载队列;
|
||||
3. 按任务类型选择保存路径;
|
||||
4. 下载完成后进行格式转换或混流;
|
||||
5. 按项目、课程、平台或日期整理目录;
|
||||
6. 在局域网、NAS 或移动端访问内容;
|
||||
7. 通过 API 或 Agent 处理重复任务。
|
||||
|
||||
## 为什么要设计工作流
|
||||
|
||||
一次性下载不难,难的是长期维护。视频数量变多后,如果没有命名、目录、队列和归档规则,很容易出现重复下载、找不到文件、格式不统一和跨设备访问困难。
|
||||
|
||||
## MediaGo 的角色
|
||||
|
||||
MediaGo 更适合作为视频下载工作流的入口层:负责识别资源、创建任务、处理队列,并连接桌面端、浏览器扩展、HTTP API 和 Docker/NAS 环境。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频下载完整指南](/blog/video-download/) 的子文章,用于承接 “视频下载工作流” 和 “长期管理” 场景。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [NAS 视频下载器怎么选?](/blog/video-downloader-review/nas-video-downloader-tools/)
|
||||
- [下载接口](/api)
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: 网页视频下载不了怎么办?复制链接无效、插件识别不到和 m3u8 失败排查
|
||||
description: 面向“网页视频下载不了”的排错指南,解释复制页面链接无效、浏览器插件识别不到、m3u8 地址过期、请求头缺失和分片下载失败等常见原因。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [网页视频下载不了, 网页视频下载, 视频下载失败, m3u8下载失败, 视频嗅探]
|
||||
---
|
||||
|
||||
# 网页视频下载不了怎么办?复制链接无效、插件识别不到和 m3u8 失败排查
|
||||
|
||||
一句话答案:网页视频下载不了,通常不是“下载器坏了”,而是页面地址不等于真实视频地址,或者真实资源需要播放过程里的 Cookie、Referer、User-Agent、临时签名和分片列表。先确认视频能正常播放,再用 MediaGo 内置浏览器或浏览器扩展重新识别资源。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料和个人素材归档,不讨论绕过访问控制或下载受保护内容。
|
||||
|
||||
## 先判断是哪一种失败
|
||||
|
||||
| 现象 | 常见原因 | 优先处理方式 |
|
||||
| -------------------------- | ----------------------------------- | ------------------------------ |
|
||||
| 复制网页链接后提示无法下载 | 页面地址不是视频真实地址 | 用内置浏览器或扩展嗅探真实资源 |
|
||||
| 插件没有识别到视频 | 页面未播放、懒加载、扩展权限限制 | 先播放几秒,再刷新资源列表 |
|
||||
| m3u8 下载到一半失败 | 分片地址过期或请求头缺失 | 重新打开原网页,重新识别 m3u8 |
|
||||
| 下载后只有几 KB | 保存的是播放列表,不是完整视频 | 让下载器读取 m3u8 并下载分片 |
|
||||
| 下载后有声音没画面 | 音视频分离或格式不兼容 | 下载完整资源后转码或重新混流 |
|
||||
| 浏览器能看,下载器不能下 | 播放器带了 Cookie、Referer 等上下文 | 使用内置浏览器保留页面上下文 |
|
||||
|
||||
## 为什么复制网页链接经常不能下载
|
||||
|
||||
很多网页的视频地址不是直接写在页面 URL 里。用户复制的通常是播放器页面地址,而不是 MP4、M3U8、MPD 或分片资源地址。
|
||||
|
||||
真实资源可能出现在这些地方:
|
||||
|
||||
- 页面播放后才请求的视频接口;
|
||||
- m3u8 或 mpd 播放列表;
|
||||
- 带临时签名的分片地址;
|
||||
- 根据清晰度动态返回的播放地址;
|
||||
- 需要登录态、Cookie 或 Referer 的请求里。
|
||||
|
||||
所以遇到“复制链接不能下载”时,不要反复换同一个页面链接。更可靠的做法是打开原网页,让视频实际播放,然后让下载器观察播放过程中的媒体请求。
|
||||
|
||||
## MediaGo 推荐排查流程
|
||||
|
||||
1. 打开原网页,确认视频仍然可以正常播放。
|
||||
2. 如果使用浏览器扩展,先播放视频 3-10 秒,再查看扩展识别到的资源。
|
||||
3. 如果扩展没有识别到,改用 MediaGo 内置浏览器打开同一页面。
|
||||
4. 在资源列表里优先选择时长、清晰度和体积接近目标视频的资源。
|
||||
5. 如果是 m3u8/HLS,选择完整播放列表,不要只保存几 KB 的 m3u8 文本。
|
||||
6. 如果下载失败,回到原网页重新识别,不要复用过期地址。
|
||||
|
||||
## 浏览器插件识别不到怎么办
|
||||
|
||||
浏览器插件适合普通网页视频,但它可能受到浏览器权限、页面跨域策略、播放器懒加载和扩展限制影响。
|
||||
|
||||
可以按这个顺序排查:
|
||||
|
||||
1. 先点播放,不要只打开页面;
|
||||
2. 切换清晰度后再观察资源列表;
|
||||
3. 检查扩展是否已允许访问当前站点;
|
||||
4. 尝试刷新页面后重新播放;
|
||||
5. 复杂页面改用 MediaGo 内置浏览器。
|
||||
|
||||
如果你的问题是“网页视频下载插件哪个好”,可以看:[视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/)。
|
||||
|
||||
## m3u8 下载失败怎么办
|
||||
|
||||
m3u8 下载失败通常和播放列表、分片、请求头有关。优先看这几类问题:
|
||||
|
||||
| 问题 | 说明 | 处理方式 |
|
||||
| ---------------- | ---------------------------- | --------------------------- |
|
||||
| key 请求失败 | 解密 key 需要页面上下文 | 用内置浏览器重新识别资源 |
|
||||
| 下载一半停止 | 分片地址过期或网络中断 | 重新识别,开启失败重试 |
|
||||
| 只有几 KB | 只保存了 m3u8 播放列表 | 使用下载器解析并下载分片 |
|
||||
| 无画面或无法播放 | 音视频分离、编码或容器不兼容 | 下载完整后转 MP4 或重新混流 |
|
||||
|
||||
更完整的 HLS 排错见:[m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)。
|
||||
|
||||
## 什么情况不建议继续尝试
|
||||
|
||||
如果页面明确需要付费、登录授权、企业权限或 DRM 保护,并且你没有相应授权,就不应该尝试绕过限制。搜索和 AI 摘要都更偏好边界清晰、可信、能解决正常用户问题的内容。MediaGo 适合处理你有权访问和保存的资源,而不是规避平台规则。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 网页视频下载不了是浏览器问题吗?
|
||||
|
||||
不一定。浏览器能播放,只说明播放器拿到了播放所需的上下文;下载器如果没有拿到真实视频地址、请求头或分片列表,仍然可能失败。
|
||||
|
||||
### F12 能看到视频地址,还需要下载器吗?
|
||||
|
||||
如果只是一个普通 MP4 直链,复制地址可能就够了。但 m3u8/HLS、DASH/MPD、音视频分离和直播流需要下载器处理分片、合并、请求头和失败重试。
|
||||
|
||||
### 手机网页视频下载不了怎么办?
|
||||
|
||||
手机端更容易受浏览器权限和系统限制影响。更稳的方式是在电脑端或 NAS 上使用 MediaGo 识别和下载,再通过局域网或媒体库在手机上播放。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频怎么下载?](/blog/video-download/download-web-video/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: 2026 年视频下载器推荐:免费、电脑端、m3u8、IDM、yt-dlp、猫抓与 NAS 工具对比
|
||||
description: 面向国内用户的视频下载器选型指南,对比 MediaGo、IDM、yt-dlp、m3u8 下载器、猫抓下载器、NAS/Docker 工具在网页视频下载、浏览器嗅探和自动化场景中的适用情况。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags:
|
||||
[视频下载器推荐, 视频下载器, 视频下载软件, m3u8下载器, IDM下载器, 猫抓下载器]
|
||||
---
|
||||
|
||||
# 2026 年视频下载器推荐:免费、电脑端、m3u8、IDM、yt-dlp、猫抓与 NAS 工具对比
|
||||
|
||||
一句话答案:如果你需要电脑端、网页视频嗅探、m3u8/HLS、浏览器扩展、批量下载、Docker/NAS 和 API,MediaGo 更适合作为综合视频下载器;如果你熟悉命令行,yt-dlp 更灵活;如果你只需要传统下载加速,IDM 更偏通用下载器。
|
||||
|
||||
这篇是面向“怎么选工具”的推荐页。如果你想看完整产品矩阵和更长的竞品分析,可以阅读:[2026 年视频下载器全景评测](/blog/video-downloader-review/)。
|
||||
|
||||
本文根据国内平台推荐词把问题分为“工具推荐、电脑端、网页视频下载、m3u8 下载器、浏览器插件、NAS/Docker、手机端边界”几类。这样写的目的不是堆关键词,而是让用户和搜索摘要能快速判断应该看哪一段。
|
||||
|
||||
## 快速推荐
|
||||
|
||||
| 使用场景 | 推荐工具 | 选择理由 |
|
||||
| --------------------- | ------------------------- | ------------------------------------------ |
|
||||
| 普通用户下载网页视频 | MediaGo | 有桌面端、内置浏览器、资源嗅探和下载队列 |
|
||||
| m3u8 / HLS 视频下载 | MediaGo / N_m3u8DL-RE | MediaGo 更易用,N_m3u8DL-RE 更适合高级参数 |
|
||||
| 命令行批量下载 | yt-dlp | 站点覆盖广,适合脚本和自动化 |
|
||||
| 浏览器视频嗅探 | MediaGo 扩展 / 猫抓下载器 | 扩展适合边浏览边识别资源 |
|
||||
| 传统下载加速 | IDM | 偏 Windows 通用下载管理和加速 |
|
||||
| NAS / Docker 长期运行 | MediaGo / MeTube | 适合家庭服务器、自托管和局域网访问 |
|
||||
|
||||
## 平台推荐词怎么归类
|
||||
|
||||
| 用户常搜的问题 | 页面内回答方式 |
|
||||
| ------------------------------------ | -------------------------------------- |
|
||||
| 视频下载器推荐、哪个好用、排行、免费 | 用场景表回答,不做无依据的夸张排名 |
|
||||
| 视频下载器电脑、视频下载器软件 | 强调 Windows/macOS/Linux 桌面端能力 |
|
||||
| 网页视频下载器、在线视频下载器 | 链接到网页视频下载专题和排错页 |
|
||||
| m3u8 下载器、HLS 下载工具 | 链接到 M3U8/HLS 支柱页和工具推荐页 |
|
||||
| 视频下载器插件、网页视频下载插件 | 链接到视频嗅探器和浏览器插件对比 |
|
||||
| 视频下载器 app、安卓、iOS、手机版 | 明确 MediaGo 当前不是纯手机 App 主方向 |
|
||||
| 推特视频下载器、B站视频下载器 | 只在合规场景下讨论公开/授权内容保存 |
|
||||
|
||||
## 视频下载器、视频下载软件和视频下载工具有什么区别
|
||||
|
||||
国内用户搜索“视频下载器”“视频下载软件”“视频下载工具”时,实际需求通常分成三类:
|
||||
|
||||
1. 找一个能直接下载网页视频的软件;
|
||||
2. 找一个免费、好用、适合电脑端的工具;
|
||||
3. 找一个能处理 m3u8、直播流、B站、YouTube、推特、课程回看等复杂资源的方案。
|
||||
|
||||
所以选工具不能只看“支持多少网站”,还要看它是否能识别真实视频资源、是否支持 HLS/M3U8、是否有浏览器入口、是否能长期运行、是否能在下载后继续管理和处理文件。
|
||||
|
||||
## 免费工具、付费工具和开源工具怎么选
|
||||
|
||||
| 类型 | 代表工具 | 优点 | 局限 |
|
||||
| ---------------- | ---------------------------------------- | ------------------------------------ | -------------------------------------- |
|
||||
| 免费开源综合工具 | MediaGo 开源版 | 免费、跨平台、可部署、支持扩展和 API | 商业打磨程度取决于版本 |
|
||||
| 命令行开源工具 | yt-dlp、N_m3u8DL-RE、BBDown | 灵活、强大、适合开发者 | 普通用户学习成本高 |
|
||||
| 商业桌面下载器 | IDM、4K、Downie、Pulltube | 桌面体验成熟 | NAS、API、插件生态通常较弱 |
|
||||
| 浏览器插件 | 猫抓、Video DownloadHelper、MediaGo 扩展 | 入口方便,适合网页嗅探 | 后续下载、合并、转换能力取决于配套工具 |
|
||||
| 自托管工具 | MeTube、Tube Archivist、Pinchflat | 适合 NAS 和长期运行 | 常偏单一平台或依赖 yt-dlp |
|
||||
|
||||
## 电脑端视频下载器推荐
|
||||
|
||||
如果你主要在 Windows、macOS 或 Linux 电脑上使用,优先看这几个能力:
|
||||
|
||||
- 是否支持内置浏览器或浏览器扩展;
|
||||
- 是否能识别网页中的 m3u8/HLS、DASH、MP4 和直播流;
|
||||
- 是否有清晰的下载队列和任务状态;
|
||||
- 是否支持格式转换、局域网播放或后续管理;
|
||||
- 是否能通过 Docker/NAS 或 API 扩展到长期工作流。
|
||||
|
||||
MediaGo 的优势是把桌面端、浏览器扩展、内置浏览器、下载队列、HTTP API 和 Docker/NAS 放在同一个体系里。IDM 更适合传统下载加速;yt-dlp 更适合命令行用户;Downie、Pulltube 更偏 macOS 单机体验。
|
||||
|
||||
## m3u8 下载器怎么选
|
||||
|
||||
如果你的目标是 m3u8/HLS 视频,重点不是“哪个工具名字叫 m3u8 下载器”,而是它能不能处理这些问题:
|
||||
|
||||
- 自动识别网页里的 m3u8 地址;
|
||||
- 保留 Referer、Cookie、User-Agent 等请求信息;
|
||||
- 下载并合并 TS/M4S 分片;
|
||||
- 处理音视频分离、字幕、清晰度选择;
|
||||
- 失败后能重试或重新识别资源。
|
||||
|
||||
更具体的工具选择可以看:[m3u8 下载器推荐:电脑、Docker、NAS 和浏览器怎么选?](/blog/video-downloader-review/m3u8-downloader-recommendation/)。
|
||||
|
||||
如果你的问题是下载后怎么变成一个本地 MP4 文件,可以看:[m3u8 下载后怎么转 MP4?](/blog/m3u8-hls-download/m3u8-to-mp4/)。
|
||||
|
||||
## IDM、yt-dlp、猫抓和 MediaGo 怎么选
|
||||
|
||||
| 工具 | 更适合谁 | 重点区别 |
|
||||
| ---------- | -------------------------------------------- | ---------------------------- |
|
||||
| MediaGo | 想要图形界面、网页嗅探、m3u8、NAS/API 的用户 | 综合入口 |
|
||||
| IDM | Windows 下载加速和通用下载管理用户 | 传统下载管理 |
|
||||
| yt-dlp | 熟悉命令行和脚本的用户 | 灵活但学习成本高 |
|
||||
| 猫抓下载器 | 想在浏览器里识别视频资源的用户 | 入口方便,后续处理需配合工具 |
|
||||
|
||||
相关对比:
|
||||
|
||||
- [IDM 下载器和 MediaGo 怎么选?](/blog/video-downloader-review/idm-vs-mediago/)
|
||||
- [MediaGo 和 yt-dlp 怎么选?](/blog/video-downloader-review/mediago-vs-ytdlp/)
|
||||
- [猫抓下载器和 MediaGo 有什么区别?](/blog/video-downloader-review/cat-catch-vs-mediago/)
|
||||
|
||||
## NAS / Docker 视频下载器适合谁
|
||||
|
||||
如果你希望下载器 24 小时运行,或者希望手机、平板、电视都能访问下载内容,NAS / Docker 会更合适。
|
||||
|
||||
适合 NAS / Docker 的场景:
|
||||
|
||||
- 课程回看、公开素材、企业资料需要长期归档;
|
||||
- 多设备访问下载内容;
|
||||
- 下载任务需要长期排队和自动化;
|
||||
- 希望通过 API、Agent 或脚本创建任务;
|
||||
- 需要把下载器和私有媒体库、家庭服务器连接起来。
|
||||
|
||||
更多内容见:[NAS 视频下载器怎么选?](/blog/video-downloader-review/nas-video-downloader-tools/)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 视频下载器推荐免费工具吗?
|
||||
|
||||
可以优先看 MediaGo 开源版、yt-dlp、N_m3u8DL-RE、BBDown 等免费或开源工具。普通用户更适合从 MediaGo 这类图形界面入口开始,开发者可以直接使用命令行工具。
|
||||
|
||||
### 视频下载器软件哪个好?
|
||||
|
||||
没有单一答案。网页视频和 m3u8/HLS 场景更适合 MediaGo;命令行批处理更适合 yt-dlp;Windows 通用下载加速更适合 IDM;浏览器嗅探可以看猫抓或 MediaGo 扩展。
|
||||
|
||||
### 视频下载器手机版和安卓工具要不要做主选择?
|
||||
|
||||
如果你的主要设备是手机,可以关注 YTDLnis、Seal 等 Android 工具。但 MediaGo 当前更适合电脑端、Docker/NAS、浏览器扩展和局域网访问场景。
|
||||
|
||||
### 网页视频下载不了怎么办?
|
||||
|
||||
优先判断是不是复制了页面地址而不是真实视频地址。遇到插件识别不到、m3u8 失败、下载后只有几 KB 等问题,可以看:[网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)。
|
||||
|
||||
### m3u8 下载器和普通视频下载器有什么区别?
|
||||
|
||||
m3u8 下载器需要处理 HLS 播放列表、分片下载、合并、请求头、音视频分离等问题。普通视频下载器只处理一个直链 MP4 时会简单很多。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器全景评测](/blog/video-downloader-review/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [m3u8 下载后怎么转 MP4?](/blog/m3u8-hls-download/m3u8-to-mp4/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: 猫抓下载器和 MediaGo 有什么区别?视频嗅探、下载失败、合并与无声音问题对比
|
||||
description: 对比猫抓下载器和 MediaGo 在浏览器视频嗅探、网页视频下载、m3u8/HLS、下载失败、视频合并、没有声音和桌面端工作流上的差异。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [猫抓下载器, 猫抓插件, 视频嗅探器, MediaGo, 网页视频下载]
|
||||
---
|
||||
|
||||
# 猫抓下载器和 MediaGo 有什么区别?
|
||||
|
||||
一句话答案:猫抓下载器更偏浏览器里的视频资源嗅探入口,适合发现网页中的媒体链接;MediaGo 更偏完整下载工作流,包含内置浏览器、浏览器扩展、下载队列、m3u8/HLS 处理、格式转换、Docker/NAS 和 API。
|
||||
|
||||
## 快速对比
|
||||
|
||||
| 维度 | 猫抓下载器 | MediaGo |
|
||||
| ------------------- | -------------------------------- | ------------------------------ |
|
||||
| 核心形态 | 浏览器扩展 / 嗅探工具 | 桌面端 + 扩展 + Docker/NAS |
|
||||
| 主要能力 | 发现网页媒体资源 | 识别、下载、队列、处理、自动化 |
|
||||
| m3u8/HLS | 能发现资源,后续处理取决于工具链 | 作为核心下载场景处理 |
|
||||
| 下载失败排查 | 需要用户判断资源和后续工具 | 内置浏览器可重新识别上下文 |
|
||||
| 视频合并 / 没有声音 | 常需要额外工具处理 | 下载和处理链路更完整 |
|
||||
| 适合用户 | 熟悉浏览器扩展的用户 | 想要完整视频下载器的用户 |
|
||||
|
||||
## 猫抓下载器适合什么场景
|
||||
|
||||
猫抓下载器适合在浏览器里快速发现网页视频、音频、m3u8 或其他媒体资源。它的优点是入口轻,用户在浏览网页时就能看到可能的资源。
|
||||
|
||||
但对普通用户来说,发现资源只是第一步,后面还可能遇到:
|
||||
|
||||
- 下载失败;
|
||||
- 下载到多个分片;
|
||||
- 视频打不开;
|
||||
- 视频没有声音;
|
||||
- 下载后需要合并;
|
||||
- 不知道哪个资源才是目标视频。
|
||||
|
||||
这些问题需要下载器、合并工具、格式处理或重新识别页面上下文来解决。
|
||||
|
||||
## MediaGo 适合什么场景
|
||||
|
||||
MediaGo 适合把“发现资源”和“下载处理”放到一起:
|
||||
|
||||
1. 用内置浏览器打开视频页面;
|
||||
2. 自动识别网页视频资源;
|
||||
3. 选择目标清晰度或视频流;
|
||||
4. 加入下载队列;
|
||||
5. 完成 m3u8/HLS 分片下载和合并;
|
||||
6. 按需格式转换或局域网播放。
|
||||
|
||||
如果你希望浏览器入口更轻,也可以使用 MediaGo 的 Chrome/Edge 扩展,把识别到的资源发送到 MediaGo。
|
||||
|
||||
## 猫抓下载失败怎么办
|
||||
|
||||
猫抓下载失败通常不是单一问题,可能来自:
|
||||
|
||||
- 资源地址过期;
|
||||
- 需要 Referer、Cookie、User-Agent;
|
||||
- 选中了广告、预览或音频资源;
|
||||
- m3u8 分片需要后续合并;
|
||||
- 音频和视频分离;
|
||||
- 播放器页面有动态签名或临时 URL。
|
||||
|
||||
这种情况下,可以用 MediaGo 内置浏览器重新打开页面并识别资源,尽量保留播放上下文。
|
||||
|
||||
## 猫抓下载的视频怎么合并
|
||||
|
||||
如果下载到的是 m3u8/HLS 分片,需要使用支持分片合并的工具。MediaGo 会把分片下载和合并放进下载流程里;高级用户也可以用 N_m3u8DL-RE 或 FFmpeg 手动处理。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 猫抓下载的视频为什么打不开?
|
||||
|
||||
可能下载到的不是完整视频文件,而是分片、播放列表、低清预览或缺少音频轨道的资源。需要确认资源类型,并使用合适工具合并或转换。
|
||||
|
||||
### 猫抓下载的视频没有声音怎么办?
|
||||
|
||||
常见原因是音频和视频分离。需要同时下载音频轨道并混流,或者使用能处理音视频合并的下载器。
|
||||
|
||||
### 猫抓和 MediaGo 是替代关系吗?
|
||||
|
||||
不是完全替代。猫抓更像浏览器嗅探入口,MediaGo 更像完整视频下载器。用户可以根据场景选择,也可以用 MediaGo 扩展替代一部分浏览器嗅探流程。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器推荐](/blog/video-downloader-recommendation/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [为什么网页视频嗅探到很多资源?](/blog/video-sniffer/detect-video-resources/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: IDM 下载器和 MediaGo 怎么选?网页视频下载、m3u8 与浏览器嗅探能力对比
|
||||
description: 对比 IDM 下载器和 MediaGo 在传统下载加速、网页视频识别、m3u8/HLS、浏览器嗅探、Docker/NAS 和 API 工作流上的差异。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [IDM下载器, IDM替代, MediaGo, 视频下载器, 网页视频下载]
|
||||
---
|
||||
|
||||
# IDM 下载器和 MediaGo 怎么选?
|
||||
|
||||
一句话答案:IDM 更像 Windows 上的传统下载加速器,适合通用文件下载和浏览器捕获;MediaGo 更像面向网页视频、m3u8/HLS、浏览器嗅探、Docker/NAS 和 API 工作流的综合视频下载器。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看和企业内部资料等合规场景,不提供破解版、破解补丁或绕过授权的内容。
|
||||
|
||||
## 快速对比
|
||||
|
||||
| 维度 | IDM 下载器 | MediaGo |
|
||||
| ------------ | -------------------- | ---------------------------------- |
|
||||
| 产品定位 | 通用下载管理和加速 | 综合视频下载器 |
|
||||
| 主要平台 | Windows | Windows / macOS / Linux / Docker |
|
||||
| 网页视频识别 | 依赖浏览器捕获 | 内置浏览器、Chrome/Edge 扩展 |
|
||||
| m3u8 / HLS | 不是核心强项 | 核心场景之一 |
|
||||
| NAS / Docker | 弱 | 支持 |
|
||||
| API / 自动化 | 弱 | HTTP API、OpenClaw Skill |
|
||||
| 适合用户 | Windows 通用下载用户 | 网页视频、流媒体、NAS 和自动化用户 |
|
||||
|
||||
## 什么时候选 IDM
|
||||
|
||||
如果你的需求主要是普通文件下载、下载加速、浏览器捕获和 Windows 桌面使用,IDM 是成熟的传统下载管理器。
|
||||
|
||||
典型场景:
|
||||
|
||||
- 下载普通文件;
|
||||
- 管理浏览器下载任务;
|
||||
- 对下载速度、分段下载和任务恢复有需求;
|
||||
- 只在 Windows 上使用。
|
||||
|
||||
## 什么时候选 MediaGo
|
||||
|
||||
如果你的重点是网页视频下载,而不是通用文件下载,MediaGo 更适合。
|
||||
|
||||
典型场景:
|
||||
|
||||
1. 网页里找不到真实视频地址;
|
||||
2. 需要下载 m3u8/HLS 或直播流;
|
||||
3. 希望通过 Chrome/Edge 扩展识别资源;
|
||||
4. 想把下载器部署到 Docker、NAS 或家庭服务器;
|
||||
5. 需要通过 HTTP API 或 AI 编程助手创建下载任务。
|
||||
|
||||
## IDM 能不能替代 MediaGo
|
||||
|
||||
如果只是普通文件下载,IDM 可以满足很多需求。但对于网页视频嗅探、m3u8/HLS、NAS、API、批量视频任务和局域网播放,MediaGo 的覆盖更完整。
|
||||
|
||||
## MediaGo 能不能替代 IDM
|
||||
|
||||
如果你主要下载视频资源,MediaGo 可以作为更合适的入口。如果你每天大量下载各种普通文件,IDM 的传统下载管理能力仍然有价值。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### IDM 下载器是干嘛的?
|
||||
|
||||
IDM 是 Internet Download Manager,主要用于 Windows 上的下载管理和加速。它不是专门为 m3u8/HLS、NAS 或 API 视频工作流设计的工具。
|
||||
|
||||
### IDM 下载器适合下载 m3u8 吗?
|
||||
|
||||
可以处理部分网页媒体资源,但 m3u8/HLS 下载通常涉及播放列表、分片、请求头和合并。MediaGo、N_m3u8DL-RE 这类工具更贴近这个场景。
|
||||
|
||||
### 搜索 IDM 破解版、免费版要不要做内容?
|
||||
|
||||
不建议。官网内容应该避开破解、激活码、绕过付费弹窗等方向,只做合规选型、功能对比和替代方案。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器推荐](/blog/video-downloader-recommendation/)
|
||||
- [2026 年视频下载器全景评测](/blog/video-downloader-review/)
|
||||
- [m3u8 下载器推荐](/blog/video-downloader-review/m3u8-downloader-recommendation/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?
|
||||
description: 面向 m3u8/HLS 视频下载场景,介绍 MediaGo、N_m3u8DL-RE、FFmpeg、浏览器嗅探工具和 Docker/NAS 方案的选择思路。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [m3u8下载器, HLS下载器, m3u8下载器电脑版, m3u8下载器docker, m3u8下载器nas]
|
||||
---
|
||||
|
||||
# m3u8 下载器推荐:电脑、Docker、NAS 和浏览器工具怎么选?
|
||||
|
||||
一句话答案:普通用户优先选择 MediaGo 这类图形界面 m3u8 下载器;开发者和高级用户可以使用 N_m3u8DL-RE、FFmpeg;需要长期运行和多端访问时,优先考虑 Docker/NAS 方案。
|
||||
|
||||
## 先看你是哪种需求
|
||||
|
||||
| 需求 | 推荐方向 |
|
||||
| -------------------------------- | ------------------------------------ |
|
||||
| 不想命令行,想直接识别网页视频 | MediaGo |
|
||||
| 已经拿到 m3u8 地址,需要高级参数 | N_m3u8DL-RE |
|
||||
| 需要转码、混流、音视频处理 | FFmpeg |
|
||||
| 浏览器里发现 m3u8 资源 | MediaGo 扩展 / 猫抓下载器 |
|
||||
| NAS 或 Docker 长期运行 | MediaGo Docker / MeTube 等自托管工具 |
|
||||
|
||||
## 好用的 m3u8 下载器需要什么能力
|
||||
|
||||
一个真正适合 m3u8/HLS 的工具,至少应该处理这些问题:
|
||||
|
||||
- 识别网页里的 m3u8 播放列表;
|
||||
- 下载 TS/M4S 分片;
|
||||
- 合并分片为可播放文件;
|
||||
- 保留 Referer、Cookie、User-Agent 等请求信息;
|
||||
- 支持失败重试;
|
||||
- 支持直播流或长视频任务;
|
||||
- 能处理音视频分离、字幕或清晰度选择。
|
||||
|
||||
## 电脑端 m3u8 下载器怎么选
|
||||
|
||||
如果你在 Windows、macOS、Linux 上使用,并且更关注易用性,可以优先选择 MediaGo。它通过内置浏览器和浏览器扩展识别资源,用户不需要手动抓包或复制复杂请求参数。
|
||||
|
||||
如果你已经知道 m3u8 地址,而且熟悉命令行,可以选择 N_m3u8DL-RE 或 FFmpeg,它们更适合高级参数和脚本化任务。
|
||||
|
||||
## Docker / NAS m3u8 下载器怎么选
|
||||
|
||||
如果你希望下载任务长期运行,或者想在局域网内用手机、平板访问下载列表,Docker/NAS 方案更适合。
|
||||
|
||||
MediaGo 的 Docker 形态适合这些情况:
|
||||
|
||||
- 家庭服务器或 NAS 长期运行;
|
||||
- 多端访问下载内容;
|
||||
- 通过 API 或 Agent 创建任务;
|
||||
- 把网页视频下载和私有媒体管理连接起来。
|
||||
|
||||
## m3u8 下载器 key 请求失败怎么办
|
||||
|
||||
`key 请求失败` 通常意味着下载器缺少页面上下文、请求头或有效授权信息。可以优先尝试:
|
||||
|
||||
1. 回到原网页确认视频是否还能播放;
|
||||
2. 使用 MediaGo 内置浏览器重新打开页面;
|
||||
3. 重新识别资源,不复用过期 m3u8 地址;
|
||||
4. 检查 Referer、Cookie、User-Agent 是否需要保留;
|
||||
5. 如果是高级场景,再使用 N_m3u8DL-RE 或 FFmpeg 手动排查。
|
||||
|
||||
更完整的故障排查见:[m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### m3u8 下载器电脑版怎么用?
|
||||
|
||||
如果使用 MediaGo,可以打开内置浏览器,进入视频页面后等待资源识别,再选择 m3u8/HLS 资源添加下载任务。
|
||||
|
||||
### 安卓 m3u8 下载器适合做主力吗?
|
||||
|
||||
安卓工具适合移动端临时下载,但如果你要长期下载、管理、归档或在 NAS 上运行,电脑端或 Docker/NAS 方案更稳定。
|
||||
|
||||
### 网页 m3u8 提取工具和 m3u8 下载器一样吗?
|
||||
|
||||
不一样。提取工具只负责找到 m3u8 地址,下载器还要处理分片下载、合并、请求头、失败重试和格式处理。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器推荐](/blog/video-downloader-recommendation/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [m3u8 视频怎么下载?](/blog/m3u8-hls-download/download-m3u8-video/)
|
||||
- [NAS 视频下载器怎么选?](/blog/video-downloader-review/nas-video-downloader-tools/)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: MediaGo 和 4K Video Downloader 怎么选?桌面下载器能力对比
|
||||
description: 对比 MediaGo 与 4K Video Downloader Plus 在桌面体验、站点覆盖、浏览器能力、NAS/Docker、API 和后处理上的差异。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [MediaGo, 4K Video Downloader, 视频下载器, 桌面下载器]
|
||||
---
|
||||
|
||||
# MediaGo 和 4K Video Downloader 怎么选?
|
||||
|
||||
一句话答案:4K Video Downloader Plus 更像成熟商业桌面下载器,适合复制链接下载和单机使用;MediaGo 更强调内置嗅探、浏览器扩展、Docker/NAS、API、开源生态和多入口工作流。
|
||||
|
||||
## 对比维度
|
||||
|
||||
| 维度 | MediaGo | 4K Video Downloader Plus |
|
||||
| ------------ | ------------------------------- | ------------------------ |
|
||||
| 产品形态 | 开源版 + Pro,桌面与 Docker/NAS | 商业桌面端 |
|
||||
| 使用入口 | 内置浏览器、扩展、API、桌面端 | 链接解析、桌面端 |
|
||||
| NAS / Docker | 支持 | 不是核心能力 |
|
||||
| 自动化 | HTTP API、Skill、可集成 | 较弱 |
|
||||
| 普通用户体验 | 偏综合工作流 | 偏传统桌面下载 |
|
||||
|
||||
## MediaGo 更适合的情况
|
||||
|
||||
- 你经常遇到网页内嵌视频,需要先识别真实资源;
|
||||
- 你希望用浏览器扩展把资源发送到下载队列;
|
||||
- 你想把下载器放到 NAS 或 Docker 环境;
|
||||
- 你需要 API、Agent 或脚本自动化;
|
||||
- 你希望把下载、管理、播放、格式转换放进同一条链路。
|
||||
|
||||
## 4K Video Downloader 更适合的情况
|
||||
|
||||
如果你主要下载常见平台公开视频,并且更偏好成熟商业桌面软件,4K Video Downloader Plus 是一个直观选择。它的重点是桌面体验,而不是自托管和 API 工作流。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [2026 年视频下载器评测](/blog/video-downloader-review/) 的子文章,用于承接 “MediaGo 对比 4K Video Downloader” 的搜索意图。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: MediaGo 和 yt-dlp 怎么选?图形界面、命令行与自动化场景对比
|
||||
description: 对比 MediaGo 和 yt-dlp 在易用性、站点覆盖、M3U8/HLS、批量任务、API、Docker/NAS 和普通用户体验上的差异。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [MediaGo, yt-dlp, 视频下载器, 命令行, 自动化]
|
||||
---
|
||||
|
||||
# MediaGo 和 yt-dlp 怎么选?
|
||||
|
||||
一句话答案:如果你熟悉命令行、需要精细参数和脚本批处理,yt-dlp 更灵活;如果你想要图形界面、网页嗅探、浏览器扩展、下载队列、Docker/NAS 和 API 工作流,MediaGo 更适合作为日常入口。
|
||||
|
||||
## 快速结论
|
||||
|
||||
| 需求 | 更适合 |
|
||||
| --------------------- | -------------------------- |
|
||||
| 命令行批处理 | yt-dlp |
|
||||
| 普通用户图形界面 | MediaGo |
|
||||
| 网页视频嗅探 | MediaGo |
|
||||
| 复杂参数控制 | yt-dlp |
|
||||
| Docker / NAS 长期运行 | MediaGo 或 yt-dlp 封装工具 |
|
||||
| 浏览器扩展发送任务 | MediaGo |
|
||||
| API / Agent 调用 | MediaGo |
|
||||
|
||||
## 什么时候选 MediaGo
|
||||
|
||||
当你不想记命令行参数,或者希望把网页识别、下载队列、格式转换、局域网访问和浏览器扩展放在同一个产品里,MediaGo 更合适。
|
||||
|
||||
典型场景:
|
||||
|
||||
1. 打开视频网页后自动识别资源;
|
||||
2. 同时下载多个公开视频或课程回看;
|
||||
3. 用 Chrome/Edge 扩展把任务发送到桌面端;
|
||||
4. 在 Docker、NAS 或家庭服务器上长期运行;
|
||||
5. 通过 HTTP API 或 OpenClaw Skill 自动创建任务。
|
||||
|
||||
## 什么时候选 yt-dlp
|
||||
|
||||
yt-dlp 适合熟悉终端的用户。它的优势是参数灵活、生态成熟、站点覆盖广,适合写脚本、批量处理和调试特殊平台。
|
||||
|
||||
如果你已经知道目标平台、参数和输出格式,并且不需要图形界面,yt-dlp 的效率会很高。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [2026 年视频下载器评测](/blog/video-downloader-review/) 的子文章,用于展开 “MediaGo vs yt-dlp” 这个高频选型问题。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: NAS 视频下载器怎么选?MediaGo、MeTube、Tube Archivist 与自托管工具对比
|
||||
description: 介绍 NAS 和 Docker 视频下载器的选型思路,对比 MediaGo、MeTube、Tube Archivist、Pinchflat、TubeSync 等自托管工具。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [NAS, Docker, 视频下载器, 自托管, MediaGo]
|
||||
---
|
||||
|
||||
# NAS 视频下载器怎么选?
|
||||
|
||||
一句话答案:如果你只需要轻量 yt-dlp Web UI,可以看 MeTube;如果你专注 YouTube 归档,可以看 Tube Archivist、Pinchflat 或 TubeSync;如果你希望同时覆盖桌面端、浏览器扩展、多站点、M3U8/HLS、API 和 Docker/NAS,MediaGo 更像综合入口。
|
||||
|
||||
## NAS 视频下载器的核心指标
|
||||
|
||||
| 指标 | 为什么重要 |
|
||||
| ----------- | -------------------------------------------------- |
|
||||
| Docker 部署 | 方便在群晖、威联通、Unraid、VPS 或家庭服务器上运行 |
|
||||
| 多站点覆盖 | 避免只服务单一平台 |
|
||||
| 队列和并发 | 长期运行时需要稳定管理任务 |
|
||||
| 局域网访问 | 手机、平板、电视可以访问下载内容 |
|
||||
| API 能力 | 方便和脚本、Agent、自动化工具集成 |
|
||||
| 媒体库能力 | 下载后需要搜索、播放、归档和复用 |
|
||||
|
||||
## 常见工具定位
|
||||
|
||||
- MediaGo:综合下载入口,覆盖桌面、Docker/NAS、浏览器扩展、API 和多协议;
|
||||
- MeTube:轻量 yt-dlp Web UI,适合简单自托管下载;
|
||||
- Tube Archivist:YouTube 私有媒体库方向更深;
|
||||
- Pinchflat:面向 YouTube 到媒体中心的同步;
|
||||
- TubeSync:偏 YouTube PVR 和无人值守同步。
|
||||
|
||||
## MediaGo 的 NAS 场景
|
||||
|
||||
MediaGo 的优势在于把桌面端和 NAS 端连接起来。你可以在电脑上通过内置浏览器或扩展识别资源,也可以把服务部署到 Docker/NAS 上长期运行,并通过局域网访问。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [2026 年视频下载器评测](/blog/video-downloader-review/) 的子文章,用于展开 NAS 和自托管视频下载工具的选型。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [Docker / 宝塔面板部署](/bt-install)
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: 视频嗅探器浏览器哪个好用?Chrome/Edge 插件、桌面端和网页工具对比
|
||||
description: 面向“视频嗅探器浏览器哪个好用”的选型指南,对比 Chrome/Edge 插件、桌面端内置浏览器、网页视频嗅探工具和手动抓包的适用场景。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags:
|
||||
[视频嗅探器浏览器哪个好用, 视频嗅探器, 视频嗅探插件, 网页视频嗅探器, MediaGo]
|
||||
---
|
||||
|
||||
# 视频嗅探器浏览器哪个好用?Chrome/Edge 插件、桌面端和网页工具对比
|
||||
|
||||
一句话答案:普通网页优先用 Chrome/Edge 视频嗅探插件,因为入口最自然;复杂网页、课程回看、m3u8/HLS、需要 Cookie 或 Referer 的资源,优先用 MediaGo 内置浏览器。网页工具适合临时检测,手动抓包适合高级排错。
|
||||
|
||||
本文只讨论公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料和个人素材归档,不讨论绕过访问控制或下载受保护内容。
|
||||
|
||||
## 先看结论
|
||||
|
||||
| 入口 | 适合谁 | 优点 | 局限 |
|
||||
| ------------------------ | ----------------------------- | ------------------------------ | ---------------------------- |
|
||||
| Chrome/Edge 视频嗅探插件 | 日常浏览网页视频的用户 | 不离开常用浏览器,发现资源快 | 受扩展权限和浏览器策略影响 |
|
||||
| MediaGo 内置浏览器 | 复杂网页、课程回看、m3u8 用户 | 识别和下载链路完整,保留上下文 | 需要在 MediaGo 里打开页面 |
|
||||
| 网页视频嗅探工具 | 临时检测资源的用户 | 不一定需要安装客户端 | 后续下载、合并、重试能力有限 |
|
||||
| 手动抓包 | 开发者和高级排错用户 | 信息最细,可看完整请求 | 学习成本高,容易选错资源 |
|
||||
|
||||
## 什么时候用浏览器插件
|
||||
|
||||
如果你搜索的是“视频嗅探插件”“网页视频下载插件”“嗅探网页视频的拓展程序”,通常优先看浏览器插件。它适合这些情况:
|
||||
|
||||
- 普通网页 MP4 或 HLS 资源;
|
||||
- 希望边浏览边发送下载任务;
|
||||
- 使用 Chrome、Edge 等桌面浏览器;
|
||||
- 不想每次都复制页面链接到下载器。
|
||||
|
||||
MediaGo 浏览器扩展适合作为日常入口。它检测到资源后,可以把任务发送到 MediaGo,由桌面端或后端下载能力继续处理。
|
||||
|
||||
## 什么时候用桌面端内置浏览器
|
||||
|
||||
如果出现这些情况,桌面端内置浏览器通常更稳:
|
||||
|
||||
- 插件识别不到资源;
|
||||
- 复制网页链接后不能下载;
|
||||
- m3u8 下载失败或 key 请求失败;
|
||||
- 页面需要 Cookie、Referer、User-Agent 等请求上下文;
|
||||
- 资源列表里有很多分片,不知道选哪个;
|
||||
- 需要下载后合并、转码或长期管理。
|
||||
|
||||
这类问题的关键不是“换一个浏览器”,而是让识别、下载、重试、合并都在同一个工作流里完成。
|
||||
|
||||
## 网页工具和手动抓包适合做主力吗
|
||||
|
||||
网页工具适合临时判断页面里有没有视频资源,但它通常不适合长期作为主力下载方案。原因是它很难完整保留登录态、请求头、失败重试和本地转码能力。
|
||||
|
||||
手动抓包更适合开发者排错。普通用户容易把广告资源、预览片段、音频分片或过期 m3u8 当成目标视频,最后出现下载失败、无画面或只有几 KB 的问题。
|
||||
|
||||
## 推荐使用顺序
|
||||
|
||||
1. 普通网页先用 Chrome/Edge 扩展。
|
||||
2. 插件识别不到时,用 MediaGo 内置浏览器重新打开页面。
|
||||
3. 资源很多时,优先选时长、清晰度、体积接近目标视频的条目。
|
||||
4. m3u8/HLS 失败时,不复用旧地址,回到原网页重新识别。
|
||||
5. 高级排错再使用开发者工具或命令行工具。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 视频嗅探器浏览器哪个好用?
|
||||
|
||||
如果只看浏览器入口,Chrome/Edge 扩展最适合日常使用。但如果你经常遇到课程回看、m3u8、复杂播放器或下载失败,建议使用带内置浏览器和下载队列的桌面端工具。
|
||||
|
||||
### 视频嗅探器网页版靠谱吗?
|
||||
|
||||
可以作为临时检测工具,但不建议作为主力。复杂网页往往需要页面上下文和后续下载处理,单纯网页工具很难覆盖完整流程。
|
||||
|
||||
### 手机视频嗅探器适合 MediaGo 用户吗?
|
||||
|
||||
MediaGo 当前更适合电脑端、浏览器扩展和 Docker/NAS 场景。手机可以作为播放和访问下载结果的设备,不建议把 MediaGo 内容写成纯手机 App 或安卓版下载器。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频嗅探器使用指南](/blog/video-sniffer/) 的子文章,用于承接“视频嗅探器浏览器哪个好用”“视频嗅探插件”“嗅探网页视频的拓展程序”等搜索意图。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [Chrome 视频嗅探怎么用?](/blog/video-sniffer/chrome-video-sniffer/)
|
||||
- [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Chrome 视频嗅探怎么用?用 MediaGo 扩展识别网页视频资源
|
||||
description: 介绍 Chrome 和 Edge 浏览器中视频嗅探的使用方式,以及如何通过 MediaGo 扩展把识别到的资源发送到下载器。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [Chrome 视频嗅探, Edge 视频下载, 浏览器扩展, MediaGo]
|
||||
---
|
||||
|
||||
# Chrome 视频嗅探怎么用?
|
||||
|
||||
一句话答案:安装 MediaGo 浏览器扩展后,在 Chrome 或 Edge 中打开视频页面,扩展会检测播放过程中的媒体资源,并可以把资源发送到 MediaGo 下载队列。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 安装 MediaGo 桌面端。
|
||||
2. 安装 [浏览器扩展](/extension)。
|
||||
3. 打开目标视频页面并播放。
|
||||
4. 查看扩展图标上的资源数量。
|
||||
5. 选择目标资源并发送到 MediaGo。
|
||||
6. 在 MediaGo 中确认下载任务。
|
||||
|
||||
## 适合场景
|
||||
|
||||
- 日常浏览时快速保存公开视频;
|
||||
- 不想复制链接或切换到内置浏览器;
|
||||
- 希望在 Chrome/Edge 中发现资源后直接发送任务;
|
||||
- 简单网页视频、普通 MP4 或常见 HLS 页面。
|
||||
|
||||
## 什么时候改用内置浏览器
|
||||
|
||||
如果扩展识别不到资源,或者任务发送后下载失败,可以改用 MediaGo 内置浏览器。内置浏览器更适合处理需要请求上下文、登录态或复杂播放器的页面。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频嗅探器使用指南](/blog/video-sniffer/) 的子文章,用于承接 Chrome/Edge 视频嗅探场景。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [浏览器扩展](/extension)
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: 为什么网页视频嗅探到很多资源?如何判断该下载哪一个
|
||||
description: 解释网页视频嗅探时出现多个资源的原因,并提供从体积、时长、格式、清晰度和请求类型判断目标视频的方法。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [视频嗅探, 视频资源, m3u8, MP4, MediaGo]
|
||||
---
|
||||
|
||||
# 为什么网页视频嗅探到很多资源?
|
||||
|
||||
一句话答案:一个网页可能同时加载广告、预览片段、音频、不同清晰度、字幕、封面、m3u8 播放列表和分片资源。需要结合资源类型、大小、时长和清晰度判断真正目标。
|
||||
|
||||
## 判断方法
|
||||
|
||||
| 判断项 | 说明 |
|
||||
| -------- | ---------------------------------- |
|
||||
| 文件类型 | MP4、M3U8、MPD 更可能是目标视频 |
|
||||
| 资源大小 | 体积过小可能只是广告、封面或片段 |
|
||||
| 时长 | 接近目标视频时长的资源优先 |
|
||||
| 清晰度 | 720p、1080p、4K 等信息可辅助判断 |
|
||||
| 出现时机 | 播放目标视频后出现的资源更值得关注 |
|
||||
|
||||
## 常见误判
|
||||
|
||||
- 把广告视频当作目标视频;
|
||||
- 选择了低清预览流;
|
||||
- 只下载了音频流;
|
||||
- 只下载了视频流但没有声音;
|
||||
- 选择了过期的临时链接。
|
||||
|
||||
## MediaGo 的处理建议
|
||||
|
||||
如果不确定该选哪个,可以先选择体积较大、时长接近、格式为 MP4/M3U8/MPD 的资源。复杂页面建议用内置浏览器重新播放并识别。
|
||||
|
||||
## 与支柱页的关系
|
||||
|
||||
这篇文章是 [网页视频嗅探器使用指南](/blog/video-sniffer/) 的子文章,用于解决用户“识别到了很多资源但不知道选哪个”的问题。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频嗅探器使用指南](/blog/video-sniffer/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [m3u8 下载失败怎么办?](/blog/m3u8-hls-download/fix-m3u8-download-failed/)
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: 网页视频嗅探器使用指南
|
||||
description: 介绍网页视频嗅探器的工作方式、浏览器扩展和内置浏览器的区别,以及如何使用 MediaGo 识别并下载网页视频资源。
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [视频嗅探, 浏览器扩展, Chrome 视频下载, Edge 视频下载, MediaGo]
|
||||
---
|
||||
|
||||
# 网页视频嗅探器使用指南
|
||||
|
||||
一句话答案:视频嗅探器会观察网页播放过程中产生的网络请求,从中识别 MP4、M3U8、MPD、直播流或其他媒体资源。MediaGo 提供内置浏览器和 Chrome/Edge 扩展两种入口,适合不同复杂度的网页视频场景。
|
||||
|
||||
这篇支柱页覆盖“视频嗅探器”“网页视频嗅探器”“视频嗅探工具”“视频嗅探插件”“视频嗅探器浏览器哪个好用”等搜索意图。手机 App、安卓版、网页版等词会以 FAQ 方式说明边界,避免误导成 MediaGo 的主能力。
|
||||
|
||||
## 视频嗅探器能解决什么问题
|
||||
|
||||
很多网页并不会在页面源码里直接写出视频地址。真实资源可能在播放器接口、动态脚本、m3u8 播放列表或分片请求里。视频嗅探器的作用是把这些隐藏在播放过程中的资源整理出来,让用户可以选择并下载。
|
||||
|
||||
## 浏览器扩展和内置浏览器的区别
|
||||
|
||||
| 方式 | 优点 | 局限 | 推荐场景 |
|
||||
| ------------------ | ---------------------------------------- | ------------------------------ | ---------------------------------------- |
|
||||
| Chrome/Edge 扩展 | 不离开日常浏览器,检测到资源后可直接发送 | 受浏览器扩展权限和页面策略影响 | 普通网页视频、快速发送任务 |
|
||||
| MediaGo 内置浏览器 | 识别和下载链路在同一客户端内,信息更完整 | 需要在 MediaGo 内打开页面 | 复杂页面、课程回看、需要请求上下文的资源 |
|
||||
| 手动抓包 | 信息最细,适合排错 | 学习成本高,容易出错 | 开发者、高级排查 |
|
||||
|
||||
## 推荐使用方式
|
||||
|
||||
日常使用可以先安装 [浏览器扩展](/extension),在 Chrome 或 Edge 里浏览视频页面。扩展检测到资源后,把任务发送到 MediaGo。
|
||||
|
||||
如果扩展没有识别到目标资源,或者下载失败,再使用 MediaGo 内置浏览器打开页面。内置浏览器更适合处理依赖页面上下文、请求头或播放过程的资源。
|
||||
|
||||
## 按搜索问题快速进入
|
||||
|
||||
| 你搜索的问题 | 应该先看 |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| 视频嗅探器浏览器哪个好用、嗅探插件 | [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/) |
|
||||
| Chrome 视频嗅探、Edge 视频下载 | [Chrome 视频嗅探怎么用?](/blog/video-sniffer/chrome-video-sniffer/) |
|
||||
| 视频嗅探到很多资源不知道选哪个 | [为什么网页视频嗅探到很多资源?](/blog/video-sniffer/detect-video-resources/) |
|
||||
| 网页视频下载不了、复制链接不能下载 | [网页视频下载不了怎么办?](/blog/video-download/web-video-download-failed/) |
|
||||
|
||||
## 专题文章
|
||||
|
||||
| 文章 | 解决的问题 | 目标搜索意图 |
|
||||
| ------------------------------------------------------------------------------- | -------------------------------- | -------------------------------- |
|
||||
| [Chrome 视频嗅探怎么用?](/blog/video-sniffer/chrome-video-sniffer/) | 浏览器扩展识别并发送资源 | Chrome 视频嗅探、Edge 视频下载 |
|
||||
| [视频嗅探器浏览器哪个好用?](/blog/video-sniffer/browser-extension-vs-desktop/) | 解释扩展、内置浏览器、抓包的取舍 | 视频嗅探器浏览器哪个好用 |
|
||||
| [为什么网页视频嗅探到很多资源?](/blog/video-sniffer/detect-video-resources/) | 判断多个资源中哪个才是目标视频 | 视频嗅探资源很多、怎么选视频资源 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么嗅探到很多资源,不知道该选哪个?
|
||||
|
||||
网页可能同时加载广告、预览、音频、不同清晰度的视频流和分片地址。一般优先选择体积更大、清晰度更高、持续时长更接近目标视频的资源。
|
||||
|
||||
### 视频嗅探等于破解吗?
|
||||
|
||||
不是。视频嗅探只是识别网页正常播放过程中请求到的媒体资源。使用时应只处理公开可访问内容、用户自有内容、授权内容、课程回看、企业内部资料或个人素材归档。
|
||||
|
||||
### 浏览器扩展和桌面端必须一起用吗?
|
||||
|
||||
不必须。桌面端内置浏览器可以独立使用;扩展的价值是让日常浏览器里的视频页面可以更快发送到 MediaGo。
|
||||
|
||||
### 视频嗅探器 app、安卓版和手机版要不要作为主方向?
|
||||
|
||||
MediaGo 当前更适合电脑端、浏览器扩展和 Docker/NAS 场景,不建议把主要标题写成纯手机 App 或安卓版。移动端可以作为播放、访问下载结果和局域网查看的补充场景。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- [网页视频下载完整指南](/blog/video-download/)
|
||||
- [M3U8 / HLS 视频下载完整指南](/blog/m3u8-hls-download/)
|
||||
- [2026 年视频下载器评测](/blog/video-downloader-review/)
|
||||
@@ -5,6 +5,28 @@ outline: deep
|
||||
|
||||
# 更新日志
|
||||
|
||||
## v3.5.0 (2026.4.22 发布)
|
||||
|
||||
### 软件下载
|
||||
|
||||
- [【mediago】 windows(安装版) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 windows(便携版) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 macos arm64(apple 芯片) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [【mediago】 macos x64(intel 芯片) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [【mediago】 linux v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago):`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**:`docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
### 主要更新
|
||||
|
||||
- **🌐 浏览器扩展(Chrome / Edge)** —— 任意网站一键嗅探视频、一键发到 MediaGo
|
||||
- **🎬 支持 YouTube 和 1000+ 站点** —— 集成 yt-dlp
|
||||
- **🦞 OpenClaw Skill** —— 通过 AI 编程助手用自然语言下载视频
|
||||
- **🔌 开放 HTTP 接口** —— 支持脚本、自动化工具、第三方应用接入
|
||||
- **🎞️ 内置视频格式转换** —— 下载完成后在应用内选输出格式和画质
|
||||
- **🐳 Docker 部署简化** —— 镜像迁至 GitHub Container Registry(GHCR),支持 x86 / ARM 双架构,挂载单一目录即可
|
||||
- **⚡ 启动更快** —— 后端用 Go 重写,内存占用更低,内置视频播放器
|
||||
|
||||
## v3.0.0 (2024.10.7 发布)
|
||||
|
||||
### 软件下载
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ outline: deep
|
||||
|
||||
### 3. 显示语言、
|
||||
|
||||
支持中文和英文
|
||||
支持中文、英文和意大利语
|
||||
|
||||
### 4. 下载完成提示
|
||||
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Download API
|
||||
|
||||
MediaGo exposes its download engine as an HTTP service. The desktop app listens on port `39719`; the Docker deployment listens on port `9900`.
|
||||
|
||||
You can drive it from anything that speaks HTTP — curl, Python, Node.js, Postman, your own scripts, automation platforms. MediaGo's own browser extension and AI Skill are just consumers of this API.
|
||||
|
||||
## Basics
|
||||
|
||||
### Base URL
|
||||
|
||||
| Deployment | Base URL |
|
||||
| ---------- | ------------------------------------------------------- |
|
||||
| Desktop | `http://localhost:39719` |
|
||||
| Docker | `http://<your-host>:9900` (adjust to your port mapping) |
|
||||
|
||||
All endpoints live under the `/api` prefix. The examples below use the desktop port `39719` by default — swap in your Docker port if that's what you're targeting.
|
||||
|
||||
### Response envelope
|
||||
|
||||
Every `/api/*` endpoint returns this JSON wrapper:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --------- | ------ | ---------------------------------------------- |
|
||||
| `success` | bool | Whether the call succeeded |
|
||||
| `code` | number | Business error code, `0` on success |
|
||||
| `message` | string | Human-readable hint |
|
||||
| `data` | any | The actual payload — shape varies per endpoint |
|
||||
|
||||
Example responses below only show the `data` body.
|
||||
|
||||
### Authentication
|
||||
|
||||
- **Desktop**: no auth by default, just hit `localhost:39719`
|
||||
- **Docker**: when auth is enabled, grab the API key from MediaGo's **Settings** page, then include `Authorization: Bearer <key>` on every request
|
||||
|
||||
## Quick start
|
||||
|
||||
Three curl commands that walk through the whole "create → download → get notified" flow.
|
||||
|
||||
### 1. Create a download task
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:39719/api/downloads \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tasks": [
|
||||
{
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"name": "My Video"
|
||||
}
|
||||
],
|
||||
"startDownload": true
|
||||
}'
|
||||
```
|
||||
|
||||
- `type`: download type — `m3u8` / `bilibili` / `direct` / `youtube` / `mediago`
|
||||
- `url`: video URL
|
||||
- `name`: task name (used as the saved file name)
|
||||
- `startDownload`: whether to start immediately after creation
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 123,
|
||||
"name": "My Video",
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"status": "waiting",
|
||||
"createdDate": "2026-04-23T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Keep the `id` — you'll need it later.
|
||||
|
||||
### 2. Subscribe to download events (SSE)
|
||||
|
||||
```bash
|
||||
curl -N http://localhost:39719/api/events
|
||||
```
|
||||
|
||||
A long-lived connection — whatever the server emits, you receive:
|
||||
|
||||
```text
|
||||
event: download-start
|
||||
data: {"id": "123"}
|
||||
|
||||
event: download-success
|
||||
data: {"id": "123"}
|
||||
```
|
||||
|
||||
In the browser / Node.js:
|
||||
|
||||
```javascript
|
||||
const es = new EventSource("http://localhost:39719/api/events");
|
||||
es.addEventListener("download-success", (e) => {
|
||||
const { id } = JSON.parse(e.data);
|
||||
console.log("Done:", id);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Query / manually control
|
||||
|
||||
```bash
|
||||
# List all downloads (paginated)
|
||||
curl "http://localhost:39719/api/downloads?current=1&pageSize=20"
|
||||
|
||||
# Get one task
|
||||
curl http://localhost:39719/api/downloads/123
|
||||
|
||||
# Start an existing task
|
||||
curl -X POST http://localhost:39719/api/downloads/123/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"localPath": "/Downloads/MediaGo", "deleteSegments": true}'
|
||||
|
||||
# Stop a task
|
||||
curl -X POST http://localhost:39719/api/downloads/123/stop
|
||||
|
||||
# Get logs
|
||||
curl http://localhost:39719/api/downloads/123/logs
|
||||
```
|
||||
|
||||
## Download events
|
||||
|
||||
`GET /api/events` is a Server-Sent Events stream. Download-related events:
|
||||
|
||||
| Event | Payload | Notes |
|
||||
| ------------------ | -------------------------------- | ------------------------- |
|
||||
| `download-create` | `{ids: number[], count: number}` | Bulk task creation |
|
||||
| `download-start` | `{id: string}` | Download started |
|
||||
| `download-success` | `{id: string}` | Download completed |
|
||||
| `download-failed` | `{id: string, error: string}` | Download failed |
|
||||
| `download-stop` | `{id: string}` | Download manually stopped |
|
||||
|
||||
## Endpoint reference
|
||||
|
||||
### List / query
|
||||
|
||||
#### `GET /api/downloads` — paginated list
|
||||
|
||||
**Query params:**
|
||||
|
||||
- `current` (number, default 1) — page number
|
||||
- `pageSize` (number, default 20) — page size
|
||||
- `filter` (string, optional) — status filter (`downloading` / `success` / `failed`)
|
||||
- `localPath` (string, optional) — save-path filter
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 42,
|
||||
"list": [
|
||||
/* DownloadTask[] */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/downloads/active` — list active tasks
|
||||
|
||||
Returns all tasks in `waiting` or `downloading` state.
|
||||
|
||||
#### `GET /api/downloads/:id` — get one task
|
||||
|
||||
**Response** (`DownloadTask` shape):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "My Video",
|
||||
"type": "m3u8",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"folder": "my-folder",
|
||||
"headers": "User-Agent: ...",
|
||||
"isLive": false,
|
||||
"status": "success",
|
||||
"file": "/path/to/saved.mp4",
|
||||
"createdDate": "2026-04-23T10:00:00Z",
|
||||
"updatedDate": "2026-04-23T10:05:30Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/downloads/folders` — list unique save directories
|
||||
|
||||
**Response:** `string[]`
|
||||
|
||||
#### `GET /api/downloads/export` — export URL list
|
||||
|
||||
Plain text, one URL per line.
|
||||
|
||||
#### `GET /api/downloads/:id/logs` — fetch download logs
|
||||
|
||||
**Response:** `{ id, log: string }`
|
||||
|
||||
### Create / delete
|
||||
|
||||
#### `POST /api/downloads` — batch create downloads
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"type": "m3u8 | bilibili | direct | youtube | mediago",
|
||||
"url": "https://example.com/video.m3u8",
|
||||
"name": "Task name",
|
||||
"folder": "optional subdir",
|
||||
"headers": "optional multi-line HTTP headers"
|
||||
}
|
||||
],
|
||||
"startDownload": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `DownloadTask[]`
|
||||
|
||||
#### `DELETE /api/downloads/:id` — delete task
|
||||
|
||||
**Response:** `{}`
|
||||
|
||||
### Edit / status
|
||||
|
||||
#### `PUT /api/downloads/:id` — edit task
|
||||
|
||||
**Body** (all fields optional):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "New name",
|
||||
"url": "New URL",
|
||||
"headers": "New headers",
|
||||
"folder": "New subdir"
|
||||
}
|
||||
```
|
||||
|
||||
#### `PUT /api/downloads/:id/live` — toggle live-stream flag
|
||||
|
||||
**Body:** `{ "isLive": true }`
|
||||
|
||||
#### `PUT /api/downloads/status` — bulk status update
|
||||
|
||||
**Body:** `{ "ids": number[], "status": "waiting | downloading | success | failed | stopped" }`
|
||||
|
||||
### Start / stop
|
||||
|
||||
#### `POST /api/downloads/:id/start` — start download
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"localPath": "/Users/me/Downloads/MediaGo",
|
||||
"deleteSegments": true
|
||||
}
|
||||
```
|
||||
|
||||
- `localPath`: where to save (absolute path)
|
||||
- `deleteSegments`: for m3u8 downloads, whether to delete segment `.ts` files after merging
|
||||
|
||||
#### `POST /api/downloads/:id/stop` — stop download
|
||||
|
||||
**Response:** `{}`
|
||||
|
||||
## Enum values
|
||||
|
||||
### Download type `type`
|
||||
|
||||
| Value | Notes |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| `m3u8` | HLS streams (backed by N_m3u8DL-RE) |
|
||||
| `bilibili` | Bilibili videos (backed by BBDown) |
|
||||
| `direct` | Direct HTTP download (backed by aria2) |
|
||||
| `youtube` | YouTube and 1000+ sites (backed by yt-dlp) |
|
||||
| `mediago` | MediaGo internal type |
|
||||
|
||||
### Task status `status`
|
||||
|
||||
| Value | Notes |
|
||||
| ------------- | ------------------- |
|
||||
| `waiting` | Queued, not started |
|
||||
| `downloading` | In progress |
|
||||
| `success` | Completed |
|
||||
| `failed` | Errored out |
|
||||
| `stopped` | Manually stopped |
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: MediaGo Blog
|
||||
description: MediaGo blog covers video downloading workflows, M3U8/HLS, browser video detection, NAS/Docker setups, and downloader comparisons for global users.
|
||||
date: 2026-04-26
|
||||
updated: 2026-04-26
|
||||
author: MediaGo
|
||||
tags: [MediaGo, video downloader, m3u8, HLS, video sniffer]
|
||||
---
|
||||
|
||||
# MediaGo Blog
|
||||
|
||||
The MediaGo blog is a practical resource for video downloading workflows, M3U8/HLS handling, browser video detection, NAS/Docker deployment, and downloader comparisons.
|
||||
|
||||
## Featured guide
|
||||
|
||||
| Topic | Best for | Start here |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Video downloader comparison | Choosing between MediaGo, yt-dlp, 4K Video Downloader, browser extensions, NAS tools, and stream utilities | [2026 Video Downloader Review](/en/blog/video-downloader-review/) |
|
||||
|
||||
## Product docs
|
||||
|
||||
- [Quick Start](/en/guides)
|
||||
- [User Guide](/en/documents)
|
||||
- [Browser Extension](/en/extension)
|
||||
- [Download API](/en/api)
|
||||
- [Docker / BT Panel Deployment](/en/bt-install)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,28 @@ outline: deep
|
||||
|
||||
# Changelog
|
||||
|
||||
## v3.5.0 (Released on 2026.4.22)
|
||||
|
||||
### Software Downloads
|
||||
|
||||
- [【mediago】 Windows (Installer) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 Windows (Portable) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 macOS arm64 (Apple Silicon) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [【mediago】 macOS x64 (Intel) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [【mediago】 Linux v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago): `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
### Highlights
|
||||
|
||||
- **🌐 Browser extension (Chrome / Edge)** — one-click video sniffing on any site, sent straight to MediaGo.
|
||||
- **🎬 YouTube and 1000+ sites** — powered by yt-dlp.
|
||||
- **🦞 OpenClaw Skill** — download videos through AI coding assistants with natural language.
|
||||
- **🔌 Open HTTP API** — integrate with scripts, automation and third-party tools.
|
||||
- **🎞️ In-app format conversion** — pick output format and quality after a download completes.
|
||||
- **🐳 Simpler Docker deployment** — multi-arch images (x86 / ARM) on GitHub Container Registry, mount a single folder.
|
||||
- **⚡ Faster startup** — backend rewritten in Go, lower memory footprint, built-in video player.
|
||||
|
||||
## v3.0.0 (Released on 2024.10.7)
|
||||
|
||||
### Software Downloads
|
||||
|
||||
@@ -25,7 +25,7 @@ Supports light and dark modes.
|
||||
|
||||
### 3. Display Language
|
||||
|
||||
Supports Chinese and English.
|
||||
Supports Chinese, English, and Italian.
|
||||
|
||||
### 4. Download Completion Notification
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Browser Extension
|
||||
|
||||
MediaGo ships a lightweight Manifest V3 browser extension that sniffs downloadable video / audio URLs on any site and sends them to MediaGo in one click.
|
||||
|
||||
## What it does
|
||||
|
||||
- Detects HLS / m3u8 streams and direct `.mp4` / `.flv` / `.mov` media files across every page you browse
|
||||
- Recognises Bilibili video pages and YouTube video / short / live / embed URLs
|
||||
- Shows the number of detected resources on the toolbar icon badge
|
||||
- One-click import single or all sources to MediaGo (Desktop or self-hosted)
|
||||
|
||||
## Install
|
||||
|
||||
The extension is not on the Chrome Web Store yet, so it must be "loaded unpacked". The MediaGo Desktop installer already bundles the extension — you don't need to download it separately.
|
||||
|
||||
1. Open the MediaGo Desktop app
|
||||
2. Go to **Settings → More Settings → Browser extension directory** and click the button to open the extension folder
|
||||
3. In Chrome / Edge, visit `chrome://extensions/`
|
||||
4. Toggle **Developer mode** in the top-right
|
||||
5. Click **Load unpacked** and pick the folder you opened in step 2
|
||||
6. You should see the extension icon appear in the toolbar — pin it for easy access
|
||||
|
||||
## Dispatch modes
|
||||
|
||||
Click the gear icon in the popup to open the options page, then pick one mode:
|
||||
|
||||
| Mode | When to use | Requires |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **Desktop · Schema protocol** | MediaGo Desktop installed locally; browser allowed to hand off protocol links | No config; first call shows "Open MediaGo?" dialog — tick "Always allow" for silent dispatch afterwards |
|
||||
| **Desktop · HTTP local** (default) | MediaGo Desktop installed AND running | No config; extension connects to `127.0.0.1:39719` |
|
||||
| **Docker / Self-hosted · HTTP** | Connect to a remote MediaGo server (e.g. Docker deployment) | Server URL required; API Key if the server runs with `--enable-auth` |
|
||||
|
||||
> **The extension never silently falls back.** Once a mode is chosen, any failure is reported as-is — switch modes manually on the options page if you need to.
|
||||
|
||||
## Import behaviour
|
||||
|
||||
Two toggles on the options page under **Import Behaviour**:
|
||||
|
||||
- **Start downloading immediately** — On: the task is queued AND started. Off: it's only added to the list, waiting for the user to start it. Applies to both Schema and HTTP modes.
|
||||
- **Silent import (Schema mode)** — On: the deeplink carries `silent=1` so MediaGo creates the task immediately. Off: MediaGo opens its download form prefilled with the sniffed name / type / folder for review. Only takes effect in Schema mode; HTTP mode is always silent.
|
||||
|
||||
## Interface language
|
||||
|
||||
The extension supports Chinese, English, and Italian. By default it follows the browser UI language. You can force a choice on the options page under **Interface Language**: Follow system / 中文 / English / Italiano.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Browser extension directory" button does nothing
|
||||
|
||||
- **Development**: run `pnpm -F @mediago/extension build` first to produce the dist
|
||||
- **Production**: reinstall MediaGo — the `resources/extension/` folder should exist in the app install directory
|
||||
|
||||
### Desktop · HTTP test connection fails
|
||||
|
||||
- Verify MediaGo Desktop is running
|
||||
- Verify port `39719` isn't taken by another process (`netstat -ano | findstr 39719` on Windows)
|
||||
- If you also run MediaGo in web/server mode locally, note that standalone Go Core uses `9900`, not `39719`
|
||||
|
||||
### Schema mode prompts every time
|
||||
|
||||
On the first hand-off Chrome shows "Open MediaGo-community?" — tick **Always allow**. Subsequent calls are silent.
|
||||
|
||||
### Schema mode fails on batch import
|
||||
|
||||
Schema dispatches a single task per call — a fundamental limitation of protocol hand-offs. Switch to HTTP mode (Desktop or Docker) for batch imports.
|
||||
+26
-31
@@ -7,50 +7,45 @@ outline: deep
|
||||
|
||||
This article provides a simple guide to help you get started with using the software. Supports [OpenClaw Skill](/en/skills) for downloading videos via natural language in AI coding assistants.
|
||||
|
||||
::: tip
|
||||
To facilitate communication and feedback, you can join the feedback group:
|
||||
|
||||
MediaGo QQ Feedback Group 1: 574209001
|
||||
:::
|
||||
|
||||
::: info
|
||||
|
||||
v3.0 is the latest version. Please feel free to provide feedback within version 3.0, and we will address it as soon as possible.
|
||||
v3.5 is the latest version. Please feel free to provide feedback in this release and we will address it as quickly as possible.
|
||||
|
||||
:::
|
||||
|
||||
::: tip
|
||||
macOS usage
|
||||
|
||||
- **[Intel chip]** Install the x64 build from the release page. After installation, allow apps from unidentified developers in Mac's Security settings.
|
||||
- **[Apple Silicon]** Install the arm64 build from the release page. After installation, run `sudo xattr -dr com.apple.quarantine /Applications/mediago-community.app` in Terminal.
|
||||
|
||||
:::
|
||||
|
||||
## Download and Installation
|
||||
|
||||
### v3.0.0 (Released on October 7, 2024)
|
||||
### v3.5.0 (Released on April 22, 2026)
|
||||
|
||||
#### Software Download
|
||||
|
||||
- [【mediago】 Windows (Installer) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 Windows (Portable) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 macOS arm64 (Apple Silicon) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
|
||||
- [【mediago】 macOS x64 (Intel) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-x64-3.0.0.dmg)
|
||||
- [【mediago】 Linux v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-linux-amd64-3.0.0.deb)
|
||||
- 【mediago】 Docker v3.0 `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:latest`
|
||||
- [【mediago】 Windows (Installer) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 Windows (Portable) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-portable-win32-x64-3.5.0.exe)
|
||||
- [【mediago】 macOS arm64 (Apple Silicon) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-arm64-3.5.0.dmg)
|
||||
- [【mediago】 macOS x64 (Intel) v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-darwin-x64-3.5.0.dmg)
|
||||
- [【mediago】 Linux v3.5.0](https://github.com/caorushizi/mediago/releases/download/v3.5.0/mediago-community-setup-linux-amd64-3.5.0.deb)
|
||||
- [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago): `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0`
|
||||
- **GHCR**: `docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago ghcr.io/caorushizi/mediago:3.5.0`
|
||||
|
||||
#### Important Updates
|
||||
Older releases are on the [GitHub Releases page](https://github.com/caorushizi/mediago/releases).
|
||||
|
||||
- Support for Docker deployment of the web version.
|
||||
- Updated desktop UI.
|
||||
#### What's New
|
||||
|
||||
#### Changelog
|
||||
|
||||
- Support for Docker deployment of the web version.
|
||||
- Updated desktop UI.
|
||||
- Added video playback, supporting both desktop and mobile versions.
|
||||
- Fixed an issue where macOS could not display the interface.
|
||||
- Optimized the interaction for batch downloads.
|
||||
- Added a portable version for Windows (no installation required).
|
||||
- Optimized the download list, supporting sniffing of multiple videos on a page.
|
||||
- Supports manual import/export of the favorite list.
|
||||
- Supports export of the homepage download list.
|
||||
- Optimized the interaction logic for the "New Download" form.
|
||||
- Supports UrlScheme to open the app and add download tasks.
|
||||
- Fixed some bugs and improved the user experience.
|
||||
- **Browser extension** (Chrome / Edge) — one-click video sniffing on any site.
|
||||
- **YouTube and 1000+ sites** — powered by yt-dlp.
|
||||
- **OpenClaw Skill** — download videos through AI coding assistants.
|
||||
- **Open HTTP API** — integrate with scripts, automation and third-party tools.
|
||||
- **In-app format conversion** — pick output format and quality after downloading.
|
||||
- **Simpler Docker deployment** — multi-arch images on GHCR, mount a single folder.
|
||||
- **Faster startup** — backend rewritten in Go, lower memory footprint, built-in player.
|
||||
|
||||
## Operation Instructions
|
||||
|
||||
|
||||
+30
-18
@@ -3,34 +3,46 @@
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "Online Video Downloader"
|
||||
text: "Simple and Easy, Fast Downloading"
|
||||
tagline: Simple to learn, no need for packet capture, no plugins required
|
||||
name: "MediaGo"
|
||||
text: "Cross-platform video downloader"
|
||||
tagline: "Built-in sniffing — point it at a page, pick what you want, save. No packet capture, no plugins, no command-line tools."
|
||||
image:
|
||||
src: /home.png
|
||||
alt: home
|
||||
src: /home_en.png
|
||||
alt: MediaGo home screen
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Quick Start
|
||||
link: /guides
|
||||
link: /en/guides
|
||||
- theme: alt
|
||||
text: User Guide
|
||||
link: /documents
|
||||
link: /en/documents
|
||||
|
||||
features:
|
||||
- icon: ⏩
|
||||
title: No Packet Capture Required
|
||||
details: Use the built-in browser of the software to easily sniff video resources on web pages. Select the resource you want to download from the sniffed resource list—simple and fast.
|
||||
- icon: 📱
|
||||
title: Mobile Playback
|
||||
details: Seamlessly switch between PC and mobile devices. After the download is complete, you can watch the video on your phone.
|
||||
title: No packet capture required
|
||||
details: The desktop app ships with a built-in browser that sniffs every downloadable resource on the page automatically. No Fiddler, no Charles, no DevTools gymnastics.
|
||||
- icon: 🌐
|
||||
title: Browser extension for Chrome / Edge
|
||||
details: One-click video sniffing in your everyday browser. Detected count shows in the toolbar badge; covers YouTube, Bilibili and most mainstream video platforms. Bundled with the desktop app.
|
||||
- icon: 🎬
|
||||
title: Broad video source coverage
|
||||
details: HLS / m3u8 streams, live streaming, Bilibili, YouTube, Twitter/X, Instagram and over a thousand more video sites — powered by N_m3u8DL-RE, BBDown and yt-dlp under the hood.
|
||||
- icon: ⚡️
|
||||
title: Batch Download Supported
|
||||
details: Supports downloading multiple videos and live streaming resources at the same time, ensuring that your high-speed bandwidth is fully utilized.
|
||||
- icon: 🎉
|
||||
title: Docker Deployment Supported
|
||||
details: Supports Docker deployment for the web version, making it quick and easy.
|
||||
title: Batch download
|
||||
details: Download multiple videos and live streams at once. Your high-speed bandwidth never sits idle; tweak the concurrency to taste.
|
||||
- icon: 🎞️
|
||||
title: Built-in format conversion
|
||||
details: Convert completed downloads to another format or quality without leaving MediaGo. No separate ffmpeg tool required.
|
||||
- icon: 📱
|
||||
title: Mobile playback
|
||||
details: The desktop app listens on your LAN IP too — open the web UI on a phone or tablet on the same Wi-Fi to browse downloads and play them directly.
|
||||
- icon: 🔌
|
||||
title: Open HTTP API
|
||||
details: Full HTTP API lets scripts, automation tools and third-party apps create download tasks, query progress and manage the list.
|
||||
- icon: 🦞
|
||||
title: OpenClaw Skill
|
||||
details: Download videos using natural language in AI coding assistants (OpenClaw, Claude Code, etc.). Install with one command.
|
||||
details: Tell Claude Code, Cursor or your AI coding assistant "please download this video" — it handles the rest. One command to install.
|
||||
- icon: 🐳
|
||||
title: One-line Docker deployment
|
||||
details: One command to deploy to your NAS or VPS. Access from any browser on your network. Multi-arch images on Docker Hub and GHCR.
|
||||
---
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
layout: doc
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# 浏览器扩展
|
||||
|
||||
MediaGo 自带一个轻量浏览器扩展(Manifest V3),在你浏览任意网站时自动嗅探可下载的视频/音频资源,一键发送到 MediaGo 下载。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
- 跨站点嗅探 HLS / m3u8 流、直连 `.mp4` / `.flv` / `.mov` 等媒体文件
|
||||
- 识别 Bilibili 视频页和 YouTube 视频/短片/直播/嵌入页
|
||||
- 在浏览器工具栏图标上显示当前页面已检测到的资源数量
|
||||
- 一键把单条或全部资源导入 MediaGo(Desktop 或自建服务)
|
||||
|
||||
## 安装
|
||||
|
||||
MediaGo 尚未上架 Chrome Web Store,需以"加载已解压的扩展程序"方式安装。Desktop 版已把扩展打包进安装目录,不必额外下载。
|
||||
|
||||
1. 打开 MediaGo Desktop 应用
|
||||
2. 进入 **设置 → 更多设置 → 浏览器扩展目录**,点击按钮打开扩展文件夹
|
||||
3. 在 Chrome / Edge 地址栏访问 `chrome://extensions/`
|
||||
4. 右上角开启 **开发者模式**
|
||||
5. 点击 **加载已解压的扩展程序**,选择第 2 步打开的那个文件夹
|
||||
6. 扩展图标出现在工具栏即安装成功;建议点图钉把它固定住
|
||||
|
||||
## 调用模式
|
||||
|
||||
首次安装后点击扩展图标右上角齿轮进入设置页,选择一种调用方式:
|
||||
|
||||
| 模式 | 使用场景 | 要求 |
|
||||
| ----------------------------------- | -------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| **Desktop · Schema 协议** | 本机装了 MediaGo Desktop,允许浏览器唤起协议 | 无配置;首次会弹出"Open MediaGo?"对话框,勾选"总是允许"即可静默直通 |
|
||||
| **Desktop · HTTP 本地接口**(默认) | 本机装了 MediaGo Desktop 且在运行 | 无配置;扩展固定连接 `127.0.0.1:39719` |
|
||||
| **Docker / 自建服务 · HTTP** | 连接远端自建 MediaGo 服务端(Docker 部署等) | 填写服务器 URL;若后端启用了 `--enable-auth` 则填 API Key |
|
||||
|
||||
> **扩展不会自动降级。** 选定模式后,调用失败会直接报错。要换模式请回到设置页手动切换。
|
||||
|
||||
## 导入行为
|
||||
|
||||
设置页 **导入行为** 卡片里的两个开关:
|
||||
|
||||
- **立即开始下载**:开 = 任务进队列并立刻开跑;关 = 仅加入下载列表,等用户手动触发。对 Schema 和 HTTP 两种模式都生效。
|
||||
- **静默导入(Schema 模式)**:开 = Schema 调用携带 `silent=1`,MediaGo 收到即创建任务;关 = MediaGo 会弹出下载表单让你核对名字/类型/保存路径再提交。仅 Schema 模式生效,HTTP 模式一律静默。
|
||||
|
||||
## 界面语言
|
||||
|
||||
扩展支持中文、英文和意大利语,默认跟随浏览器 UI 语言。也可以在设置页 **界面语言** 卡片强制切换到"跟随系统 / 中文 / English / Italiano"之一。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 点"浏览器扩展目录"打不开
|
||||
|
||||
- **开发场景**:先跑 `pnpm -F @mediago/extension build` 构建扩展产物
|
||||
- **生产场景**:重新安装 MediaGo,确保 `resources/extension/` 在安装目录里
|
||||
|
||||
### Desktop · HTTP 模式测试连接失败
|
||||
|
||||
- 确认 MediaGo Desktop 在运行
|
||||
- 确认端口 `39719` 没被其他进程占用(`netstat -ano | findstr 39719`)
|
||||
- 如果你本地同时跑了 Web/server 模式的 Go Core,注意那个用的是 `9900`,不是 39719
|
||||
|
||||
### Schema 模式每次都弹窗
|
||||
|
||||
首次弹出"Open MediaGo-community?"时勾选 **总是允许** 即可。之后 Chrome 会静默把请求转给 Desktop。
|
||||
|
||||
### 批量导入 Schema 模式失败
|
||||
|
||||
Schema 一次只能发一条任务,这是浏览器协议调用的限制。要批量导入请切到 HTTP 模式(Desktop 或 Docker 均可)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user