From 0278c8fa6ac1fb31072f91f8377b8c7e387d6fc5 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sun, 14 Sep 2025 09:56:02 +0800 Subject: [PATCH 1/2] feat(ffmpeg): add China mirror support for FFmpeg downloads (#164) * feat(ffmpeg): add China mirror support for FFmpeg downloads - Add IP-based region detection using ipinfo.io API - Support China mainland, Hong Kong, Macau, Taiwan regions - Add dedicated China mirror URLs from gitcode.com - Implement automatic fallback from China to global mirrors - Add comprehensive test coverage for new functionality - Default to China mirror on detection failure for better UX Breaking change: Service now defaults to China mirror for better performance in Chinese regions * fix(test): remove unused parameter in FFmpegDownloadService test - Fix TypeScript error TS6133 for unused 'url' parameter - Replace unused 'url' with underscore in mock implementation --- src/main/services/FFmpegDownloadService.ts | 232 ++++++++++++++-- .../__tests__/FFmpegDownloadService.test.ts | 250 ++++++++++++++++++ 2 files changed, 467 insertions(+), 15 deletions(-) diff --git a/src/main/services/FFmpegDownloadService.ts b/src/main/services/FFmpegDownloadService.ts index dd85fcbf..b9e02729 100644 --- a/src/main/services/FFmpegDownloadService.ts +++ b/src/main/services/FFmpegDownloadService.ts @@ -103,29 +103,139 @@ const FFMPEG_VERSIONS: Record> = { } } -// 镜像源配置 - TODO: 将来实现镜像源切换 -// const MIRROR_SOURCES = { -// china: { -// github: 'https://ghproxy.com/', // GitHub 代理 -// evermeet: 'https://cdn.example.cn/ffmpeg/', // 假设的国内镜像 -// johnvansickle: 'https://cdn.example.cn/ffmpeg/' // 假设的国内镜像 -// }, -// global: { -// github: '', -// evermeet: '', -// johnvansickle: '' -// } -// } +// 中国区专供的 FFmpeg 配置 +const CHINA_FFMPEG_VERSIONS: Record> = { + win32: { + x64: { + version: '6.1', + platform: 'win32', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/win32-x64.zip', + size: 60 * 1024 * 1024, + extractPath: 'win32-x64/ffmpeg.exe' + }, + arm64: { + version: '6.1', + platform: 'win32', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/win32-arm64.zip', + size: 45 * 1024 * 1024, + extractPath: 'win32-arm64/ffmpeg.exe' + } + }, + darwin: { + x64: { + version: '6.1', + platform: 'darwin', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-x64.zip', + size: 24 * 1024 * 1024, + extractPath: 'darwin-x64/ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'darwin', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-arm64.zip', + size: 24 * 1024 * 1024, + extractPath: 'darwin-arm64/ffmpeg' + } + }, + linux: { + x64: { + version: '6.1', + platform: 'linux', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/linux-x64.zip', + size: 28 * 1024 * 1024, + extractPath: 'linux-x64/ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'linux', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/linux-arm64.zip', + size: 24 * 1024 * 1024, + extractPath: 'linux-arm64/ffmpeg' + } + } +} export class FFmpegDownloadService { private downloadProgress = new Map() private downloadController = new Map() private readonly binariesDir: string + private useChinaMirror: boolean = false + private regionDetectionPromise: Promise | null = null constructor() { // FFmpeg 存储在 userData/binaries/ffmpeg/ 目录 this.binariesDir = path.join(app.getPath('userData'), 'binaries', 'ffmpeg') this.ensureDir(this.binariesDir) + // 异步检测地区并设置镜像源(不阻塞初始化) + this.regionDetectionPromise = this.detectRegionAndSetMirror() + } + + /** + * 通过 IP 地理位置检测用户地区并设置镜像源 + */ + private async detectRegionAndSetMirror(): Promise { + try { + const country = await this.getIpCountry() + + // 中国大陆、香港、澳门、台湾用户都使用中国镜像源 + const chineseRegions = ['cn', 'hk', 'mo', 'tw'] + this.useChinaMirror = chineseRegions.includes(country?.toLowerCase() || '') + + logger.info('通过IP检测地区,设置镜像源', { + country, + useChinaMirror: this.useChinaMirror + }) + } catch (error) { + logger.warn('无法检测用户地区,使用默认镜像源', { error }) + this.useChinaMirror = true // 检测失败时默认使用中国镜像源 + } + } + + /** + * 获取用户IP对应的国家代码 + */ + private async getIpCountry(): Promise { + try { + // 使用 AbortController 设置 5 秒超时 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const response = await fetch('https://ipinfo.io/json', { + signal: controller.signal, + headers: { + 'User-Agent': 'EchoPlayer-FFmpeg-Downloader/2.0', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + + clearTimeout(timeoutId) + const data = await response.json() + return data.country || 'CN' // 默认返回 CN,这样中国用户即使检测失败也能使用中国镜像源 + } catch (error) { + logger.warn('获取IP地理位置失败,默认使用中国镜像源', { error }) + return 'CN' // 默认返回 CN + } + } + + /** + * 手动设置镜像源 + */ + public setMirrorSource(useChina: boolean): void { + this.useChinaMirror = useChina + logger.info('手动设置镜像源', { useChinaMirror: this.useChinaMirror }) + } + + /** + * 获取当前使用的镜像源 + */ + public getCurrentMirrorSource(): 'china' | 'global' { + return this.useChinaMirror ? 'china' : 'global' } /** @@ -167,6 +277,16 @@ export class FFmpegDownloadService { platform = process.platform as Platform, arch = process.arch as Arch ): FFmpegVersion | null { + // 优先使用中国镜像源(如果启用) + if (this.useChinaMirror) { + const chinaVersion = CHINA_FFMPEG_VERSIONS[platform]?.[arch] + if (chinaVersion) { + return chinaVersion + } + logger.warn('中国镜像源不支持当前平台,回退到全球镜像源', { platform, arch }) + } + + // 回退到全球镜像源 return FFMPEG_VERSIONS[platform]?.[arch] || null } @@ -175,11 +295,31 @@ export class FFmpegDownloadService { */ public getAllSupportedVersions(): FFmpegVersion[] { const versions: FFmpegVersion[] = [] - for (const platformConfigs of Object.values(FFMPEG_VERSIONS)) { + + // 添加当前镜像源的版本 + const currentVersions = this.useChinaMirror ? CHINA_FFMPEG_VERSIONS : FFMPEG_VERSIONS + for (const platformConfigs of Object.values(currentVersions)) { + for (const version of Object.values(platformConfigs)) { + versions.push(version) + } + } + + return versions + } + + /** + * 获取指定镜像源的所有支持版本 + */ + public getAllVersionsByMirror(mirrorType: 'china' | 'global'): FFmpegVersion[] { + const versions: FFmpegVersion[] = [] + const versionConfigs = mirrorType === 'china' ? CHINA_FFMPEG_VERSIONS : FFMPEG_VERSIONS + + for (const platformConfigs of Object.values(versionConfigs)) { for (const version of Object.values(platformConfigs)) { versions.push(version) } } + return versions } @@ -205,14 +345,76 @@ export class FFmpegDownloadService { return false } + // 尝试下载(如果中国镜像源失败会自动回退) + return await this.downloadFFmpegWithFallback(platform, arch, onProgress) + } + + /** + * 带回退机制的下载方法 + */ + private async downloadFFmpegWithFallback( + platform: Platform, + arch: Arch, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // 等待地区检测完成(最多等待 10 秒) + if (this.regionDetectionPromise) { + try { + await Promise.race([ + this.regionDetectionPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('地区检测超时')), 10000)) + ]) + } catch (error) { + logger.warn('地区检测超时或失败,使用当前镜像源设置', { error }) + } + } + + // 首先尝试当前镜像源 const version = this.getFFmpegVersion(platform, arch) if (!version) { logger.error('不支持的平台', { platform, arch }) return false } - logger.info('开始下载 FFmpeg', { platform, arch, version: version.version }) + logger.info('开始下载 FFmpeg', { + platform, + arch, + version: version.version, + mirrorSource: this.getCurrentMirrorSource(), + url: version.url + }) + + // 尝试下载 + let success = await this.performDownload(platform, arch, version, onProgress) + + // 如果使用中国镜像源失败,自动回退到全球镜像源 + if (!success && this.useChinaMirror) { + logger.warn('中国镜像源下载失败,尝试回退到全球镜像源', { platform, arch }) + + const globalVersion = FFMPEG_VERSIONS[platform]?.[arch] + if (globalVersion) { + logger.info('使用全球镜像源重新下载', { + platform, + arch, + url: globalVersion.url + }) + success = await this.performDownload(platform, arch, globalVersion, onProgress) + } + } + + return success + } + /** + * 执行实际的下载操作 + */ + private async performDownload( + platform: Platform, + arch: Arch, + version: FFmpegVersion, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + const key = `${platform}-${arch}` const controller = new AbortController() this.downloadController.set(key, controller) diff --git a/src/main/services/__tests__/FFmpegDownloadService.test.ts b/src/main/services/__tests__/FFmpegDownloadService.test.ts index 0c5c6e6e..2d3243f7 100644 --- a/src/main/services/__tests__/FFmpegDownloadService.test.ts +++ b/src/main/services/__tests__/FFmpegDownloadService.test.ts @@ -25,6 +25,9 @@ vi.mock('../LoggerService', () => ({ vi.mock('https') vi.mock('child_process') +// Mock global fetch for IP detection tests +global.fetch = vi.fn() + describe('FFmpegDownloadService', () => { let service: FFmpegDownloadService const mockUserDataPath = '/mock/user/data' @@ -101,6 +104,9 @@ describe('FFmpegDownloadService', () => { describe('getFFmpegVersion', () => { it('should return version config for supported platforms', () => { + // 由于现在默认使用中国镜像源,我们需要明确设置镜像源来测试 + service.setMirrorSource(false) // 设置为全球镜像源 + const winVersion = service.getFFmpegVersion('win32', 'x64') expect(winVersion).toMatchObject({ version: '6.1', @@ -144,6 +150,29 @@ describe('FFmpegDownloadService', () => { expect(platforms).toContain('win32-x64') expect(platforms).toContain('darwin-arm64') expect(platforms).toContain('linux-x64') + + // Since we default to China mirror now, verify URLs contain gitcode.com + versions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + }) + }) + + it('should return different versions based on mirror source', () => { + // Test China mirror (default) + service.setMirrorSource(true) + const chinaVersions = service.getAllSupportedVersions() + expect(chinaVersions).toHaveLength(6) + chinaVersions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + }) + + // Test global mirror + service.setMirrorSource(false) + const globalVersions = service.getAllSupportedVersions() + expect(globalVersions).toHaveLength(6) + globalVersions.forEach((version) => { + expect(version.url).not.toContain('gitcode.com') + }) }) }) @@ -238,4 +267,225 @@ describe('FFmpegDownloadService', () => { expect(result).toBe(false) }) }) + + describe('IP 地区检测和镜像源选择', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getIpCountry', () => { + it('should detect China region and return CN', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ + country: 'CN', + city: 'Beijing', + region: 'Beijing' + }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + // 通过反射访问私有方法进行测试 + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') + expect(global.fetch).toHaveBeenCalledWith('https://ipinfo.io/json', { + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'EchoPlayer-FFmpeg-Downloader/2.0', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + }) + + it('should detect Hong Kong region and return HK', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ + country: 'HK', + city: 'Hong Kong', + region: 'Hong Kong' + }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + const country = await (service as any).getIpCountry() + expect(country).toBe('HK') + }) + + it('should return CN as default when API fails', async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error('Network error')) + + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') // 默认返回 CN,验证回退逻辑 + }) + + it('should handle timeout properly', async () => { + // Mock fetch that will be aborted due to timeout + vi.mocked(global.fetch).mockImplementation((_, options) => { + return new Promise((resolve, reject) => { + const signal = options?.signal as AbortSignal + if (signal) { + signal.addEventListener('abort', () => { + reject(new Error('The operation was aborted')) + }) + } + // Simulate a long-running request that doesn't resolve in time + setTimeout(() => resolve({} as any), 10000) + }) + }) + + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') // 超时后默认返回 CN + }, 10000) // 增加测试超时时间 + }) + + describe('镜像源选择逻辑', () => { + it('should use China mirror for Chinese regions', () => { + // 手动设置为中国镜像源 + service.setMirrorSource(true) + + const darwinVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(darwinVersion).toMatchObject({ + platform: 'darwin', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-arm64.zip', + extractPath: 'darwin-arm64/ffmpeg' + }) + }) + + it('should use global mirror for non-Chinese regions', () => { + // 手动设置为全球镜像源 + service.setMirrorSource(false) + + const darwinVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(darwinVersion).toMatchObject({ + platform: 'darwin', + arch: 'arm64', + url: 'https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip', + extractPath: 'ffmpeg' + }) + }) + + it('should correctly detect Chinese regions', async () => { + const testCases = [ + { country: 'CN', expected: true }, + { country: 'HK', expected: true }, + { country: 'MO', expected: true }, + { country: 'TW', expected: true }, + { country: 'US', expected: false }, + { country: 'JP', expected: false }, + { country: 'SG', expected: false } + ] + + for (const testCase of testCases) { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: testCase.country }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + const currentMirror = service.getCurrentMirrorSource() + + expect(currentMirror).toBe(testCase.expected ? 'china' : 'global') + } + }) + }) + + describe('getCurrentMirrorSource', () => { + it('should return current mirror source', () => { + service.setMirrorSource(true) + expect(service.getCurrentMirrorSource()).toBe('china') + + service.setMirrorSource(false) + expect(service.getCurrentMirrorSource()).toBe('global') + }) + }) + + describe('setMirrorSource', () => { + it('should allow manual mirror source override', () => { + // 设置为中国镜像源 + service.setMirrorSource(true) + expect(service.getCurrentMirrorSource()).toBe('china') + + // 切换到全球镜像源 + service.setMirrorSource(false) + expect(service.getCurrentMirrorSource()).toBe('global') + }) + }) + + describe('getAllVersionsByMirror', () => { + it('should return China mirror versions', () => { + const chinaVersions = service.getAllVersionsByMirror('china') + + expect(chinaVersions).toHaveLength(6) + chinaVersions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + expect(version.extractPath).toContain(`${version.platform}-${version.arch}`) + }) + }) + + it('should return global mirror versions', () => { + const globalVersions = service.getAllVersionsByMirror('global') + + expect(globalVersions).toHaveLength(6) + globalVersions.forEach((version) => { + expect(version.url).not.toContain('gitcode.com') + }) + }) + }) + }) + + describe('地区检测集成测试', () => { + it('should set China mirror after successful IP detection', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: 'CN' }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('china') + }) + + it('should set global mirror for non-Chinese regions', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: 'US' }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('global') + }) + + it('should default to China mirror when detection fails', async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error('Network error')) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('china') + }) + }) + + describe('版本配置切换测试', () => { + it('should return different URLs based on mirror source', () => { + // 测试中国镜像源 + service.setMirrorSource(true) + const chinaVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(chinaVersion?.url).toContain('gitcode.com') + expect(chinaVersion?.extractPath).toBe('darwin-arm64/ffmpeg') + + // 测试全球镜像源 + service.setMirrorSource(false) + const globalVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(globalVersion?.url).toContain('evermeet.cx') + expect(globalVersion?.extractPath).toBe('ffmpeg') + }) + + it('should fallback to global mirror when China mirror not supported', () => { + service.setMirrorSource(true) + + // 假设有一个平台在中国镜像源中不支持(这里只是测试逻辑) + // 实际上所有平台都支持,所以这个测试更多是为了测试回退逻辑的代码结构 + const version = service.getFFmpegVersion('darwin', 'arm64') + expect(version).toBeDefined() + expect(version?.platform).toBe('darwin') + expect(version?.arch).toBe('arm64') + }) + }) }) From e29dbed8db8b16b8bfd4f344328a59005df7aea9 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sun, 14 Sep 2025 10:13:34 +0800 Subject: [PATCH 2/2] ci: Sync release to gitcode --- .github/workflows/sync-release-to-gitcode.yml | 447 ++++++++++++++++++ scripts/upload-assets.js | 426 +++++++++++++++++ 2 files changed, 873 insertions(+) create mode 100644 .github/workflows/sync-release-to-gitcode.yml create mode 100644 scripts/upload-assets.js diff --git a/.github/workflows/sync-release-to-gitcode.yml b/.github/workflows/sync-release-to-gitcode.yml new file mode 100644 index 00000000..3aedf707 --- /dev/null +++ b/.github/workflows/sync-release-to-gitcode.yml @@ -0,0 +1,447 @@ +name: Sync Release to GitCode + +on: + release: + types: [published, edited] + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name to sync (e.g., v1.0.0)' + required: true + type: string + release_name: + description: 'Release name' + required: false + type: string + release_body: + description: 'Release description/body' + required: false + type: string + prerelease: + description: 'Is this a prerelease?' + required: false + type: boolean + default: false + draft: + description: 'Is this a draft release?' + required: false + type: boolean + default: false + test_mode: + description: 'Test mode (dry run - no actual sync to GitCode)' + required: false + type: boolean + default: false + +env: + GITCODE_API_BASE: https://gitcode.com/api/v5 + GITCODE_OWNER: ${{ vars.GITCODE_OWNER || 'mkdir700' }} + GITCODE_REPO: EchoPlayer + +jobs: + sync-to-gitcode: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get release information + id: release + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual trigger - use inputs + echo "🔧 Manual trigger detected, using workflow inputs" + echo "tag_name=${{ github.event.inputs.tag_name }}" >> $GITHUB_OUTPUT + echo "release_name=${{ github.event.inputs.release_name || github.event.inputs.tag_name }}" >> $GITHUB_OUTPUT + echo "release_body<> $GITHUB_OUTPUT + echo "${{ github.event.inputs.release_body || 'Test release created via manual trigger' }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "prerelease=${{ github.event.inputs.prerelease }}" >> $GITHUB_OUTPUT + echo "draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT + echo "test_mode=${{ github.event.inputs.test_mode }}" >> $GITHUB_OUTPUT + else + # Automatic trigger - use release event data + echo "🚀 Release event detected, using release data" + echo "tag_name=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + echo "release_name=${{ github.event.release.name }}" >> $GITHUB_OUTPUT + echo "release_body<> $GITHUB_OUTPUT + echo "${{ github.event.release.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT + echo "draft=${{ github.event.release.draft }}" >> $GITHUB_OUTPUT + echo "test_mode=false" >> $GITHUB_OUTPUT + fi + + - name: Sync repository to GitCode + if: steps.release.outputs.test_mode != 'true' + run: | + echo "🔄 Syncing repository to GitCode using HTTPS..." + + # Configure git with token authentication + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Construct GitCode repository URL with token authentication + GITCODE_REPO_URL="https://oauth2:${{ secrets.GITCODE_ACCESS_TOKEN }}@gitcode.com/$GITCODE_OWNER/$GITCODE_REPO.git" + + echo "Repository: $GITCODE_OWNER/$GITCODE_REPO" + + # Add GitCode remote (remove if exists) + if git remote | grep -q "gitcode"; then + echo "Removing existing gitcode remote" + git remote remove gitcode + fi + + echo "Adding GitCode remote with HTTPS authentication" + git remote add gitcode "$GITCODE_REPO_URL" + + echo "📤 Force pushing branches to GitCode..." + + # Show available branches + echo "Available branches:" + git branch -a | grep -E "(main|dev|alpha|beta)" || echo "Target branches not found" + + # Force push main branches to GitCode + for branch in main dev alpha beta; do + if git show-ref --verify --quiet refs/heads/$branch || git show-ref --verify --quiet refs/remotes/origin/$branch; then + echo "Pushing branch: $branch" + if git show-ref --verify --quiet refs/heads/$branch; then + git push --force gitcode $branch:$branch || { + echo "❌ Failed to push local branch $branch" + exit 1 + } + else + git push --force gitcode origin/$branch:$branch || { + echo "❌ Failed to push remote branch $branch" + exit 1 + } + fi + echo "✅ Successfully pushed branch: $branch" + else + echo "⚠️ Branch $branch not found, skipping" + fi + done + + echo "🏷️ Pushing all tags to GitCode..." + echo "Available tags (last 10):" + git tag | tail -10 || echo "No tags found" + + git push --force gitcode --tags || { + echo "❌ Failed to push tags" + exit 1 + } + + echo "✅ Repository sync completed successfully" + + - name: Test mode - Skip repository sync + if: steps.release.outputs.test_mode == 'true' + run: | + echo "🧪 Test mode enabled - skipping repository sync to GitCode" + echo "Would sync the following branches: main, dev, alpha, beta" + echo "Would force push all tags to GitCode" + echo "This would ensure tag ${{ steps.release.outputs.tag_name }} exists before creating release" + + - name: Download release assets + id: download-assets + run: | + mkdir -p ./release-assets + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual trigger - fetch release data from GitHub API + echo "📦 Fetching release assets for tag: ${{ steps.release.outputs.tag_name }}" + + release_response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.release.outputs.tag_name }}") + + if [ "$(echo "$release_response" | jq -r '.message // empty')" = "Not Found" ]; then + echo "⚠️ Release not found for tag: ${{ steps.release.outputs.tag_name }}" + assets_json='[]' + else + assets_json=$(echo "$release_response" | jq '.assets') + fi + else + # Automatic trigger - use event data + assets_json='${{ toJson(github.event.release.assets) }}' + fi + + echo "Assets to download:" + echo "$assets_json" | jq -r '.[] | "\(.name) - \(.browser_download_url)"' + + asset_files="" + if [ "$(echo "$assets_json" | jq 'length')" -gt 0 ]; then + for asset in $(echo "$assets_json" | jq -r '.[] | @base64'); do + name=$(echo "$asset" | base64 --decode | jq -r '.name') + url=$(echo "$asset" | base64 --decode | jq -r '.browser_download_url') + + echo "Downloading $name from $url" + curl -L -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -o "./release-assets/$name" "$url" + + if [ -n "$asset_files" ]; then + asset_files="$asset_files," + fi + asset_files="$asset_files./release-assets/$name" + done + fi + + echo "asset_files=$asset_files" >> $GITHUB_OUTPUT + echo "has_assets=$([ -n "$asset_files" ] && echo "true" || echo "false")" >> $GITHUB_OUTPUT + + - name: Check if release exists on GitCode + id: check-release + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping GitCode API check" + echo "exists=false" >> $GITHUB_OUTPUT + echo "Test mode: Simulating release does not exist on GitCode" + else + # First check if tag exists using GitCode tags API + echo "Checking if tag exists..." + tags_response=$(curl -s -w "%{http_code}" \ + -H "Accept: application/json" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/tags?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + tags_http_code="${tags_response: -3}" + tags_response_body="${tags_response%???}" + + echo "Tags API HTTP Code: $tags_http_code" + + tag_exists=false + if [ "$tags_http_code" = "200" ] || [ "$tags_http_code" = "201" ]; then + echo "Available tags (first 20):" + echo "$tags_response_body" | jq -r '.[] | .name' 2>/dev/null | head -20 || echo "Failed to parse tags" + + # Check if our target tag exists + if echo "$tags_response_body" | jq -e --arg tag "${{ steps.release.outputs.tag_name }}" '.[] | select(.name == $tag)' > /dev/null 2>&1; then + tag_exists=true + echo "✅ Tag ${{ steps.release.outputs.tag_name }} exists on GitCode" + else + echo "❌ Tag ${{ steps.release.outputs.tag_name }} does not exist on GitCode" + fi + else + echo "❌ Failed to fetch tags from GitCode (HTTP $tags_http_code): $tags_response_body" + fi + + # Then check if release exists (only if tag exists) + if [ "$tag_exists" = "true" ]; then + echo "Checking if release exists..." + response=$(curl -s -w "%{http_code}" \ + -H "Accept: application/json" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases/tags/${{ steps.release.outputs.tag_name }}?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + else + echo "⚠️ Skipping release check since tag does not exist" + response="404Not Found" + fi + + http_code="${response: -3}" + response_body="${response%???}" + + echo "HTTP Code: $http_code" + echo "Response: $response_body" + + if [ "$http_code" = "200" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release already exists on GitCode" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Release does not exist on GitCode" + fi + fi + + - name: Create release on GitCode + if: steps.check-release.outputs.exists == 'false' + id: create-release + run: | + payload=$(jq -n \ + --arg tag_name "${{ steps.release.outputs.tag_name }}" \ + --arg name "${{ steps.release.outputs.release_name }}" \ + --arg body "${{ steps.release.outputs.release_body }}" \ + --argjson prerelease "${{ steps.release.outputs.prerelease }}" \ + --argjson draft "${{ steps.release.outputs.draft }}" \ + '{ + tag_name: $tag_name, + name: $name, + body: $body, + prerelease: $prerelease, + draft: $draft + }') + + echo "Creating release with payload:" + echo "$payload" | jq . + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping release creation on GitCode" + echo "✅ Test mode: Would create release successfully on GitCode" + echo "created=true" >> $GITHUB_OUTPUT + else + response=$(curl -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + http_code="${response: -3}" + response_body="${response%???}" + + echo "Create Release Response Code: $http_code" + echo "Create Release Response: $response_body" + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + echo "✅ Release created successfully on GitCode (HTTP $http_code)" + echo "created=true" >> $GITHUB_OUTPUT + else + echo "❌ Failed to create release on GitCode (HTTP $http_code)" + echo "Response: $response_body" + echo "created=false" >> $GITHUB_OUTPUT + exit 1 + fi + fi + + - name: Update existing release on GitCode + if: steps.check-release.outputs.exists == 'true' + id: update-release + run: | + payload=$(jq -n \ + --arg name "${{ steps.release.outputs.release_name }}" \ + --arg body "${{ steps.release.outputs.release_body }}" \ + --argjson prerelease "${{ steps.release.outputs.prerelease }}" \ + --argjson draft "${{ steps.release.outputs.draft }}" \ + '{ + name: $name, + body: $body, + prerelease: $prerelease, + draft: $draft + }') + + echo "Updating release with payload:" + echo "$payload" | jq . + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping release update on GitCode" + echo "✅ Test mode: Would update release successfully on GitCode" + echo "updated=true" >> $GITHUB_OUTPUT + else + response=$(curl -s -w "%{http_code}" \ + -X PATCH \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases/${{ steps.release.outputs.tag_name }}?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + http_code="${response: -3}" + response_body="${response%???}" + + echo "Update Release Response Code: $http_code" + echo "Update Release Response: $response_body" + + if [ "$http_code" = "200" ]; then + echo "✅ Release updated successfully on GitCode" + echo "updated=true" >> $GITHUB_OUTPUT + else + echo "❌ Failed to update release on GitCode" + echo "updated=false" >> $GITHUB_OUTPUT + exit 1 + fi + fi + + - name: Upload assets to GitCode release + if: steps.download-assets.outputs.has_assets == 'true' + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping asset upload to GitCode" + echo "Would upload the following assets:" + IFS=',' read -ra ASSET_FILES <<< "${{ steps.download-assets.outputs.asset_files }}" + for asset_file in "${ASSET_FILES[@]}"; do + if [ -f "$asset_file" ]; then + echo " - $(basename "$asset_file")" + fi + done + echo "✅ Test mode: Would upload all assets successfully to GitCode" + else + echo "📦 Uploading assets to GitCode release using JavaScript uploader..." + + # Make upload script executable + chmod +x ./scripts/upload-assets.js + + # Convert comma-separated asset files to array for JavaScript uploader + IFS=',' read -ra ASSET_FILES <<< "${{ steps.download-assets.outputs.asset_files }}" + + # Upload assets using the JavaScript uploader + node ./scripts/upload-assets.js \ + --token "${{ secrets.GITCODE_ACCESS_TOKEN }}" \ + --owner "$GITCODE_OWNER" \ + --repo "$GITCODE_REPO" \ + --tag "${{ steps.release.outputs.tag_name }}" \ + --concurrency 3 \ + --retry 3 \ + "${ASSET_FILES[@]}" + + upload_exit_code=$? + if [ $upload_exit_code -eq 0 ]; then + echo "✅ All assets uploaded successfully to GitCode" + else + echo "❌ Asset upload failed with exit code: $upload_exit_code" + exit 1 + fi + fi + + - name: Summary + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "## 🧪 Test Mode - Release Sync Summary" >> $GITHUB_STEP_SUMMARY + else + echo "## 🚀 Release Sync Summary" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "**Trigger:** ${{ github.event_name == 'workflow_dispatch' && '🔧 Manual' || '🚀 Automatic' }}" >> $GITHUB_STEP_SUMMARY + echo "**Release:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Name:** ${{ steps.release.outputs.release_name }}" >> $GITHUB_STEP_SUMMARY + echo "**GitCode Repository:** $GITCODE_OWNER/$GITCODE_REPO" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Mode:** 🧪 Test Mode (Dry Run)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.check-release.outputs.exists }}" = "true" ]; then + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Action:** Would update existing release ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Action:** Updated existing release ✅" >> $GITHUB_STEP_SUMMARY + fi + else + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Action:** Would create new release ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Action:** Created new release ✅" >> $GITHUB_STEP_SUMMARY + fi + fi + + if [ "${{ steps.download-assets.outputs.has_assets }}" = "true" ]; then + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Assets:** Would upload to GitCode ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Assets:** Uploaded to GitCode ✅" >> $GITHUB_STEP_SUMMARY + fi + else + echo "**Assets:** No assets to upload" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "Test completed successfully! 🧪 No actual changes were made to GitCode." >> $GITHUB_STEP_SUMMARY + else + echo "Release has been successfully synced to GitCode! 🎉" >> $GITHUB_STEP_SUMMARY + fi diff --git a/scripts/upload-assets.js b/scripts/upload-assets.js new file mode 100644 index 00000000..de54fc51 --- /dev/null +++ b/scripts/upload-assets.js @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +const https = require('https') +const fs = require('fs') +const path = require('path') +const { URL } = require('url') + +/** + * GitCode 资产上传脚本 + * 功能: + * 1. 并发上传文件到 GitCode + * 2. 检查文件是否已存在,避免重复上传 + * 3. 支持断点续传和错误重试 + */ + +class GitCodeUploader { + constructor(options) { + this.accessToken = options.accessToken + this.owner = options.owner + this.repo = options.repo + this.tag = options.tag + this.concurrency = options.concurrency || 3 + this.retryAttempts = options.retryAttempts || 3 + this.baseUrl = 'https://api.gitcode.com/api/v5' + } + + /** + * HTTP 请求工具方法 + */ + async httpRequest(url, options = {}) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url) + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: options.method || 'GET', + headers: options.headers || {}, + ...options.httpsOptions + } + + const req = https.request(requestOptions, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + const result = { + statusCode: res.statusCode, + headers: res.headers, + data: data + } + + try { + if (data && res.headers['content-type']?.includes('application/json')) { + result.json = JSON.parse(data) + } + } catch (e) { + // JSON 解析失败,保持原始数据 + } + + resolve(result) + }) + }) + + req.on('error', reject) + + if (options.body) { + if (options.body instanceof Buffer || typeof options.body === 'string') { + req.write(options.body) + } else { + req.write(JSON.stringify(options.body)) + } + } + + req.end() + }) + } + + /** + * 获取现有的 release 信息和资产列表 + */ + async getExistingAssets() { + const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/releases?access_token=${this.accessToken}` + + try { + const response = await this.httpRequest(url) + + if (response.statusCode === 200 && response.json && Array.isArray(response.json)) { + // 从 releases 数组中找到匹配的 tag + const targetRelease = response.json.find((release) => release.tag_name === this.tag) + + if (targetRelease) { + const assets = targetRelease.assets || [] + const assetNames = new Set(assets.map((asset) => asset.name)) + console.log(`✓ 找到现有 release ${this.tag},包含 ${assets.length} 个资产`) + + // GitCode releases API 使用 tag_name 作为标识符 + const releaseId = targetRelease.tag_name + console.log(` 使用标识符: ${releaseId}`) + + if (assets.length > 0) { + console.log(` 现有资产:`) + assets.slice(0, 3).forEach((asset) => { + console.log(` - ${asset.name} (${asset.type})`) + }) + if (assets.length > 3) { + console.log(` ... 以及其他 ${assets.length - 3} 个文件`) + } + } + + return { releaseId: releaseId, existingAssets: assetNames } + } else { + console.log(`✗ Release ${this.tag} 不存在`) + return { releaseId: null, existingAssets: new Set() } + } + } else { + throw new Error(`获取 releases 列表失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error('获取现有资产失败:', error.message) + throw error + } + } + + /** + * 获取上传 URL + */ + async getUploadUrl(releaseId, fileName) { + const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/releases/${releaseId}/upload_url?access_token=${this.accessToken}&file_name=${encodeURIComponent(fileName)}` + + try { + const response = await this.httpRequest(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (response.statusCode === 200 && response.json) { + return response.json + } else { + throw new Error(`获取上传 URL 失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error(`获取 ${fileName} 上传 URL 失败:`, error.message) + throw error + } + } + + /** + * 上传文件到 GitCode 对象存储 + */ + async uploadFileToStorage(uploadInfo, filePath) { + const fileName = path.basename(filePath) + const fileBuffer = fs.readFileSync(filePath) + const fileSize = fileBuffer.length + + const uploadUrl = uploadInfo.url + + console.log(uploadInfo.url) + console.log(uploadInfo.headers) + + try { + const response = await this.httpRequest(uploadUrl, { + method: 'PUT', + headers: { ...uploadInfo.headers, 'Content-Length': fileSize }, + body: fileBuffer + }) + + if (response.statusCode === 200) { + console.log(`✓ ${fileName} 上传成功 (${fileSize} bytes)`) + return true + } else { + throw new Error(`上传失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error(`上传 ${fileName} 到存储失败:`, error.message) + throw error + } + } + + /** + * 上传单个文件(带重试) + */ + async uploadSingleFile(releaseId, filePath, existingAssets) { + const fileName = path.basename(filePath) + + // 检查文件是否已存在 + if (existingAssets.has(fileName)) { + console.log(`⚠ ${fileName} 已存在,跳过上传`) + return { success: true, skipped: true } + } + + if (!fs.existsSync(filePath)) { + console.log(`⚠ ${fileName} 文件不存在,跳过`) + return { success: false, error: 'File not found' } + } + + const fileStats = fs.statSync(filePath) + const fileSize = fileStats.size + + for (let attempt = 1; attempt <= this.retryAttempts; attempt++) { + try { + console.log( + `⏳ 上传 ${fileName} (${fileSize} bytes) - 尝试 ${attempt}/${this.retryAttempts}` + ) + + // 获取上传 URL + const uploadInfo = await this.getUploadUrl(releaseId, fileName) + + // 上传到对象存储 + await this.uploadFileToStorage(uploadInfo, filePath) + + return { success: true, skipped: false } + } catch (error) { + console.error( + `上传 ${fileName} 失败 (尝试 ${attempt}/${this.retryAttempts}):`, + error.message + ) + + if (attempt === this.retryAttempts) { + return { success: false, error: error.message } + } + + // 等待后重试 + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) + } + } + } + + /** + * 并发上传多个文件 + */ + async uploadFiles(filePaths) { + console.log(`开始上传 ${filePaths.length} 个文件 (并发数: ${this.concurrency})`) + + // 获取现有资产列表 + const { releaseId, existingAssets } = await this.getExistingAssets() + + if (!releaseId) { + throw new Error(`Release ${this.tag} 不存在,无法上传资产`) + } + + // 过滤出需要上传的文件 + const filesToUpload = filePaths.filter((filePath) => { + const fileName = path.basename(filePath) + return !existingAssets.has(fileName) && fs.existsSync(filePath) + }) + + console.log(`需要上传 ${filesToUpload.length} 个新文件`) + + if (filesToUpload.length === 0) { + console.log('所有文件都已存在,无需上传') + return { + total: filePaths.length, + success: filePaths.length, + failed: 0, + skipped: filePaths.length + } + } + + // 并发上传 + const results = [] + const semaphore = new Array(this.concurrency).fill(null) + + const uploadPromises = filesToUpload.map(async (filePath) => { + // 等待信号量 + await new Promise((resolve) => { + const checkSemaphore = () => { + const index = semaphore.indexOf(null) + if (index !== -1) { + semaphore[index] = filePath + resolve() + } else { + setTimeout(checkSemaphore, 100) + } + } + checkSemaphore() + }) + + try { + const result = await this.uploadSingleFile(releaseId, filePath, existingAssets) + result.filePath = filePath + results.push(result) + } finally { + // 释放信号量 + const index = semaphore.indexOf(filePath) + if (index !== -1) { + semaphore[index] = null + } + } + }) + + await Promise.all(uploadPromises) + + // 统计结果 + const stats = { + total: filePaths.length, + success: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + skipped: + results.filter((r) => r.skipped || existingAssets.has(path.basename(r.filePath))).length + + (filePaths.length - filesToUpload.length) + } + + console.log(`\n上传完成:`) + console.log(` 总计: ${stats.total}`) + console.log(` 成功: ${stats.success}`) + console.log(` 失败: ${stats.failed}`) + console.log(` 跳过: ${stats.skipped}`) + + // 输出失败的文件 + const failedFiles = results.filter((r) => !r.success) + if (failedFiles.length > 0) { + console.log('\n失败的文件:') + failedFiles.forEach((result) => { + console.log(` - ${path.basename(result.filePath)}: ${result.error}`) + }) + } + + return stats + } +} + +// 命令行接口 +async function main() { + const args = process.argv.slice(2) + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(` +GitCode 资产上传工具 + +用法: node upload-assets.js [选项] <文件路径...> + +选项: + --token GitCode access token (必需) + --owner 仓库所有者 (必需) + --repo 仓库名称 (必需) + --tag 发布标签 (必需) + --concurrency 并发数量 (默认: 3) + --retry 重试次数 (默认: 3) + --help, -h 显示帮助信息 + +示例: + node upload-assets.js --token xxx --owner mkdir700 --repo EchoPlayer --tag v1.0.0 file1.zip file2.deb + +环境变量: + GITCODE_ACCESS_TOKEN GitCode access token + GITCODE_OWNER 仓库所有者 + GITCODE_REPO 仓库名称 + GITCODE_TAG 发布标签 +`) + process.exit(0) + } + + // 解析命令行参数 + const options = { + accessToken: process.env.GITCODE_ACCESS_TOKEN, + owner: process.env.GITCODE_OWNER, + repo: process.env.GITCODE_REPO, + tag: process.env.GITCODE_TAG, + concurrency: 3, + retryAttempts: 3 + } + + const filePaths = [] + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + + if (arg === '--token' && i + 1 < args.length) { + options.accessToken = args[++i] + } else if (arg === '--owner' && i + 1 < args.length) { + options.owner = args[++i] + } else if (arg === '--repo' && i + 1 < args.length) { + options.repo = args[++i] + } else if (arg === '--tag' && i + 1 < args.length) { + options.tag = args[++i] + } else if (arg === '--concurrency' && i + 1 < args.length) { + options.concurrency = parseInt(args[++i]) + } else if (arg === '--retry' && i + 1 < args.length) { + options.retryAttempts = parseInt(args[++i]) + } else if (!arg.startsWith('--')) { + filePaths.push(arg) + } + } + + // 验证必需参数 + const required = ['accessToken', 'owner', 'repo', 'tag'] + const missing = required.filter((key) => !options[key]) + + if (missing.length > 0) { + console.error(`错误: 缺少必需参数: ${missing.join(', ')}`) + process.exit(1) + } + + if (filePaths.length === 0) { + console.error('错误: 未指定要上传的文件') + process.exit(1) + } + + try { + const uploader = new GitCodeUploader(options) + const stats = await uploader.uploadFiles(filePaths) + + if (stats.failed > 0) { + process.exit(1) + } + } catch (error) { + console.error('上传失败:', error.message) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main().catch((error) => { + console.error('未处理的错误:', error) + process.exit(1) + }) +} + +module.exports = GitCodeUploader