Compare commits
65 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 | |||
| fb671d5790 | |||
| e32bfd1cb2 | |||
| 50a1a90e6a | |||
| 2406b5b617 | |||
| 913d645d89 | |||
| c7dd925a34 | |||
| c289d3690f | |||
| 8224c68f66 | |||
| 6571fc022d | |||
| 109c09a911 | |||
| 90d7fe578f | |||
| 82f948cdd9 | |||
| 25d149e9ef | |||
| 8d757ba18d | |||
| 4f1c9fe65c | |||
| 78e3f96e3a |
@@ -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
-12
@@ -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,19 +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", \
|
||||
"--ffmpeg-bin=/app/deps/ffmpeg"]
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
<div align="center">
|
||||
<h1>MediaGo</h1>
|
||||
<a href="https://downloader.caorushizi.cn/en/guides.html?form=github">Quick start</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/en?form=github">Website</a>
|
||||
<span> • </span>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## Intro
|
||||
|
||||
This project supports m3u8 video extraction tools, streaming media download, and m3u8 download.
|
||||
|
||||
- **✅ No need to capture packets**: You can easily sniff video resources on web pages using the built-in browser. Choose the resource you want to download from the sniffed resource list—simple and fast.
|
||||
- **📱 Mobile playback**: Easily switch between PC and mobile devices seamlessly. Once downloaded, you can watch the video on your phone.
|
||||
- **⚡️ Batch download**: Supports downloading multiple videos and live streams simultaneously, ensuring high bandwidth is fully utilized.
|
||||
- **🎉 Docker deployment supported**: Supports Docker deployment for the web version, making it convenient and quick.
|
||||
|
||||
## Quickstart
|
||||
|
||||
To run the code, you'll need Node.js and pnpm. Node.js can be downloaded and installed from the official website, and pnpm can be installed via `npm i -g pnpm`.
|
||||
|
||||
## Run the code
|
||||
|
||||
```shell
|
||||
# Code download
|
||||
git clone https://github.com/caorushizi/mediago.git
|
||||
|
||||
# Installation dependency
|
||||
pnpm i
|
||||
|
||||
# Development environment
|
||||
pnpm dev
|
||||
|
||||
# Package run
|
||||
pnpm release
|
||||
|
||||
# Build a docker image
|
||||
docker buildx build -t caorushizi/mediago:latest .
|
||||
|
||||
# docker startup
|
||||
docker run -d --name mediago -p 8899:8899 -v /root/mediago:/root/mediago registry.cn-beijing.aliyuncs.com/caorushizi/mediago
|
||||
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
### v3.0.0 (Released on 2024.10.7)
|
||||
|
||||
#### Software Downloads
|
||||
|
||||
- [【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 /root/mediago:/root/mediago registry.cn-beijing.aliyuncs.com/caorushizi/mediago:v3.0.0`
|
||||
|
||||
#### Domestic Downloads
|
||||
|
||||
- [【mediago】 Windows (Installer) v3.0.0](https://static.ziying.site/mediago/mediago-setup-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 Windows (Portable) v3.0.0](https://static.ziying.site/mediago/mediago-portable-win32-x64-3.0.0.exe)
|
||||
- [【mediago】 macOS ARM64 (Apple Silicon) 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`
|
||||
|
||||
### One-click Docker Panel Deployment (Recommended)
|
||||
|
||||
1. Install the BT Panel by visiting [BT Panel](https://www.bt.cn/new/download.html?r=dk_mediago) and downloading the official version script.
|
||||
2. After installation, log into the BT Panel, click on `Docker` in the menu bar. The first time you enter, it will prompt you to install the `Docker` service. Click to install and follow the instructions to complete the installation.
|
||||
3. Once installed, find `MediaGo` in the application store, click install, configure domain names, and other basic information to complete the installation.
|
||||
|
||||
### Software Screenshots
|
||||
|
||||

|
||||
|
||||
### Major Updates
|
||||
|
||||
- Support for Docker deployment on the web version
|
||||
- Updated desktop UI
|
||||
|
||||
### Changelog
|
||||
|
||||
- Updated desktop UI
|
||||
- Support for Docker deployment on the web version
|
||||
- Added video playback, supporting both desktop and mobile playback
|
||||
- Fixed issue where the macOS version couldn't display the interface
|
||||
- Optimized the batch download interaction
|
||||
- Added portable version for Windows (no installation required)
|
||||
- Optimized the download list, supporting sniffing multiple videos on a page
|
||||
- Supported manual import/export of the favorites list
|
||||
- Supported export of the homepage download list
|
||||
- Optimized the interaction logic for the "New Download" form
|
||||
- Supported opening the app via UrlScheme and adding download tasks
|
||||
- Fixed several bugs and enhanced the user experience
|
||||
|
||||
## Software Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React <https://react.dev/>
|
||||
- Electron <https://www.electronjs.org>
|
||||
- Koa <https://koajs.com>
|
||||
- Vite <https://cn.vitejs.dev>
|
||||
- Ant Design <https://ant.design>
|
||||
- Tailwind CSS <https://tailwindcss.com>
|
||||
- Shadcn <https://ui.shadcn.com/>
|
||||
- Inversify <https://inversify.io>
|
||||
- TypeORM <https://typeorm.io>
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- N_m2u8DL-CLI from <https://github.com/nilaoda/N_m3u8DL-CLI>
|
||||
- N_m3u8DL-RE from <https://github.com/nilaoda/N_m3u8DL-RE>
|
||||
- mediago from <https://github.com/caorushizi/hls-downloader>
|
||||
+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)(英語)をご参照ください。
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
<div align="center">
|
||||
<h1>MediaGo</h1>
|
||||
<a href="https://downloader.caorushizi.cn/guides.html?form=github">快速开始</a>
|
||||
<a href="https://downloader.caorushizi.cn/en/guides.html?form=github">Quick Start</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
|
||||
<a href="https://downloader.caorushizi.cn/en?form=github">Website</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn/documents.html?form=github">文档</a>
|
||||
<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.en.md">English</a>
|
||||
<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/✨_全新发布-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,124 +34,137 @@
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
## Intro
|
||||
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.
|
||||
|
||||
本项目支持 m3u8 视频在线提取工具 流媒体下载 m3u8 下载。
|
||||
The app UI currently ships with English, Simplified Chinese, and Italian.
|
||||
|
||||
- **✅ 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
|
||||
- **📱 移动播放**: 可以轻松无缝的在 PC 和移动设备之前切换,下载完成后即可使用手机观看视频。
|
||||
- **⚡️ 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
|
||||
- **🎉 支持 docker 部署**: 支持 docker 部署 web 端,方便快捷。
|
||||
## ✨ What's inside
|
||||
|
||||
## Quickstart
|
||||
### 🌐 Browser extension for Chrome / Edge
|
||||
|
||||
运行代码需要 node 和 pnpm,node 需要在官网下载安装,pnpm 可以通过`npm i -g 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
|
||||
# 代码下载
|
||||
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
|
||||
### 🔌 Works with other tools
|
||||
|
||||
### v3.0.0 (2024.10.7 发布)
|
||||
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
|
||||
|
||||
- [【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`
|
||||
After a download finishes, convert it to another format or quality
|
||||
without leaving MediaGo. No more opening a separate tool for ffmpeg.
|
||||
|
||||
### docker 宝塔面板一键部署(推荐)
|
||||
### 🐳 One-line Docker deployment
|
||||
|
||||
1. 安装宝塔面板,前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_mediago) 官网,选择正式版的脚本下载安装
|
||||
Headless install on your server, then access the web UI from anywhere on
|
||||
the same network:
|
||||
|
||||
2. 安装后登录宝塔面板,在菜单栏中点击 `Docker`,首次进入会提示安装`Docker`服务,点击立即安装,按提示完成安装
|
||||
```shell
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
3. 安装完成后在应用商店中找到`MediaGo`,点击安装,配置域名等基本信息即可完成安装
|
||||
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.
|
||||
|
||||
### 软件截图
|
||||
## 📷 Screenshots
|
||||
|
||||

|
||||

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

|
||||
|
||||
- 支持 docker 部署 web 端
|
||||
- 更新桌面端 UI
|
||||

|
||||
|
||||
### 更新日志
|
||||

|
||||
|
||||
- 更新桌面端 UI
|
||||
- 支持 docker 部署 web 端
|
||||
- 新增视频播放,支持桌面端和移动端播放
|
||||
- 修复 mac 打开无法展示界面的问题
|
||||
- 优化了批量下载的交互
|
||||
- 添加了 windows 的便携版(免安装哦)
|
||||
- 优化了下载列表,支持页面中多个视频的嗅探
|
||||
- 支持收藏列表手动导入导出
|
||||
- 支持首页下载列表导出
|
||||
- 优化了【新建下载】表单的交互逻辑
|
||||
- 支持 UrlScheme 打开应用,并添加下载任务
|
||||
- 修复了一些 bug 并提升用户体验
|
||||
## 📥 Download
|
||||
|
||||
## 软件截图
|
||||
### 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 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.
|
||||
|
||||
## 技术栈
|
||||
## 📝 What's new in v3.5.0
|
||||
|
||||
- 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>
|
||||
- **🌐 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
|
||||
|
||||
- N_m3u8DL-RE 来自于 <https://github.com/nilaoda/N_m3u8DL-RE>
|
||||
- BBDown 来自于 <https://github.com/nilaoda/BBDown>
|
||||
- mediago 来自于 <https://github.com/caorushizi/mediago-core>
|
||||
[](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)
|
||||
|
||||
## 免责声明
|
||||
## 🙏 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
|
||||
|
||||
> **This project is for educational and research purposes only. Do not use it for any commercial or illegal purposes.**
|
||||
>
|
||||
> 1. 本项目提供的所有代码和功能仅作为学习流媒体技术的参考,使用者需自行遵守所在地区的法律法规。
|
||||
> 2. 使用本项目下载的任何内容,其版权归原始内容所有者所有。使用者应在下载后 24 小时内删除,或取得版权方授权。
|
||||
> 3. 本项目开发者不对使用者的任何行为承担责任,包括但不限于:下载受版权保护的内容、对第三方平台造成的影响等。
|
||||
> 4. 禁止将本项目用于大规模抓取、破坏平台服务或任何侵犯他人合法权益的行为。
|
||||
> 5. 使用本项目即表示您已阅读并同意本免责声明。如不同意,请立即停止使用并删除本项目。
|
||||
> 1. All code and functionality provided by this project are intended solely as a reference for learning about streaming media technologies. Users must comply with the laws and regulations of their jurisdiction.
|
||||
> 2. Any content downloaded using this project remains the property of its original copyright holders. Users should delete downloaded content within 24 hours or obtain proper authorization.
|
||||
> 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).
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<div align="center">
|
||||
<h1>MediaGo</h1>
|
||||
<a href="https://downloader.caorushizi.cn/guides.html?form=github">快速开始</a>
|
||||
<span> • </span>
|
||||
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
|
||||
<span> • </span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
跨平台视频下载器,内置嗅探 —— 打开网页、选一下想要的资源、保存完事。不用抓包、不用折腾浏览器插件、不用面对命令行。
|
||||
|
||||
应用界面目前内置中文、英文和意大利语。
|
||||
|
||||
## ✨ 主打功能
|
||||
|
||||
### 🌐 浏览器扩展(Chrome / Edge)
|
||||
|
||||
浏览网页时遇到想下的视频 → 点扩展图标 → 一键发到 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
|
||||
npx clawhub@latest install mediago
|
||||
```
|
||||
|
||||
### 🔌 可以和其他工具联动
|
||||
|
||||
MediaGo 提供一整套 HTTP 接口 —— 脚本、自动化工具、其他 App 都能直接调用 MediaGo 创建下载任务、查询进度、管理列表。浏览器扩展就是通过这套接口和桌面端对话的,你也可以接入自己的工作流。
|
||||
|
||||
### 🎞️ 内置格式转换
|
||||
|
||||
下载完成后可以直接在 MediaGo 里转换格式、选画质,不用再打开别的软件。
|
||||
|
||||
### 🐳 Docker 一键部署
|
||||
|
||||
服务器端一条命令部署,局域网内任意设备都能打开 Web 界面:
|
||||
|
||||
```shell
|
||||
docker run -d --name mediago -p 8899:8899 -v /path/to/mediago:/app/mediago caorushizi/mediago:3.5.0
|
||||
```
|
||||
|
||||
在 [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 界面。
|
||||
|
||||
## 📷 软件截图
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 📥 下载
|
||||
|
||||
### 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`
|
||||
|
||||
查看历史版本请移步 [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)
|
||||
|
||||
## ⚖️ 免责声明
|
||||
|
||||
> **本项目仅供学习和研究使用,请勿用于任何商业或非法用途。**
|
||||
>
|
||||
> 1. 本项目提供的所有代码和功能仅作为学习流媒体技术的参考,使用者需自行遵守所在地区的法律法规。
|
||||
> 2. 使用本项目下载的任何内容,其版权归原始内容所有者所有。使用者应在下载后 24 小时内删除,或取得版权方授权。
|
||||
> 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
-346
@@ -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,230 +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"`
|
||||
M3U8Bin string `json:"m3u8_bin"`
|
||||
BilibiliBin string `json:"bilibili_bin"`
|
||||
DirectBin string `json:"direct_bin"`
|
||||
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"`
|
||||
FFmpegBin string `json:"ffmpeg_bin"`
|
||||
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: cfg.FFmpegBin,
|
||||
FFmpegBin: app.FFmpegBinPath(cfg),
|
||||
VideoRoot: cfg.LocalDir,
|
||||
EnvPaths: handler.EnvPaths{
|
||||
ConfigDir: cfg.ConfigDir,
|
||||
@@ -283,132 +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
|
||||
M3U8Bin: "",
|
||||
BilibiliBin: "",
|
||||
DirectBin: "",
|
||||
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.M3U8Bin, "m3u8-bin", cfg.M3U8Bin, "M3U8 downloader binary path")
|
||||
flag.StringVar(&cfg.BilibiliBin, "bilibili-bin", cfg.BilibiliBin, "Bilibili downloader binary path")
|
||||
flag.StringVar(&cfg.DirectBin, "direct-bin", cfg.DirectBin, "Direct downloader binary path")
|
||||
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.StringVar(&cfg.FFmpegBin, "ffmpeg-bin", cfg.FFmpegBin, "FFmpeg binary path")
|
||||
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"
|
||||
}
|
||||
|
||||
// getBinaryMap returns a map of downloader binary paths from the configuration.
|
||||
func getBinaryMap(cfg *AppConfig) map[core.DownloadType]string {
|
||||
return map[core.DownloadType]string{
|
||||
core.TypeM3U8: cfg.M3U8Bin,
|
||||
core.TypeBilibili: cfg.BilibiliBin,
|
||||
core.TypeDirect: cfg.DirectBin,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -4,6 +4,8 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -15,7 +17,6 @@ import (
|
||||
|
||||
var (
|
||||
ErrUnsupportedType = errors.New("unsupported download type")
|
||||
ErrBinNotFound = errors.New("binary not found for type")
|
||||
)
|
||||
|
||||
// DownloaderSvc is the downloader service
|
||||
@@ -72,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)
|
||||
@@ -117,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)
|
||||
@@ -148,16 +196,26 @@ func (d *DownloaderSvc) Download(ctx context.Context, p DownloadParams, cb Callb
|
||||
logger.Error("Unsupported download type",
|
||||
zap.String("id", string(p.ID)),
|
||||
zap.String("type", string(p.Type)))
|
||||
return ErrUnsupportedType
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedType, p.Type)
|
||||
}
|
||||
|
||||
// get the executable path for the corresponding download type
|
||||
bin, ok := d.binMap[p.Type]
|
||||
if !ok || bin == "" {
|
||||
logger.Error("Binary not found for download type",
|
||||
logger.Error("Binary not configured for download type",
|
||||
zap.String("id", string(p.ID)),
|
||||
zap.String("type", string(p.Type)))
|
||||
return ErrBinNotFound
|
||||
return fmt.Errorf("binary not configured for type %q", p.Type)
|
||||
}
|
||||
|
||||
// check if the binary file actually exists on disk
|
||||
if _, statErr := os.Stat(bin); statErr != nil {
|
||||
logger.Error("Binary file not found on disk",
|
||||
zap.String("id", string(p.ID)),
|
||||
zap.String("type", string(p.Type)),
|
||||
zap.String("binary", bin),
|
||||
zap.Error(statErr))
|
||||
return fmt.Errorf("binary %q not found for type %q: %w", bin, p.Type, statErr)
|
||||
}
|
||||
|
||||
logger.Debug("Using downloader binary",
|
||||
|
||||
@@ -86,19 +86,79 @@ 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@@"},
|
||||
"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{
|
||||
// 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
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "youtube",
|
||||
Args: map[string]ArgSpec{
|
||||
"url": {ArgsName: []string{}},
|
||||
"__common__": {ArgsName: []string{"-x", "16", "-s", "16", "-k", "1M"}},
|
||||
"localDir": {ArgsName: []string{"-P"}},
|
||||
"name": {ArgsName: []string{"-o"}},
|
||||
"headers": {ArgsName: []string{"--add-header"}},
|
||||
"proxy": {ArgsName: []string{"--proxy"}},
|
||||
"__common__": {ArgsName: []string{"--no-mtime", "--progress", "--newline", "--no-colors"}},
|
||||
},
|
||||
ConsoleReg: ConsoleReg{
|
||||
Percent: `([\d.]+)%`,
|
||||
Speed: `([\d.]+[GMK]B/s)`,
|
||||
Error: "fail",
|
||||
Start: "downloading...",
|
||||
IsLive: "检测到直播流",
|
||||
Speed: `([\d.]+\s?[MKG]?i?B/s)`,
|
||||
Error: `ERROR`,
|
||||
Start: `\[download\] Destination:`,
|
||||
IsLive: `\[live\]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "mediago",
|
||||
Args: map[string]ArgSpec{
|
||||
"url": {ArgsName: []string{}},
|
||||
"localDir": {ArgsName: []string{"--save-dir", "--tmp-dir"}},
|
||||
"name": {ArgsName: []string{"--save-name"}},
|
||||
"headers": {ArgsName: []string{"--header"}},
|
||||
"deleteSegments": {ArgsName: []string{"--del-after-done"}},
|
||||
"proxy": {ArgsName: []string{"--proxy"}},
|
||||
"__common__": {ArgsName: []string{"--auto-select", "--thread-count", "8"}},
|
||||
},
|
||||
ConsoleReg: ConsoleReg{
|
||||
Percent: `([\d.]+)%`,
|
||||
Speed: `([\d.]+\s?[MKG]?B/s)`,
|
||||
Error: `Error:`,
|
||||
Start: `\[download\] \d+ segments`,
|
||||
IsLive: `is_live:\s*true|\[live\]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,8 +10,22 @@ const (
|
||||
TypeM3U8 DownloadType = "m3u8"
|
||||
TypeBilibili DownloadType = "bilibili"
|
||||
TypeDirect DownloadType = "direct"
|
||||
TypeMediago DownloadType = "mediago"
|
||||
TypeYoutube DownloadType = "youtube"
|
||||
)
|
||||
|
||||
// BinaryNames maps each DownloadType to its executable filename (without extension).
|
||||
var BinaryNames = map[DownloadType]string{
|
||||
TypeM3U8: "N_m3u8DL-RE",
|
||||
TypeBilibili: "BBDown",
|
||||
TypeDirect: "aria2c",
|
||||
TypeMediago: "mediago",
|
||||
TypeYoutube: "yt-dlp",
|
||||
}
|
||||
|
||||
// FFmpegBinaryName is the filename for the ffmpeg binary (without extension).
|
||||
const FFmpegBinaryName = "ffmpeg"
|
||||
|
||||
// TaskID is the unique identifier for a task
|
||||
type TaskID string
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,13 +11,13 @@ export const config = {
|
||||
BIN_DIR: "./bin",
|
||||
RELEASE_DIR: "./release",
|
||||
DEPS_DIR: join("..", "..", ".deps"),
|
||||
TOOLS_BIN_DIR: join("..", "..", ".deps"),
|
||||
GO_LDFLAGS: "-s -w",
|
||||
PLAYER_UI_DIR: join("..", "player-ui"),
|
||||
PLAYER_ASSETS_DIR: join("assets", "player"),
|
||||
};
|
||||
|
||||
const exeExt = process.platform === "win32" ? ".exe" : "";
|
||||
const platformKey = `${process.platform}/${process.arch}`;
|
||||
const platformKey = `${process.platform}-${process.arch}`;
|
||||
const homeDir =
|
||||
process.platform === "win32"
|
||||
? win32.join(process.env.USERPROFILE || "C:\\", ".mediago")
|
||||
@@ -29,10 +29,7 @@ export const devConfig = {
|
||||
log_dir: `${homeDir}/logs`,
|
||||
config_dir: `${homeDir}/data`,
|
||||
schema_path: "./configs/config.json",
|
||||
m3u8_bin: `${config.DEPS_DIR}/${platformKey}/N_m3u8DL-RE${exeExt}`,
|
||||
bilibili_bin: `${config.DEPS_DIR}/${platformKey}/BBDown${exeExt}`,
|
||||
direct_bin: `${config.DEPS_DIR}/${platformKey}/gopeed${exeExt}`,
|
||||
ffmpeg_bin: `${config.DEPS_DIR}/${platformKey}/ffmpeg${exeExt}`,
|
||||
deps_dir: `${config.DEPS_DIR}/${platformKey}`,
|
||||
max_runner: 3,
|
||||
local_dir: `${homeDir}/downloads`,
|
||||
delete_segments: true,
|
||||
|
||||
@@ -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",
|
||||
@@ -23,10 +23,7 @@ export async function dev() {
|
||||
`-delete-segments=${devConfig.delete_segments.toString()}`,
|
||||
`-proxy=${devConfig.proxy}`,
|
||||
`-use-proxy=${devConfig.use_proxy.toString()}`,
|
||||
`-bilibili-bin=${devConfig.bilibili_bin}`,
|
||||
`-m3u8-bin=${devConfig.m3u8_bin}`,
|
||||
`-direct-bin=${devConfig.direct_bin}`,
|
||||
`-ffmpeg-bin=${devConfig.ffmpeg_bin}`,
|
||||
`-deps-dir=${devConfig.deps_dir}`,
|
||||
];
|
||||
await runCommand("go", args, { description: "Start development server" });
|
||||
}
|
||||
@@ -35,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
|
||||
@@ -51,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();
|
||||
@@ -83,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.0",
|
||||
"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
|
||||
@@ -41,8 +41,7 @@
|
||||
"@mediago/service-runner": "workspace:*",
|
||||
"@mediago/shared-common": "workspace:*",
|
||||
"@sentry/electron": "^7.2.0",
|
||||
"axios": "^1.12.2",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"axios": "^1.15.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-is-dev": "^3.0.1",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+24
-10
@@ -20,16 +20,19 @@ import { db, isMac, logDir } from "./constants";
|
||||
import ElectronDevtools from "./vendor/ElectronDevtools";
|
||||
import ElectronUpdater from "./vendor/ElectronUpdater";
|
||||
import GoConfigCache from "./services/go-config-cache";
|
||||
import OverlayDialogService from "./services/overlay-dialog.service";
|
||||
import WebviewService from "./services/webview.service";
|
||||
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,
|
||||
@@ -45,6 +48,8 @@ export default class ElectronApp {
|
||||
private readonly downloaderServer: DownloaderServer,
|
||||
@inject(WebviewService)
|
||||
private readonly webviewService: WebviewService,
|
||||
@inject(OverlayDialogService)
|
||||
private readonly overlayDialogService: OverlayDialogService,
|
||||
@inject(GoConfigCache)
|
||||
private readonly configCache: GoConfigCache,
|
||||
@inject(BrowserWindowService)
|
||||
@@ -55,6 +60,7 @@ export default class ElectronApp {
|
||||
|
||||
private async serviceInit(): Promise<void> {
|
||||
this.mainWindow.init();
|
||||
this.overlayDialogService.init();
|
||||
}
|
||||
|
||||
private async vendorInit() {
|
||||
@@ -79,10 +85,6 @@ export default class ElectronApp {
|
||||
|
||||
// 2. Start Go download service in the background; errors are non-fatal
|
||||
try {
|
||||
this.logger.info("[ElectronApp] Starting Go core service...", {
|
||||
logDir,
|
||||
db,
|
||||
});
|
||||
await this.downloaderServer.start({
|
||||
logDir: logDir,
|
||||
dbPath: db,
|
||||
@@ -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);
|
||||
}
|
||||
@@ -107,8 +109,8 @@ export default class ElectronApp {
|
||||
this.configCache.update(key, value);
|
||||
|
||||
// Forward to UI windows
|
||||
this.mainWindow.send("config-changed", { key, value });
|
||||
this.browserWindow.send("config-changed", { key, value });
|
||||
this.mainWindow.send("config:changed", { key, value });
|
||||
this.browserWindow.send("config:changed", { key, value });
|
||||
|
||||
// Platform side effects
|
||||
const handlers: Record<string, (v: any) => void> = {
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import {
|
||||
type ContextMenuItem,
|
||||
type Controller,
|
||||
IPC,
|
||||
} from "@mediago/shared-common";
|
||||
import { handle } from "../core/decorators";
|
||||
import { TYPES } from "../types/symbols";
|
||||
import {
|
||||
type IpcMainEvent,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
type MenuItemConstructorOptions,
|
||||
} from "electron";
|
||||
import { injectable } from "inversify";
|
||||
|
||||
@injectable()
|
||||
@provide(TYPES.Controller)
|
||||
export default class ContextMenuController implements Controller {
|
||||
@handle(IPC.contextMenu.show)
|
||||
async show(
|
||||
e: IpcMainEvent,
|
||||
items: ContextMenuItem[],
|
||||
): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = items.map(
|
||||
(item) => {
|
||||
if (item.type === "separator") {
|
||||
return { type: "separator" as const };
|
||||
}
|
||||
return {
|
||||
label: item.label,
|
||||
click: () => {
|
||||
resolved = true;
|
||||
resolve(item.key);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup({
|
||||
callback: () => {
|
||||
if (!resolved) resolve(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import {
|
||||
type Controller,
|
||||
type DialogOpenOptions,
|
||||
type DialogSaveOptions,
|
||||
IPC,
|
||||
} from "@mediago/shared-common";
|
||||
import { handle } from "../core/decorators";
|
||||
import { i18n } from "../core/i18n";
|
||||
import { TYPES } from "../types/symbols";
|
||||
import { dialog, type IpcMainEvent } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import { inject, injectable } from "inversify";
|
||||
import MainWindow from "../windows/main.window";
|
||||
|
||||
@injectable()
|
||||
@provide(TYPES.Controller)
|
||||
export default class DialogController implements Controller {
|
||||
constructor(
|
||||
@inject(MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
) {}
|
||||
|
||||
@handle(IPC.dialog.open)
|
||||
async open(e: IpcMainEvent, options: DialogOpenOptions): Promise<string[]> {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return Promise.reject(i18n.t("noMainWindow"));
|
||||
|
||||
const properties: Electron.OpenDialogOptions["properties"] =
|
||||
options.type === "directory" ? ["openDirectory"] : ["openFile"];
|
||||
if (options.multiple) {
|
||||
properties.push("multiSelections");
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties,
|
||||
filters: options.filters,
|
||||
});
|
||||
|
||||
if (result.canceled) return [];
|
||||
|
||||
// Optionally read file content instead of returning paths
|
||||
if (options.readContent && options.type === "file") {
|
||||
const contents = await Promise.all(
|
||||
result.filePaths.map((p) => fs.readFile(p, "utf-8")),
|
||||
);
|
||||
return contents;
|
||||
}
|
||||
|
||||
return result.filePaths;
|
||||
}
|
||||
|
||||
@handle(IPC.dialog.save)
|
||||
async save(e: IpcMainEvent, options: DialogSaveOptions): Promise<string> {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return Promise.reject(i18n.t("noMainWindow"));
|
||||
|
||||
const result = await dialog.showSaveDialog(window, {
|
||||
defaultPath: options.defaultPath,
|
||||
filters: options.filters,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) return "";
|
||||
|
||||
await fs.writeFile(result.filePath, options.content, "utf-8");
|
||||
return result.filePath;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,12 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import {
|
||||
CHECK_UPDATE,
|
||||
COMBINE_TO_HOME_PAGE,
|
||||
type Controller,
|
||||
EXPORT_DOWNLOAD_LIST,
|
||||
EXPORT_FAVORITES,
|
||||
EnvPath,
|
||||
GET_ENV_PATH,
|
||||
GET_SHARED_STATE,
|
||||
IMPORT_FAVORITES,
|
||||
INSTALL_UPDATE,
|
||||
ON_DOWNLOAD_LIST_CONTEXT_MENU,
|
||||
ON_FAVORITE_ITEM_CONTEXT_MENU,
|
||||
OPEN_DIR,
|
||||
OPEN_URL,
|
||||
SELECT_DOWNLOAD_DIR,
|
||||
SELECT_FILE,
|
||||
SET_SHARED_STATE,
|
||||
SHOW_BROWSER_WINDOW,
|
||||
START_UPDATE,
|
||||
safeParseJSON,
|
||||
} from "@mediago/shared-common";
|
||||
import { type Controller, EnvPath, IPC } from "@mediago/shared-common";
|
||||
import { handle } from "../core/decorators";
|
||||
import { i18n } from "../core/i18n";
|
||||
import { DownloaderServer } from "../services/downloader.server";
|
||||
import { TYPES } from "../types/symbols";
|
||||
import {
|
||||
dialog,
|
||||
type IpcMainEvent,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
type MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
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";
|
||||
@@ -56,7 +27,7 @@ export default class HomeController implements Controller {
|
||||
private readonly downloaderServer: DownloaderServer,
|
||||
) {}
|
||||
|
||||
@handle(GET_ENV_PATH)
|
||||
@handle(IPC.app.getEnvPath)
|
||||
async getEnvPath(): Promise<EnvPath> {
|
||||
const client = this.downloaderServer.getClient();
|
||||
const { data: config } = await client.getConfig();
|
||||
@@ -72,211 +43,55 @@ export default class HomeController implements Controller {
|
||||
};
|
||||
}
|
||||
|
||||
@handle(ON_FAVORITE_ITEM_CONTEXT_MENU)
|
||||
async onFavoriteItemContextMenu(e: IpcMainEvent, id: number) {
|
||||
const send = (action: string) => {
|
||||
this.mainWindow.send("favorite-item-event", {
|
||||
action,
|
||||
payload: id,
|
||||
});
|
||||
};
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{
|
||||
label: i18n.t("open"),
|
||||
click: () => {
|
||||
send("open");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: i18n.t("delete"),
|
||||
click: () => {
|
||||
send("delete");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
/**
|
||||
* 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(SELECT_DOWNLOAD_DIR)
|
||||
async selectDownloadDir(): Promise<string> {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return Promise.reject(i18n.t("noMainWindow"));
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const dir = result.filePaths[0];
|
||||
const client = this.downloaderServer.getClient();
|
||||
await client.setConfigKey("local", dir);
|
||||
return dir;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@handle(OPEN_DIR)
|
||||
async openDir(_e: IpcMainEvent, dir: string) {
|
||||
await shell.openPath(dir);
|
||||
}
|
||||
|
||||
@handle(OPEN_URL)
|
||||
async openUrl(e: IpcMainEvent, url: string) {
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
@handle(ON_DOWNLOAD_LIST_CONTEXT_MENU)
|
||||
async downloadListContextMenu(e: IpcMainEvent, id: number) {
|
||||
const send = (action: string) => {
|
||||
this.mainWindow.send("download-item-event", {
|
||||
action,
|
||||
payload: id,
|
||||
});
|
||||
};
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{
|
||||
label: i18n.t("select"),
|
||||
click: () => {
|
||||
send("select");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.t("download"),
|
||||
click: () => {
|
||||
send("download");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.t("refresh"),
|
||||
click: () => {
|
||||
send("refresh");
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: i18n.t("delete"),
|
||||
click: () => {
|
||||
send("delete");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
@handle(SHOW_BROWSER_WINDOW)
|
||||
@handle(IPC.app.showBrowserWindow)
|
||||
async showBrowserWindow() {
|
||||
this.browserWindow.showWindow();
|
||||
}
|
||||
|
||||
@handle(COMBINE_TO_HOME_PAGE)
|
||||
@handle(IPC.app.combineToHomePage)
|
||||
async combineToHomePage() {
|
||||
// Close browser window
|
||||
this.browserWindow.hideWindow();
|
||||
// Modify the properties in the Settings
|
||||
const client = this.downloaderServer.getClient();
|
||||
await client.setConfigKey("openInNewWindow", false);
|
||||
}
|
||||
|
||||
@handle(GET_SHARED_STATE)
|
||||
@handle(IPC.app.getSharedState)
|
||||
async getSharedState() {
|
||||
return this.sharedState;
|
||||
}
|
||||
|
||||
@handle(SET_SHARED_STATE)
|
||||
@handle(IPC.app.setSharedState)
|
||||
async setSharedState(event: IpcMainEvent, state: any) {
|
||||
this.sharedState = state;
|
||||
}
|
||||
|
||||
@handle(SELECT_FILE)
|
||||
async selectFile() {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return Promise.reject(i18n.t("noMainWindow"));
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
return result.filePaths[0];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@handle(EXPORT_FAVORITES)
|
||||
async exportFavorites() {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return;
|
||||
|
||||
const client = this.downloaderServer.getClient();
|
||||
const res = await client.exportFavorites();
|
||||
const content = res.data;
|
||||
|
||||
const result = await dialog.showSaveDialog(window, {
|
||||
defaultPath: "favorites.json",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await fs.writeFile(result.filePath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
@handle(IMPORT_FAVORITES)
|
||||
async importFavorites() {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return;
|
||||
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const content = await fs.readFile(result.filePaths[0], "utf-8");
|
||||
const favorites = safeParseJSON(content, []);
|
||||
if (Array.isArray(favorites)) {
|
||||
const client = this.downloaderServer.getClient();
|
||||
await client.importFavorites(favorites);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@handle(CHECK_UPDATE)
|
||||
@handle(IPC.update.check)
|
||||
async checkUpdate() {
|
||||
this.updater.manualUpdate();
|
||||
}
|
||||
|
||||
@handle(START_UPDATE)
|
||||
@handle(IPC.update.startDownload)
|
||||
async startUpdate() {
|
||||
this.updater.startDownload();
|
||||
}
|
||||
|
||||
@handle(INSTALL_UPDATE)
|
||||
@handle(IPC.update.install)
|
||||
async installUpdate() {
|
||||
this.updater.install();
|
||||
}
|
||||
|
||||
@handle(EXPORT_DOWNLOAD_LIST)
|
||||
async exportDownloadList() {
|
||||
const window = this.mainWindow.window;
|
||||
if (!window) return;
|
||||
|
||||
const client = this.downloaderServer.getClient();
|
||||
const res = await client.exportDownloadList();
|
||||
const content = res.data;
|
||||
|
||||
const result = await dialog.showSaveDialog(window, {
|
||||
defaultPath: "downloads.txt",
|
||||
filters: [{ name: "Text", extensions: ["txt"] }],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await fs.writeFile(result.filePath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
import "./home.controller";
|
||||
import "./webview.controller";
|
||||
import "./dialog.controller";
|
||||
import "./shell.controller";
|
||||
import "./context-menu.controller";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import { type Controller, IPC } from "@mediago/shared-common";
|
||||
import { handle } from "../core/decorators";
|
||||
import { TYPES } from "../types/symbols";
|
||||
import { type IpcMainEvent, shell } from "electron";
|
||||
import { injectable } from "inversify";
|
||||
|
||||
@injectable()
|
||||
@provide(TYPES.Controller)
|
||||
export default class ShellController implements Controller {
|
||||
@handle(IPC.shell.open)
|
||||
async open(e: IpcMainEvent, target: string): Promise<void> {
|
||||
// URL → openExternal, path → openPath
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
await shell.openExternal(target);
|
||||
} else {
|
||||
await shell.openPath(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,14 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import {
|
||||
CLEAR_WEBVIEW_CACHE,
|
||||
type Controller,
|
||||
type DownloadTask,
|
||||
PLUGIN_READY,
|
||||
SET_WEBVIEW_BOUNDS,
|
||||
SHOW_DOWNLOAD_DIALOG,
|
||||
WEBVIEW_CHANGE_USER_AGENT,
|
||||
WEBVIEW_GO_BACK,
|
||||
WEBVIEW_GO_HOME,
|
||||
WEBVIEW_HIDE,
|
||||
WEBVIEW_LOAD_URL,
|
||||
WEBVIEW_RELOAD,
|
||||
WEBVIEW_SHOW,
|
||||
WEBVIEW_URL_CONTEXTMENU,
|
||||
IPC,
|
||||
} from "@mediago/shared-common";
|
||||
import { handle } from "../core/decorators";
|
||||
import { i18n } from "../core/i18n";
|
||||
import { DownloaderServer } from "../services/downloader.server";
|
||||
import OverlayDialogService from "../services/overlay-dialog.service";
|
||||
import { TYPES } from "../types/symbols";
|
||||
import {
|
||||
type IpcMainEvent,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
type MenuItemConstructorOptions,
|
||||
} from "electron";
|
||||
import { type IpcMainEvent } from "electron";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { SniffingHelper } from "../services/sniffing-helper.service";
|
||||
import WebviewService from "../services/webview.service";
|
||||
@@ -39,80 +23,69 @@ export default class WebviewController implements Controller {
|
||||
private readonly downloaderServer: DownloaderServer,
|
||||
@inject(SniffingHelper)
|
||||
private readonly sniffingHelper: SniffingHelper,
|
||||
@inject(OverlayDialogService)
|
||||
private readonly overlayDialog: OverlayDialogService,
|
||||
) {}
|
||||
|
||||
@handle(SET_WEBVIEW_BOUNDS)
|
||||
@handle(IPC.browser.setBounds)
|
||||
async setWebviewBounds(e: IpcMainEvent, bounds: Electron.Rectangle) {
|
||||
this.webview.setBounds(bounds);
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_LOAD_URL)
|
||||
@handle(IPC.browser.loadURL)
|
||||
async browserViewLoadUrl(e: IpcMainEvent, url: string): Promise<void> {
|
||||
await this.webview.loadURL(url);
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_URL_CONTEXTMENU)
|
||||
async appContextMenu() {
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{
|
||||
label: i18n.t("copy"),
|
||||
role: "copy",
|
||||
},
|
||||
{
|
||||
label: i18n.t("paste"),
|
||||
role: "paste",
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
menu.popup();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_GO_BACK)
|
||||
@handle(IPC.browser.back)
|
||||
async webviewGoBack(): Promise<boolean> {
|
||||
return this.webview.goBack();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_RELOAD)
|
||||
@handle(IPC.browser.reload)
|
||||
async webviewReload() {
|
||||
await this.webview.reload();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_SHOW)
|
||||
@handle(IPC.browser.show)
|
||||
async webviewShow() {
|
||||
this.webview.show();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_HIDE)
|
||||
@handle(IPC.browser.hide)
|
||||
async webviewHide() {
|
||||
this.webview.hide();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_GO_HOME)
|
||||
@handle(IPC.browser.home)
|
||||
async webviewGoHome() {
|
||||
await this.webview.goHome();
|
||||
}
|
||||
|
||||
@handle(WEBVIEW_CHANGE_USER_AGENT)
|
||||
@handle(IPC.browser.setUserAgent)
|
||||
async webviewChangeUserAgent(e: IpcMainEvent, isMobile: boolean) {
|
||||
this.webview.setUserAgent(isMobile);
|
||||
const client = this.downloaderServer.getClient();
|
||||
await client.setConfigKey("isMobile", isMobile);
|
||||
}
|
||||
|
||||
@handle(PLUGIN_READY)
|
||||
@handle(IPC.browser.pluginReady)
|
||||
async pluginReady() {
|
||||
this.sniffingHelper.pluginReady();
|
||||
}
|
||||
|
||||
@handle(CLEAR_WEBVIEW_CACHE)
|
||||
@handle(IPC.browser.clearCache)
|
||||
async clearWebviewCache() {
|
||||
return this.webview.clearCache();
|
||||
}
|
||||
|
||||
@handle(SHOW_DOWNLOAD_DIALOG)
|
||||
async showDownloadDialog(e: IpcMainEvent, data: DownloadTask) {
|
||||
const image = await this.webview.captureView();
|
||||
this.webview.sendToWindow(SHOW_DOWNLOAD_DIALOG, data, image?.toDataURL());
|
||||
@handle(IPC.browser.showDownloadDialog)
|
||||
async showDownloadDialog(e: IpcMainEvent, data: DownloadTask[]) {
|
||||
this.overlayDialog.show(data);
|
||||
}
|
||||
|
||||
@handle(IPC.browser.dismissOverlayDialog)
|
||||
async dismissOverlayDialog() {
|
||||
this.overlayDialog.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,8 +54,6 @@ export class DownloaderServer extends EventEmitter {
|
||||
const core = resolveCoreBinaries();
|
||||
const deps = resolveDepsBinaries();
|
||||
|
||||
this.logger.info("Resolved core binary:", path.dirname(core.coreBin));
|
||||
|
||||
const runner = new ServiceRunner({
|
||||
executableName: "mediago-core",
|
||||
executableDir: path.dirname(core.coreBin),
|
||||
@@ -65,10 +63,7 @@ export class DownloaderServer extends EventEmitter {
|
||||
`-log-level=info`,
|
||||
`-log-dir=${opts.logDir}`,
|
||||
`-schema-path=${core.coreConfig}`,
|
||||
`-m3u8-bin=${deps.n_m3u8dl_re}`,
|
||||
`-bilibili-bin=${deps.bbdown}`,
|
||||
`-direct-bin=${deps.gopeed}`,
|
||||
`-ffmpeg-bin=${deps.ffmpeg}`,
|
||||
`-deps-dir=${deps.depsDir}`,
|
||||
`-db-path=${opts.dbPath}`,
|
||||
`-config-dir=${path.dirname(opts.dbPath)}`,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { provide } from "@inversifyjs/binding-decorators";
|
||||
import { type DownloadTask, IpcEvent } from "@mediago/shared-common";
|
||||
import { WebContentsView } from "electron";
|
||||
import isDev from "electron-is-dev";
|
||||
import { inject, injectable } from "inversify";
|
||||
import { defaultScheme, preloadUrl } from "../utils";
|
||||
import BrowserWindow from "../windows/browser.window";
|
||||
import MainWindow from "../windows/main.window";
|
||||
import ElectronLogger from "../vendor/ElectronLogger";
|
||||
|
||||
@injectable()
|
||||
@provide()
|
||||
export default class OverlayDialogService {
|
||||
private view: WebContentsView | null = null;
|
||||
private visible = false;
|
||||
private ready = false;
|
||||
private pendingData: Omit<DownloadTask, "id">[] | null = null;
|
||||
|
||||
private readonly url = isDev
|
||||
? "http://localhost:8555/download-dialog"
|
||||
: `${defaultScheme}://index.html/download-dialog`;
|
||||
|
||||
constructor(
|
||||
@inject(MainWindow)
|
||||
private readonly mainWindow: MainWindow,
|
||||
@inject(BrowserWindow)
|
||||
private readonly browserWindow: BrowserWindow,
|
||||
@inject(ElectronLogger)
|
||||
private readonly logger: ElectronLogger,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.view = new WebContentsView({
|
||||
webPreferences: {
|
||||
preload: preloadUrl,
|
||||
transparent: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.view.setBackgroundColor("#00000000");
|
||||
|
||||
this.view.webContents.on("dom-ready", () => {
|
||||
this.ready = true;
|
||||
if (this.pendingData) {
|
||||
this.view?.webContents.send(
|
||||
IpcEvent.browser.showOverlayDialog,
|
||||
this.pendingData,
|
||||
);
|
||||
this.pendingData = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
this.view.webContents.openDevTools({ mode: "detach" });
|
||||
}
|
||||
|
||||
await this.view.webContents.loadURL(this.url);
|
||||
}
|
||||
|
||||
private get window() {
|
||||
if (this.browserWindow.window) return this.browserWindow.window;
|
||||
if (this.mainWindow.window) return this.mainWindow.window;
|
||||
return null;
|
||||
}
|
||||
|
||||
show(data: Omit<DownloadTask, "id">[]): void {
|
||||
if (!this.view || !this.window) {
|
||||
this.logger.error("[OverlayDialog] view or window not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.visible) return;
|
||||
|
||||
// Set bounds to cover the full window content area
|
||||
const { width, height } = this.window.getContentBounds();
|
||||
this.view.setBounds({ x: 0, y: 0, width, height });
|
||||
|
||||
// Add as topmost child view
|
||||
this.window.contentView.addChildView(this.view);
|
||||
this.visible = true;
|
||||
|
||||
// Listen for resize to keep overlay in sync
|
||||
this.window.on("resize", this.onResize);
|
||||
|
||||
// Send data to the overlay renderer
|
||||
if (this.ready) {
|
||||
this.view.webContents.send(IpcEvent.browser.showOverlayDialog, data);
|
||||
} else {
|
||||
this.pendingData = data;
|
||||
}
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.view || !this.window || !this.visible) return;
|
||||
|
||||
this.window.contentView.removeChildView(this.view);
|
||||
this.visible = false;
|
||||
this.window.removeListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
private onResize = () => {
|
||||
if (!this.view || !this.window || !this.visible) return;
|
||||
const { width, height } = this.window.getContentBounds();
|
||||
this.view.setBounds({ x: 0, y: 0, width, height });
|
||||
};
|
||||
}
|
||||
@@ -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,44 +23,17 @@ 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",
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
private pageInfo: PageInfo = { title: "", url: "" };
|
||||
private readonly prepareDelay = 1000;
|
||||
private checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
@inject(ElectronLogger)
|
||||
@@ -71,34 +48,35 @@ export class SniffingHelper extends EventEmitter {
|
||||
|
||||
update(pageInfo: PageInfo) {
|
||||
this.pageInfo = pageInfo;
|
||||
// Cancel pending check from previous page
|
||||
if (this.checkTimer) {
|
||||
clearTimeout(this.checkTimer);
|
||||
this.checkTimer = null;
|
||||
}
|
||||
// Reset dedup cache when navigating to a new page
|
||||
urlCache.clear();
|
||||
}
|
||||
|
||||
checkPageInfo() {
|
||||
// Send page related information
|
||||
const sendPageInfo = () => {
|
||||
listLoop: for (const filter of filterList) {
|
||||
if (filter.hosts) {
|
||||
for (const host of filter.hosts) {
|
||||
if (!host.test(this.pageInfo.url)) {
|
||||
continue;
|
||||
}
|
||||
// Cancel any pending check
|
||||
if (this.checkTimer) {
|
||||
clearTimeout(this.checkTimer);
|
||||
}
|
||||
|
||||
this.send({
|
||||
url: this.pageInfo.url,
|
||||
documentURL: this.pageInfo.url,
|
||||
name: this.pageInfo.title,
|
||||
type: filter.type,
|
||||
});
|
||||
break listLoop;
|
||||
}
|
||||
}
|
||||
// Capture current page info to avoid race conditions
|
||||
const pageInfo = { ...this.pageInfo };
|
||||
|
||||
this.checkTimer = setTimeout(() => {
|
||||
this.checkTimer = null;
|
||||
const filter = matchPageUrl(pageInfo.url);
|
||||
if (filter) {
|
||||
this.send({
|
||||
url: pageInfo.url,
|
||||
documentURL: pageInfo.url,
|
||||
name: pageInfo.title,
|
||||
type: filter.type,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
sendPageInfo();
|
||||
}, this.prepareDelay);
|
||||
}
|
||||
|
||||
@@ -125,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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import GoConfigCache from "./go-config-cache";
|
||||
import BrowserWindow from "../windows/browser.window";
|
||||
import MainWindow from "../windows/main.window";
|
||||
import { SniffingHelper, type SourceParams } from "./sniffing-helper.service";
|
||||
import fetch from "cross-fetch";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -63,6 +62,8 @@ export default class WebviewService {
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.view) return;
|
||||
|
||||
this.view = new WebContentsView({
|
||||
webPreferences: {
|
||||
partition: this.defaultSession,
|
||||
@@ -98,14 +99,14 @@ export default class WebviewService {
|
||||
if (!this.view) return;
|
||||
const pageInfo = this.getPageInfo();
|
||||
this.sniffingHelper.update(pageInfo);
|
||||
this.window?.webContents.send("webview-dom-ready", pageInfo);
|
||||
this.window?.webContents.send("browser:domReady", pageInfo);
|
||||
};
|
||||
|
||||
onDidNavigate = async () => {
|
||||
if (!this.view) return;
|
||||
const pageInfo = this.getPageInfo();
|
||||
this.sniffingHelper.update(pageInfo);
|
||||
this.window?.webContents.send("webview-did-navigate", pageInfo);
|
||||
this.window?.webContents.send("browser:didNavigate", pageInfo);
|
||||
|
||||
try {
|
||||
const content = await readFile(pluginUrl, "utf-8");
|
||||
@@ -118,7 +119,7 @@ export default class WebviewService {
|
||||
};
|
||||
|
||||
onDidFailLoad = (e: Event, code: number, desc: string) => {
|
||||
// this.window.webContents.send("webview-fail-load", { code, desc });
|
||||
this.window?.webContents.send("browser:failLoad", { code, desc });
|
||||
this.logger.error(`[Webview] fail load: ${code} ${desc}`);
|
||||
};
|
||||
|
||||
@@ -127,7 +128,7 @@ export default class WebviewService {
|
||||
const pageInfo = this.getPageInfo();
|
||||
this.sniffingHelper.update(pageInfo);
|
||||
this.sniffingHelper.checkPageInfo();
|
||||
this.window?.webContents.send("webview-did-navigate-in-page", pageInfo);
|
||||
this.window?.webContents.send("browser:didNavigateInPage", pageInfo);
|
||||
};
|
||||
|
||||
onPageTitleUpdated = () => {
|
||||
@@ -146,7 +147,7 @@ export default class WebviewService {
|
||||
if (!this.view) return;
|
||||
// Send to the window for processing
|
||||
// Previously addDownloadTask was called here; now handled by Go server
|
||||
this.window?.webContents.send("webview-link-message", item);
|
||||
this.window?.webContents.send("browser:sourceDetected", item);
|
||||
};
|
||||
|
||||
getPageInfo() {
|
||||
@@ -207,8 +208,8 @@ export default class WebviewService {
|
||||
throw new Error(i18n.t("browserViewNotFound"));
|
||||
}
|
||||
const { webContents } = this.view;
|
||||
if (webContents.canGoBack()) {
|
||||
webContents.goBack();
|
||||
if (webContents.navigationHistory.canGoBack()) {
|
||||
webContents.navigationHistory.goBack();
|
||||
return true;
|
||||
} else {
|
||||
this.destroyView();
|
||||
@@ -344,12 +345,26 @@ export default class WebviewService {
|
||||
this.window?.webContents.send(channel, ...args);
|
||||
}
|
||||
|
||||
private removeViewListeners() {
|
||||
if (!this.view) return;
|
||||
const { webContents } = this.view;
|
||||
webContents.removeListener("dom-ready", this.onDomReady);
|
||||
webContents.removeListener("did-navigate", this.onDidNavigate);
|
||||
webContents.removeListener("did-fail-load", this.onDidFailLoad);
|
||||
webContents.removeListener(
|
||||
"did-navigate-in-page",
|
||||
this.onDidNavigateInPage,
|
||||
);
|
||||
webContents.removeListener("page-title-updated", this.onPageTitleUpdated);
|
||||
webContents.removeListener("will-navigate", this.onWillNavigate);
|
||||
}
|
||||
|
||||
destroyView() {
|
||||
if (this.view && this.window) {
|
||||
if (this.view) {
|
||||
this.removeViewListeners();
|
||||
this.view.webContents.close();
|
||||
this.window.contentView.removeChildView(this.view);
|
||||
this.window?.contentView.removeChildView(this.view);
|
||||
}
|
||||
// FIXME: To avoid memory leaks, the view needs to be destroyed here
|
||||
this.view = null;
|
||||
}
|
||||
|
||||
@@ -375,7 +390,7 @@ export default class WebviewService {
|
||||
}
|
||||
|
||||
if (!init) {
|
||||
this.window?.webContents.send("change-privacy");
|
||||
this.window?.webContents.send("browser:privacyChanged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,36 +48,26 @@ export function resolveCoreBinaries(): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves paths to helper binaries: ffmpeg, N_m3u8DL-RE, BBDown, gopeed.
|
||||
* Resolves the deps directory containing helper binaries (ffmpeg, N_m3u8DL-RE, BBDown, etc.).
|
||||
*
|
||||
* Development: .deps/{platform}-{arch}/ (downloaded by `pnpm deps:download`)
|
||||
* Development: .deps/{platform}/{arch}/ (downloaded by `pnpm deps:download`)
|
||||
* Production: extraResources/deps/
|
||||
*
|
||||
* Override with MEDIAGO_DEPS_DIR env var.
|
||||
*/
|
||||
export function resolveDepsBinaries(): {
|
||||
ffmpeg: string;
|
||||
n_m3u8dl_re: string;
|
||||
bbdown: string;
|
||||
gopeed: string;
|
||||
} {
|
||||
let binDir: string;
|
||||
export function resolveDepsBinaries(): { depsDir: string } {
|
||||
let depsDir: string;
|
||||
|
||||
if (process.env.MEDIAGO_DEPS_DIR) {
|
||||
binDir = process.env.MEDIAGO_DEPS_DIR;
|
||||
depsDir = process.env.MEDIAGO_DEPS_DIR;
|
||||
} else if (isDev) {
|
||||
const platformKey = `${os.platform()}-${os.arch()}`;
|
||||
binDir = path.join(getMonorepoRoot(), ".deps", platformKey);
|
||||
depsDir = path.join(getMonorepoRoot(), ".deps", platformKey);
|
||||
} else {
|
||||
binDir = path.join(process.resourcesPath, "deps");
|
||||
depsDir = path.join(process.resourcesPath, "deps");
|
||||
}
|
||||
|
||||
return {
|
||||
ffmpeg: path.resolve(binDir, `ffmpeg${ext}`),
|
||||
n_m3u8dl_re: path.resolve(binDir, `N_m3u8DL-RE${ext}`),
|
||||
bbdown: path.resolve(binDir, `BBDown${ext}`),
|
||||
gopeed: path.resolve(binDir, `gopeed${ext}`),
|
||||
};
|
||||
return { depsDir };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,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"),
|
||||
};
|
||||
}
|
||||
|
||||
+17
-12
@@ -35,30 +35,31 @@ export default class UpdateService {
|
||||
this.checkForUpdates();
|
||||
}
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
this.mainWindow.send("checkingForUpdate");
|
||||
this.mainWindow.send("update:checking");
|
||||
});
|
||||
autoUpdater.on("update-available", () => {
|
||||
this.mainWindow.send("updateAvailable");
|
||||
this.mainWindow.send("update:available");
|
||||
});
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
this.mainWindow.send("updateNotAvailable");
|
||||
this.mainWindow.send("update:notAvailable");
|
||||
});
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
this.logger.info(`progress: ${progress.percent}`);
|
||||
this.mainWindow.send("updateDownloadProgress", progress);
|
||||
this.mainWindow.send("update:downloadProgress", progress);
|
||||
});
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
this.mainWindow.send("updateDownloaded");
|
||||
this.mainWindow.send("update:downloaded");
|
||||
});
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
setTimeout(
|
||||
() => {
|
||||
autoUpdater.checkForUpdates();
|
||||
},
|
||||
1 * 1000 * 60,
|
||||
);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (e) {
|
||||
this.logger.error("Check for updates failed", e);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
async autoUpdate() {
|
||||
@@ -77,7 +78,11 @@ export default class UpdateService {
|
||||
}
|
||||
|
||||
async manualUpdate() {
|
||||
autoUpdater.checkForUpdates();
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (e) {
|
||||
this.logger.error("Manual update check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async startDownload() {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@videojs/themes": "^1.0.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
@@ -54,31 +54,21 @@ export function resolvePlayerBinary(): { playerBin: string } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves paths to helper binaries: ffmpeg, N_m3u8DL-RE, BBDown, gopeed.
|
||||
* Resolves the deps directory containing helper binaries (ffmpeg, N_m3u8DL-RE, BBDown, etc.).
|
||||
*
|
||||
* Uses .deps/{platform}-{arch}/ directory.
|
||||
* Uses .deps/{platform}/{arch}/ directory.
|
||||
*
|
||||
* Override with MEDIAGO_DEPS_DIR env var.
|
||||
*/
|
||||
export function resolveDepsBinaries(): {
|
||||
ffmpeg: string;
|
||||
n_m3u8dl_re: string;
|
||||
bbdown: string;
|
||||
gopeed: string;
|
||||
} {
|
||||
let binDir: string;
|
||||
export function resolveDepsBinaries(): { depsDir: string } {
|
||||
let depsDir: string;
|
||||
|
||||
if (process.env.MEDIAGO_DEPS_DIR) {
|
||||
binDir = process.env.MEDIAGO_DEPS_DIR;
|
||||
depsDir = process.env.MEDIAGO_DEPS_DIR;
|
||||
} else {
|
||||
const platformKey = `${os.platform()}-${os.arch()}`;
|
||||
binDir = path.join(getMonorepoRoot(), ".deps", platformKey);
|
||||
depsDir = path.join(getMonorepoRoot(), ".deps", platformKey);
|
||||
}
|
||||
|
||||
return {
|
||||
ffmpeg: path.resolve(binDir, `ffmpeg${ext}`),
|
||||
n_m3u8dl_re: path.resolve(binDir, `N_m3u8DL-RE${ext}`),
|
||||
bbdown: path.resolve(binDir, `BBDown${ext}`),
|
||||
gopeed: path.resolve(binDir, `gopeed${ext}`),
|
||||
};
|
||||
return { depsDir };
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
||||
const core = resolveCoreBinaries();
|
||||
const deps = resolveDepsBinaries();
|
||||
|
||||
console.log("Resolved core binary:", path.dirname(core.coreBin));
|
||||
|
||||
const runner = new ServiceRunner({
|
||||
executableName: "mediago-core",
|
||||
executableDir: path.dirname(core.coreBin),
|
||||
@@ -49,9 +47,7 @@ const runner = new ServiceRunner({
|
||||
`--log-dir=${LOG_DIR}`,
|
||||
`--local-dir=${DOWNLOAD_DIR}`,
|
||||
`--schema-path=${core.coreConfig}`,
|
||||
`--m3u8-bin=${deps.n_m3u8dl_re}`,
|
||||
`--bilibili-bin=${deps.bbdown}`,
|
||||
`--direct-bin=${deps.gopeed}`,
|
||||
`--deps-dir=${deps.depsDir}`,
|
||||
`--db-path=${DB_PATH}`,
|
||||
`--config-dir=${DATA_DIR}`,
|
||||
],
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ahooks": "^3.8.0",
|
||||
"antd": "^6.3.4",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
@@ -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",
|
||||
|
||||
+40
-25
@@ -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"));
|
||||
@@ -34,6 +36,7 @@ const SourceExtract = lazy(() => import("./pages/source-extract"));
|
||||
const SettingPage = lazy(() => import("./pages/setting-page"));
|
||||
const ConverterPage = lazy(() => import("./pages/converter-page"));
|
||||
const SigninPage = lazy(() => import("./pages/signin-page"));
|
||||
const OverlayDialog = lazy(() => import("./pages/overlay-dialog"));
|
||||
|
||||
function getAlgorithm(appTheme: "dark" | "light") {
|
||||
return appTheme === "dark"
|
||||
@@ -41,9 +44,23 @@ 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 { addIpcListener, removeIpcListener } = usePlatform();
|
||||
const { on, off } = usePlatform();
|
||||
const { setUpdateAvailable, setUploadChecking } = useSessionStore(
|
||||
useShallow(updateSelector),
|
||||
);
|
||||
@@ -55,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) => {
|
||||
@@ -104,17 +110,17 @@ const App: FC = () => {
|
||||
const unsubConfig = onConfigChanged(handleConfigChanged);
|
||||
|
||||
// Electron IPC events
|
||||
addIpcListener("change-privacy", onChangePrivacy);
|
||||
addIpcListener("updateAvailable", onUpdateAvailable);
|
||||
addIpcListener("updateNotAvailable", onUpdateNotAvailable);
|
||||
addIpcListener("checkingForUpdate", checkingForUpdate);
|
||||
on("browser:privacyChanged", onChangePrivacy);
|
||||
on("update:available", onUpdateAvailable);
|
||||
on("update:notAvailable", onUpdateNotAvailable);
|
||||
on("update:checking", checkingForUpdate);
|
||||
|
||||
return () => {
|
||||
unsubConfig();
|
||||
removeIpcListener("change-privacy", onChangePrivacy);
|
||||
removeIpcListener("updateAvailable", onUpdateAvailable);
|
||||
removeIpcListener("updateNotAvailable", onUpdateNotAvailable);
|
||||
removeIpcListener("checkingForUpdate", checkingForUpdate);
|
||||
off("browser:privacyChanged", onChangePrivacy);
|
||||
off("update:available", onUpdateAvailable);
|
||||
off("update:notAvailable", onUpdateNotAvailable);
|
||||
off("update:checking", checkingForUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -144,7 +150,8 @@ const App: FC = () => {
|
||||
// Electron mode: get coreUrl directly from preload IPC
|
||||
// FIXME: wait for Go Core to fully start
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const ipcResult = await window.electron?.getEnvPath();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ipcResult: any = await window.electron?.app?.getEnvPath();
|
||||
const envPath = ipcResult?.code === 0 ? ipcResult.data : ipcResult;
|
||||
if (envPath?.coreUrl) {
|
||||
coreUrl = envPath.coreUrl;
|
||||
@@ -266,6 +273,14 @@ const App: FC = () => {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/download-dialog"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<OverlayDialog />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -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";
|
||||
@@ -80,9 +79,8 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
);
|
||||
const [folders, setFolders] = useState<Options[]>([]);
|
||||
const [videoFolders, setVideoFolders] = useState<string[]>([]);
|
||||
const { appContextMenu } = usePlatform();
|
||||
const { contextMenu } = usePlatform();
|
||||
const { addVideosToDocker } = useDockerApi();
|
||||
const { increase } = useDownloadStore(useShallow(downloadStoreSelector));
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (modalOpen) {
|
||||
@@ -106,8 +104,9 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
openModal: (value) => {
|
||||
form.setFieldsValue(value);
|
||||
setModalOpen(true);
|
||||
// Defer so the Form is mounted before setting values
|
||||
queueMicrotask(() => form.setFieldsValue(value));
|
||||
},
|
||||
setFieldsValue: (value) => {
|
||||
form.setFieldsValue(value);
|
||||
@@ -148,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 });
|
||||
@@ -183,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 });
|
||||
@@ -291,7 +293,9 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
colon={false}
|
||||
onValuesChange={handleValuesChange}
|
||||
>
|
||||
<Form.Item name="id" hidden />
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item hidden={isEdit} label={t("batchDownload")} name={"batch"}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
@@ -317,10 +321,18 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
label: t("bilibiliMedia"),
|
||||
value: "bilibili",
|
||||
},
|
||||
{
|
||||
label: t("youtubeMedia"),
|
||||
value: "youtube",
|
||||
},
|
||||
{
|
||||
label: t("direct"),
|
||||
value: "direct",
|
||||
},
|
||||
{
|
||||
label: t("mediagoMedia"),
|
||||
value: "mediago",
|
||||
},
|
||||
]}
|
||||
placeholder={t("pleaseSelectVideoType")}
|
||||
/>
|
||||
@@ -347,7 +359,12 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterVideoName")}
|
||||
onContextMenu={appContextMenu}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
@@ -393,7 +410,12 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
<BatchUrlTextarea
|
||||
rows={5}
|
||||
placeholder={t("videoLikeDescription")}
|
||||
onContextMenu={appContextMenu}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
@@ -422,7 +444,12 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterOnlineVideoUrlOrDragM3U8Here")}
|
||||
onContextMenu={appContextMenu}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
onDrop={(e) => {
|
||||
const file = e.dataTransfer.files[0] as File & {
|
||||
path: string;
|
||||
@@ -456,6 +483,7 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
{(formInstance) => {
|
||||
if (
|
||||
formInstance.getFieldValue("type") !== "m3u8" &&
|
||||
formInstance.getFieldValue("type") !== "mediago" &&
|
||||
!formInstance.getFieldValue("batch")
|
||||
) {
|
||||
return null;
|
||||
@@ -465,7 +493,12 @@ export default forwardRef<DownloadFormRef, DownloadFormProps>(
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder={t("additionalHeadersDescription")}
|
||||
onContextMenu={appContextMenu}
|
||||
onContextMenu={() =>
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TerminalProps {
|
||||
|
||||
const Terminal: FC<TerminalProps> = ({ className, id, header }) => {
|
||||
const terminalRef = useRef<HTMLDivElement | null>(null);
|
||||
const { addIpcListener, removeIpcListener } = usePlatform();
|
||||
const { on, off } = usePlatform();
|
||||
const { data } = useSWR({ key: "download-log", args: id }, ({ args }) =>
|
||||
getDownloadLog(args),
|
||||
);
|
||||
@@ -35,8 +35,30 @@ const Terminal: FC<TerminalProps> = ({ className, id, header }) => {
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
if (data) {
|
||||
terminal.write(data);
|
||||
// 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);
|
||||
}
|
||||
|
||||
const onDownloadMessage = (
|
||||
@@ -53,11 +75,11 @@ const Terminal: FC<TerminalProps> = ({ className, id, header }) => {
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
addIpcListener("download-message", onDownloadMessage);
|
||||
on("download-message", onDownloadMessage);
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
removeIpcListener("download-message", onDownloadMessage);
|
||||
off("download-message", onDownloadMessage);
|
||||
window.removeEventListener("resize", resize);
|
||||
terminal.dispose();
|
||||
};
|
||||
|
||||
@@ -25,31 +25,36 @@ interface WebViewProps {
|
||||
|
||||
const WebView: FC<WebViewProps> = ({ className }) => {
|
||||
const webviewRef = useRef<HTMLDivElement>(null);
|
||||
const resizeObserver = useRef<ResizeObserver>();
|
||||
const resizeObserver = useRef<ResizeObserver>(null);
|
||||
const rafId = useRef<number>(0);
|
||||
|
||||
const { setWebviewBounds, webviewHide, webviewShow } = usePlatform();
|
||||
const { browser } = usePlatform();
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return;
|
||||
|
||||
// Monitor the size of webview elements
|
||||
// Monitor the size of webview elements, throttled to one IPC call per frame
|
||||
resizeObserver.current = new ResizeObserver((entries) => {
|
||||
if (!webviewRef.current) return;
|
||||
if (rafId.current) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
if (!webviewRef.current) return;
|
||||
|
||||
const rect = computeRect(webviewRef.current.getBoundingClientRect());
|
||||
const entry = entries[0];
|
||||
const viewRect = computeRect(entry.contentRect);
|
||||
viewRect.x += rect.x;
|
||||
viewRect.y += rect.y;
|
||||
setWebviewBounds(viewRect);
|
||||
const rect = computeRect(webviewRef.current.getBoundingClientRect());
|
||||
const entry = entries[0];
|
||||
const viewRect = computeRect(entry.contentRect);
|
||||
viewRect.x += rect.x;
|
||||
viewRect.y += rect.y;
|
||||
browser.setBounds(viewRect);
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.current.observe(webviewRef.current);
|
||||
webviewShow();
|
||||
browser.show();
|
||||
|
||||
return () => {
|
||||
if (rafId.current) cancelAnimationFrame(rafId.current);
|
||||
resizeObserver.current?.disconnect();
|
||||
webviewHide();
|
||||
browser.hide();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,53 +1,20 @@
|
||||
import type { PlatformApi } from "@mediago/shared-common";
|
||||
import { getIpcId, type IpcListener } from "./utils";
|
||||
|
||||
/**
|
||||
* Electron platform adapter.
|
||||
* Only exposes PlatformApi methods (those with real IPC handlers).
|
||||
* GoApi methods go directly to Go Core HTTP — never through IPC.
|
||||
* Simply delegates to window.electron which exposes the nested PlatformApi.
|
||||
*/
|
||||
export const electronPlatformAdapter: PlatformApi = new Proxy(
|
||||
{} as PlatformApi,
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
if (!window.electron || !(prop in window.electron)) {
|
||||
return async () => ({
|
||||
code: -1,
|
||||
msg: `Method '${prop}' not implemented`,
|
||||
data: null as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
const electronFun = window.electron[prop as keyof typeof window.electron];
|
||||
if (typeof electronFun !== "function") {
|
||||
return async () => ({
|
||||
code: -1,
|
||||
msg: `Property '${prop}' is not callable`,
|
||||
data: null as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
return electronFun.bind(window.electron);
|
||||
},
|
||||
},
|
||||
);
|
||||
export const electronPlatformAdapter: PlatformApi =
|
||||
(window.electron as PlatformApi) ?? ({} as PlatformApi);
|
||||
|
||||
/**
|
||||
* Electron IPC event adapter.
|
||||
* Electron IPC event adapter — uses on/off from window.electron.
|
||||
*/
|
||||
export const electronIpcAdapter: IpcListener = {
|
||||
addIpcListener: (eventName: string, func: any) => {
|
||||
const id = getIpcId(func);
|
||||
if (!window.electron || !window.electron.rendererEvent) {
|
||||
return;
|
||||
}
|
||||
window.electron.rendererEvent(eventName, id, func);
|
||||
export const electronIpcAdapter = {
|
||||
on: (eventName: string, func: (...args: unknown[]) => void) => {
|
||||
window.electron?.on?.(eventName, func);
|
||||
},
|
||||
removeIpcListener: (eventName: string, func: any) => {
|
||||
const id = getIpcId(func);
|
||||
if (!window.electron || !window.electron.removeEventListener) {
|
||||
return;
|
||||
}
|
||||
window.electron.removeEventListener(eventName, id);
|
||||
off: (eventName: string, func: (...args: unknown[]) => void) => {
|
||||
window.electron?.off?.(eventName, func);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const platformApi: PlatformApi = isWeb
|
||||
* Go SSE events are handled separately by api/events.ts.
|
||||
*/
|
||||
export const platformEventListener: IpcListener = isWeb
|
||||
? { addIpcListener: () => {}, removeIpcListener: () => {} }
|
||||
? { on: () => {}, off: () => {} }
|
||||
: electronIpcAdapter;
|
||||
|
||||
export type { IpcListener } from "./utils";
|
||||
|
||||
@@ -1,55 +1,69 @@
|
||||
import type { PlatformApi } from "@mediago/shared-common";
|
||||
|
||||
const noop = async () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const noop = async (..._args: unknown[]): Promise<any> => {};
|
||||
|
||||
/**
|
||||
* Web/server mode stubs for PlatformApi.
|
||||
* All Electron-native operations are no-ops in web mode.
|
||||
*/
|
||||
export const webPlatformStubs: PlatformApi = {
|
||||
onSelectDownloadDir: async () => "",
|
||||
openDir: noop,
|
||||
setWebviewBounds: noop,
|
||||
webviewGoBack: async () => false,
|
||||
webviewReload: noop,
|
||||
webviewLoadURL: noop,
|
||||
webviewGoHome: noop,
|
||||
webviewHide: noop,
|
||||
webviewShow: noop,
|
||||
onDownloadListContextMenu: noop,
|
||||
onFavoriteItemContextMenu: noop,
|
||||
showBrowserWindow: noop,
|
||||
appContextMenu: noop,
|
||||
combineToHomePage: noop,
|
||||
selectFile: async () => "",
|
||||
getSharedState: async () => ({}),
|
||||
setSharedState: noop,
|
||||
setUserAgent: noop,
|
||||
showDownloadDialog: async () => null,
|
||||
pluginReady: noop,
|
||||
getMachineId: async () => "",
|
||||
clearWebviewCache: noop,
|
||||
exportFavorites: noop,
|
||||
importFavorites: noop,
|
||||
checkUpdate: noop,
|
||||
startUpdate: noop,
|
||||
installUpdate: noop,
|
||||
exportDownloadList: noop,
|
||||
openUrl: async (url: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.click();
|
||||
browser: {
|
||||
loadURL: noop,
|
||||
back: async () => false,
|
||||
reload: noop,
|
||||
show: noop,
|
||||
hide: noop,
|
||||
home: noop,
|
||||
setBounds: noop,
|
||||
setUserAgent: noop,
|
||||
clearCache: noop,
|
||||
pluginReady: noop,
|
||||
showDownloadDialog: noop,
|
||||
dismissOverlayDialog: noop,
|
||||
},
|
||||
openBrowser: async (url: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.click();
|
||||
app: {
|
||||
getEnvPath: async () => ({
|
||||
binPath: "",
|
||||
dbPath: "",
|
||||
workspace: "",
|
||||
platform: "",
|
||||
local: "",
|
||||
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,
|
||||
combineToHomePage: noop,
|
||||
},
|
||||
getLocalIP: async () => "",
|
||||
rendererEvent: () => {},
|
||||
removeEventListener: () => {},
|
||||
dialog: {
|
||||
open: async () => [],
|
||||
save: async () => "",
|
||||
},
|
||||
shell: {
|
||||
open: async (target: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = target;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.click();
|
||||
},
|
||||
},
|
||||
contextMenu: {
|
||||
show: async () => null,
|
||||
},
|
||||
update: {
|
||||
check: noop,
|
||||
startDownload: noop,
|
||||
install: noop,
|
||||
},
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
const eventMap = new Map();
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const getIpcId = (func: any) => {
|
||||
let id = "";
|
||||
if (eventMap.get(func)) {
|
||||
id = eventMap.get(func);
|
||||
} else {
|
||||
id = nanoid();
|
||||
eventMap.set(func, id);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export interface IpcListener {
|
||||
addIpcListener: (eventName: string, func: any) => void;
|
||||
removeIpcListener: (eventName: string, func: any) => void;
|
||||
on: (eventName: string, func: (...args: unknown[]) => void) => void;
|
||||
off: (eventName: string, func: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemoizedFn } from "ahooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { OPEN_URL } from "@/const";
|
||||
import { setBrowserSelector, useBrowserStore, PageMode } from "@/store/browser";
|
||||
import { generateUrl, tdApp } from "@/utils";
|
||||
import { usePlatform } from "./use-platform";
|
||||
|
||||
export function useBrowserActions() {
|
||||
const { browser } = usePlatform();
|
||||
const { startNavigation, setBrowserStore } = useBrowserStore(
|
||||
useShallow(setBrowserSelector),
|
||||
);
|
||||
|
||||
const loadUrl = useMemoizedFn((url: string) => {
|
||||
tdApp.onEvent(OPEN_URL);
|
||||
startNavigation(url);
|
||||
browser.loadURL(url);
|
||||
});
|
||||
|
||||
const goto = useMemoizedFn((currentUrl: string) => {
|
||||
const link = generateUrl(currentUrl);
|
||||
loadUrl(link);
|
||||
});
|
||||
|
||||
const goHome = useMemoizedFn(async () => {
|
||||
await browser.home();
|
||||
setBrowserStore({ url: "", title: "", mode: PageMode.Default });
|
||||
});
|
||||
|
||||
return { loadUrl, goto, goHome };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ADD_DOWNLOAD_ITEMS, DownloadTask } from "@mediago/shared-common";
|
||||
import { DownloadTask } from "@mediago/shared-common";
|
||||
import axios from "axios";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { appStoreSelector, useAppStore } from "@/store/app";
|
||||
@@ -13,7 +13,7 @@ export function useDockerApi() {
|
||||
items: Omit<DownloadTask, "id">[];
|
||||
immediate?: boolean;
|
||||
}) => {
|
||||
return axios.post(`${dockerUrl}/api/${ADD_DOWNLOAD_ITEMS}`, {
|
||||
return axios.post(`${dockerUrl}/api/add-download-items`, {
|
||||
videos: items,
|
||||
startDownload: immediate,
|
||||
auth: apiKey,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
platformEventListener,
|
||||
type IpcListener,
|
||||
} from "./adapters";
|
||||
import type { PlatformApi } from "@mediago/shared-common";
|
||||
|
||||
/**
|
||||
* Unwrap IPC response: { code, data, msg } → data
|
||||
@@ -15,6 +16,55 @@ function unwrapIpcResult(result: unknown): unknown {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-copy an object so we get a plain, configurable target
|
||||
* (contextBridge objects are frozen / non-configurable).
|
||||
*/
|
||||
function shallowCopy(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
copy[key] = obj[key];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recursive Proxy that auto-unwraps IPC results for nested objects.
|
||||
* Namespace objects (browser, app, dialog, etc.) return sub-proxies.
|
||||
* Function properties are wrapped to auto-unwrap {code,data,msg} responses.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createUnwrapProxy(source: Record<string, unknown>): any {
|
||||
const target = shallowCopy(source);
|
||||
const cache = new Map<string, unknown>();
|
||||
return new Proxy(target, {
|
||||
get(_t, prop: string) {
|
||||
if (cache.has(prop)) return cache.get(prop);
|
||||
|
||||
const val = target[prop];
|
||||
if (val == null) return val;
|
||||
|
||||
if (typeof val === "object" && !Array.isArray(val)) {
|
||||
// Namespace object — recurse
|
||||
const sub = createUnwrapProxy(val as Record<string, unknown>);
|
||||
cache.set(prop, sub);
|
||||
return sub;
|
||||
}
|
||||
if (typeof val === "function") {
|
||||
const wrapped = async (...args: unknown[]) => {
|
||||
const result = await (val as (...a: unknown[]) => Promise<unknown>)(
|
||||
...args,
|
||||
);
|
||||
return unwrapIpcResult(result);
|
||||
};
|
||||
cache.set(prop, wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
return val;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides Electron IPC methods + platform event listeners.
|
||||
* Non-SWR hook for imperative platform interactions.
|
||||
@@ -24,29 +74,17 @@ function unwrapIpcResult(result: unknown): unknown {
|
||||
*
|
||||
* Go SSE events (download/config) are handled separately by api/events.ts.
|
||||
*/
|
||||
export function usePlatform() {
|
||||
export function usePlatform(): PlatformApi & IpcListener {
|
||||
return useMemo(() => {
|
||||
return new Proxy({} as typeof platformApi & IpcListener, {
|
||||
get(_target, prop: string) {
|
||||
// Event listener methods (no unwrap needed)
|
||||
if (prop === "addIpcListener")
|
||||
return platformEventListener.addIpcListener;
|
||||
if (prop === "removeIpcListener")
|
||||
return platformEventListener.removeIpcListener;
|
||||
const unwrapped = createUnwrapProxy(
|
||||
platformApi as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
// Platform API methods — wrap to auto-unwrap IPC responses
|
||||
const val = (platformApi as Record<string, unknown>)[prop];
|
||||
if (typeof val === "function") {
|
||||
return async (...args: unknown[]) => {
|
||||
const result = await (val as (...a: unknown[]) => Promise<unknown>)(
|
||||
...args,
|
||||
);
|
||||
return unwrapIpcResult(result);
|
||||
};
|
||||
}
|
||||
return val;
|
||||
},
|
||||
});
|
||||
// Attach event listener methods directly
|
||||
unwrapped.on = platformEventListener.on;
|
||||
unwrapped.off = platformEventListener.off;
|
||||
|
||||
return unwrapped as PlatformApi & IpcListener;
|
||||
}, []);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,12 +10,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AppHeader({ className }: Props) {
|
||||
const { openUrl } = usePlatform();
|
||||
const { shell } = usePlatform();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openHelpUrl = useMemoizedFn(() => {
|
||||
const url = "https://downloader.caorushizi.cn/guides.html?form=client";
|
||||
openUrl(url);
|
||||
shell.open(url);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -88,7 +88,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AppSideBar({ className }: Props) {
|
||||
const { showBrowserWindow, combineToHomePage } = usePlatform();
|
||||
const { app } = usePlatform();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -112,7 +112,7 @@ export function AppSideBar({ className }: Props) {
|
||||
if (appStore.openInNewWindow) {
|
||||
setAppStore({ openInNewWindow: false });
|
||||
navigate("/source");
|
||||
await combineToHomePage({
|
||||
await app.combineToHomePage({
|
||||
url: "",
|
||||
sourceList: [],
|
||||
});
|
||||
@@ -121,7 +121,7 @@ export function AppSideBar({ className }: Props) {
|
||||
if (location.pathname === "/source") {
|
||||
navigate("/");
|
||||
}
|
||||
await showBrowserWindow();
|
||||
await app.showBrowserWindow();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -135,7 +135,7 @@ export function AppSideBar({ className }: Props) {
|
||||
if (appStore.openInNewWindow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showBrowserWindow();
|
||||
app.showBrowserWindow();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ const Converter = () => {
|
||||
startConversion,
|
||||
stopConversion,
|
||||
} = useConversions({ current: page, pageSize });
|
||||
const { selectFile, openDir } = usePlatform();
|
||||
const { dialog, shell } = usePlatform();
|
||||
const { message } = App.useApp();
|
||||
const [outputFormat, setOutputFormat] = useState("mp3");
|
||||
const [quality, setQuality] = useState("medium");
|
||||
@@ -85,7 +85,8 @@ const Converter = () => {
|
||||
|
||||
const handleBrowseFile = useMemoizedFn(async () => {
|
||||
try {
|
||||
const file = await selectFile();
|
||||
const paths = await dialog.open({ type: "file" });
|
||||
const file = paths?.[0];
|
||||
if (file) setFilePath(file);
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error).message);
|
||||
@@ -147,7 +148,7 @@ const Converter = () => {
|
||||
const dir =
|
||||
targetPath.substring(0, targetPath.lastIndexOf("/")) ||
|
||||
targetPath.substring(0, targetPath.lastIndexOf("\\"));
|
||||
await openDir(dir || targetPath);
|
||||
await shell.open(dir || targetPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@ export const DownloadTaskItem = memo(function DownloadTaskItem({
|
||||
}: Props) {
|
||||
const appStore = useAppStore(useShallow(appStoreSelector));
|
||||
const { t } = useTranslation();
|
||||
const { openUrl } = usePlatform();
|
||||
const { shell } = usePlatform();
|
||||
const { envPath } = useEnvPath();
|
||||
|
||||
// Handlers
|
||||
const handlePlay = useMemoizedFn(() => {
|
||||
tdApp.onEvent(PLAY_VIDEO);
|
||||
if (envPath?.playerUrl) {
|
||||
openUrl(`${envPath.playerUrl}?id=${task.id}`);
|
||||
shell.open(`${envPath.playerUrl}?id=${task.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ interface Props {
|
||||
|
||||
export function DownloadTaskList({ filter }: Props) {
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const { addIpcListener, removeIpcListener, onDownloadListContextMenu } =
|
||||
usePlatform();
|
||||
const { contextMenu } = usePlatform();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const editFormRef = useRef<DownloadFormRef>(null);
|
||||
@@ -34,29 +33,7 @@ export function DownloadTaskList({ filter }: Props) {
|
||||
const { mutate, isLoading, data } = useTasks(filter);
|
||||
|
||||
useEffect(() => {
|
||||
const onDownloadMenuEvent = async (
|
||||
_e: unknown,
|
||||
params: { action: string; payload: number },
|
||||
) => {
|
||||
const { action, payload } = params;
|
||||
|
||||
if (action === "select") {
|
||||
setSelected((keys) => [...keys, payload]);
|
||||
} else if (action === "download") {
|
||||
onStartDownload(payload);
|
||||
} else if (action === "refresh") {
|
||||
mutate();
|
||||
} else if (action === "delete") {
|
||||
await deleteDownloadTask(payload);
|
||||
mutate();
|
||||
}
|
||||
};
|
||||
|
||||
addIpcListener("download-item-event", onDownloadMenuEvent);
|
||||
|
||||
return () => {
|
||||
removeIpcListener("download-item-event", onDownloadMenuEvent);
|
||||
|
||||
// Clean up any pending refresh timers
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
@@ -119,8 +96,24 @@ export function DownloadTaskList({ filter }: Props) {
|
||||
mutate();
|
||||
});
|
||||
|
||||
const handleContext = useMemoizedFn((item: number) => {
|
||||
onDownloadListContextMenu(item);
|
||||
const handleContext = useMemoizedFn(async (id: number) => {
|
||||
const action = await contextMenu.show([
|
||||
{ key: "select", label: t("select") },
|
||||
{ key: "download", label: t("download") },
|
||||
{ key: "refresh", label: t("refresh") },
|
||||
{ key: "separator", label: "", type: "separator" },
|
||||
{ key: "delete", label: t("delete") },
|
||||
]);
|
||||
if (action === "select") {
|
||||
setSelected((keys) => [...keys, id]);
|
||||
} else if (action === "download") {
|
||||
onStartDownload(id);
|
||||
} else if (action === "refresh") {
|
||||
mutate();
|
||||
} else if (action === "delete") {
|
||||
await deleteDownloadTask(id);
|
||||
mutate();
|
||||
}
|
||||
});
|
||||
|
||||
const onDeleteItems = useMemoizedFn(async (ids: number[]) => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ListHeader({
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const disabled = useMemo(() => selected.length === 0, [selected.length]);
|
||||
const { exportDownloadList } = usePlatform();
|
||||
const { dialog } = usePlatform();
|
||||
const items: MenuProps["items"] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -43,7 +43,14 @@ export function ListHeader({
|
||||
const { key } = e;
|
||||
if (key === "exportDownloadList") {
|
||||
try {
|
||||
await exportDownloadList();
|
||||
const { exportDownloadList } = await import("@/api/download-task");
|
||||
const content = await exportDownloadList();
|
||||
await dialog.save({
|
||||
content:
|
||||
typeof content === "string" ? content : JSON.stringify(content),
|
||||
defaultPath: "downloads.txt",
|
||||
filters: [{ name: "Text", extensions: ["txt"] }],
|
||||
});
|
||||
} catch {
|
||||
message.error(t("exportDownloadListFailed"));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
|
||||
const { openDir } = usePlatform();
|
||||
const { shell } = usePlatform();
|
||||
const appStore = useAppStore(useShallow(appStoreSelector));
|
||||
const { t } = useTranslation();
|
||||
const newFormRef = useRef<DownloadFormRef>(null);
|
||||
@@ -77,7 +77,7 @@ const HomePage: FC<Props> = ({ filter = DownloadFilter.list }) => {
|
||||
rightExtra={
|
||||
<div className="flex flex-row gap-2">
|
||||
{!isWeb && (
|
||||
<Button onClick={() => openDir(appStore.local)}>
|
||||
<Button onClick={() => shell.open(appStore.local)}>
|
||||
<FolderIcon />
|
||||
{t("openFolder")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
html,
|
||||
body {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type DownloadTask, IpcEvent } from "@mediago/shared-common";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import DownloadForm, { type DownloadFormRef } from "@/components/download-form";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
export default function OverlayDialog() {
|
||||
const { on, off, browser } = usePlatform();
|
||||
const downloadForm = useRef<DownloadFormRef>(null);
|
||||
const dialogId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const onShowOverlayDialog = (
|
||||
_e: unknown,
|
||||
data: Omit<DownloadTask, "id">[],
|
||||
) => {
|
||||
const item = data[0];
|
||||
downloadForm.current?.openModal({
|
||||
batch: false,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
headers: item.headers,
|
||||
});
|
||||
};
|
||||
|
||||
on(IpcEvent.browser.showOverlayDialog, onShowOverlayDialog);
|
||||
|
||||
return () => {
|
||||
off(IpcEvent.browser.showOverlayDialog, onShowOverlayDialog);
|
||||
};
|
||||
}, [on, off]);
|
||||
|
||||
const handleFormVisibleChange = (visible: boolean) => {
|
||||
if (!visible) {
|
||||
browser.dismissOverlayDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DownloadForm
|
||||
id={dialogId}
|
||||
isEdit
|
||||
ref={downloadForm}
|
||||
destroyOnClose
|
||||
onFormVisibleChange={handleFormVisibleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -31,6 +30,10 @@ import { CHECK_UPDATE } from "@/const";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { useEnvPath } from "@/hooks/use-config";
|
||||
import { setConfigValue } from "@/api/config";
|
||||
import {
|
||||
exportFavorites as exportFavoritesApi,
|
||||
importFavorites,
|
||||
} from "@/api/favorite";
|
||||
import {
|
||||
appStoreSelector,
|
||||
setAppStoreSelector,
|
||||
@@ -43,19 +46,8 @@ import { AppLanguage, AppStore, AppTheme } from "@mediago/shared-common";
|
||||
const version = import.meta.env.APP_VERSION;
|
||||
|
||||
const SettingPage: React.FC = () => {
|
||||
const {
|
||||
onSelectDownloadDir,
|
||||
openDir,
|
||||
clearWebviewCache,
|
||||
exportFavorites,
|
||||
importFavorites,
|
||||
checkUpdate,
|
||||
startUpdate,
|
||||
addIpcListener,
|
||||
removeIpcListener,
|
||||
installUpdate,
|
||||
appContextMenu,
|
||||
} = usePlatform();
|
||||
const { dialog, shell, browser, contextMenu, update, on, off, app } =
|
||||
usePlatform();
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<FormInstance<AppStore>>(null);
|
||||
const settings = useAppStore(useShallow(appStoreSelector));
|
||||
@@ -69,13 +61,40 @@ 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 local = await onSelectDownloadDir();
|
||||
const paths = await dialog.open({ type: "directory" });
|
||||
const local = paths?.[0];
|
||||
if (local) {
|
||||
await setConfigValue("local", local);
|
||||
setAppStore({ local });
|
||||
formRef.current?.setFieldValue("local", local);
|
||||
}
|
||||
@@ -108,7 +127,16 @@ const SettingPage: React.FC = () => {
|
||||
|
||||
const onMenuClick = useMemoizedFn(async (_) => {
|
||||
try {
|
||||
await importFavorites();
|
||||
const contents = await dialog.open({
|
||||
type: "file",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
readContent: true,
|
||||
});
|
||||
if (!contents?.length) return;
|
||||
const favorites = JSON.parse(contents[0]);
|
||||
if (Array.isArray(favorites)) {
|
||||
await importFavorites(favorites);
|
||||
}
|
||||
message.success(t("importFavoriteSuccess"));
|
||||
} catch {
|
||||
message.error(t("importFavoriteFailed"));
|
||||
@@ -117,7 +145,15 @@ const SettingPage: React.FC = () => {
|
||||
|
||||
const handleExportFavorite = useMemoizedFn(async () => {
|
||||
try {
|
||||
await exportFavorites();
|
||||
const content = await exportFavoritesApi();
|
||||
await dialog.save({
|
||||
content:
|
||||
typeof content === "string"
|
||||
? content
|
||||
: JSON.stringify(content, null, 2),
|
||||
defaultPath: "favorites.json",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
message.success(t("exportFavoriteSuccess"));
|
||||
} catch {
|
||||
message.error(t("exportFavoriteFailed"));
|
||||
@@ -127,7 +163,7 @@ const SettingPage: React.FC = () => {
|
||||
const handleCheckUpdate = useMemoizedFn(async () => {
|
||||
tdApp.onEvent(CHECK_UPDATE);
|
||||
setOpenUpdateModal(true);
|
||||
await checkUpdate();
|
||||
await update.check();
|
||||
});
|
||||
|
||||
const handleHiddenUpdateModal = useMemoizedFn(() => {
|
||||
@@ -135,11 +171,11 @@ const SettingPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const handleUpdate = useMemoizedFn(() => {
|
||||
startUpdate();
|
||||
update.startDownload();
|
||||
});
|
||||
|
||||
const handleInstallUpdate = useMemoizedFn(() => {
|
||||
installUpdate();
|
||||
update.install();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,366 +188,450 @@ const SettingPage: React.FC = () => {
|
||||
const onDownloaded = () => {
|
||||
setUpdateDownloaded(true);
|
||||
};
|
||||
addIpcListener("updateDownloadProgress", onDownloadProgress);
|
||||
addIpcListener("updateDownloaded", onDownloaded);
|
||||
on("update:downloadProgress", onDownloadProgress);
|
||||
on("update:downloaded", onDownloaded);
|
||||
|
||||
return () => {
|
||||
removeIpcListener("updateDownloadProgress", onDownloadProgress);
|
||||
removeIpcListener("updateDownloaded", onDownloaded);
|
||||
off("update:downloadProgress", onDownloadProgress);
|
||||
off("update:downloaded", onDownloaded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClearWebviewCache = useMemoizedFn(async () => {
|
||||
try {
|
||||
await clearWebviewCache();
|
||||
await browser.clearCache();
|
||||
message.success(t("clearCacheSuccess"));
|
||||
} catch {
|
||||
message.error(t("clearCacheFailed"));
|
||||
}
|
||||
});
|
||||
|
||||
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={appContextMenu}
|
||||
/>
|
||||
</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={appContextMenu}
|
||||
/>
|
||||
</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={appContextMenu}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dockerUrl" label={t("dockerUrl")}>
|
||||
<Input
|
||||
placeholder={t("pleaseEnterDockerUrl")}
|
||||
onContextMenu={appContextMenu}
|
||||
/>
|
||||
</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
|
||||
? `设置 mediago 地址为 ${url},api key 为 ${apiKey}`
|
||||
: `设置 mediago 地址为 ${url}`;
|
||||
} else {
|
||||
// Electron mode: only need URL
|
||||
setupCmd = coreUrl
|
||||
? `设置 mediago 地址为 ${coreUrl}`
|
||||
: "设置 mediago 地址为 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={() => openDir(envPath?.workspace)}
|
||||
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={() => openDir(envPath?.binPath)}
|
||||
icon={<FolderOpenOutlined />}
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("privacy")}
|
||||
tooltip={t("privacyTooltip")}
|
||||
name="privacy"
|
||||
>
|
||||
{t("binPath")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openDir(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>
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function SigninPage() {
|
||||
htmlFor="repeat-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{t("reppeatPassword")}
|
||||
{t("repeatPassword")}
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import { useMemoizedFn } from "ahooks";
|
||||
import { Button as AntdButton, App } from "antd";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { appStoreSelector, useAppStore } from "@/store/app";
|
||||
import {
|
||||
browserStoreSelector,
|
||||
browserSourcesSelector,
|
||||
type SourceData,
|
||||
setBrowserSelector,
|
||||
useBrowserStore,
|
||||
@@ -19,20 +20,90 @@ import { usePlatform } from "@/hooks/use-platform";
|
||||
import { createDownloadTasks } from "@/api/download-task";
|
||||
import { DownloadTask } from "@mediago/shared-common";
|
||||
|
||||
interface SourceItemProps {
|
||||
item: SourceData;
|
||||
enableDocker: boolean;
|
||||
onDelete: (url: string) => void;
|
||||
onEdit: (items: SourceData[]) => void;
|
||||
onDownload: (item: SourceData) => void;
|
||||
}
|
||||
|
||||
const SourceItem = memo(function SourceItem({
|
||||
item,
|
||||
enableDocker,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onDownload,
|
||||
}: SourceItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-[#FAFCFF] p-2 dark:bg-[#27292F]">
|
||||
<span
|
||||
className="line-clamp-2 cursor-default break-words text-sm text-[#343434] dark:text-[#B4B4B4]"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className="line-clamp-2 cursor-default break-words text-xs dark:text-[#515257]"
|
||||
title={item.url}
|
||||
>
|
||||
{item.url}
|
||||
</span>
|
||||
<div className="flex flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<AntdButton
|
||||
icon={<DeleteOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => onDelete(item.url)}
|
||||
title={t("delete")}
|
||||
danger
|
||||
/>
|
||||
<AntdButton
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
title={t("edit")}
|
||||
onClick={() => onEdit([item])}
|
||||
/>
|
||||
{enableDocker && (
|
||||
<AntdButton
|
||||
icon={<DockerOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
title={t("edit")}
|
||||
onClick={() => onEdit([item])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => onDownload(item)}>
|
||||
{t("downloadNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function BrowserViewPanel() {
|
||||
const store = useBrowserStore(useShallow(browserStoreSelector));
|
||||
const { sources } = useBrowserStore(useShallow(browserSourcesSelector));
|
||||
const { enableDocker } = useAppStore(useShallow(appStoreSelector));
|
||||
const { deleteSource, clearSources } = useBrowserStore(
|
||||
useShallow(setBrowserSelector),
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const { showDownloadDialog } = usePlatform();
|
||||
const { browser } = usePlatform();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const handleClear = useMemoizedFn(() => {
|
||||
clearSources();
|
||||
});
|
||||
|
||||
const handleEdit = useMemoizedFn((items: SourceData[]) => {
|
||||
browser.showDownloadDialog(items);
|
||||
});
|
||||
|
||||
const handleDownloadNow = useMemoizedFn(async (item: SourceData) => {
|
||||
try {
|
||||
const downloadTask: Omit<DownloadTask, "id"> = {
|
||||
@@ -43,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);
|
||||
}
|
||||
@@ -52,61 +125,19 @@ export function BrowserViewPanel() {
|
||||
<div className="flex h-full flex-col gap-3 overflow-y-auto bg-white p-3 dark:bg-[#1F2024]">
|
||||
<div>
|
||||
<AntdButton size="small" danger onClick={handleClear}>
|
||||
清空
|
||||
{t("clear")}
|
||||
</AntdButton>
|
||||
</div>
|
||||
{store.sources.map((item) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-lg bg-[#FAFCFF] p-2 dark:bg-[#27292F]"
|
||||
key={item.url}
|
||||
>
|
||||
<span
|
||||
className="line-clamp-2 cursor-default break-words text-sm text-[#343434] dark:text-[#B4B4B4]"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className="line-clamp-2 cursor-default break-words text-xs dark:text-[#515257]"
|
||||
title={item.url}
|
||||
>
|
||||
{item.url}
|
||||
</span>
|
||||
<div className="flex flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<AntdButton
|
||||
icon={<DeleteOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => deleteSource(item.url)}
|
||||
title={t("delete")}
|
||||
danger
|
||||
/>
|
||||
<AntdButton
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
title={t("edit")}
|
||||
onClick={() => showDownloadDialog([item])}
|
||||
/>
|
||||
{enableDocker && (
|
||||
<AntdButton
|
||||
icon={<DockerOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
title={t("edit")}
|
||||
onClick={() => showDownloadDialog([item])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => handleDownloadNow(item)}>
|
||||
{t("downloadNow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sources.map((item) => (
|
||||
<SourceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
enableDocker={enableDocker}
|
||||
onDelete={deleteSource}
|
||||
onEdit={handleEdit}
|
||||
onDownload={handleDownloadNow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,139 +1,80 @@
|
||||
import { DownloadTask, SHOW_DOWNLOAD_DIALOG } from "@mediago/shared-common";
|
||||
import { useMemoizedFn } from "ahooks";
|
||||
import { Empty, Space, Spin, Splitter } from "antd";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import DownloadForm, { type DownloadFormRef } from "@/components/download-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import WebView from "@/components/web-view";
|
||||
import { useBrowserActions } from "@/hooks/use-browser-actions";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import {
|
||||
BrowserStatus,
|
||||
browserStoreSelector,
|
||||
PageMode,
|
||||
browserErrorSelector,
|
||||
browserSourcesSelector,
|
||||
setBrowserSelector,
|
||||
type SourceData,
|
||||
useBrowserStore,
|
||||
} from "@/store/browser";
|
||||
import { generateUrl } from "@/utils";
|
||||
import { BrowserViewPanel } from "./browser-view-panel";
|
||||
|
||||
export function BrowserView() {
|
||||
const { webviewLoadURL, addIpcListener, removeIpcListener, webviewGoHome } =
|
||||
usePlatform();
|
||||
const downloadForm = useRef<DownloadFormRef>(null);
|
||||
const store = useBrowserStore(useShallow(browserStoreSelector));
|
||||
const { addSource, setBrowserStore } = useBrowserStore(
|
||||
useShallow(setBrowserSelector),
|
||||
const { on, off } = usePlatform();
|
||||
const { goto, goHome } = useBrowserActions();
|
||||
const { status, errMsg, errCode } = useBrowserStore(
|
||||
useShallow(browserErrorSelector),
|
||||
);
|
||||
const { sources } = useBrowserStore(useShallow(browserSourcesSelector));
|
||||
const url = useBrowserStore((s) => s.url);
|
||||
const { addSource } = useBrowserStore(useShallow(setBrowserSelector));
|
||||
const { t } = useTranslation();
|
||||
const [placeHolder, setPlaceHolder] = useState<string>("");
|
||||
const browserId = useId();
|
||||
|
||||
const onSourceDetected = useMemoizedFn((...args: unknown[]) => {
|
||||
addSource(args[1] as SourceData);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onShowDownloadDialog = async (
|
||||
e: unknown,
|
||||
data: DownloadTask[],
|
||||
image: string,
|
||||
) => {
|
||||
if (image) {
|
||||
setPlaceHolder(image);
|
||||
}
|
||||
|
||||
const item = data[0];
|
||||
downloadForm.current?.openModal({
|
||||
batch: false,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
headers: item.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const onWebviewLinkMessage = async (e: unknown, data: unknown) => {
|
||||
addSource(data);
|
||||
};
|
||||
|
||||
addIpcListener(SHOW_DOWNLOAD_DIALOG, onShowDownloadDialog);
|
||||
addIpcListener("webview-link-message", onWebviewLinkMessage);
|
||||
on("browser:sourceDetected", onSourceDetected);
|
||||
|
||||
return () => {
|
||||
removeIpcListener(SHOW_DOWNLOAD_DIALOG, onShowDownloadDialog);
|
||||
removeIpcListener("webview-link-message", onWebviewLinkMessage);
|
||||
off("browser:sourceDetected", onSourceDetected);
|
||||
};
|
||||
}, [store.status]);
|
||||
|
||||
const onClickGoHome = useMemoizedFn(async () => {
|
||||
await webviewGoHome();
|
||||
setBrowserStore({
|
||||
url: "",
|
||||
title: "",
|
||||
mode: PageMode.Default,
|
||||
});
|
||||
});
|
||||
|
||||
const loadUrl = useMemoizedFn((url: string) => {
|
||||
setBrowserStore({
|
||||
url,
|
||||
mode: PageMode.Browser,
|
||||
status: BrowserStatus.Loading,
|
||||
});
|
||||
webviewLoadURL(url);
|
||||
});
|
||||
|
||||
const goto = useMemoizedFn(() => {
|
||||
const link = generateUrl(store.url);
|
||||
loadUrl(link);
|
||||
});
|
||||
|
||||
const handleFormVisibleChange = useMemoizedFn((visible: boolean) => {
|
||||
if (!visible) {
|
||||
setPlaceHolder("");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderContent = useMemoizedFn(() => {
|
||||
// Loaded state
|
||||
if (store.status === BrowserStatus.Loading) {
|
||||
// Loading or Loaded: show the WebView so the native WebContentsView is visible
|
||||
if (status === BrowserStatus.Loading || status === BrowserStatus.Loaded) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row items-center justify-center">
|
||||
<Spin />
|
||||
<div className="relative h-full w-full flex-1">
|
||||
<WebView className="h-full w-full flex-1" />
|
||||
{status === BrowserStatus.Loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-black/40">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal box
|
||||
if (placeHolder) {
|
||||
return <img alt="" src={placeHolder} className="h-full w-full" />;
|
||||
}
|
||||
|
||||
// Load failure
|
||||
if (store.status === BrowserStatus.Failed) {
|
||||
if (status === BrowserStatus.Failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-row items-center justify-center">
|
||||
<Empty
|
||||
description={`${store.errMsg || t("loadFailed")} (${store.errCode})`}
|
||||
>
|
||||
<Empty description={`${errMsg || t("loadFailed")} (${errCode})`}>
|
||||
<Space>
|
||||
<Button onClick={onClickGoHome}>{t("backToHome")}</Button>
|
||||
<Button onClick={goto}>{t("refresh")}</Button>
|
||||
<Button onClick={goHome}>{t("backToHome")}</Button>
|
||||
<Button onClick={() => goto(url)}>{t("refresh")}</Button>
|
||||
</Space>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Load successfully
|
||||
if (store.status === BrowserStatus.Loaded) {
|
||||
return <WebView className="h-full w-full flex-1" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{!store.sources.length ? (
|
||||
{!sources.length ? (
|
||||
renderContent()
|
||||
) : (
|
||||
<Splitter className="flex h-full flex-1 gap-2">
|
||||
@@ -143,13 +84,6 @@ export function BrowserView() {
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
)}
|
||||
<DownloadForm
|
||||
id={browserId}
|
||||
isEdit
|
||||
ref={downloadForm}
|
||||
destroyOnClose
|
||||
onFormVisibleChange={handleFormVisibleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useMemoizedFn } from "ahooks";
|
||||
import { App, Form, Input, Modal } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Form, Input, Modal, Spin } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { ADD_FAVORITE, OPEN_FAVORITE } from "@/const";
|
||||
import {
|
||||
BrowserStatus,
|
||||
PageMode,
|
||||
setBrowserSelector,
|
||||
useBrowserStore,
|
||||
} from "@/store/browser";
|
||||
import { getFavIcon, tdApp } from "@/utils";
|
||||
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 { data: favoriteList, addFavorite, removeFavorite } = useFavorites();
|
||||
const {
|
||||
webviewLoadURL,
|
||||
onFavoriteItemContextMenu,
|
||||
addIpcListener,
|
||||
removeIpcListener,
|
||||
} = usePlatform();
|
||||
data: favoriteList,
|
||||
isLoading,
|
||||
error,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
} = useFavorites();
|
||||
const { contextMenu } = usePlatform();
|
||||
const { loadUrl } = useBrowserActions();
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const [favoriteAddForm] = Form.useForm<Favorite>();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { setBrowserStore } = useBrowserStore(useShallow(setBrowserSelector));
|
||||
|
||||
const loadUrl = useMemoizedFn((url: string) => {
|
||||
setBrowserStore({
|
||||
url,
|
||||
mode: PageMode.Browser,
|
||||
status: BrowserStatus.Loading,
|
||||
});
|
||||
webviewLoadURL(url);
|
||||
});
|
||||
|
||||
const onClickLoadItem = useMemoizedFn((item: Favorite) => {
|
||||
loadUrl(item.url);
|
||||
@@ -56,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,
|
||||
@@ -74,60 +61,65 @@ export function FavoriteList() {
|
||||
setIsModalOpen(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleLoadItem = (item: Favorite) => {
|
||||
setBrowserStore({
|
||||
url: item.url,
|
||||
mode: PageMode.Browser,
|
||||
status: BrowserStatus.Loading,
|
||||
});
|
||||
webviewLoadURL(item.url);
|
||||
};
|
||||
|
||||
const onFavoriteEvent = async (
|
||||
e: unknown,
|
||||
{
|
||||
action,
|
||||
payload,
|
||||
}: {
|
||||
action: string;
|
||||
payload: number;
|
||||
},
|
||||
) => {
|
||||
if (action === "open") {
|
||||
const found = favoriteList.find(
|
||||
(fav: Record<string, unknown>) => fav.id === payload,
|
||||
);
|
||||
if (found) {
|
||||
handleLoadItem(found);
|
||||
}
|
||||
} else if (action === "delete") {
|
||||
await removeFavorite(payload);
|
||||
// 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.
|
||||
}
|
||||
});
|
||||
|
||||
addIpcListener("favorite-item-event", onFavoriteEvent);
|
||||
const handleContextMenu = useMemoizedFn(async (item: Favorite) => {
|
||||
const action = await contextMenu.show([
|
||||
{ key: "open", label: t("open") },
|
||||
{ key: "separator", label: "", type: "separator" },
|
||||
{ key: "delete", label: t("delete") },
|
||||
]);
|
||||
if (action === "open") {
|
||||
loadUrl(item.url);
|
||||
} else if (action === "delete") {
|
||||
await removeFavorite(item.id);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeIpcListener("favorite-item-event", onFavoriteEvent);
|
||||
};
|
||||
}, [favoriteList]);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-red-500">
|
||||
{t("loadFailed")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full py-4">
|
||||
<div className="grid grid-cols-4 place-items-center gap-4 overflow-auto md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-9">
|
||||
{favoriteList.map((item: Record<string, unknown>) => {
|
||||
return (
|
||||
<FavItem
|
||||
key={item.id}
|
||||
onContextMenu={() => onFavoriteItemContextMenu(item.id)}
|
||||
onClick={() => onClickLoadItem(item)}
|
||||
onClose={() => handleRemoveFavorite(item.id)}
|
||||
src={item.icon}
|
||||
title={item.title}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{favoriteList.map((item) => (
|
||||
<FavItem
|
||||
key={item.id}
|
||||
onContextMenu={() => handleContextMenu(item)}
|
||||
onClick={() => onClickLoadItem(item)}
|
||||
onClose={() => handleRemoveFavorite(item.id)}
|
||||
src={item.icon}
|
||||
title={item.title}
|
||||
/>
|
||||
))}
|
||||
<FavItem
|
||||
key={"add"}
|
||||
onClick={showModal}
|
||||
@@ -147,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")}
|
||||
@@ -173,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EyeInvisibleOutlined } from "@ant-design/icons";
|
||||
import { useMemoizedFn } from "ahooks";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { type React, useMemo } from "react";
|
||||
import { type KeyboardEvent, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ShareIcon,
|
||||
} from "@/assets/svg";
|
||||
import { IconButton } from "@/components/icon-button";
|
||||
import { OPEN_URL } from "@/const";
|
||||
import {
|
||||
appStoreSelector,
|
||||
setAppStoreSelector,
|
||||
@@ -25,13 +24,14 @@ import {
|
||||
} from "@/store/app";
|
||||
import {
|
||||
BrowserStatus,
|
||||
browserStoreSelector,
|
||||
browserNavSelector,
|
||||
PageMode,
|
||||
setBrowserSelector,
|
||||
useBrowserStore,
|
||||
} from "@/store/browser";
|
||||
import { themeSelector, useSessionStore } from "@/store/session";
|
||||
import { cn, generateUrl, getFavIcon, tdApp } from "@/utils";
|
||||
import { cn, getFavIcon } from "@/utils";
|
||||
import { useBrowserActions } from "@/hooks/use-browser-actions";
|
||||
import { useFavorites } from "@/hooks/use-favorites";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
|
||||
@@ -41,16 +41,10 @@ interface Props {
|
||||
|
||||
export function ToolBar({ page }: Props) {
|
||||
const { data: favoriteList, addFavorite, removeFavorite } = useFavorites();
|
||||
const {
|
||||
webviewLoadURL,
|
||||
webviewGoBack,
|
||||
webviewGoHome,
|
||||
combineToHomePage,
|
||||
setUserAgent,
|
||||
appContextMenu,
|
||||
} = usePlatform();
|
||||
const { browser, app, contextMenu } = usePlatform();
|
||||
const { goto, goHome } = useBrowserActions();
|
||||
const { theme } = useSessionStore(useShallow(themeSelector));
|
||||
const store = useBrowserStore(useShallow(browserStoreSelector));
|
||||
const store = useBrowserStore(useShallow(browserNavSelector));
|
||||
const { setBrowserStore } = useBrowserStore(useShallow(setBrowserSelector));
|
||||
const appStore = useAppStore(useShallow(appStoreSelector));
|
||||
const { setAppStore } = useAppStore(useShallow(setAppStoreSelector));
|
||||
@@ -62,74 +56,47 @@ export function ToolBar({ page }: Props) {
|
||||
// Set default UA
|
||||
const onSetDefaultUA = useMemoizedFn(() => {
|
||||
const nextMode = !appStore.isMobile;
|
||||
setUserAgent(nextMode);
|
||||
browser.setUserAgent(nextMode);
|
||||
setAppStore({
|
||||
isMobile: nextMode,
|
||||
});
|
||||
});
|
||||
|
||||
const curIsFavorite = useMemo(() => {
|
||||
return favoriteList.find(
|
||||
(item: Record<string, unknown>) => item.url === store.url,
|
||||
);
|
||||
return favoriteList.find((item) => item.url === store.url);
|
||||
}, [favoriteList, store.url]);
|
||||
|
||||
const onInputKeyDown = useMemoizedFn(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!store.url) {
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto();
|
||||
async (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!store.url || e.key !== "Enter") return;
|
||||
goto(store.url);
|
||||
},
|
||||
);
|
||||
|
||||
const onClickGoBack = useMemoizedFn(async () => {
|
||||
const back = await webviewGoBack();
|
||||
const back = await browser.back();
|
||||
if (!back) {
|
||||
setBrowserStore({ url: "", title: "", mode: PageMode.Default });
|
||||
}
|
||||
});
|
||||
|
||||
const onClickGoHome = useMemoizedFn(async () => {
|
||||
await webviewGoHome();
|
||||
setBrowserStore({
|
||||
url: "",
|
||||
title: "",
|
||||
mode: PageMode.Default,
|
||||
});
|
||||
});
|
||||
|
||||
const loadUrl = useMemoizedFn((url: string) => {
|
||||
tdApp.onEvent(OPEN_URL);
|
||||
setBrowserStore({
|
||||
url,
|
||||
mode: PageMode.Browser,
|
||||
status: BrowserStatus.Loading,
|
||||
});
|
||||
webviewLoadURL(url);
|
||||
});
|
||||
|
||||
const onInputContextMenu = useMemoizedFn(() => {
|
||||
appContextMenu();
|
||||
contextMenu.show([
|
||||
{ key: "copy", label: t("copy") },
|
||||
{ key: "paste", label: t("paste") },
|
||||
]);
|
||||
});
|
||||
|
||||
const onClickEnter = useMemoizedFn(async () => {
|
||||
if (!store.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto();
|
||||
const onClickEnter = useMemoizedFn(() => {
|
||||
if (!store.url) return;
|
||||
goto(store.url);
|
||||
});
|
||||
|
||||
const onClickAddFavorite = useMemoizedFn(async () => {
|
||||
if (curIsFavorite) {
|
||||
await removeFavorite((curIsFavorite as Record<string, unknown>).id);
|
||||
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,
|
||||
@@ -138,19 +105,13 @@ export function ToolBar({ page }: Props) {
|
||||
}
|
||||
});
|
||||
|
||||
// Merge to home page
|
||||
const onCombineToHome = useMemoizedFn(() => {
|
||||
combineToHomePage({
|
||||
app.combineToHomePage({
|
||||
url: store.url,
|
||||
sourceList: [],
|
||||
});
|
||||
});
|
||||
|
||||
const goto = useMemoizedFn(() => {
|
||||
const link = generateUrl(store.url);
|
||||
loadUrl(link);
|
||||
});
|
||||
|
||||
const iconColor = theme === "dark" ? "white" : "black";
|
||||
|
||||
return (
|
||||
@@ -176,7 +137,7 @@ export function ToolBar({ page }: Props) {
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
title={t("home")}
|
||||
onClick={onClickGoHome}
|
||||
onClick={goHome}
|
||||
icon={<HomeIcon fill={iconColor} />}
|
||||
/>
|
||||
<IconButton
|
||||
@@ -189,14 +150,14 @@ export function ToolBar({ page }: Props) {
|
||||
store.status === BrowserStatus.Loading ? (
|
||||
<IconButton
|
||||
title={t("cancle")}
|
||||
onClick={onClickGoHome}
|
||||
onClick={goHome}
|
||||
icon={<CloseIcon fill={iconColor} />}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
title={t("refresh")}
|
||||
onClick={goto}
|
||||
onClick={() => goto(store.url)}
|
||||
icon={<RefreshIcon fill={iconColor} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useAsyncEffect, useMemoizedFn } from "ahooks";
|
||||
import { type React, useEffect, useRef } from "react";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import PageContainer from "@/components/page-container";
|
||||
import { setAppStoreSelector, useAppStore } from "@/store/app";
|
||||
import {
|
||||
BrowserStatus,
|
||||
browserStoreSelector,
|
||||
PageMode,
|
||||
setBrowserSelector,
|
||||
useBrowserStore,
|
||||
} from "@/store/browser";
|
||||
import { cn, convertPlainObject } from "@/utils";
|
||||
import { cn } from "@/utils";
|
||||
import { BrowserView } from "./components/browser-view";
|
||||
import { FavoriteList } from "./components/favorite-list";
|
||||
import { ToolBar } from "./components/tool-bar";
|
||||
@@ -21,19 +20,24 @@ interface SourceExtractProps {
|
||||
page?: boolean;
|
||||
}
|
||||
|
||||
const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
const { addIpcListener, removeIpcListener, getSharedState, setSharedState } =
|
||||
usePlatform();
|
||||
const SourceExtract: FC<SourceExtractProps> = ({ page = false }) => {
|
||||
const { on, off, app } = usePlatform();
|
||||
const { setAppStore } = useAppStore(useShallow(setAppStoreSelector));
|
||||
const store = useBrowserStore(useShallow(browserStoreSelector));
|
||||
const mode = useBrowserStore((s) => s.mode);
|
||||
const title = useBrowserStore((s) => s.title);
|
||||
const { setBrowserStore } = useBrowserStore(useShallow(setBrowserSelector));
|
||||
const originTitle = useRef(document.title);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useBrowserStore.subscribe(
|
||||
(state) => state,
|
||||
(state) => {
|
||||
setSharedState(convertPlainObject(state));
|
||||
(state) => ({
|
||||
url: state.url,
|
||||
title: state.title,
|
||||
mode: state.mode,
|
||||
status: state.status,
|
||||
}),
|
||||
(selected) => {
|
||||
app.setSharedState(selected);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,65 +49,63 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
const configData = await getConfig();
|
||||
setAppStore(configData as Record<string, unknown>);
|
||||
setAppStore(configData);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const state = await getSharedState();
|
||||
setBrowserStore(state as Record<string, unknown>);
|
||||
const state = await app.getSharedState();
|
||||
if (state) setBrowserStore(state as Partial<BrowserStore>);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
addIpcListener("webview-dom-ready", onDomReady);
|
||||
addIpcListener("webview-fail-load", onFailLoad);
|
||||
addIpcListener("webview-did-navigate", onDidNavigate);
|
||||
addIpcListener("webview-did-navigate-in-page", onDidNavigateInPage);
|
||||
on("browser:domReady", onDomReady);
|
||||
on("browser:failLoad", onFailLoad);
|
||||
on("browser:didNavigate", onDidNavigate);
|
||||
on("browser:didNavigateInPage", onDidNavigateInPage);
|
||||
|
||||
return () => {
|
||||
removeIpcListener("webview-dom-ready", onDomReady);
|
||||
removeIpcListener("webview-fail-load", onFailLoad);
|
||||
removeIpcListener("webview-did-navigate", onDidNavigate);
|
||||
removeIpcListener("webview-did-navigate-in-page", onDidNavigateInPage);
|
||||
off("browser:domReady", onDomReady);
|
||||
off("browser:failLoad", onFailLoad);
|
||||
off("browser:didNavigate", onDidNavigate);
|
||||
off("browser:didNavigateInPage", onDidNavigateInPage);
|
||||
};
|
||||
}, [store.status]);
|
||||
}, []);
|
||||
|
||||
const setPageInfo = useMemoizedFn(({ url, title }: UrlDetail) => {
|
||||
document.title = title;
|
||||
setBrowserStore({ url, title });
|
||||
});
|
||||
|
||||
const onDomReady = useMemoizedFn((e: unknown, info: UrlDetail) => {
|
||||
setPageInfo(info);
|
||||
const onDomReady = useMemoizedFn((...args: unknown[]) => {
|
||||
setPageInfo(args[1] as UrlDetail);
|
||||
});
|
||||
|
||||
const onFailLoad = useMemoizedFn(
|
||||
(e: unknown, data: { code: number; desc: string }) => {
|
||||
setBrowserStore({
|
||||
status: BrowserStatus.Failed,
|
||||
errCode: data.code,
|
||||
errMsg: data.desc,
|
||||
});
|
||||
},
|
||||
);
|
||||
const onFailLoad = useMemoizedFn((...args: unknown[]) => {
|
||||
const data = args[1] as { code: number; desc: string };
|
||||
setBrowserStore({
|
||||
status: BrowserStatus.Failed,
|
||||
errCode: data.code,
|
||||
errMsg: data.desc,
|
||||
});
|
||||
});
|
||||
|
||||
const onDidNavigate = useMemoizedFn((e: unknown, info: UrlDetail) => {
|
||||
setPageInfo(info);
|
||||
const onDidNavigate = useMemoizedFn((...args: unknown[]) => {
|
||||
setPageInfo(args[1] as UrlDetail);
|
||||
setBrowserStore({ status: BrowserStatus.Loaded });
|
||||
});
|
||||
|
||||
const onDidNavigateInPage = useMemoizedFn((e: unknown, info: UrlDetail) => {
|
||||
setPageInfo(info);
|
||||
const onDidNavigateInPage = useMemoizedFn((...args: unknown[]) => {
|
||||
setPageInfo(args[1] as UrlDetail);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = store.title || document.title;
|
||||
document.title = title || document.title;
|
||||
return () => {
|
||||
document.title = originTitle.current;
|
||||
};
|
||||
}, [store.title]);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
@@ -112,7 +114,7 @@ const SourceExtract: React.FC<SourceExtractProps> = ({ page = false }) => {
|
||||
>
|
||||
<ToolBar page={page} />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{store.mode === PageMode.Browser ? <BrowserView /> : <FavoriteList />}
|
||||
{mode === PageMode.Browser ? <BrowserView /> : <FavoriteList />}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const initialState: BrowserStore = {
|
||||
|
||||
type Actions = {
|
||||
setBrowserStore: (values: Partial<BrowserStore>) => void;
|
||||
startNavigation: (url: string) => void;
|
||||
addSource: (source: SourceData) => void;
|
||||
deleteSource: (url: string) => void;
|
||||
setSources: (sources: SourceData[]) => void;
|
||||
@@ -54,13 +55,29 @@ export const useBrowserStore = create<BrowserStore & Actions>()(
|
||||
}
|
||||
});
|
||||
}),
|
||||
startNavigation: (url) =>
|
||||
set((state) => {
|
||||
state.url = url;
|
||||
state.mode = PageMode.Browser;
|
||||
state.status = BrowserStatus.Loading;
|
||||
state.sources = [];
|
||||
state.errMsg = "";
|
||||
state.errCode = 0;
|
||||
}),
|
||||
addSource: (source) =>
|
||||
set((state) => {
|
||||
state.sources.push(source);
|
||||
if (!state.sources.some((s: SourceData) => s.url === source.url)) {
|
||||
state.sources.push({
|
||||
...source,
|
||||
id: state.sources.length + 1,
|
||||
});
|
||||
}
|
||||
}),
|
||||
deleteSource: (url) =>
|
||||
set((state) => {
|
||||
state.sources = state.sources.filter((item: any) => item.url !== url);
|
||||
state.sources = state.sources.filter(
|
||||
(item: SourceData) => item.url !== url,
|
||||
);
|
||||
}),
|
||||
setSources: (sources) =>
|
||||
set((state) => {
|
||||
@@ -74,6 +91,7 @@ export const useBrowserStore = create<BrowserStore & Actions>()(
|
||||
),
|
||||
);
|
||||
|
||||
/** Full selector — use only when all fields are needed */
|
||||
export const browserStoreSelector = (state: BrowserStore & Actions) => {
|
||||
return {
|
||||
mode: state.mode,
|
||||
@@ -86,9 +104,30 @@ export const browserStoreSelector = (state: BrowserStore & Actions) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Navigation-only selector — url/title/status/mode (no sources) */
|
||||
export const browserNavSelector = (state: BrowserStore & Actions) => ({
|
||||
url: state.url,
|
||||
title: state.title,
|
||||
status: state.status,
|
||||
mode: state.mode,
|
||||
});
|
||||
|
||||
/** Sources-only selector */
|
||||
export const browserSourcesSelector = (state: BrowserStore & Actions) => ({
|
||||
sources: state.sources,
|
||||
});
|
||||
|
||||
/** Error-only selector */
|
||||
export const browserErrorSelector = (state: BrowserStore & Actions) => ({
|
||||
status: state.status,
|
||||
errMsg: state.errMsg,
|
||||
errCode: state.errCode,
|
||||
});
|
||||
|
||||
export const setBrowserSelector = (state: BrowserStore & Actions) => {
|
||||
return {
|
||||
setBrowserStore: state.setBrowserStore,
|
||||
startNavigation: state.startNavigation,
|
||||
addSource: state.addSource,
|
||||
deleteSource: state.deleteSource,
|
||||
setSources: state.setSources,
|
||||
|
||||
@@ -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) => {
|
||||
@@ -84,6 +89,9 @@ export const urlDownloadType = (url: string): DownloadType => {
|
||||
if (url.includes("bilibili")) {
|
||||
return DownloadType.bilibili;
|
||||
}
|
||||
if (url.includes("youtube.com") || url.includes("youtu.be")) {
|
||||
return DownloadType.youtube;
|
||||
}
|
||||
if (url.includes("m3u8")) {
|
||||
return DownloadType.m3u8;
|
||||
}
|
||||
@@ -102,3 +110,9 @@ export function getBrowserLang(): string {
|
||||
|
||||
return lang.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveAppLanguage(
|
||||
language: string | undefined,
|
||||
): ReturnType<typeof sharedResolveAppLanguage> {
|
||||
return sharedResolveAppLanguage(language, getBrowserLang());
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -2,7 +2,7 @@ import "vite/client";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: MediaGoApi;
|
||||
electron: import("@mediago/shared-common").PlatformApi;
|
||||
TDAPP?: {
|
||||
onEvent: (
|
||||
eventId: string,
|
||||
|
||||
+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` | 已手动停止 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user