Compare commits

...

65 Commits

Author SHA1 Message Date
caorushizi d654368290 feat(core): add cobra cli entrypoint 2026-05-17 03:37:28 +08:00
y2hlbg ab67f6b409 docs: remove commercial entrypoints
Deploy MediaGo Docs / Build Docs (push) Failing after 1s
2026-04-28 19:14:58 +08:00
y2hlbg 3eb7f13eca docs(readme): update 52pojie credit note 2026-04-28 15:03:57 +08:00
y2hlbg 3b279e4a05 docs(readme): add 52pojie credit note 2026-04-28 15:03:57 +08:00
caorushizi ed03781692 fix: package.json name 2026-04-28 00:28:27 +08:00
y2hlbg fb9dd15186 fix(docs): restore canonical site url 2026-04-26 20:52:58 +08:00
y2hlbg 115572c513 feat(docs): add seo blog pillar pages 2026-04-26 20:24:22 +08:00
y2hlbg 1aa2bcd511 docs: add community links to readme 2026-04-23 10:08:23 +08:00
Charlie 248237d593 feat(i18n): add Italian localization (#646) 2026-04-23 09:04:18 +08:00
caorushizi 99281d89f9 docs: refresh VitePress homepages and add download API reference
A batch of VitePress site updates around the v3.5.0 release:

- Refresh the zh / en / jp homepages into a 3x3 feature grid aligned
  with README v3.5.0: keep the foundational "no packet capture /
  batch download / mobile playback" cards, add v3.5.0 pillars
  (browser extension / broad video source coverage / OpenClaw Skill /
  HTTP API / built-in format conversion / one-line Docker). Rewrite
  the hero with a tighter "cross-platform video downloader + built-in
  sniffing" tagline and point the English hero at the
  English-localized `home_en.png`.
- Fix en / jp hero action links that previously routed to the Chinese
  root pages (`/guides` → `/en/guides`, `/jp/guides` etc.).
- Add the macOS usage block to the en / jp quick-start guides
  (Intel vs Apple Silicon + `sudo xattr -dr com.apple.quarantine`
  unblock command), and drop the QQ feedback group tip from those
  locales — QQ is China-only, not useful for en / jp audiences.
- Add a new Download API reference page (`/api`) for all three locales,
  registered in the sidebar under "开始 / Quick start / 始める".
  Covers Base URL (desktop `39719`, Docker `9900`), auth (API key from
  Settings page), curl quick-start, download SSE events, and the full
  `/api/downloads/*` endpoint reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:12:17 +08:00
caorushizi aa24ef5966 docs(readme): use English-localized screenshots in English README
The English README was reusing the Chinese-localized screenshots
(`home.png`, `home-dark.png`, `settings.png`, `browser.png`), which
left English readers staring at a UI they could not read. Ship a
parallel set of English screenshots under the `_en` suffix and point
`README.md` at them; `README.zh.md` / `README.jp.md` keep their own
assets untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:29:58 +08:00
caorushizi e94b91f2f7 docs(i18n): add TRANSLATION.md contribution guide
Addresses #638, where a prospective Italian translator asked for clearer
translator onboarding and a way to preview translations in place. The new
guide documents where strings live, the four-file registration path for a
new language, and the Vite HMR dev workflow that already solves the live
preview need. Linked from all three READMEs alongside CONTRIBUTING.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:13:22 +08:00
Charlie fc99be56df Merge pull request #640 from caorushizi/fix/installer-metadata
fix(installer): show version in title bar, set installer FileDescription (#637)
2026-04-22 23:40:23 +08:00
Charlie 34bbe42600 Merge pull request #639 from bovirus/master
Add Italian langauge
2026-04-22 23:36:50 +08:00
caorushizi dfcdf6a6a5 fix(installer): show version in title bar, set installer FileDescription (#637)
- `installer/installer.nsh`: customHeader macro sets Caption to
  "Setup - ${PRODUCT_NAME} ${VERSION}" so users can see which release
  they're installing from the window title (the default $(^SetupCaption)
  omits the version, and re-setting Name trips NSIS warning 6029 which
  electron-builder's -WX flag treats as a hard error).
- `scripts/build.ts`: afterAllArtifactBuild hook runs the app-builder
  rcedit helper on the generated NSIS installer to rewrite its
  FileDescription to "${APP_NAME} installer". electron-builder's
  NsisTarget.computeVersionKey() hardcodes VIAddVersionKey /LANG=1033
  "FileDescription" "${appInfo.description}", binding the installer's
  FileDescription to the app binary's (both drawn from package.json
  description); any in-NSIS override collides on the same LANG+key
  with a hard "already defined!" error that -WX does not gate.
  Post-processing with rcedit sidesteps this and lets installer and
  app binary carry distinct descriptions — same approach VS Code's
  Inno Setup pipeline uses (where "{AppName} Setup" is the default).

Closes #637

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:23:51 +08:00
bovirus f395d49a10 GUI - Add Italian language 2026-04-22 17:17:10 +02:00
bovirus f6a228f318 Browser extension - Add italian language 2026-04-22 16:41:46 +02:00
caorushizi ba958b9f44 docs: flatten Docker feature-section command to a single line
Collapses the line-continuation form (`docker run ... \\n -v ... \\n caorushizi/mediago:3.5.0`)
into one line across README.md / README.jp.md so all three language
READMEs match (zh was already single-line). Makes the command easier
to copy-paste — readers don't have to deal with trailing backslashes
that some terminals / browsers mangle when highlighting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:15:15 +08:00
caorushizi 19e49e89b9 docs: list Docker Hub alongside GHCR in README and docs
The CI workflow mirrors every built image to Docker Hub
(`caorushizi/mediago`), but the install lists in README (zh/en/jp)
and docs (guides / changelog for all three languages) only mentioned
GHCR. Most users default to Docker Hub, so they would miss the
simpler pull path.

Switch every Docker row into a two-line form showing both registries
explicitly:

    - [**Docker Hub**](https://hub.docker.com/r/caorushizi/mediago): docker run ... caorushizi/mediago:3.5.0
    - **GHCR**: docker run ... ghcr.io/caorushizi/mediago:3.5.0

Docker Hub appears first (shorter image name, more familiar to most
users) and its label is a link to the Hub page. GHCR is kept as a
second bullet for users who prefer it or are already authenticated.

The README feature-section (`🐳 Docker deployment`) code block was
also switched to the unqualified `caorushizi/mediago:3.5.0` form so
the showcase command defaults to Docker Hub, with a prose note that
the same image lives on GHCR too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:09:57 +08:00
caorushizi 3f889da389 fix(ci): move secrets check off if: to dodge Unrecognized named-value: 'secrets'
The Docker Hub mirror workflow used `secrets.DOCKERHUB_USERNAME != ''`
directly inside step `if:` conditions and step-level `env:`
expressions. GitHub's expression parser now rejects that with
`Unrecognized named-value: 'secrets'` — the `secrets` context is only
allowed in a narrow set of positions (mostly `with:` inputs and step
`env:` values, not inside `if:` expressions).

Route the check through a dedicated detection step whose `env:` reads
the secrets (the one permitted way to surface them) and writes a
plain boolean to step outputs. Every downstream conditional then
guards on `steps.dockerhub.outputs.enabled == 'true'` instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:46:52 +08:00
caorushizi ab669f2e74 ci(docker): mirror image to Docker Hub alongside GHCR
Every image published to `ghcr.io/caorushizi/mediago` now also gets
pushed to `docker.io/caorushizi/mediago` under the same tags, so users
can `docker pull caorushizi/mediago:3.5.0` without setting up GHCR
auth.

- New `DOCKERHUB_IMAGE` env var pinning the Docker Hub slug.
- New `Login to Docker Hub` step guarded by
  `secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != ''`
  so forks without Docker Hub credentials can still build + push to
  GHCR without failing the login.
- New `Resolve image targets` step that composes the metadata-action
  `images:` list at runtime — only adds the Docker Hub target when
  both secrets are present. Without this, metadata-action would keep
  emitting Docker Hub tags that build-push-action would then 401 on.
- Build Summary shows both `docker pull` hints when Docker Hub is
  enabled.

Setup (one-time, in repo Settings → Secrets and variables → Actions):
- `DOCKERHUB_USERNAME`: Docker Hub account name
- `DOCKERHUB_TOKEN`:    PAT from https://hub.docker.com/settings/security
                        with Read+Write scope on caorushizi/mediago

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:10:33 +08:00
caorushizi 5386bfc8a2 docs: bump guides / changelog to v3.5.0 across zh / en / jp
`docs/guides.md` still linked to v3.5.0-beta.0 assets under a stale
v3.0.1 heading; the English and Japanese versions lagged further
behind at v3.0.0. Update the "Download and install" block on each
guide to point at v3.5.0 (installer / portable / macOS arm64 + x64 /
Linux / Docker) with a link back to the GitHub Releases page for
older builds.

Prepend a v3.5.0 entry to each `changelog.md` using the same
user-facing highlights the README now uses (browser extension /
YouTube + 1000+ sites / OpenClaw Skill / HTTP API / in-app format
conversion / Docker simplification / Go backend). Older entries
(v3.0.0 and earlier) are left untouched — changelog is append-only.

`docs/history.md` intentionally left alone: it's the "old permalinks"
archive and doesn't track the current release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:10:13 +08:00
caorushizi aa998d9a3a chore: add build:docker npm script for local image builds
Wraps `docker build -t mediago:local .` so contributors don't have to
remember the tag convention, and keeps local Docker builds discoverable
alongside pack:electron / release:electron in `pnpm run`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:11:37 +08:00
caorushizi dde75321a7 fix(docker): copy extra/ into build context so aria2 ships in the image
`scripts/download-deps.ts` treats the aria2 entry as `source: "local"`
and copies from `extra/aria2/<os>/<arch>/aria2c` into `.deps/`. Every
other downloader (ffmpeg / N_m3u8DL-RE / BBDown / yt-dlp / mediago) is
`source: "github"` and fetched over the network, so they reach the
image fine — but the Docker build context never included `extra/`,
which made `copyLocalTool()` hit its "source missing" branch and
silently skip aria2. Resulting image had no `aria2c`, any direct
download inside the container failed with a binary-not-found error
the moment a task was dispatched.

Add a `COPY extra/ extra/` in the node-builder stage, right before
`pnpm deps:download` runs, so the vendored binary is actually visible
to the script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:11:23 +08:00
caorushizi d23f594b88 ci(docker): allow rebuilding an existing tag via workflow_dispatch
When a released image has to be re-cut (e.g. the broken 3.5.0 that
shipped with the stale per-downloader flags), there was no clean way
to re-publish under the same tag from the workflow — only a fresh
git tag push would trigger a build.

Extend `workflow_dispatch` with two inputs:

- `tag`        — image tag to publish under (e.g. `3.5.0` to overwrite
                 the existing one, or `3.5.0-fix.1` for a side-by-side).
                 Empty value falls back to the pre-existing
                 `dev-<run_id>` behaviour.
- `push_latest` — when rebuilding a stable release manually, optionally
                  re-stamp the `latest` tag at the same time.

`docker/metadata-action` already emits the standard OCI labels,
including `org.opencontainers.image.revision` sourced from `github.sha`.
That field alone is enough to tell two same-tag builds apart via
`docker inspect` — no Dockerfile changes needed, no manually-computed
build args. Summary step now reads version / commit from metadata-action's
own outputs (`steps.meta.outputs.version`) and `github.sha`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:36:30 +08:00
caorushizi b1cbb74f32 fix(docker): use --deps-dir and move CMD flags into entrypoint script
The image was failing at startup with:

    flag provided but not defined: -m3u8-bin

mediago-core consolidated the per-downloader `--*-bin` flags into a
single `--deps-dir` in 3.5.0-beta.1, but the Dockerfile was still
passing the old five flags (`--m3u8-bin`, `--bilibili-bin`,
`--direct-bin`, `--mediago-bin`, `--ffmpeg-bin`). Go flag parsing
rejects unknown flags, so the container exited immediately.

While fixing, extracted the invocation into
`scripts/docker-entrypoint.sh` rather than leaving a nine-line
quoted-array `CMD` at the bottom of the Dockerfile:

- `exec mediago-core …` replaces the shell so SIGTERM from
  `docker stop` reaches the Go process directly — no shell parent to
  swallow signals.
- `"$@"` forwards any extra flags from `docker run image … --foo=bar`
  so callers can override individual options without replacing the
  whole command.
- Switching from `CMD` to `ENTRYPOINT` makes the script the fixed
  launch path; runtime overrides still work via the forwarded `"$@"`.
- Editing default args no longer touches the Dockerfile (and thus
  doesn't invalidate the full image layer).

New `.gitattributes` pins `*.sh` to LF line endings so a Windows
checkout doesn't turn the entrypoint into a `/bin/sh^M: bad interpreter`
error inside the Linux container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:24:50 +08:00
caorushizi f01cae6554 docs: rewrite README (zh/en/jp) around user-facing features, extract dev setup
The three READMEs had drifted into a dev-first shape — the first screen
of each started with pnpm / install commands before telling end users
what the product actually does — and the Japanese version had fallen
behind (outdated commands, no v3.5.x downloads, screenshots pointing
at an external CDN rather than the in-repo images).

Rework all three around the v3.5.0 user-friendly feature story and
move the developer setup into its own file:

- Unified skeleton across zh/en/jp: header badges → one-sentence pitch →
  six feature sections (browser extension · YouTube / 1000+ sites ·
  OpenClaw Skill · HTTP API · in-app format conversion · Docker +
  LAN) → screenshots → v3.5.0 downloads (+ BT Panel one-click) →
  v3.5.0 highlights → tech-stack badge row → acknowledgements →
  disclaimer → link to CONTRIBUTING.
- Header elements preserved verbatim: nav links, language switcher,
  MediaGo Pro / Try Now banners, downloads / stars / forks / GitCode /
  Trendshift badges, 4 screenshots, BT Panel one-click deployment,
  the full 5-point disclaimer.
- Tech-stack prose list compressed to a single row of shields.io
  badges (React / Electron / Vite / TypeScript / Tailwind / shadcn/ui /
  Go / Ant Design).
- Acknowledgements gain aria2 (now powering direct downloads after the
  gopeed swap).
- README.jp.md specifically: fixed four `static.ziying.site/images/*`
  references to use the in-repo `./images/*` (matching zh/en); replaced
  the outdated v3.0.0-only download block with v3.5.0; added the
  English-language switcher link that was missing from the header.
- README.md/zh.md: dropped the v3.0.0 changelog block (history lives
  on GitHub Releases) and the "Quick Start" / "Running locally" pnpm
  command blocks.

New file: `CONTRIBUTING.md`. English only — prerequisites, repo layout,
a short list of the commands contributors actually type (deps:download
/ dev:electron / dev:server / pack:electron / release:electron / check),
Conventional Commits note, PR guidance. Each README ends with a one-
line pointer to it in the local language.

No product behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:14:28 +08:00
caorushizi 74b67101ae feat(extension): pin manifest version to the Desktop app version
Build & Push Docker Image / Build and push multi-arch image (push) Failing after 1s
Users naturally expect the "Current version" label in chrome://extensions
to match the version shown inside the MediaGo window. Tie both to a single
source of truth: `apps/electron/app/package.json`, which Electron's build
pipeline (`apps/electron/tsdown.config.ts`) already reads and exposes as
`process.env.APP_VERSION`.

`manifest.config.ts` now imports the same file and:

- `version`   — stripped down to the numeric prefix (`split(/[-+]/)[0]`)
                so Chrome accepts it. Chrome's manifest parser only
                allows 1–4 dot-separated integers; SemVer pre-release
                or build suffixes (`-beta.2`, `+build.7`) get rejected
                at install time.
- `version_name` — keeps the full SemVer string. It's free-form, not
                used for update-ordering, and is what chrome://extensions
                displays when present.

Effect: bumping `apps/electron/app/package.json` once is enough to
re-stamp both the Desktop binary and the bundled extension manifest.
The extension's own `packages/mediago-extension/package.json` is a
private workspace package — its `version` field is unused by the
manifest and intentionally left alone to avoid a pointless sync step
on every release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:16:07 +08:00
caorushizi 3279f40541 v3.5.0 2026-04-21 18:16:07 +08:00
caorushizi 62331e5c7b fix(ui,shared): refresh download list on externally-created tasks
Go Core already broadcast a `download-create` SSE event whenever
`/api/downloads` POST lands (UI, browser extension, Docker clients —
any route). The UI listened, but only to bump the sidebar badge via
`useDownloadStore.increase()`. The SWR list fetched by `useTasks`
stayed stale until the user refreshed manually, so tasks imported
through the extension's desktop-http / docker-http modes never
appeared without a page reload.

Fan the same SSE event through the existing `dispatchDownload`
pipeline so `useTasks` can mutate its SWR cache alongside the other
lifecycle events (success / failed / stopped).

- `packages/shared/common/src/types/index.ts`: new
  `DownloadCreatedEvent` (`type: "created"`, carries `{ ids, count }`).
- `apps/ui/src/api/events.ts`: existing `download-create` handler now
  additionally calls `dispatchDownload({ type: "created", ... })`
  after its badge-increment side effect. Parses `ids` from the
  payload for downstream filtering if anyone needs it later.
- `apps/ui/src/hooks/use-tasks.ts`: adds `isCreatedEvent` type guard
  and calls `mutate()` on hit, same pattern as the other events.

Internal task creation (UI form submit) is untouched — it still
refreshes via its own local mutate. The SSE path only matters for
tasks that show up out-of-band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:11:37 +08:00
caorushizi d9c3d8ae1d fix(ui): let users copy text from the download-log terminal
Two separate bugs had to be fixed together before copy worked:

1. `terminal-dialog.tsx` used to swallow every contextmenu event
   inside the dialog (`e.preventDefault(); e.stopPropagation()`) —
   killing Electron's built-in "Copy" menu for selected text in the
   xterm log. Drop the preventDefault so the native menu fires, but
   keep stopPropagation (now scoped to the contextmenu handler
   alone) so the event doesn't bubble up through the React tree to
   `DownloadTaskItem`'s `onContextMenu` and pop its "select /
   download / refresh / delete" menu instead. Radix Dialog portals
   the DOM to document.body, but React synthetic events still
   travel the virtual tree — this is the classic portal-bubbling
   gotcha.

2. xterm.js does not bind Ctrl+C / Cmd+C to "copy selection" by
   default; it forwards them as control chars. Since the log view
   uses `disableStdin: true`, hijacking those keys is safe.
   `attachCustomKeyEventHandler` now intercepts copy shortcuts,
   writes the selection to the clipboard via `navigator.clipboard`,
   and returns false so xterm stops handling the event. No selection
   → passthrough.

Result: both right-click → Copy and Ctrl/Cmd+C paths work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:11:16 +08:00
caorushizi c34812841e fix(core): sanitize filenames at task creation to prevent path-separator injection
Twitter-style page titles like "(2) 主页 / X" land a literal "/" in
the download filename. Gopeed used to strip such characters silently;
aria2 is strict and passes them through to the OS, which then reads
"/" as a path separator — the save ends up at
`.../(2) 主页 /X-....mp4` pointing at a non-existent sub-directory
and fails with `ERROR_PATH_NOT_FOUND (errNum=3)`.

Sanitize once, at the task-creation boundary, so the DB row, the
downloader command-line `-o` arg, and the post-download
`CheckFileExists(rec.Name, ...)` probe all agree on the same
filesystem-safe value.

- New exported `core.SanitizeFilename` replaces reserved path /
  wildcard characters (`\ / : * ? " < > |`) and ASCII control chars
  with `_`, and right-trims dots / spaces (Windows strips those
  silently, producing a filename that doesn't match the DB row).
  Falls back to "download" if every character was illegal.
- `service/download_task.go` `AddDownloadTask` and `AddDownloadTasks`
  run titles through `SanitizeFilename` before the
  `FindByName` de-duplication check, so both the dedup lookup and
  the persisted row see the cleaned value.
- `core/downloader.go` `buildArgs` still calls `SanitizeFilename`
  defensively on `p.Name` — cheap, and guards any future path that
  bypasses the service layer.

Applies to every downloader (aria2, yt-dlp, BBDown, N_m3u8DL-RE,
mediago) since the fix is in the shared `name` arg-building branch
used by all Schema entries. Pre-existing broken tasks in the DB will
still fail "file not found" on the UI and need to be deleted +
re-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:10:54 +08:00
caorushizi 28e2c58366 fix(node-service): fail fast when the preferred port is already bound
If a stray MediaGo instance (typically the installed Desktop build
running in the background from the Start menu) already holds the
preferred port, a freshly-spawned child fails to bind and exits
immediately — but `waitForHealthy()` happily answers green because
the *other* instance's `/healthy` responds on the same port.
ServiceRunner then considers the service "started" and every
subsequent request silently lands on the wrong process. In practice
this surfaced as dev mode talking to the installed Go Core and
reporting a stale gopeed binary path, even though the dev build had
already been rebuilt with aria2c.

Pre-check: before spawning, open and close a throwaway TCP listener
on `<bindHost>:<preferredPort>`. If the bind fails, throw a
human-readable error instead of proceeding. The tiny check-vs-bind
race window is acceptable for catching the real-world "long-running
stray instance" case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:10:30 +08:00
caorushizi a9e3ed6a22 feat(core): switch direct downloader from gopeed to aria2
Gopeed's last release is aging and its SChannel-linked Windows build
chokes on modern CDN TLS handshakes (we hit SEC_I_MESSAGE_FRAGMENT on
twimg.com). Replace it with aria2 — same short flags (-x/-s/-k carry
over verbatim), stricter arg handling, and well-trodden cross-platform
builds.

Binaries are vendored in-tree at extra/aria2/<os>/<arch>/ rather than
pulled from a single GitHub release, because aria2 static builds for
Linux / macOS / Windows come from different upstream repos. To keep
the deps pipeline unified:

- `scripts/download-deps.ts` grows a "source": "local" branch that
  copies from `extra/<tool>/<os>/<arch>/` into `.deps/<os>-<arch>/`
  instead of fetching from GitHub. Resulting layout matches the rest
  of the tools, so binaryResolver.ts needs no changes.
- `scripts/deps-versions.json`: `gopeed` entry removed, `aria2`
  entry added with source:"local" + path:"extra/aria2".
- `apps/core/internal/core/types.go`: BinaryNames[TypeDirect]
  `"gopeed"` → `"aria2c"`.
- `apps/core/internal/core/schema/loader.go`: direct schema's Args
  map to aria2's flags (-d / -o, plus --console-log-level=notice /
  --summary-interval=1 / --allow-overwrite=true /
  --auto-file-renaming=false for parseable output and predictable
  rerun behaviour). ConsoleReg regexes updated to aria2's summary
  line format (e.g. "DL:512KiB" for speed, "(83%)" for percent).
  `--check-certificate=false` tacked on as a workaround for the
  SChannel handshake issue on the bundled 1.19.0 Windows build —
  proper fix is upgrading the vendored binary to a build that links
  against OpenSSL (e.g. 1.37.0), comment calls that out.
- `apps/core/.env`, `Dockerfile`, `apps/electron/scripts/build.ts`,
  `apps/core/README.md`: gopeed → aria2c / aria2 references.

No DB migration: existing `type: "direct"` rows keep working, the
underlying binary just swaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:09:35 +08:00
caorushizi 48aabc9a7d v3.5.0-beta.2 2026-04-21 14:26:34 +08:00
caorushizi b72b39ad7e fix(node-service): bind to 0.0.0.0 when internal=false so localhost reaches the server
ServiceRunner used to pass the detected LAN IPv4 as the `HOST` env var
when `internal: false`, so the Go Core bound *only* to that LAN IP —
requests to 127.0.0.1 / localhost were refused. That blocked the
browser-extension's `desktop-http` mode (fixed at `http://127.0.0.1`).

Split bind host from display host:
- Bind (`HOST` env to child): `0.0.0.0` when `internal: false`, else
  `127.0.0.1`. Listener now accepts traffic on every interface.
- Display (`state.host` / `getURL()`): keeps the detected LAN IPv4
  when `internal: false` so the settings UI can surface a shareable
  LAN URL, falls back to 127.0.0.1 otherwise.

Net effect: Desktop is reachable via 127.0.0.1, localhost, and its LAN
IP simultaneously, while the UI still shows the LAN address.

Also translates all remaining Chinese JSDoc / inline comments in
`index.ts` and `utils.ts` to English, matching the rest of the package.
Behaviour otherwise unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:56:22 +08:00
caorushizi f04989e973 feat(extension): bundle into desktop app with settings entry and docs
Ship the MediaGo browser extension alongside the Desktop installer so
users can "Load unpacked" it from Chrome / Edge without building the
repo or downloading a separate zip. Adds a discovery path from inside
the app, a three-language doc page, and fixes the Desktop HTTP port
the extension was pointing at.

Packaging:
- `pnpm build:electron` now also builds `@mediago/extension` (turbo
  filter), and `apps/electron/scripts/build.ts` copies the dist to
  `app/build/extension/` and declares it in electron-builder
  `extraResources` → installers land `resources/extension/`.

Runtime:
- `resolveExtensionDir()` in binaryResolver — dev points at the
  monorepo dist, prod at `process.resourcesPath/extension`, env
  override via `MEDIAGO_EXTENSION_DIR`.
- New IPC `app.getExtensionDir()` returns the resolved path. UI pairs
  it with the existing `shell.open()` (no dedicated open-folder IPC)
  so the pattern matches configDir / binDir / localDir.

Settings UI:
- New "Browser extension directory" button in Settings → More Settings,
  right next to the existing folder shortcuts. Web/server mode hides
  the row behind `isWeb` and the stub returns "".
- New i18n key `extensionDir` in shared zh/en resources; duplicate
  `currentVersion` key removed from zh.ts along the way.

Port fix:
- Extension `DESKTOP_HTTP_BASE` corrected from `:9900` → `:39719` to
  match the Electron-side `preferredPort`. 9900 is the standalone
  Go Core / web-server port — two separate deployments.

Docs:
- New `docs/extension.md` + `docs/en/extension.md` + `docs/jp/extension.md`
  covering what the extension does, how to install the unpacked
  build, the three dispatch modes (schema / desktop-http / docker-http),
  import-behaviour toggles, language switching, and common pitfalls.
- Sidebar entries registered in `.vitepress/config.ts` for all three
  locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:56:05 +08:00
caorushizi e5bcd7a224 chore(core): translate build-script console logs to English
Mirror the English-only convention already used in the Electron/UI
scripts. Covers dev.ts (start dev server / build Player UI / compile
dev binary) and release.ts (build all-platform binaries / package /
clean). Behaviour unchanged — message text only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:55:34 +08:00
caorushizi fbf31d706c style(extension): apply Cursor-inspired warm palette and typography
Reskin popup + options to match Cursor's design language — warm off-
white over cream surfaces, gothic/serif/mono three-voice typography,
oklab borders, diffused atmospheric shadows. Preserves every feature;
only visuals change.

Palette & tokens (globals.css):
- Light: `#f2f1ed` page, `#26251e` text, `#e6e5e0` card, `#cf2d56`
  crimson for hover, `#f54e00` orange accent, surface-100..500 scale,
  AI-timeline colors (peach / sage / blue / lavender)
- Dark: warm-offset variant (`#1e1d16` page, `#ebeae5` text) — no
  cold neutral inversion
- Borders: `oklab(0.263084 -0.00230259 0.0124794 / 0.1)` perceptually
  uniform warm brown
- Shadows: ambient (16/8px), elevated (28/70px + oklab ring), focus
  (4/12px) — all token-driven
- Radius scale: 4px inline / 8px default / 10px featured / 14px breathing
- Motion: `--transition-color: 150ms ease` / `--transition-shadow: 200ms`

Fonts (locally bundled variable woff2, no remote fetch):
- Geist Variable (→ CursorGothic analogue)
- Source Serif 4 Variable (→ jjannon analogue)
- JetBrains Mono Variable (→ berkeleyMono analogue)
- OpenType feat utilities: `.font-feat-display` (ss09/ss01) and
  `.font-feat-editorial` (cv11/liga)
- Global h1/h2/h3 letter-spacing follows Cursor's size curve
  (-0.03em / -0.02em / -0.0125em)

Components:
- Button — warm-primary (hover → crimson text) + dark / outline /
  secondary-pill / tertiary-pill / ghost / light-surface / link
- Badge — full pill + timeline-color variants mapped to DownloadType
  (thinking/grep/read/edit/mediago) via `variantForDownloadType()`
- Card — ambient by default; `elevated` for permanent lift;
  `interactive` eases ambient → elevated on hover (used on ServerCard,
  the page's primary action target)
- Sonner / Switch / Input / RadioGroup — warm-palette rewrites with
  oklab focus rings and serif descriptions where appropriate

Pages:
- Popup — tonal bands (header cream → page-info surface-100 → list
  bg → footer surface-100), source type badges colored by DownloadType
- Options — responsive hero (40 → 52 → 60px with scaled letter-
  spacing), decorative blur glows behind headline, "cream → bg →
  cream" section rhythm with a closing surface-200 strip, per-card
  mono chapter dots in timeline colors, RuleListCard redone as a
  vertical timeline echoing Cursor's AI-step visualisation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:16:43 +08:00
caorushizi b782329ae2 feat(extension): add i18n with system/zh/en language toggle
The MediaGo extension shipped Chinese-only (50+ hardcoded strings across
popup, options, background). Wire up i18next + react-i18next so the UI
follows the browser language by default and lets the user override to
zh / en in a new LanguageCard on the options page.

- `ExtensionSettings.language: "system" | "zh" | "en"` (default system),
  persisted via existing `chrome.storage.local`
- `resolveLanguage()` maps system -> `chrome.i18n.getUILanguage()` or
  navigator.language with a zh/en fallback
- Bootstrap one i18next instance per page (popup / options), subscribe
  to `chrome.storage.onChanged` so switching on one surface updates the
  other without a reload
- Background service worker emits `LocalizedMessage` descriptors
  (`{ key, values? }`) for toast copy; popup calls `t(key, values)` so
  the SW never has to carry an i18n instance
- Full zh/en coverage for popup, options, error paths
- Chrome-native `_locales/{en,zh_CN}/messages.json` + `__MSG_*__` for
  `manifest.name` / `description` / `action.default_title` so the
  chrome://extensions row and toolbar tooltip follow the browser UI
  language (independent dimension from the in-UI toggle, by design)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:16:03 +08:00
caorushizi 65e20638ff feat(extension): expose silent / downloadNow knobs in options
The useUrlInvoke hook at apps/ui/src/hooks/use-url-invoke.ts reads
both `silent` and `downloadNow` query params; they were previously
hard-coded on the extension side. Surface them as user toggles so
Schema-mode users can opt into "open the download form for review"
and both modes can opt into "start downloading immediately instead
of just queuing".

- ExtensionSettings gains `downloadNow` (default false) and
  `schemaSilent` (default true).
- importViaHttp threads `startDownload` into the POST body.
- buildTaskDeeplink writes `silent=1` / `downloadNow=1` into the
  deeplink URL based on the flags (falsy => omit the key, matching
  the truthy-check in useUrlInvoke).
- New headless Switch component (no @radix-ui/react-switch dep).
- New ImportBehaviourCard in options, with a patch-style save hook
  so each toggle persists immediately.
- use-options.ts now merges on top of the current persisted settings
  so the server-config card doesn't wipe the new fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:58:44 +08:00
caorushizi 19048f6ead feat(extension): add MediaGo browser extension for media URL sniffing
A Manifest V3 extension that sniffs downloadable video / audio URLs
across every site the user visits and hands captured sources to a
MediaGo server in one click. Ships unlisted (load-unpacked .zip), no
Chrome Web Store.

Dispatch modes (user-picked in options, no silent fallback):
- desktop-schema: navigates the current tab to
  `mediago-community://index.html/?n=1&silent=1&url=…` using the
  cat-catch `chrome.tabs.update` pattern. Reuses the existing
  `useUrlInvoke` hook in apps/ui — no new deeplink plumbing.
- desktop-http: POST http://127.0.0.1:9900/api/downloads against the
  Go Core bundled in a running Desktop.
- docker-http: same POST against a user-configured host, with an
  optional X-API-Key header for `--enable-auth` deployments.

UI is React 19 + Tailwind v4 + shadcn/ui (new-york, neutral), matched
to apps/ui's stack. Popup shows per-tab sources with a red badge count;
options page has a 3-mode radio + per-mode field panel.

Supporting changes:
- Abstract sniff filter rules into @mediago/shared-common/sniff and
  point the Electron sniffing helper at the same exports so desktop
  and extension stay in lock-step.
- Tighten the YouTube host rule to actual video / short / live / embed
  URLs (drops the homepage and feed false-positives).
- Fix the macOS electron-builder config: CFBundleURLSchemes was
  hard-coded to a test string "mediagoaaa" — now sourced from
  process.env.APP_NAME (mediago-community in .env) like everywhere
  else. Also add a top-level `protocols` entry for clarity.
- Vite build pulls APP_NAME from the repo root .env via loadEnv() +
  `define`, so the scheme name stays single-sourced.
- Root scripts: `pnpm dev:extension`, `pnpm build:extension`,
  `pnpm pack:extension` (+ scripts/pack-extension.ts that cross-
  platform-zips dist/ into release/).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:54:31 +08:00
caorushizi b29a5f1748 perf(settings): mount cards one per frame to break up navigation jank
DevTools profiling showed a ~319ms long task when switching to the
settings page, dominated by scripting inside node_modules — Ant Design
Form.Item registration and cssinjs style generation firing for ~25
fields in a single synchronous pass. Chunk loading was only ~9ms, so
earlier fixes around lazy-import and Suspense fallbacks missed the real
cause.

Changes:
- Stream the 6 cards in one per animation frame (visibleCount driven
  by requestAnimationFrame), so the longest task is a single card's
  registration instead of the full page. The first card paints
  immediately and the rest follow over the next few frames.
- Skip the initial setFieldsValue via an isFirstSync ref;
  initialValues already seeds the form, so that effect was only
  forcing Ant Form to diff every Form.Item again right after mount.
- Wrap cardSections in useMemo with a narrow dep list
  (settings.apiKey, settings.local, envPath, updateAvailable, stable
  callbacks) so SSE config-changed updates don't rebuild the 340-line
  JSX tree unless those specific fields changed.
- Move the key from <Card> to the outer <div> so React stops treating
  each re-render as a remount of the card subtree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:03:33 +08:00
caorushizi 8783d949ba feat(favorite): optional title with auto-fetch and URL-first form
- Make favorite title optional in both the DTO (drop binding:"required")
  and the add-favorite modal (no validator). Server falls back to the
  URL when the title is empty so the list entry always has a label.
- Auto-fill the title via the existing GET /api/url/title scraper when
  the user leaves the URL field, unless they already typed their own
  title. Failures are swallowed (server fallback still applies).
- Reorder the modal so URL is on top and Title below, matching the
  actual data-flow: users paste a URL first and the title follows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 05:28:35 +08:00
caorushizi bee6bd1cbd fix(ui,core): drive download badge via SSE and polish favorite error UX
- Go Core now broadcasts a "download-create" SSE event after
  POST /api/downloads succeeds, carrying the new task ids and count.
  The renderer's events.ts listens for it and calls useDownloadStore's
  increase() imperatively, so the sidebar badge updates regardless of
  which WebContents issued the request — the source-extract overlay
  dialog has its own Zustand instance and can no longer silently swallow
  the increment.
- Remove the now-redundant local increase() calls in download-form and
  browser-view-panel so the counter is incremented exactly once per task
  from a single source of truth.
- Favorite.Create returns 409 Conflict with a translated
  MsgURLAlreadyExists message when the URL already exists, instead of
  500 with the raw "url_already_exists" string.
- http.ts response interceptor now surfaces the server's translated
  message on 4xx/5xx instead of Axios's generic
  "Request failed with status code XXX".
- Simplify getFavIcon: drop the 1s <img> probe that dropped most URLs
  and return the canonical /favicon.ico; <Avatar> already falls back to
  a link icon when the image fails to load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 05:28:22 +08:00
caorushizi bdea15ace9 fix(electron): rebuild tray menu on language change
The tray context menu was built once at startup, before the language
was loaded from Go Core, so its labels were stuck in the fallback
locale and never updated when the user switched languages. Keep the
Tray instance on the class, extract the menu build into refreshTrayMenu,
and subscribe to i18n "languageChanged" so the menu follows the
current language for both startup hydration and runtime changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 04:01:20 +08:00
caorushizi 72acd2ca94 refactor(ui,electron): fix "system" language fallback and rework settings layout
- Resolve AppStore.language at apply-time via a shared resolveAppLanguage
  helper so both renderer (navigator.language) and Electron main
  (app.getLocale) follow the actual OS locale instead of silently
  falling back to zh when the stored value is "system".
- Settings page: horizontal form with a fixed label column, borderless
  cards flowing naturally in a 2-column CSS columns layout; drop
  width="xl" on inputs and wrap button groups to fix English-label
  overflow.
- scripts/download-deps: add a PowerShell fallback for zip extraction
  on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:46:38 +08:00
caorushizi ba5f389dbe fix: upgrade zustand to stable and fix broken player build scripts
- Upgrade zustand from 5.0.0-rc.2 to ^5.0.0 (resolved 5.0.12)
- Fix player:dev and player:build scripts that referenced non-existent
  @mediago/player-build, now correctly point to @mediago/core-build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 04:22:05 +08:00
caorushizi aaa5ee69d2 docs: fix documentation drift in CLAUDE.md
Correct 8 inaccuracies where CLAUDE.md diverged from the actual codebase:
server is a Node.js launcher not Koa 3, apps/player/ doesn't exist,
remove non-existent commands, fix adapter layer and event bridge file
names, correct DI and module format claims.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:21:14 +08:00
dependabot[bot] fb671d5790 chore(deps): bump axios from 1.14.0 to 1.15.0
Build & Push Docker Image / Build and push multi-arch image (push) Failing after 0s
Bumps [axios](https://github.com/axios/axios) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 00:30:13 +08:00
caorushizi e32bfd1cb2 v3.5.0-beta.1 2026-04-13 00:27:24 +08:00
caorushizi 50a1a90e6a docs: rewrite README in English and polish UI translations
- Rewrite README.md in idiomatic English as the default language
- Move original Chinese README to README.zh.md, delete README.en.md
- Polish ~80 UI translation strings in en.ts for natural English
- Fix hardcoded Chinese in Skills setup commands (setting-page)
- Fix mismatched translation key: reppeatPassword → repeatPassword

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 2406b5b617 chore(electron,server): remove verbose startup logs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 913d645d89 fix(electron): resolve deprecation warnings and unhandled promise rejection
- Replace deprecated webContents.canGoBack/goBack with navigationHistory API
- Add try/catch to autoUpdater.checkForUpdates to handle ERR_CONNECTION_REFUSED
- Replace cross-fetch with Node native fetch to fix url.parse() deprecation
- Remove unused cross-fetch dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi c7dd925a34 feat(core): add yt-dlp plugin and consolidate binary paths into --deps-dir
- Add youtube download type with yt-dlp schema (CLI args, progress regex)
- Replace 6 individual --xxx-bin flags with single --deps-dir flag
- Add BinaryNames map in Go core to auto-resolve binary paths from deps dir
- Simplify Electron/Server binary resolvers to return depsDir only
- Add yt-dlp to deps-versions.json (v2026.03.17)
- Add YouTube option to UI download form with URL auto-detection
- Fix download-terminal.tsx writing [object Object] instead of log content
- Improve error messages when binary not found (include path and type)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi c289d3690f refactor(browser-extension): remove dead FloatButton, jQuery and Lit dependencies
FloatButton was effectively dead — it listened for "webview-link-message" which
no one sends after the IPC namespace refactoring, and its functionality is fully
covered by BrowserViewPanel in the main UI. Remove it along with jQuery, Lit and
nanoid dependencies. Consolidate remaining BilibiliButton into a single-file
vanilla custom element, replace fragile global-index DOM lookup with
closest()-based card-relative queries, and replace setInterval polling with
MutationObserver. Build output reduced from 607KB to 1.9KB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 8224c68f66 refactor(ui): optimize source-extract page and browser navigation flow
Extract shared navigation logic into useBrowserActions hook, fix race conditions
in sniffing helper, add event listener cleanup in webview service, split store
selectors for granular re-renders, memoize list items, throttle ResizeObserver,
and add loading/error states to favorites list. Also remove unused getMachineId
IPC handler and fix IPC callback type signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 6571fc022d refactor(ipc): namespace IPC events and consolidate similar methods
Replace flat IPC string constants with namespaced groups (browser.*,
app.*, dialog.*, shell.*, contextMenu.*, update.*) and consolidate
similar operations into generic methods:
- dialog.open/save replaces selectFile, selectDownloadDir, export/import
- shell.open replaces openDir, openUrl, openBrowser
- contextMenu.show replaces 3 separate context menu handlers
- on/off replaces rendererEvent/removeEventListener

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 109c09a911 refactor(electron): replace screenshot hack with overlay WebContentsView for download dialog
Instead of capturing a screenshot, transferring a large DataURL via IPC,
and hiding the browser WebContentsView, use a separate overlay
WebContentsView layered above the browser view to render the download
form. This eliminates flicker, avoids potential page state loss, and
removes the inefficient screenshot transfer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 90d7fe578f feat(core): upgrade mediago-core to v0.2.1 and adapt log parsing
- Bump mediago-core from v0.1.0 to v0.2.1 in deps-versions.json
- Update ConsoleReg to match new structured log format:
  speed regex for B/s units, start/isLive/error patterns
- Remove --no-log flag so structured logs are visible for parsing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 82f948cdd9 fix(ui): fix openDir using wrong field names and antd 6 form warnings
- Use correct GoEnvPath fields (configDir/binDir) instead of non-existent workspace/binPath
- Defer form.setFieldsValue after modal opens so Form is mounted
- Add Input child to hidden Form.Item with name prop to satisfy antd 6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 25d149e9ef feat(core): integrate mediago-core as new download type
Add mediago-core (caorushizi/mediago-core) as a fourth download type
alongside m3u8, bilibili, and direct. This enables HLS/DASH streaming
downloads using the custom Go-based downloader.

Changes span the full integration path: dependency download config,
Go Core backend (type, schema, CLI flag, binary map), binary resolution
for Electron/Server, shared TypeScript types, i18n labels, UI dropdown,
dev scripts, and Dockerfile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:19:14 +08:00
caorushizi 8d757ba18d docs: fix docker image tag (remove v prefix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:20:01 +08:00
caorushizi 4f1c9fe65c docs: add OpenClaw Skill to README intro, docs homepage and guides
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:17:23 +08:00
caorushizi 78e3f96e3a docs: update README and guides for v3.5.0-beta.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:10:53 +08:00
257 changed files with 15999 additions and 3700 deletions
+3
View File
@@ -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
+84 -7
View File
@@ -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
+5
View File
@@ -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/
+12 -15
View File
@@ -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)
+96
View File
@@ -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
View File
@@ -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
View File
@@ -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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/en?form=github">Website</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/en/documents.html?form=github">Docs</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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.
- **✅&nbsp; 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.
- **📱&nbsp; Mobile playback**: Easily switch between PC and mobile devices seamlessly. Once downloaded, you can watch the video on your phone.
- **⚡️&nbsp; Batch download**: Supports downloading multiple videos and live streams simultaneously, ensuring high bandwidth is fully utilized.
- **🎉&nbsp; 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
![Homepage](https://static.ziying.site/images/home.png)
### 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
![Homepage](https://static.ziying.site/images/home.png)
![Homepage-dark](https://static.ziying.site/images/home-dark.png)
![Settings Page](https://static.ziying.site/images/settings.png)
![Resource Extraction](https://static.ziying.site/images/browser.png)
## 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
View File
@@ -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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/it?form=github">Sito web</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/it/documents.html?form=github">Documentazione</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/discussions">Discussioni</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/blob/master/README.zh.md">中文</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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:
&lt;url&gt;"_. 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
![Home](./images/home_en.png)
![Home — modalità scura](./images/home-dark_en.png)
![Impostazioni](./images/settings_en.png)
![Estrazione risorse](./images/browser_en.png)
## 📥 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
[![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)](https://react.dev/)
[![Electron](https://img.shields.io/badge/Electron-191970?logo=electron&logoColor=white)](https://www.electronjs.org)
[![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vitejs.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com)
[![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-000?logo=shadcnui&logoColor=white)](https://ui.shadcn.com/)
[![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Ant Design](https://img.shields.io/badge/Ant_Design-0170FE?logo=antdesign&logoColor=white)](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
View File
@@ -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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/jp?form=github">公式サイトです</a>
<a href="https://downloader.caorushizi.cn/jp?form=github">公式サイト</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/blob/master/README.zh.md">中文</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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 は現在、英語・簡体中国語・イタリア語に対応しています。
- **✅&nbsp; パケットキャプチャ不要**: ソフトウェアに内蔵されたブラウザを使用して、ウェブページ内のビデオリソースを簡単に検出し、検出したリソースリストからダウンロードしたいリソースを選択することで、シンプルかつ迅速にダウンロードできます。
- **📱&nbsp; モバイル再生**: PCとモバイルデバイス間で簡単にシームレスに切り替えが可能で、ダウンロードが完了した後はスマートフォンでビデオを視聴できます。
- **⚡️&nbsp; バッチダウンロード**: 複数のビデオやライブストリームリソースを同時にダウンロードでき、高速帯域幅を無駄にしません。
- **🎉&nbsp; 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 に「この動画をダウンロードして:&lt;URL&gt;」と言うだけでダウンロードが始まります。
```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 arm64Appleチップ) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
- [【mediago】 macos x64Intelチップ) 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 arm64Appleチップ) v3.0.0](https://static.ziying.site/mediago/mediago-setup-darwin-arm64-3.0.0.dmg)
- [【mediago】 macos x64Intelチップ) 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」を見つけ、インストールをクリックし、ドメイン名などの基本情報を設定してインストールを完了します。
![ホームページ](./images/home.png)
### ソフトウェアスクリーンショット
![ホームページ — ダークモード](./images/home-dark.png)
![ホームページ](https://static.ziying.site/images/home.png)
![設定](./images/settings.png)
### 重要な更新
![リソース抽出](./images/browser.png)
- 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) をご覧ください。
![ホームページ](https://static.ziying.site/images/home.png)
### 🪄 宝塔パネルでワンクリック Docker デプロイ
![ホームページ(ダークモード)](https://static.ziying.site/images/home-dark.png)
1. [宝塔パネル公式サイト](https://www.bt.cn/new/download.html?r=dk_mediago) から正式版のスクリプトをダウンロードしてインストール
2. 宝塔パネルにログイン、メニューから **Docker** をクリック。初回アクセス時に Docker サービスのインストールを求められるので、「今すぐインストール」をクリックして完了
3. アプリストアで **MediaGo** を見つけて、インストールをクリック、ドメインなどの基本情報を設定すれば完了
![設定ページ](https://static.ziying.site/images/settings.png)
## 📝 v3.5.0 の新機能
![リソース抽出](https://static.ziying.site/images/browser.png)
- **🌐 ブラウザ拡張機能** — 任意のサイトで動画をスニッフィング、ワンクリックで 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>
[![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)](https://react.dev/)
[![Electron](https://img.shields.io/badge/Electron-191970?logo=electron&logoColor=white)](https://www.electronjs.org)
[![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vitejs.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com)
[![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-000?logo=shadcnui&logoColor=white)](https://ui.shadcn.com/)
[![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Ant Design](https://img.shields.io/badge/Ant_Design-0170FE?logo=antdesign&logoColor=white)](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)(英語)をご参照ください。
+110 -100
View File
@@ -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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
<a href="https://downloader.caorushizi.cn/en?form=github">Website</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/blob/master/README.jp.md">日本語</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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.
- **✅&nbsp; 无需抓包**: 使用软件自带浏览器可以轻松嗅探网页中的视频资源,通过嗅探到的资源列表选择自己想要下载的资源,简单快速。
- **📱&nbsp; 移动播放**: 可以轻松无缝的在 PC 和移动设备之前切换,下载完成后即可使用手机观看视频。
- **⚡️&nbsp; 批量下载**: 支持同时下载多个视频和直播资源,高速带宽不闲置。
- **🎉&nbsp; 支持 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: &lt;url&gt;"_.
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 arm64apple 芯片) v3.0.0](https://github.com/caorushizi/mediago/releases/download/v3.0.0/mediago-setup-darwin-arm64-3.0.0.dmg)
- [【mediago】 macos x64intel 芯片) 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
![首页](./images/home.png)
![Home](./images/home_en.png)
### 重要更新
![Home — dark mode](./images/home-dark_en.png)
- 支持 docker 部署 web 端
- 更新桌面端 UI
![Settings](./images/settings_en.png)
### 更新日志
![Resource extraction](./images/browser_en.png)
- 更新桌面端 UI
- 支持 docker 部署 web 端
- 新增视频播放,支持桌面端和移动端播放
- 修复 mac 打开无法展示界面的问题
- 优化了批量下载的交互
- 添加了 windows 的便携版(免安装哦)
- 优化了下载列表,支持页面中多个视频的嗅探
- 支持收藏列表手动导入导出
- 支持首页下载列表导出
- 优化了【新建下载】表单的交互逻辑
- 支持 UrlScheme 打开应用,并添加下载任务
- 修复了一些 bug 并提升用户体验
## 📥 Download
## 软件截图
### v3.5.0 (stable)
![首页](./images/home.png)
- [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`
![首页-dark](./images/home-dark.png)
Browsing older releases? See the [GitHub Releases page](https://github.com/caorushizi/mediago/releases).
![设置页面](./images/settings.png)
### 🪄 One-click Docker deployment via BT Panel
![资源提取](./images/browser.png)
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>
[![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)](https://react.dev/)
[![Electron](https://img.shields.io/badge/Electron-191970?logo=electron&logoColor=white)](https://www.electronjs.org)
[![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vitejs.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com)
[![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-000?logo=shadcnui&logoColor=white)](https://ui.shadcn.com/)
[![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Ant Design](https://img.shields.io/badge/Ant_Design-0170FE?logo=antdesign&logoColor=white)](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
View File
@@ -0,0 +1,153 @@
<div align="center">
<h1>MediaGo</h1>
<a href="https://downloader.caorushizi.cn/guides.html?form=github">快速开始</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn?form=github">官网</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://downloader.caorushizi.cn/documents.html?form=github">文档</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/discussions">Discussions</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://discord.gg/yxWBVRWGqM">Discord</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/caorushizi/mediago/blob/master/README.jp.md">日本語</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</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 说"帮我下载这个视频:&lt;链接&gt;"就行,剩下的交给 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 界面。
## 📷 软件截图
![首页](./images/home.png)
![首页 — 深色模式](./images/home-dark.png)
![设置](./images/settings.png)
![资源提取](./images/browser.png)
## 📥 下载
### 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
- **⚡ 启动更快**:后端重写,资源占用更低,内置视频播放器
## 🛠️ 技术栈
[![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)](https://react.dev/)
[![Electron](https://img.shields.io/badge/Electron-191970?logo=electron&logoColor=white)](https://www.electronjs.org)
[![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)](https://vitejs.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com)
[![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-000?logo=shadcnui&logoColor=white)](https://ui.shadcn.com/)
[![Go](https://img.shields.io/badge/Go-00ADD8?logo=go&logoColor=white)](https://go.dev/)
[![Ant Design](https://img.shields.io/badge/Ant_Design-0170FE?logo=antdesign&logoColor=white)](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
View File
@@ -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
View File
@@ -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
View File
@@ -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
- 分开发布可以单独更新,减少不必要的下载
---
+130
View File
@@ -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")
}
-63
View 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
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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=
+3 -1
View File
@@ -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"`
}
+18 -2
View File
@@ -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})
}
+16 -1
View File
@@ -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
+1 -1
View File
@@ -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
+63
View File
@@ -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: "",
}
}
+107
View File
@@ -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
}
+232
View File
@@ -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
}
+64 -6
View File
@@ -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 (0x000x1F)
// - 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",
+67 -7
View File
@@ -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\]`,
},
},
},
+14
View File
@@ -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
+3 -6
View File
@@ -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,
+6 -9
View File
@@ -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}`);
}
+7 -9
View File
@@ -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,
+2 -2
View File
@@ -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",
+56
View File
@@ -0,0 +1,56 @@
; ============================================================================
; Custom NSIS overrides injected by electron-builder.
;
; electron-builder splices `!insertmacro customHeader` into the top of its
; generated installer script (see
; node_modules/app-builder-lib/templates/nsis/installer.nsi around line 45),
; which is AFTER `common.nsh` is included. That makes this macro the right
; place to override directives like `Name`, `Caption`, `BrandingText`.
;
; Note on FileDescription: we intentionally do NOT override it here.
; electron-builder's `NsisTarget.computeVersionKey()` (app-builder-lib ->
; out/targets/nsis/NsisTarget.js) unconditionally emits
; VIAddVersionKey /LANG=1033 "FileDescription" "${appInfo.description}"
; from `apps/electron/app/package.json`'s `description`. Any customHeader
; VIAddVersionKey targeting the same LANG+key triggers a hard NSIS error
; ("already defined!") that `-WX` does not gate. Different LANG (e.g. 0)
; produces a `warning 9100: without standard key FileVersion` which IS
; gated by -WX and still fails the build.
;
; The installer's FileDescription is rewritten post-build via `app-builder
; rcedit` in `afterAllArtifactBuild` (see apps/electron/scripts/build.ts).
; That sidesteps the NSIS constraint and lets the installer and app
; binary carry distinct descriptions (like VS Code / Chrome's Inno Setup
; default of "{AppName} Setup").
;
; Wired up through `nsis.include` in apps/electron/scripts/build.ts.
; ============================================================================
!macro customHeader
; ---------------------------------------------------------------------
; Surface the version in the installer's title bar.
;
; electron-builder's common.nsh sets `Name "${PRODUCT_NAME}"`, from
; which NSIS derives the default `$(^SetupCaption)` (e.g. "Setup -
; mediago-community"). That caption omits the version — users had to
; look at the gray BrandingText at the bottom-left to see which
; release they were installing.
;
; Why `Caption` and not `Name`: re-setting `Name` emits
; `warning 6029: Name: specified multiple times`, and electron-builder
; compiles NSIS with `-WX` (warnings-as-errors), so the build fails.
;
; Why a plain literal and not `$(^SetupCaption)`: the localized lang
; string is evaluated at runtime via the MUI plugin. electron-builder
; compiles an intermediate installer with `-DBUILD_UNINSTALLER` and
; executes it silently (NsisTarget.js line ~370, `execWine(installerPath,
; ..., __COMPAT_LAYER=RunAsInvoker)`) just to extract the uninstaller.
; Evaluating a language string in the `Caption` directive during that
; silent pass crashes with STATUS_STACK_BUFFER_OVERRUN (exit 3221225725)
; and aborts the whole packaging. A plain literal sidesteps the MUI
; runtime call entirely, so the intermediate pass finishes cleanly.
; Trade-off: title bar reads "Setup - mediago-community 3.5.0" in every
; locale instead of the translated "Installazione di ..." — acceptable.
; ---------------------------------------------------------------------
Caption "Setup - ${PRODUCT_NAME} ${VERSION}"
!macroend
+1 -2
View File
@@ -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",
+100 -3
View File
@@ -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
View File
@@ -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;
}
}
+24 -209
View File
@@ -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");
}
}
}
+3
View File
@@ -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();
}
}
+12 -5
View File
@@ -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),
});
}
};
}
+27 -12
View File
@@ -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");
}
}
}
+43 -18
View File
@@ -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
View File
@@ -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() {
+1 -1
View File
@@ -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",
+7 -17
View File
@@ -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 };
}
+1 -5
View File
@@ -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}`,
],
+2 -2
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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) } });
+44 -11
View File
@@ -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>
);
+27 -5
View File
@@ -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();
};
+17 -12
View File
@@ -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();
};
}, []);
+9 -42
View File
@@ -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);
},
};
+1 -1
View File
@@ -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";
+58 -44
View File
@@ -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: () => {},
};
+2 -16
View File
@@ -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;
}
+31
View File
@@ -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 };
}
+2 -2
View File
@@ -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,
+59 -21
View File
@@ -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;
}, []);
}
+11
View File
@@ -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,
+2 -2
View File
@@ -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 (
+4 -4
View File
@@ -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();
}
},
);
+4 -3
View File
@@ -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>
+2 -2
View File
@@ -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}
/>
);
}
+474 -354
View File
@@ -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>
+1 -1
View File
@@ -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} />}
/>
)}
+43 -41
View File
@@ -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>
);
+2 -1
View File
@@ -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]) => {
+41 -2
View File
@@ -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,
+7
View File
@@ -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);
},
);
+22 -8
View File
@@ -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());
}
+1 -1
View File
@@ -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
View File
@@ -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",
+55 -14
View File
@@ -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>
+2 -32
View File
@@ -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>
+3 -30
View File
@@ -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: "关闭",
},
},
},
});
+57 -7
View File
@@ -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
View File
@@ -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