diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6e60e0c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,126 @@ +# QZMusic 自动构建工作流 +# +# 触发方式: +# - push 到 master +# - 推送到 v* tag (会自动创建 GitHub Release 并附上安装包) +# - 手动触发 (workflow_dispatch) +# +# 产物: +# - Linux (amd64): AppImage + deb +# - Windows (x64): NSIS 安装包 +# +# 注意: 请在提交前确保以下二进制已纳入 git 版本库 (否则 CI 拉不到): +# core/qzplayer (Linux 播放核心, ELF) +# core/libfftw3f.so.3 (FFTW 单精度运行库) +# core/qzplayer.exe (Windows 播放核心) +# core/libfftw3f-3.dll (Windows FFTW) +# native/taglib_reader/build/taglib_reader_cli.exe (Windows 标签扫描器) + +name: Build + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-linux: + name: Linux (amd64) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Install TagLib (build taglib_reader) + run: | + sudo apt-get update + sudo apt-get install -y libtag1-dev + + - name: Build taglib_reader (local music scanner) + run: bash native/taglib_reader/build.sh + + - name: Ensure native binaries are executable + run: | + chmod +x core/qzplayer + chmod +x native/taglib_reader/build/taglib_reader_cli + + - name: Build Linux packages + run: bun run electron:build:linux + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: qzmusic-linux-amd64 + path: | + release/*.AppImage + release/*.deb + if-no-files-found: error + + build-windows: + name: Windows (x64) + runs-on: windows-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build Windows installer + run: bun run electron:build + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: qzmusic-windows-x64 + path: | + release/*.exe + release/*.blockmap + if-no-files-found: error + + release: + name: Create GitHub Release + needs: [build-linux, build-windows] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a7b1cfb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +QZ Music is a cross-platform desktop music player (Electron + Vue 3 + TypeScript). The backend is a separate FastAPI Python project at `C:\Develop\SuperApi\app_api`. This repo is the Electron client only. + +## Build & Dev Commands + +```bash +# Install dependencies +npm install + +# Development (hot reload) +npm run dev + +# Production build +npm run build + +# Build Windows installer +npm run electron:build +``` + +No test runner is configured. No linter is configured. + +## Architecture + +### Three-Process Model (Critical) + +The app uses Electron's three-process architecture with strict separation: + +``` +Renderer (Vue) → Preload (IPC bridge) → Main Process → HTTP fetch → Backend API +``` + +**The renderer process NEVER makes HTTP requests directly.** All API calls go through this chain: + +1. **Renderer** calls `window.electronAPI.xxx()` methods +2. **Preload** (`src/preload/index.ts`) maps each method to `ipcRenderer.invoke('channel:name', args)` +3. **Main process** (`src/main/index.ts`) registers `ipcMain.handle('channel:name', handler)` +4. **Main process** calls the backend via `qzFetch()` (defined in `src/main/authStore.ts`) which uses native `fetch()` + +To add a new API feature, you must touch all 4 layers: +- Backend endpoint (FastAPI) +- Main process function (`src/main/authStore.ts` or similar) +- IPC handler (`src/main/index.ts`) +- Preload bridge (`src/preload/index.ts`) +- Type definition (`src/renderer/src/types/electron.d.ts`) + +### Backend API + +- Base URL: `https://api.qz.shiqianjiang.cn/app` +- Auth: JWT Bearer token, auto-refreshed via `getValidAccessToken()` +- `qzFetch(path, init)` is the universal API caller — attaches auth, handles errors + +### Key Directories + +``` +src/main/ - Electron main process (window, IPC handlers, audio engine control) + authStore.ts - Auth state, token management, qzFetch(), user API calls + playlistStore.ts - Playlist CRUD (local JSON files + cloud API) + qzpController.ts - IPC controller for QZPlayer (C audio engine binary) + proxyServer.ts - Local HTTP proxy (:5266) that streams remote music to QZPlayer + pluginSystem.ts - Plugin framework for music source plugins + settingsStore.ts - App settings persistence + +src/preload/ - Context bridge (ONLY file that can use both Node and browser APIs) + index.ts - Defines the complete window.electronAPI surface + +src/renderer/src/ - Vue 3 frontend + main.ts - App entry, Vue Router config, Pinia setup + stores/ - Pinia stores (player, playlists, auth, listenTogether) + views/ - Page components (Playlist.vue is reused for 5+ routes) + components/ - Shared UI components + types/ - TypeScript interfaces (electron.d.ts is the IPC contract) +``` + +### Audio Pipeline + +QZPlayer is a C binary (`core/` directory) that plays audio via WASAPI+FFmpeg. Communication: +- Main process sends commands via IPC (`src/main/qzpController.ts`) +- Music is streamed through a local HTTP proxy (`src/main/proxyServer.ts` on port 5266) +- URL format: `http://localhost:5266/music?source={source}&id={id}&quality={quality}` + +### Local vs Cloud Playlists + +- **Local** (`scope: 'local'`): JSON files in `userData/playlists/`, UUID-based IDs +- **Cloud** (`scope: 'cloud'`): Backend API at `/playlist/*`, auto-incrementing numeric IDs +- **Plugin** (`scope: 'plugin'`): Read-only collections from music source plugins + +### AMLL (Apple Music-like Lyrics) + +`amll-local/` is a local copy of the AMLL library (lyrics rendering + background effects). It's aliased in `electron.vite.config.ts` to resolve from source. Do not modify AMLL packages directly — they are a third-party dependency. + +## Conventions + +- Use `Icon` component from `@iconify/vue` for all icons (e.g. ``) +- UI component library: Element Plus (`ElMessage` for toasts, `ElMessageBox` for confirmations) +- State management: Pinia stores in `src/renderer/src/stores/` +- Routing: Vue Router with hash history, defined inline in `src/renderer/src/main.ts` +- CSS: Scoped styles with CSS custom properties (`--color-accent`, `--color-bg-*`, etc.) +- `Playlist.vue` is a monolithic view serving multiple routes (Liked, Recent, PlaylistDetail, UserLikedPlaylist, PluginCollection) — use `route.name` and computed properties to branch behavior diff --git a/README.md b/README.md index 7d538dc..37a5803 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,52 @@ | **QZ Plugins** | 高拓展性的插件运行环境 | | **AMLL** | 背景渲染 | | **QZPlayer** | 基于WASAPI和FFmpeg的轻量级模块化音频播放器,使用C编写,IPC与主程序通信 | + +## 🐧 Linux (amd64) 支持 + +```bash +# 安装 TagLib 开发库 (用于编译本地音乐标签扫描器) +# Debian / Ubuntu +sudo apt install -y libtag1-dev +# Fedora +sudo dnf install -y taglib-devel +# Arch Linux +sudo pacman -S --needed taglib + +# 编译本地音乐标签扫描器 +bash native/taglib_reader/build.sh + +# 确保播放核心二进制有可执行位 (Windows 下提交的二进制会丢失该位) +chmod +x core/qzplayer native/taglib_reader/build/taglib_reader_cli + +# 安装依赖并打包 +bun install +bun run electron:build:linux +# 产物: release/*.AppImage 与 release/*.deb +``` + +### 运行时依赖 + +安装/运行 Linux 版时需要以下系统库(`libfftw3f.so.3` 已随安装包内置,无需手动安装): + +| 依赖 | Debian / Ubuntu | 说明 | +|------|----------------|------| +| ALSA | `libasound2` | 音频输出(必需) | +| TagLib | `libtag1` | 本地音乐标签扫描(不使用本地音乐可不装) | +| zlib | `zlib1g` | 通常已预装 | +| FFTW(单精度) | `libfftw3-3` | 已内置,无需安装 | + +```bash +# Debian / Ubuntu +sudo apt install -y libasound2 libtag1 + +# Fedora +sudo dnf install -y alsa-lib taglib + +# Arch Linux +sudo pacman -S --needed alsa-lib taglib +``` + ## 📖 项目说明 本项目为 **Vue + Electron** 的学习实践作品,旨在完善QZ Music的多平台生态。 diff --git a/core/libfftw3f.so.3 b/core/libfftw3f.so.3 new file mode 100644 index 0000000..96d4a0e Binary files /dev/null and b/core/libfftw3f.so.3 differ diff --git a/core/qzplayer b/core/qzplayer new file mode 100644 index 0000000..33fae11 Binary files /dev/null and b/core/qzplayer differ diff --git a/native/taglib_reader/build.sh b/native/taglib_reader/build.sh new file mode 100644 index 0000000..6fc2937 --- /dev/null +++ b/native/taglib_reader/build.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# 在 Linux 上编译 taglib_reader_cli (本地音乐标签扫描器) +# 依赖: g++ (C++17), TagLib (pkg-config 或手动指定 TAGLIB_PREFIX) +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +outDir="$root/build" +mkdir -p "$outDir" + +cliSrc="$root/taglib_reader_cli.cpp" +cliOut="$outDir/taglib_reader_cli" + +if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists taglib; then + taglib_cflags="$(pkg-config --cflags taglib)" + taglib_libs="$(pkg-config --libs taglib)" +else + TAGLIB_PREFIX="${TAGLIB_PREFIX:-/usr/local}" + taglib_cflags="-I${TAGLIB_PREFIX}/include" + taglib_libs="-L${TAGLIB_PREFIX}/lib -ltag" +fi + +# 注意: taglib 依赖 zlib (静态链接时尤其需要显式 -lz) +g++ -std=c++17 -O2 -fPIC ${taglib_cflags} "$cliSrc" -o "$cliOut" ${taglib_libs} -lz + +echo "Built $cliOut" diff --git a/native/taglib_reader/taglib_reader_cli.cpp b/native/taglib_reader/taglib_reader_cli.cpp index 6af180b..ddb95a9 100644 --- a/native/taglib_reader/taglib_reader_cli.cpp +++ b/native/taglib_reader/taglib_reader_cli.cpp @@ -6,7 +6,12 @@ #include #include +#include +#include + +#ifdef _WIN32 #include +#endif #include #include @@ -31,6 +36,7 @@ namespace { constexpr unsigned int kMaxCoverBytes = 8 * 1024 * 1024; +#ifdef _WIN32 std::string WideToUtf8(const std::wstring &value) { if (value.empty()) return ""; const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); @@ -48,6 +54,25 @@ std::wstring Utf8ToWide(const std::string &value) { MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), size); return result; } +#endif + +// 将文件路径转成 UTF-8 字符串 (Windows 底层是 UTF-16, Unix 本身就是 UTF-8) +std::string PathToUtf8(const fs::path &value) { +#ifdef _WIN32 + return WideToUtf8(value.wstring()); +#else + return value.string(); +#endif +} + +// 从 UTF-8 字符串构造文件路径 +fs::path PathFromUtf8(const std::string &value) { +#ifdef _WIN32 + return fs::path(Utf8ToWide(value)); +#else + return fs::path(value); +#endif +} std::string ToUtf8(const TagLib::String &value) { return value.to8Bit(true); @@ -131,13 +156,13 @@ std::string WriteCoverFile( fs::create_directories(artworkDir, ec); if (ec) return ""; - const auto id = HexHash(WideToUtf8(filePath.wstring())); - const auto outPath = artworkDir / fs::path(Utf8ToWide(id + CoverExtension(mime))); + const auto id = HexHash(PathToUtf8(filePath)); + const auto outPath = artworkDir / fs::path(id + CoverExtension(mime)); std::ofstream out(outPath, std::ios::binary | std::ios::trunc); if (!out) return ""; out.write(cover.data(), cover.size()); if (!out) return ""; - return WideToUtf8(outPath.wstring()); + return PathToUtf8(outPath); } std::string Base64Encode(const TagLib::ByteVector &data) { @@ -282,20 +307,23 @@ std::string ReadLyrics(TagLib::File *file) { } bool IsAudioPath(const fs::path &file) { - const auto ext = file.extension().wstring(); - const std::wstring lower = [&]() { - std::wstring out = ext; - for (auto &ch : out) ch = static_cast(towlower(ch)); - return out; - }(); - return lower == L".mp3" || lower == L".flac" || lower == L".m4a" || lower == L".mp4" || - lower == L".aac" || lower == L".ogg" || lower == L".opus" || lower == L".wav" || - lower == L".aiff" || lower == L".aif" || lower == L".ape" || lower == L".wv"; + std::string ext = file.extension().string(); + for (auto &ch : ext) ch = static_cast(std::tolower(static_cast(ch))); + return ext == ".mp3" || ext == ".flac" || ext == ".m4a" || ext == ".mp4" || + ext == ".aac" || ext == ".ogg" || ext == ".opus" || ext == ".wav" || + ext == ".aiff" || ext == ".aif" || ext == ".ape" || ext == ".wv"; +} + +TagLib::FileRef OpenFileRef(const fs::path &filePath) { +#ifdef _WIN32 + return TagLib::FileRef(TagLib::FileName(filePath.wstring().c_str()), true, TagLib::AudioProperties::Average); +#else + return TagLib::FileRef(TagLib::FileName(filePath.c_str()), true, TagLib::AudioProperties::Average); +#endif } std::string ReadFileJson(const fs::path &filePath, const fs::path &artworkDir = {}) { - const std::wstring nativePath = filePath.wstring(); - TagLib::FileRef fileRef(TagLib::FileName(nativePath.c_str()), true, TagLib::AudioProperties::Average); + TagLib::FileRef fileRef = OpenFileRef(filePath); if (fileRef.isNull() || !fileRef.file()) { throw std::runtime_error("Unsupported or unreadable audio file"); } @@ -315,7 +343,7 @@ std::string ReadFileJson(const fs::path &filePath, const fs::path &artworkDir = std::ostringstream json; json << "{"; - json << "\"path\":" << Q(WideToUtf8(filePath.wstring())) << ","; + json << "\"path\":" << Q(PathToUtf8(filePath)) << ","; json << "\"title\":" << Q(tag ? ToUtf8(tag->title()) : "") << ","; json << "\"artist\":" << Q(tag ? ToUtf8(tag->artist()) : "") << ","; json << "\"album\":" << Q(tag ? ToUtf8(tag->album()) : "") << ","; @@ -352,31 +380,28 @@ std::vector CollectAudioFiles(const std::vector &roots) { return files; } -} // namespace - -int wmain(int argc, wchar_t **argv) { - SetConsoleOutputCP(CP_UTF8); - if (argc < 3) { - std::cerr << "Usage: taglib_reader_cli.exe read | scan [--artwork-dir ] \n"; +int run(const std::vector &args) { + if (args.size() < 3) { + std::cerr << "Usage: taglib_reader_cli read | scan [--artwork-dir ] \n"; return 2; } try { - const std::wstring mode = argv[1]; - if (mode == L"read") { - std::cout << ReadFileJson(fs::path(argv[2])); + const std::string mode = args[1]; + if (mode == "read") { + std::cout << ReadFileJson(PathFromUtf8(args[2])); return 0; } - if (mode == L"scan") { + if (mode == "scan") { fs::path artworkDir; std::vector roots; int startIndex = 2; - if (argc >= 5 && std::wstring(argv[2]) == L"--artwork-dir") { - artworkDir = fs::path(argv[3]); + if (args.size() >= 5 && args[2] == "--artwork-dir") { + artworkDir = PathFromUtf8(args[3]); startIndex = 4; } - for (int i = startIndex; i < argc; i++) roots.emplace_back(argv[i]); + for (size_t i = static_cast(startIndex); i < args.size(); i++) roots.emplace_back(PathFromUtf8(args[i])); const auto files = CollectAudioFiles(roots); std::cout << "{\"songs\":["; @@ -387,7 +412,7 @@ int wmain(int argc, wchar_t **argv) { std::cout << ReadFileJson(file, artworkDir); first = false; } catch (const std::exception &error) { - std::cerr << "Failed to read " << WideToUtf8(file.wstring()) << ": " << error.what() << "\n"; + std::cerr << "Failed to read " << PathToUtf8(file) << ": " << error.what() << "\n"; } } std::cout << "]}"; @@ -401,3 +426,22 @@ int wmain(int argc, wchar_t **argv) { return 1; } } + +} // namespace + +#ifdef _WIN32 +int wmain(int argc, wchar_t **argv) { + SetConsoleOutputCP(CP_UTF8); + std::vector args; + args.reserve(static_cast(argc)); + for (int i = 0; i < argc; i++) args.push_back(WideToUtf8(argv[i])); + return run(args); +} +#else +int main(int argc, char **argv) { + std::vector args; + args.reserve(static_cast(argc)); + for (int i = 0; i < argc; i++) args.push_back(argv[i]); + return run(args); +} +#endif diff --git a/package.json b/package.json index 4b74d49..be9c2e3 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,14 @@ "private": true, "author": "lqtmcstudio", "description": "QZMusic - 简洁、美观、拓展性强的音乐播放器", - "version": "1.0.2", + "homepage": "https://github.com/lqtmcstudio/QZMusic_PC", + "version": "1.0.3", "scripts": { "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", - "electron:build": "electron-vite build && electron-builder --win --x64 --config --publish never" + "electron:build": "electron-vite build && electron-builder --win --x64 --config --publish never", + "electron:build:linux": "electron-vite build && electron-builder --linux --x64 --config --publish never" }, "dependencies": { "@applemusic-like-lyrics/core": "file:amll-local/packages/core", @@ -62,7 +64,46 @@ }, "win": { "target": "nsis", - "icon": "public/icon.ico" + "icon": "public/icon.ico", + "extraResources": [ + { + "from": "core/qzplayer.exe", + "to": "core/qzplayer.exe" + }, + { + "from": "core/libfftw3f-3.dll", + "to": "core/libfftw3f-3.dll" + }, + { + "from": "native/taglib_reader/build/taglib_reader_cli.exe", + "to": "native/taglib_reader_cli.exe" + } + ] + }, + "linux": { + "target": [ + "AppImage", + "deb" + ], + "icon": "public/icon.png", + "category": "Audio", + "executableName": "qzmusic", + "maintainer": "lqtmcstudio ", + "vendor": "lqtmcstudio", + "extraResources": [ + { + "from": "core/qzplayer", + "to": "core/qzplayer" + }, + { + "from": "core/libfftw3f.so.3", + "to": "core/libfftw3f.so.3" + }, + { + "from": "native/taglib_reader/build/taglib_reader_cli", + "to": "native/taglib_reader_cli" + } + ] }, "nsis": { "oneClick": false, @@ -71,12 +112,8 @@ }, "extraResources": [ { - "from": "core/", - "to": "core/" - }, - { - "from": "native/taglib_reader/build/taglib_reader_cli.exe", - "to": "native/taglib_reader_cli.exe" + "from": "core/ffmpeg_license/", + "to": "core/ffmpeg_license/" } ] } diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..73dc478 Binary files /dev/null and b/public/icon.png differ diff --git a/release/win-unpacked/resources/core/ffmpeg_license/LICENSE.md b/release/win-unpacked/resources/core/ffmpeg_license/LICENSE.md index e69de29..613070e 100644 --- a/release/win-unpacked/resources/core/ffmpeg_license/LICENSE.md +++ b/release/win-unpacked/resources/core/ffmpeg_license/LICENSE.md @@ -0,0 +1,129 @@ +# License + +Most files in FFmpeg are under the GNU Lesser General Public License version 2.1 +or later (LGPL v2.1+). Read the file `COPYING.LGPLv2.1` for details. Some other +files have MIT/X11/BSD-style licenses. In combination the LGPL v2.1+ applies to +FFmpeg. + +Some optional parts of FFmpeg are licensed under the GNU General Public License +version 2 or later (GPL v2+). See the file `COPYING.GPLv2` for details. None of +these parts are used by default, you have to explicitly pass `--enable-gpl` to +configure to activate them. In this case, FFmpeg's license changes to GPL v2+. + +Specifically, the GPL parts of FFmpeg are: + +- libpostproc +- optional x86 optimization in the files + - `libavcodec/x86/flac_dsp_gpl.asm` + - `libavcodec/x86/idct_mmx.c` + - `libavfilter/x86/vf_removegrain.asm` +- the following building and testing tools + - `compat/solaris/make_sunver.pl` + - `doc/t2h.pm` + - `doc/texi2pod.pl` + - `libswresample/tests/swresample.c` + - `tests/checkasm/*` + - `tests/tiny_ssim.c` +- the following filters in libavfilter: + - `signature_lookup.c` + - `vf_blackframe.c` + - `vf_boxblur.c` + - `vf_colormatrix.c` + - `vf_cover_rect.c` + - `vf_cropdetect.c` + - `vf_delogo.c` + - `vf_eq.c` + - `vf_find_rect.c` + - `vf_fspp.c` + - `vf_histeq.c` + - `vf_hqdn3d.c` + - `vf_kerndeint.c` + - `vf_lensfun.c` (GPL version 3 or later) + - `vf_mcdeint.c` + - `vf_mpdecimate.c` + - `vf_nnedi.c` + - `vf_owdenoise.c` + - `vf_perspective.c` + - `vf_phase.c` + - `vf_pp.c` + - `vf_pp7.c` + - `vf_pullup.c` + - `vf_repeatfields.c` + - `vf_sab.c` + - `vf_signature.c` + - `vf_smartblur.c` + - `vf_spp.c` + - `vf_stereo3d.c` + - `vf_super2xsai.c` + - `vf_tinterlace.c` + - `vf_uspp.c` + - `vf_vaguedenoiser.c` + - `vsrc_mptestsrc.c` + +Should you, for whatever reason, prefer to use version 3 of the (L)GPL, then +the configure parameter `--enable-version3` will activate this licensing option +for you. Read the file `COPYING.LGPLv3` or, if you have enabled GPL parts, +`COPYING.GPLv3` to learn the exact legal terms that apply in this case. + +There are a handful of files under other licensing terms, namely: + +* The files `libavcodec/jfdctfst.c`, `libavcodec/jfdctint_template.c` and + `libavcodec/jrevdct.c` are taken from libjpeg, see the top of the files for + licensing details. Specifically note that you must credit the IJG in the + documentation accompanying your program if you only distribute executables. + You must also indicate any changes including additions and deletions to + those three files in the documentation. +* `tests/reference.pnm` is under the expat license. + + +## External libraries + +FFmpeg can be combined with a number of external libraries, which sometimes +affect the licensing of binaries resulting from the combination. + +### Compatible libraries + +The following libraries are under GPL version 2: +- avisynth +- frei0r +- libcdio +- libdavs2 +- librubberband +- libvidstab +- libx264 +- libx265 +- libxavs +- libxavs2 +- libxvid + +When combining them with FFmpeg, FFmpeg needs to be licensed as GPL as well by +passing `--enable-gpl` to configure. + +The following libraries are under LGPL version 3: +- gmp +- libaribb24 +- liblensfun + +When combining them with FFmpeg, use the configure option `--enable-version3` to +upgrade FFmpeg to the LGPL v3. + +The VMAF, mbedTLS, RK MPI, OpenCORE and VisualOn libraries are under the Apache License +2.0. That license is incompatible with the LGPL v2.1 and the GPL v2, but not with +version 3 of those licenses. So to combine these libraries with FFmpeg, the +license version needs to be upgraded by passing `--enable-version3` to configure. + +The smbclient library is under the GPL v3, to combine it with FFmpeg, +the options `--enable-gpl` and `--enable-version3` have to be passed to +configure to upgrade FFmpeg to the GPL v3. + +### Incompatible libraries + +There are certain libraries you can combine with FFmpeg whose licenses are not +compatible with the GPL and/or the LGPL. If you wish to enable these +libraries, even in circumstances that their license may be incompatible, pass +`--enable-nonfree` to configure. This will cause the resulting binary to be +unredistributable. + +The Fraunhofer FDK AAC and OpenSSL libraries are under licenses which are +incompatible with the GPLv2 and v3. To the best of our knowledge, they are +compatible with the LGPL. diff --git a/src/main/authStore.ts b/src/main/authStore.ts index 75bb905..a7ddc3a 100644 --- a/src/main/authStore.ts +++ b/src/main/authStore.ts @@ -395,16 +395,57 @@ export async function addRecentSong(userId: string, song: any): Promise { }) } -// === Playlist Likes === +// === Favorite Playlists === -export async function togglePlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> { - return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`, { +export interface FavoritePlaylistRef { + id: string + source: string +} + +export interface FavoritePlaylistStatus { + status: string + collected: boolean + collection_count?: number | null + message?: string | null +} + +function normalizeFavoritePlaylistSource(source: string): string { + const normalized = String(source || '').trim() + return normalized === 'cloud' ? '' : normalized +} + +export async function getFavoritePlaylists(userId?: string): Promise { + const state = loadAuthState() + const targetUserId = userId || state.userInfo?.id + if (!targetUserId) throw new Error('Not logged in') + const result = await qzFetch(`/user/${encodeURIComponent(targetUserId)}/fav/playlists`) + return Array.isArray(result) ? result : [] +} + +export async function collectPlaylist(playlistId: string, source = ''): Promise { + const state = loadAuthState() + const userId = state.userInfo?.id + if (!userId) throw new Error('Not logged in') + return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists`, { method: 'POST', + body: JSON.stringify({ + id: String(playlistId || '').trim(), + source: normalizeFavoritePlaylistSource(source), + }), }) } -export async function getPlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> { - return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`) +export async function uncollectPlaylist(playlistId: string, source = ''): Promise { + const state = loadAuthState() + const userId = state.userInfo?.id + if (!userId) throw new Error('Not logged in') + const query = new URLSearchParams({ + id: String(playlistId || '').trim(), + source: normalizeFavoritePlaylistSource(source), + }) + return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists?${query.toString()}`, { + method: 'DELETE', + }) } // === User Follow === diff --git a/src/main/index.ts b/src/main/index.ts index 070951d..dcb17a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,8 +31,9 @@ import { uploadImage, getRecentSongs, addRecentSong, - togglePlaylistLike, - getPlaylistLike, + getFavoritePlaylists, + collectPlaylist, + uncollectPlaylist, toggleUserFollow, getUserSubscriptions, type AuthCallbackPayload, @@ -375,12 +376,13 @@ ipcMain.handle('user:addRecentSong', (_event, userId: string, song: any) => { return addRecentSong(String(userId || ''), song || {}) }) -// Playlist Likes -ipcMain.handle('playlist:toggleLike', (_event, playlistId: string) => { - return togglePlaylistLike(String(playlistId || '')) +// Favorite Playlists +ipcMain.handle('playlist:getFavorites', () => getFavoritePlaylists()) +ipcMain.handle('playlist:collect', (_event, playlistId: string, source = '') => { + return collectPlaylist(String(playlistId || ''), String(source || '')) }) -ipcMain.handle('playlist:getLike', (_event, playlistId: string) => { - return getPlaylistLike(String(playlistId || '')) +ipcMain.handle('playlist:uncollect', (_event, playlistId: string, source = '') => { + return uncollectPlaylist(String(playlistId || ''), String(source || '')) }) // User Follow @@ -421,8 +423,8 @@ ipcMain.handle('playlist:publicList', (_event, search = '', sort = 'visit', page return listPublicPlaylists(String(search || ''), String(sort || 'visit'), Number(page) || 1, Number(limit) || 50) }) -ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string) => { - return getPlaylist(scope, id) +ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string, recordVisit = true) => { + return getPlaylist(scope, id, Boolean(recordVisit)) }) ipcMain.handle('playlist:create', (_event, scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => { diff --git a/src/main/localMusicStore.ts b/src/main/localMusicStore.ts index 86618f5..9a130f6 100644 --- a/src/main/localMusicStore.ts +++ b/src/main/localMusicStore.ts @@ -54,12 +54,18 @@ function getLibraryPath(): string { } function getReaderExe(): string { + const binaryName = process.platform === 'win32' ? 'taglib_reader_cli.exe' : 'taglib_reader_cli'; const candidates = [ - path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', 'taglib_reader_cli.exe'), - path.join(process.resourcesPath || '', 'native', 'taglib_reader_cli.exe'), + path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', binaryName), + path.join(process.resourcesPath || '', 'native', binaryName), ] const target = candidates.find((candidate) => candidate && fs.existsSync(candidate)) - if (!target) throw new Error('TagLib reader executable not found') + if (!target) throw new Error(`TagLib reader executable not found (${binaryName})`) + if (process.platform !== 'win32') { + try { + fs.chmodSync(target, 0o755) + } catch {} + } return target } @@ -68,14 +74,22 @@ function getArtworkDir(): string { } function getDefaultRoots(): string[] { - const username = path.basename(app.getPath('home')) const roots: string[] = [] - for (let code = 67; code <= 90; code++) { - const drive = `${String.fromCharCode(code)}:\\` - if (!fs.existsSync(drive)) continue - const userRoot = path.join(drive, 'Users', username) + if (process.platform === 'win32') { + const username = path.basename(app.getPath('home')) + for (let code = 67; code <= 90; code++) { + const drive = `${String.fromCharCode(code)}:\\` + if (!fs.existsSync(drive)) continue + const userRoot = path.join(drive, 'Users', username) + for (const dirName of ['Music', '音乐', 'Downloads', '下载']) { + const candidate = path.join(userRoot, dirName) + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate) + } + } + } else { + const home = app.getPath('home') for (const dirName of ['Music', '音乐', 'Downloads', '下载']) { - const candidate = path.join(userRoot, dirName) + const candidate = path.join(home, dirName) if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate) } } diff --git a/src/main/playlistStore.ts b/src/main/playlistStore.ts index 85c1441..ffc73e6 100644 --- a/src/main/playlistStore.ts +++ b/src/main/playlistStore.ts @@ -30,7 +30,7 @@ export interface PlaylistInfo { author?: string play_count?: string visit_count?: number - like_count?: number + collection_count?: number is_public?: boolean } @@ -138,7 +138,7 @@ function normalizeCloudPlaylist(raw: any): AppPlaylist { author: info?.author || '', play_count: info?.play_count || '', visit_count: Number(info?.visit_count ?? info?.play_count ?? 0) || 0, - like_count: Number(info?.like_count ?? 0) || 0, + collection_count: Number(info?.collection_count ?? info?.like_count ?? 0) || 0, is_public: Boolean(info?.is_public ?? info?.public ?? false), }, list, @@ -269,7 +269,7 @@ export async function listPublicPlaylists( const query = new URLSearchParams({ page: String(Math.max(1, Number(page) || 1)), limit: String(Math.max(1, Math.min(50, Number(limit) || 50))), - sort: ['visit', 'name', 'total', 'like'].includes(sort) ? sort : 'visit', + sort: ['visit', 'name', 'total', 'collection'].includes(sort) ? sort : 'visit', }) if (search.trim()) query.set('search', search.trim()) const raw = await qzFetch(`/playlist/public?${query.toString()}`) @@ -283,9 +283,9 @@ export async function listPublicPlaylists( } } -export async function getPlaylist(scope: PlaylistScope, id: string): Promise { +export async function getPlaylist(scope: PlaylistScope, id: string, recordVisit = true): Promise { if (scope === 'local') return readLocalPlaylist(id) - const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}`) + const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}?record_visit=${recordVisit}`) return normalizeCloudPlaylist(raw) } diff --git a/src/main/qzpController.ts b/src/main/qzpController.ts index 327a16c..9c23439 100644 --- a/src/main/qzpController.ts +++ b/src/main/qzpController.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { Socket } from 'node:net'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; import path from 'node:path'; import { app } from 'electron'; @@ -35,15 +36,25 @@ export class QzpController extends EventEmitter { if (process.platform === 'win32') { return '\\\\.\\pipe\\qzplayer'; } - return '/tmp/qzmusic_mpv_socket'; + return '/tmp/qzplayer.sock'; } private getCorePath(): string { const appRoot = process.env.APP_ROOT || process.cwd(); + const binaryName = process.platform === 'win32' ? 'qzplayer.exe' : 'qzplayer'; if (app.isPackaged) { - return path.join(process.resourcesPath, 'core', 'qzplayer.exe'); + return path.join(process.resourcesPath, 'core', binaryName); + } + return path.join(appRoot, 'core', binaryName); + } + + private isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; } - return path.join(appRoot, 'core', 'qzplayer.exe'); } start(): void { @@ -56,9 +67,36 @@ export class QzpController extends EventEmitter { console.log('Starting QZPlayer from:', playerPath); try { - const child = spawn(playerPath, [], { + const env: NodeJS.ProcessEnv = { ...process.env }; + let spawnPath = playerPath; + if (process.platform !== 'win32') { + // 让动态链接器在二进制同级目录查找自带共享库 (如 libfftw3f.so.3) + const coreDir = path.dirname(playerPath); + env.LD_LIBRARY_PATH = coreDir + (process.env.LD_LIBRARY_PATH ? path.delimiter + process.env.LD_LIBRARY_PATH : ''); + + // git / Windows 文件系统可能丢失可执行位, 打包到 Linux 后需要补上 + if (!this.isExecutable(spawnPath)) { + try { fs.chmodSync(spawnPath, 0o755); } catch { /* ignore */ } + } + if (!this.isExecutable(spawnPath)) { + // AppImage 只读挂载 / deb 安装到 root 目录时无 chmod 权限, 复制到用户数据目录再执行 + const altPath = path.join(app.getPath('userData'), 'bin', path.basename(spawnPath)); + try { + fs.mkdirSync(path.dirname(altPath), { recursive: true }); + fs.copyFileSync(spawnPath, altPath); + fs.chmodSync(altPath, 0o755); + spawnPath = altPath; + console.log('QZPlayer copied to writable path:', altPath); + } catch (err) { + console.warn('Failed to prepare writable QZPlayer:', err); + } + } + } + + const child = spawn(spawnPath, [], { stdio: 'ignore', windowsHide: true, + env, }); this.process = child; diff --git a/src/preload/index.ts b/src/preload/index.ts index d11356c..4c8ae5b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -88,7 +88,7 @@ contextBridge.exposeInMainWorld('electronAPI', { playlist: { list: () => ipcRenderer.invoke('playlist:list'), publicList: (search = '', sort = 'visit', page = 1, limit = 50) => ipcRenderer.invoke('playlist:publicList', search, sort, page, limit), - get: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:get', scope, id), + get: (scope: 'local' | 'cloud', id: string, recordVisit = true) => ipcRenderer.invoke('playlist:get', scope, id, recordVisit), create: (scope: 'local' | 'cloud', data: { name: string; desc?: string; is_public?: boolean }) => ipcRenderer.invoke('playlist:create', scope, data), update: (scope: 'local' | 'cloud', id: string, info: any) => ipcRenderer.invoke('playlist:update', scope, id, info), delete: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:delete', scope, id), @@ -98,8 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', { import: () => ipcRenderer.invoke('playlist:import'), convertScope: (scope: 'local' | 'cloud', id: string, targetScope: 'local' | 'cloud') => ipcRenderer.invoke('playlist:convertScope', scope, id, targetScope), copyToLocal: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:copyToLocal', scope, id), - toggleLike: (playlistId: string) => ipcRenderer.invoke('playlist:toggleLike', playlistId), - getLike: (playlistId: string) => ipcRenderer.invoke('playlist:getLike', playlistId), + getFavorites: () => ipcRenderer.invoke('playlist:getFavorites'), + collect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:collect', playlistId, source), + uncollect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:uncollect', playlistId, source), }, image: { diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 6250e8e..401f5ec 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -2,11 +2,11 @@ - + diff --git a/src/renderer/src/components/Sidebar.vue b/src/renderer/src/components/Sidebar.vue index 91ac95f..26cbc47 100644 --- a/src/renderer/src/components/Sidebar.vue +++ b/src/renderer/src/components/Sidebar.vue @@ -23,6 +23,10 @@ 我喜欢的 + + + 收藏歌单 + 最近播放 diff --git a/src/renderer/src/components/TopBar.vue b/src/renderer/src/components/TopBar.vue index 00ea581..ab15bb2 100644 --- a/src/renderer/src/components/TopBar.vue +++ b/src/renderer/src/components/TopBar.vue @@ -1,5 +1,5 @@ + + diff --git a/src/renderer/src/views/Playlist.vue b/src/renderer/src/views/Playlist.vue index 16a9d0d..526aa1e 100644 --- a/src/renderer/src/views/Playlist.vue +++ b/src/renderer/src/views/Playlist.vue @@ -2,10 +2,10 @@
-
+
-
@@ -33,9 +33,9 @@ {{ playlist.info.author }} 访问 {{ accessCount }} 次 - - - {{ likeCount }} + + + {{ collectionCount }}
@@ -43,7 +43,7 @@ 播放全部 - @@ -55,26 +55,26 @@ 另存本地 - - -
@@ -117,7 +117,7 @@ :key="`${song.source}:${song.id}:${index}`" :song="song" :display-index="displayStartIndex + index + 1" - :removable="isManagedPlaylist" + :removable="isEditablePlaylist" reserve-action @play="playSong(index)" @remove="removeSong(index)" @@ -238,10 +238,10 @@ const loadMoreTrigger = ref(null) const pageSize = 50 let loadMoreObserver: IntersectionObserver | null = null -// Like state -const isLikedByUser = ref(false) -const likeCount = ref(-1) -const likingPlaylist = ref(false) +// Playlist collection state +const isCollectedByUser = ref(false) +const collectionCount = ref(-1) +const collectingPlaylist = ref(false) type PublicSong = Partial & { // 兼容云端 API 可能返回的旧字段名 @@ -270,17 +270,20 @@ const isOwnPlaylist = computed(() => { if (!authStore.state.userInfo?.id || !playlist.value?.owner) return false return authStore.state.userInfo.id === playlist.value.owner.id }) +const isEditablePlaylist = computed(() => + routeScope.value === 'local' || (isCloudPlaylist.value && isOwnPlaylist.value) +) const showAuthorLink = computed(() => isCloudPlaylist.value && !isOwnPlaylist.value && playlist.value?.info.is_public && playlist.value?.owner ) -const showLikeButton = computed(() => - isCloudPlaylist.value && - !isOwnPlaylist.value && - authStore.isLoggedIn -) +const collectionSource = computed(() => isCloudPlaylist.value ? '' : String(routePluginId.value || '')) +const showCollectButton = computed(() => authStore.isLoggedIn && Boolean( + (isCloudPlaylist.value && routeId.value) || + (isPluginCollection.value && routeKind.value === 'playlist' && routePluginId.value && routeId.value) +)) const targetScope = computed(() => routeScope.value === 'local' ? 'cloud' : 'local') const songCount = computed(() => playlist.value?.total ?? playlist.value?.list.length ?? 0) const accessCount = computed(() => Number(playlist.value?.info.visit_count ?? playlist.value?.info.play_count ?? 0) || 0) @@ -433,8 +436,8 @@ const loadPlaylist = async () => { descriptionExpanded.value = false currentPage.value = 1 loadedPage.value = 1 - isLikedByUser.value = false - likeCount.value = -1 + isCollectedByUser.value = false + collectionCount.value = -1 if (isLiked.value) { loading.value = true try { @@ -470,6 +473,7 @@ const loadPlaylist = async () => { try { await loadPageMode() await fetchPluginCollectionPage(1, false) + await loadCollectionState() } catch (err: any) { playlist.value = null errorMessage.value = getErrorMessage(err) @@ -488,7 +492,7 @@ const loadPlaylist = async () => { try { await loadPageMode() playlist.value = await playlistStore.get(routeScope.value as ManagedPlaylistScope, routeId.value) - if (isCloudPlaylist.value) await loadLikeState() + if (isCloudPlaylist.value) await loadCollectionState() } catch (err: any) { playlist.value = null errorMessage.value = getErrorMessage(err) @@ -603,35 +607,51 @@ const removeSong = async (index: number) => { playlist.value = await playlistStore.removeSong(routeScope.value, routeId.value, displayStartIndex.value + index) } -// === Like Feature === +// === Playlist Collection Feature === -const loadLikeState = async () => { - if (!isCloudPlaylist.value || !routeId.value) { - isLikedByUser.value = false - likeCount.value = -1 +const loadCollectionState = async () => { + if (!showCollectButton.value || !routeId.value) { + isCollectedByUser.value = false + collectionCount.value = isCloudPlaylist.value + ? Number(playlist.value?.info.collection_count ?? 0) || 0 + : -1 return } try { - const result = await window.electronAPI.playlist.getLike(routeId.value) - isLikedByUser.value = result.liked - likeCount.value = result.like_count + const favorites = await window.electronAPI.playlist.getFavorites() + const source = collectionSource.value + isCollectedByUser.value = favorites.some((item) => + String(item.id) === routeId.value && String(item.source || '') === source + ) + collectionCount.value = isCloudPlaylist.value + ? Number(playlist.value?.info.collection_count ?? 0) || 0 + : -1 } catch { - isLikedByUser.value = false - likeCount.value = -1 + isCollectedByUser.value = false + collectionCount.value = isCloudPlaylist.value + ? Number(playlist.value?.info.collection_count ?? 0) || 0 + : -1 } } -const toggleLikePlaylist = async () => { - if (!routeId.value || likingPlaylist.value) return - likingPlaylist.value = true +const toggleCollectPlaylist = async () => { + if (!routeId.value || collectingPlaylist.value || !showCollectButton.value) return + collectingPlaylist.value = true try { - const result = await window.electronAPI.playlist.toggleLike(routeId.value) - isLikedByUser.value = result.liked - likeCount.value = result.like_count + const result = isCollectedByUser.value + ? await window.electronAPI.playlist.uncollect(routeId.value, collectionSource.value) + : await window.electronAPI.playlist.collect(routeId.value, collectionSource.value) + if (result.status !== 'success') throw new Error(result.message || '收藏同步失败') + isCollectedByUser.value = result.collected + if (isCloudPlaylist.value && result.collection_count != null) { + collectionCount.value = Number(result.collection_count) || 0 + if (playlist.value) playlist.value.info.collection_count = collectionCount.value + } + ElMessage.success(result.collected ? '已收藏歌单' : '已取消收藏') } catch (err: any) { ElMessage.error(err?.message || '操作失败') } finally { - likingPlaylist.value = false + collectingPlaylist.value = false } } @@ -884,19 +904,19 @@ h1 { text-decoration: underline; } -.icon-btn.like-btn { +.icon-btn.collect-btn { color: var(--color-text-muted); } -.icon-btn.like-btn:hover { +.icon-btn.collect-btn:hover { color: #ff6b8a; } -.icon-btn.like-btn.liked { +.icon-btn.collect-btn.collected { color: #ff4d6d; } -.icon-btn.like-btn.liked svg { +.icon-btn.collect-btn.collected svg { fill: currentColor; } diff --git a/src/renderer/src/views/PlaylistSquare.vue b/src/renderer/src/views/PlaylistSquare.vue index df40abe..ee5d748 100644 --- a/src/renderer/src/views/PlaylistSquare.vue +++ b/src/renderer/src/views/PlaylistSquare.vue @@ -16,9 +16,9 @@ 访问量 -