diff --git a/.env.example b/.env.example index 225fe8583..3365871f9 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ VITE_WEB_PORT=14558 VITE_SERVER_PORT=25884 # API 地址 - 结尾不要加 / VITE_API_URL=/api/netease +# Google Drive +GOOGLE_DRIVE_CLIENT_ID= +GOOGLE_DRIVE_CLIENT_SECRET= diff --git a/components.d.ts b/components.d.ts index 041183198..5fa993532 100644 --- a/components.d.ts +++ b/components.d.ts @@ -12,6 +12,7 @@ export {} declare module 'vue' { export interface GlobalComponents { AboutSetting: typeof import('./src/components/Setting/AboutSetting.vue')['default'] + AddRemoteFolderModal: typeof import('./src/components/Modal/AddRemoteFolderModal.vue')['default'] AMLLServer: typeof import('./src/components/Modal/Setting/AMLLServer.vue')['default'] AMLyric: typeof import('./src/components/Player/PlayerLyric/AMLyric.vue')['default'] ArtistList: typeof import('./src/components/List/ArtistList.vue')['default'] diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 7c0af72ef..22988688d 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -13,6 +13,8 @@ import initSocketIpc from "./ipc-socket"; import initMediaIpc from "./ipc-media"; import initMpvIpc from "./ipc-mpv"; import initRendererLogIpc from "./ipc-renderer-log"; +import { registerRemoteFolderIpc } from "./ipc-remote-folder"; +import initGoogleDriveIpc from "./ipc-google-drive"; /** * 初始化全部 IPC 通信 @@ -34,6 +36,8 @@ const initIpc = (): void => { initMediaIpc(); initMpvIpc(); initRendererLogIpc(); + registerRemoteFolderIpc(); + initGoogleDriveIpc(); }; export default initIpc; diff --git a/electron/main/ipc/ipc-file.ts b/electron/main/ipc/ipc-file.ts index b32292efb..a5feb1f23 100644 --- a/electron/main/ipc/ipc-file.ts +++ b/electron/main/ipc/ipc-file.ts @@ -1,10 +1,11 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "path"; import { access, mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises"; -import { parseFile } from "music-metadata"; +import { parseFile, parseStream as parseFileFromStream } from "music-metadata"; import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper"; import { File, Picture, Id3v2Settings, TagTypes } from "node-taglib-sharp"; -import { ipcLog } from "../logger"; +import { GoogleDriveService } from "../services/GoogleDriveService"; +import { ipcLog, processLog } from "../logger"; import { createWriteStream } from "fs"; import { pipeline } from "stream/promises"; import { Options as GlobOptions } from "fast-glob/out/settings"; @@ -58,6 +59,47 @@ const initFileIpc = (): void => { const store = useStore(); const localCachePath = join(store.get("cachePath"), "local-data"); const coverDir = join(localCachePath, "covers"); + + // --- Google Drive 集成 --- + try { + const driveService = GoogleDriveService.getInstance(); + if (driveService.hasValidTokens()) { + processLog.info("☁️ Fetching Google Drive audio files..."); + const driveFiles = await driveService.getAudioFiles(); + + const driveTracks = driveFiles.map(file => ({ + id: file.id || "", + path: `google-drive://${file.id}`, + // 去除扩展名作为标题 + title: file.name?.replace(/\.[^/.]+$/, "") || "Unknown Title", + artist: "Google Drive", + album: "Cloud Drive", + // 暂无时长信息 + duration: 0, + size: parseInt(file.size || "0"), + mtime: file.modifiedTime ? new Date(file.modifiedTime).getTime() : Date.now(), + cover: undefined, + bitrate: 0 + })); + + // 发送 Drive 数据批次 + event.sender.send("music-sync-tracks-batch", driveTracks); + processLog.info(`☁️ Sent ${driveTracks.length} Google Drive tracks`); + } + } catch (error: any) { + processLog.error("❌ Failed to sync Google Drive files:", error); + // 如果是权限不足 (Permission denied),发送明确提示 + if (error.message && (error.message.includes("403") || error.message.includes("insufficient"))) { + event.sender.send("music-sync-complete", { + success: false, + message: "Google Drive 权限不足,请在目录管理中重新连接以授权" + }); + return { success: false, message: "Google Drive Auth Error" }; + } + } + // ------------------------ + + // 使用批量流式传输,减少 IPC 通信次数 // 使用批量流式传输,减少 IPC 通信次数 await localMusicService.refreshLibrary( @@ -175,6 +217,45 @@ const initFileIpc = (): void => { // 获取音乐元信息 ipcMain.handle("get-music-metadata", async (_, path: string) => { try { + if (path.startsWith("google-drive://")) { + const fileId = path.replace("google-drive://", ""); + try { + const driveService = GoogleDriveService.getInstance(); + + // 并行获取:文件流(用于解析 tag)和 API 元数据(用于获取准确大小) + const [metadata, streamReuslt] = await Promise.all([ + driveService.getFileMetadata(fileId), + driveService.getFileStream(fileId), + ]); + + const { data } = streamReuslt; + // 解析流 + const { common, format } = await parseFileFromStream(data); + + return { + fileName: common.title || metadata.name || "Unknown Title", + fileSize: metadata.size / (1024 * 1024), // 转换为 MB + common, + lyric: + metaDataLyricsArrayToLrc(common?.lyrics?.[0]?.syncText || []) || + common?.lyrics?.[0]?.text || + "", + format, + md5: "", + }; + } catch (e) { + processLog.error(`⚠️ Failed to parse Google Drive metadata for ${fileId}:`, e); + // Fallback + return { + fileName: "Google Drive File", + fileSize: 0, + common: { title: "Cloud Song", artist: "Google Drive" }, + lyric: "", + format: {}, + md5: "", + }; + } + } const filePath = resolve(path).replace(/\\/g, "/"); const { common, format } = await parseFile(filePath); return { @@ -243,6 +324,32 @@ const initFileIpc = (): void => { format: "lrc" | "ttml"; }> => { try { + // Google Drive 逻辑 + if (musicPath.startsWith("google-drive://")) { + const fileId = musicPath.replace("google-drive://", ""); + try { + const driveService = GoogleDriveService.getInstance(); + const { data } = await driveService.getFileStream(fileId); + const { common } = await parseFileFromStream(data); + + const syncedLyric = common?.lyrics?.[0]?.syncText; + if (syncedLyric && syncedLyric.length > 0) { + return { + lyric: metaDataLyricsArrayToLrc(syncedLyric), + format: "lrc", + }; + } else if (common?.lyrics?.[0]?.text) { + return { + lyric: common?.lyrics?.[0]?.text, + format: "lrc", + }; + } + } catch (e) { + processLog.error(`⚠️ Failed to parse lyrics from Drive file ${fileId}:`, e); + } + return { lyric: "", format: "lrc" }; + } + // 获取文件基本信息 const absPath = resolve(musicPath); const dir = dirname(absPath); @@ -305,6 +412,21 @@ const initFileIpc = (): void => { "get-music-cover", async (_, path: string): Promise<{ data: Buffer; format: string } | null> => { try { + if (path.startsWith("google-drive://")) { + const fileId = path.replace("google-drive://", ""); + try { + const driveService = GoogleDriveService.getInstance(); + const { data } = await driveService.getFileStream(fileId); + const { common } = await parseFileFromStream(data); + const picture = common.picture?.[0]; + if (picture) { + return { data: Buffer.from(picture.data), format: picture.format }; + } + } catch (e) { + processLog.error(`⚠️ Failed to parse cover from Drive file ${fileId}:`, e); + } + return null; + } const { common } = await parseFile(path); // 获取封面数据 const picture = common.picture?.[0]; diff --git a/electron/main/ipc/ipc-google-drive.ts b/electron/main/ipc/ipc-google-drive.ts new file mode 100644 index 000000000..7756d63ee --- /dev/null +++ b/electron/main/ipc/ipc-google-drive.ts @@ -0,0 +1,102 @@ +import { ipcMain } from "electron"; +import { GoogleDriveService } from "../services/GoogleDriveService"; +import { processLog } from "../logger"; + +/** + * 初始化 Google Drive IPC 通信 + */ +const initGoogleDriveIpc = (): void => { + processLog.info("🔌 Initializing Google Drive IPC handlers"); + + // 触发 Google 登录 + ipcMain.handle("google-drive-login", async () => { + try { + const service = GoogleDriveService.getInstance(); + const result = await service.startAuth(); + return result; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive login failed:", err); + return { status: "error", message: err.message }; + } + }); + + // 获取认证状态 + ipcMain.handle("google-drive-status", () => { + try { + const service = GoogleDriveService.getInstance(); + return service.getAuthStatus(); + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive status check failed:", err); + return { authenticated: false, hasRefreshToken: false }; + } + }); + + // 获取文件列表 + ipcMain.handle("google-drive-files", async (_event, pageSize = 10) => { + try { + const service = GoogleDriveService.getInstance(); + const files = await service.getFiles(pageSize); + return { status: "success", files }; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive files fetch failed:", err); + return { status: "error", message: err.message, files: [] }; + } + }); + + // 登出 + ipcMain.handle("google-drive-logout", () => { + try { + const service = GoogleDriveService.getInstance(); + service.logout(); + return { status: "success" }; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive logout failed:", err); + return { status: "error", message: err.message }; + } + }); + + // 扫描音频文件 + ipcMain.handle("google-drive-scan", async (_event, pageSize = 1000) => { + try { + const service = GoogleDriveService.getInstance(); + const files = await service.getAudioFiles(pageSize); + return { status: "success", files }; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive scan failed:", err); + return { status: "error", message: err.message, files: [] }; + } + }); + + // 获取流式播放信息 + ipcMain.handle("google-drive-stream-info", async (_event, fileId: string) => { + try { + const service = GoogleDriveService.getInstance(); + const info = await service.getStreamInfo(fileId); + return { status: "success", info }; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive stream info failed:", err); + return { status: "error", message: err.message }; + } + }); + + // 下载文件 + ipcMain.handle("google-drive-download", async (_event, fileId: string, destPath: string) => { + try { + const service = GoogleDriveService.getInstance(); + await service.downloadFile(fileId, destPath); + return { status: "success" }; + } catch (error) { + const err = error as Error; + processLog.error("❌ Google Drive download failed:", err); + return { status: "error", message: err.message }; + } + }); +}; + +export default initGoogleDriveIpc; diff --git a/electron/main/ipc/ipc-remote-folder.ts b/electron/main/ipc/ipc-remote-folder.ts new file mode 100644 index 000000000..4683ba737 --- /dev/null +++ b/electron/main/ipc/ipc-remote-folder.ts @@ -0,0 +1,36 @@ +import { ipcMain } from "electron"; +import { RemoteFolderService, type TestConnectionParams } from "../services/RemoteFolderService"; + +const remoteFolderService = RemoteFolderService.getInstance(); + +/** + * 注册远程文件夹相关的 IPC 处理器 + */ +export const registerRemoteFolderIpc = () => { + /** + * 测试远程文件夹连接 + */ + ipcMain.handle("remote-folder-test", async (_event, params: TestConnectionParams) => { + try { + const result = await remoteFolderService.testConnection(params); + return result; + } catch (error) { + return { + success: false, + message: `测试连接失败: ${(error as Error).message}`, + }; + } + }); + + /** + * 检查远程路径是否可访问 + */ + ipcMain.handle("remote-folder-check", async (_event, path: string) => { + try { + const accessible = await remoteFolderService.isPathAccessible(path); + return { success: accessible }; + } catch (error) { + return { success: false }; + } + }); +}; diff --git a/electron/main/services/GoogleDriveService.ts b/electron/main/services/GoogleDriveService.ts new file mode 100644 index 000000000..7cc8febdd --- /dev/null +++ b/electron/main/services/GoogleDriveService.ts @@ -0,0 +1,481 @@ +import { shell } from "electron"; +import { google, drive_v3 } from "googleapis"; +import http from "http"; +import { URL } from "url"; +import { useStore } from "../store"; +import { processLog } from "../logger"; +import axios from "axios"; + +// --- 配置区域 --- +const CLIENT_ID = process.env["GOOGLE_DRIVE_CLIENT_ID"] || ""; +const CLIENT_SECRET = process.env["GOOGLE_DRIVE_CLIENT_SECRET"] || ""; +const REDIRECT_URI = "http://localhost:3000/oauth2callback"; +// 使用 drive.readonly 权限以支持文件内容读取 +const SCOPES = ["https://www.googleapis.com/auth/drive.readonly"]; + +// 支持的音频文件 MIME 类型 +const AUDIO_MIME_TYPES = [ + "audio/mpeg", // mp3 + "audio/mp4", // m4a + "audio/flac", // flac + "audio/wav", // wav + "audio/x-wav", // wav + "audio/ogg", // ogg + "audio/aac", // aac + "audio/x-m4a", // m4a +]; + + + +/** + * Google Drive 服务类 + * 负责 OAuth2 认证、Token 管理和 Drive API 调用 + */ +export class GoogleDriveService { + private static instance: GoogleDriveService | null = null; + private oauth2Client: InstanceType; + private store = useStore(); + private isAuthenticating = false; + private authServer: http.Server | null = null; + + private constructor() { + this.oauth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + + // 尝试从 store 恢复 tokens + this.restoreTokens(); + } + + /** + * 获取单例实例 + */ + static getInstance(): GoogleDriveService { + if (!GoogleDriveService.instance) { + GoogleDriveService.instance = new GoogleDriveService(); + } + return GoogleDriveService.instance; + } + + /** + * 从 store 恢复 tokens + */ + private restoreTokens(): void { + const stored = this.store.get("googleDrive"); + if (stored?.refreshToken) { + processLog.info("🔑 Restoring Google Drive tokens from store"); + this.oauth2Client.setCredentials({ + refresh_token: stored.refreshToken, + access_token: stored.accessToken, + expiry_date: stored.expiryDate, + }); + } + } + + /** + * 保存 tokens 到 store + */ + private saveTokens(tokens: { + access_token?: string | null; + refresh_token?: string | null; + expiry_date?: number | null; + }): void { + const existing = this.store.get("googleDrive") || {}; + this.store.set("googleDrive", { + accessToken: tokens.access_token || existing.accessToken, + refreshToken: tokens.refresh_token || existing.refreshToken, + expiryDate: tokens.expiry_date || existing.expiryDate, + }); + processLog.info("💾 Saved Google Drive tokens to store"); + } + + /** + * 关闭认证服务器 + */ + private closeAuthServer(): void { + if (this.authServer) { + this.authServer.close(); + this.authServer = null; + } + this.isAuthenticating = false; + } + + /** + * 检查是否有有效的 tokens + */ + hasValidTokens(): boolean { + const stored = this.store.get("googleDrive"); + return !!(stored?.refreshToken); + } + + /** + * 获取当前认证状态 + */ + getAuthStatus(): { authenticated: boolean; hasRefreshToken: boolean } { + const stored = this.store.get("googleDrive"); + return { + authenticated: !!stored?.refreshToken, + hasRefreshToken: !!stored?.refreshToken, + }; + } + + /** + * 启动 OAuth2 授权流程 + */ + startAuth(): Promise<{ status: string; message?: string }> { + // 如果已经认证,直接返回成功 + if (this.hasValidTokens()) { + processLog.info("✅ Already authenticated with Google Drive"); + return Promise.resolve({ status: "success", message: "Already authenticated" }); + } + + // 如果正在认证中,返回提示 + if (this.isAuthenticating) { + processLog.warn("⚠️ Authentication already in progress"); + return Promise.resolve({ status: "pending", message: "认证正在进行中,请在浏览器中完成授权" }); + } + + this.isAuthenticating = true; + + return new Promise((resolve, reject) => { + processLog.info("🔐 Starting Google OAuth2 flow..."); + + // 创建临时本地服务器来接收回调 + this.authServer = http.createServer(async (req, res) => { + try { + if (!req.url) { + res.end("Invalid request"); + return; + } + + const parsedUrl = new URL(req.url, "http://localhost:3000"); + + // 只处理 oauth2callback 路径 + if (parsedUrl.pathname !== "/oauth2callback") { + res.end("Invalid path"); + return; + } + + const code = parsedUrl.searchParams.get("code"); + const error = parsedUrl.searchParams.get("error"); + + if (error) { + res.end(`Authentication failed: ${error}`); + this.closeAuthServer(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (code) { + // 关闭浏览器显示的页面 + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(` + + 认证成功 + +

✅ 认证成功!

+

您可以关闭此窗口并返回应用。

+ + + + `); + + // 关闭服务器 + this.closeAuthServer(); + + // 用 Code 换取 Tokens + const { tokens } = await this.oauth2Client.getToken(code); + this.oauth2Client.setCredentials(tokens); + + // 保存 tokens + this.saveTokens(tokens); + + processLog.info("✅ Google OAuth2 authentication successful"); + resolve({ status: "success" }); + } + } catch (e) { + const error = e as Error; + processLog.error("❌ Error getting tokens:", error); + this.closeAuthServer(); + reject(error); + } + }); + + // 处理服务器错误 + this.authServer.on("error", (err) => { + processLog.error("❌ OAuth server error:", err); + this.closeAuthServer(); + reject(new Error(`Server error: ${err.message}`)); + }); + + // 监听端口 + this.authServer.listen(3000, () => { + processLog.info("📡 OAuth callback server listening on port 3000"); + + // 生成授权 URL 并打开用户默认浏览器 + const authUrl = this.oauth2Client.generateAuthUrl({ + access_type: "offline", // 获取 refresh_token + scope: SCOPES, + prompt: "consent", // 强制显示同意页面以获取 refresh_token + }); + + shell.openExternal(authUrl); + processLog.info("🌐 Opened browser for Google authorization"); + }); + + // 设置超时(2分钟) + setTimeout(() => { + if (this.isAuthenticating) { + this.closeAuthServer(); + reject(new Error("Authentication timeout")); + } + }, 120000); + }); + } + + /** + * 获取 Drive 文件列表 + */ + async getFiles(pageSize = 10): Promise { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + + const drive = google.drive({ version: "v3", auth: this.oauth2Client }); + + try { + const res = await drive.files.list({ + pageSize, + fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime)", + }); + + const files = res.data.files || []; + processLog.info(`📁 Retrieved ${files.length} files from Google Drive`); + return files; + } catch (err) { + const error = err as Error; + processLog.error("❌ Error fetching Drive files:", error); + throw error; + } + } + + /** + * 扫描音频文件 + */ + async getAudioFiles(pageSize = 1000): Promise { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + + const drive = google.drive({ version: "v3", auth: this.oauth2Client }); + + try { + // 1. 根据 MIME 类型过滤 + const mimeTypeQuery = AUDIO_MIME_TYPES.map((type) => `mimeType = '${type}'`).join(" or "); + + // 2. 根据文件后缀名过滤 (更宽松,防止某些上传文件 MIME 类型不正确) + const extensions = ["mp3", "flac", "wav", "m4a", "ogg", "aac", "wma", "ape", "opus"]; + const extQuery = extensions.map((ext) => `name contains '.${ext}'`).join(" or "); + + // 组合查询:(MIME类型 OR 后缀名) 且 未删除 + const query = `trashed = false and ((${mimeTypeQuery}) or (${extQuery}))`; + + let allFiles: drive_v3.Schema$File[] = []; + let pageToken: string | undefined = undefined; + + processLog.info("🔍 Scanning for audio files in Google Drive..."); + + do { + const res = await drive.files.list({ + q: query, + pageSize, + pageToken, + fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime, webContentLink, parents)", + }); + + const files = res.data.files || []; + allFiles = allFiles.concat(files); + pageToken = res.data.nextPageToken || undefined; + } while (pageToken); + + processLog.info(`🎵 Found ${allFiles.length} audio files`); + return allFiles; + } catch (err) { + const error = err as Error; + processLog.error("❌ Error scanning audio files:", error); + throw error; + } + } + + /** + * 获取单个文件元数据 (用于补充信息) + */ + async getFileMetadata(fileId: string): Promise<{ size: number; name: string }> { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + const drive = google.drive({ version: "v3", auth: this.oauth2Client }); + try { + const res = await drive.files.get({ + fileId, + fields: "id, name, size", + }); + return { + size: Number(res.data.size) || 0, + name: res.data.name || "", + }; + } catch (error) { + processLog.error(`⚠️ Failed to fetch metadata for ${fileId}`, error); + return { size: 0, name: "" }; + } + } + + /** + * 验证 File ID 格式 + * Google Drive ID 通常由字母、数字、下划线和连字符组成 + */ + private isValidFileId(fileId: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(fileId); + } + + /** + * 获取流式播放信息 + * 返回 URL 和需要的 Headers + */ + async getStreamInfo(fileId: string): Promise<{ url: string; headers: Record }> { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + + if (!this.isValidFileId(fileId)) { + throw new Error("Invalid File ID"); + } + + // 确保 token 是新的 (虽然 proxy 会再次检查,但这里用于快速校验) + const { token } = await this.oauth2Client.getAccessToken(); + + if (!token) { + throw new Error("Failed to get access token"); + } + + // 获取 AppServer 端口 + const port = Number(process.env["VITE_SERVER_PORT"] || 25884); + const proxyUrl = `http://localhost:${port}/api/proxy/google-drive/${fileId}`; + + return { + url: proxyUrl, + headers: {}, // Proxy 不需要额外的 headers + }; + } + + /** + * 获取文件流 (用于后端代理) + */ + /** + * 获取文件流 (用于后端代理) + */ + async getFileStream(fileId: string, headers: Record = {}): Promise<{ data: any; headers: Record; statusCode: number }> { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + + if (!this.isValidFileId(fileId)) { + throw new Error("Invalid File ID"); + } + + // 确保 token 是新的 + const { token } = await this.oauth2Client.getAccessToken(); + if (!token) { + throw new Error("Failed to get access token"); + } + + const url = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`; + + // 构造请求头 + const requestHeaders: Record = { + Authorization: `Bearer ${token}`, + }; + if (headers.range) requestHeaders["Range"] = headers.range; + + try { + // 使用 axios 发起 stream 请求 + const response = await axios({ + method: 'get', + url: url, + headers: requestHeaders, + responseType: 'stream', + validateStatus: () => true // 允许所有状态码,自己处理错误 + }); + + // 提取需要的 headers + const responseHeaders: Record = {}; + + // 转发关键头 + ["content-type", "content-length", "accept-ranges", "content-range"].forEach(key => { + const val = response.headers[key]; + if (val) { + responseHeaders[key] = Array.isArray(val) ? val.join(",") : String(val); + } + }); + + return { + data: response.data, + headers: responseHeaders, + statusCode: response.status + }; + + } catch (err) { + const error = err as Error; + processLog.error(`❌ Error initiating file stream for ${fileId}:`, error); + throw error; + } + } + + /** + * 下载文件到指定路径 + */ + async downloadFile(fileId: string, destPath: string): Promise { + if (!this.hasValidTokens()) { + throw new Error("Not authenticated"); + } + + const drive = google.drive({ version: "v3", auth: this.oauth2Client }); + const fs = require("fs"); + + try { + const dest = fs.createWriteStream(destPath); + + const res = await drive.files.get( + { fileId, alt: "media" }, + { responseType: "stream" } + ); + + return new Promise((resolve, reject) => { + res.data + .on("end", () => { + processLog.info(`✅ Downloaded file ${fileId} to ${destPath}`); + resolve(); + }) + .on("error", (err: Error) => { + processLog.error(`❌ Error downloading file ${fileId}:`, err); + reject(err); + }) + .pipe(dest); + }); + } catch (err) { + const error = err as Error; + processLog.error(`❌ Error initiating download for ${fileId}:`, error); + throw error; + } + } + + /** + * 登出并清除 tokens + */ + logout(): void { + this.oauth2Client.revokeCredentials().catch((err) => { + processLog.warn("⚠️ Error revoking credentials:", err); + }); + this.store.delete("googleDrive" as keyof ReturnType["store"]); + this.oauth2Client.setCredentials({}); + processLog.info("🚪 Logged out from Google Drive"); + } +} diff --git a/electron/main/services/RemoteFolderService.ts b/electron/main/services/RemoteFolderService.ts new file mode 100644 index 000000000..f38f4703c --- /dev/null +++ b/electron/main/services/RemoteFolderService.ts @@ -0,0 +1,175 @@ +import { existsSync, promises as fs } from "fs"; +import { createClient } from "webdav"; +import { Client } from "basic-ftp"; + +/** 远程文件夹类型 */ +export type RemoteFolderType = "smb" | "nfs" | "webdav" | "ftp"; + +/** 测试连接参数 */ +export interface TestConnectionParams { + type: RemoteFolderType; + path: string; + username?: string; + password?: string; +} + +/** 测试连接结果 */ +export interface TestConnectionResult { + success: boolean; + message?: string; +} + +/** + * 远程文件夹服务 + * 负责处理 SMB/NFS/WebDAV/FTP 连接 + */ +export class RemoteFolderService { + private static instance: RemoteFolderService; + + private constructor() {} + + static getInstance(): RemoteFolderService { + if (!RemoteFolderService.instance) { + RemoteFolderService.instance = new RemoteFolderService(); + } + return RemoteFolderService.instance; + } + + /** + * 测试远程连接 + * @param params 连接参数 + * @returns 测试结果 + */ + async testConnection(params: TestConnectionParams): Promise { + const { type, path } = params; + + switch (type) { + case "smb": + case "nfs": + return this.testNetworkPath(path); + case "webdav": + return this.testWebDav(params); + case "ftp": + return this.testFtp(params); + default: + return { success: false, message: "不支持的协议类型" }; + } + } + + /** + * 测试 SMB/NFS 网络路径 + * Windows 上 SMB 和 NFS 可以通过 UNC 路径直接访问 + */ + private async testNetworkPath(path: string): Promise { + try { + // 验证路径格式 (仅 Windows 需要 UNC 格式) + if (process.platform === "win32" && !path.startsWith("\\\\")) { + return { success: false, message: "路径格式错误,应为 \\\\server\\share 格式" }; + } + + // 检查路径是否存在 + if (existsSync(path)) { + // 尝试读取目录 + try { + await fs.readdir(path); + return { success: true, message: "连接成功" }; + } catch (readError) { + return { + success: false, + message: `无法读取目录: ${(readError as Error).message}` + }; + } + } else { + return { success: false, message: "路径不存在或无法访问" }; + } + } catch (error) { + return { + success: false, + message: `连接失败: ${(error as Error).message}` + }; + } + } + + /** + * 测试 WebDAV 连接 + * 使用 webdav 库 + */ + private async testWebDav(params: TestConnectionParams): Promise { + try { + const client = createClient(params.path, { + username: params.username, + password: params.password, + }); + + // 尝试获取根目录信息以验证连接 + await client.stat("/"); + return { success: true, message: "WebDAV 连接成功" }; + } catch (error) { + return { success: false, message: `连接失败: ${(error as Error).message}` }; + } + } + + /** + * 测试 FTP 连接 + * 使用 basic-ftp 库 + */ + private async testFtp(params: TestConnectionParams): Promise { + const client = new Client(); + // 设置超时 + client.ftp.verbose = false; + + try { + const url = new URL(params.path); + const port = parseInt(url.port) || 21; + + await client.access({ + host: url.hostname, + port: port, + user: params.username, + password: params.password, + secure: false, // 暂不支持 FTPS,如有需要可扩展 + }); + + return { success: true, message: "FTP 连接成功" }; + } catch (error) { + return { success: false, message: `连接失败: ${(error as Error).message}` }; + } finally { + client.close(); + } + } + + /** + * 检查远程路径是否可访问 + * @param path 路径 + * @returns 是否可访问 + */ + async isPathAccessible(path: string): Promise { + try { + if (path.startsWith("\\\\")) { + // 网络路径 + return existsSync(path); + } + return false; + } catch { + return false; + } + } + + /** + * 获取远程文件夹的音乐文件列表 + * 对于 SMB/NFS,可以直接使用 LocalMusicService 的扫描逻辑 + * 因为 Windows 上网络路径行为与本地路径相同 + */ + async getMusicFiles(path: string): Promise { + if (path.startsWith("\\\\")) { + // SMB/NFS 网络路径可以直接由 LocalMusicService 处理 + // 这里只做检查 + if (!existsSync(path)) { + throw new Error("远程路径不可访问"); + } + return [path]; // 返回路径让 LocalMusicService 处理 + } + + throw new Error("不支持的远程路径格式"); + } +} diff --git a/electron/main/store/index.ts b/electron/main/store/index.ts index 63eb75723..343b9944e 100644 --- a/electron/main/store/index.ts +++ b/electron/main/store/index.ts @@ -54,6 +54,12 @@ export interface StoreType { /** 端口 */ port: number; }; + /** Google Drive OAuth2 tokens */ + googleDrive?: { + accessToken?: string; + refreshToken?: string; + expiryDate?: number; + }; } /** diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 62259bbee..a17b22379 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -20,6 +20,15 @@ declare global { error(message: string, ...args: unknown[]): void; debug(message: string, ...args: unknown[]): void; }; + googleDrive: { + login: () => Promise<{ status: string; message?: string }>; + getStatus: () => Promise<{ authenticated: boolean; hasRefreshToken: boolean }>; + getFiles: (pageSize?: number) => Promise<{ status: string; message?: string; files: any[] }>; + logout: () => Promise<{ status: string; message?: string }>; + scanAudio: (pageSize?: number) => Promise<{ status: string; message?: string; files: any[] }>; + getStreamInfo: (fileId: string) => Promise<{ status: string; message?: string; info?: { url: string; headers: Record } }>; + downloadFile: (fileId: string, destPath: string) => Promise<{ status: string; message?: string }>; + }; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index fec03db0a..3b9c6c433 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -29,6 +29,18 @@ if (process.contextIsolated) { debug: (message: string, ...args: unknown[]) => ipcRenderer.send("renderer-log", "debug", message, args), }, + // Google Drive API + googleDrive: { + login: () => ipcRenderer.invoke("google-drive-login"), + getStatus: () => ipcRenderer.invoke("google-drive-status"), + getFiles: (pageSize?: number) => ipcRenderer.invoke("google-drive-files", pageSize), + logout: () => ipcRenderer.invoke("google-drive-logout"), + // 新增 API + scanAudio: (pageSize?: number) => ipcRenderer.invoke("google-drive-scan", pageSize), + getStreamInfo: (fileId: string) => ipcRenderer.invoke("google-drive-stream-info", fileId), + downloadFile: (fileId: string, destPath: string) => + ipcRenderer.invoke("google-drive-download", fileId, destPath), + }, }); } catch (error) { console.error(error); diff --git a/electron/server/google-drive.ts b/electron/server/google-drive.ts new file mode 100644 index 000000000..2affcac74 --- /dev/null +++ b/electron/server/google-drive.ts @@ -0,0 +1,56 @@ +import { FastifyInstance } from "fastify"; +import { GoogleDriveService } from "../main/services/GoogleDriveService"; + +import { serverLog } from "../main/logger"; + +/** + * 初始化 Google Drive 代理服务 + * @param server Fastify 实例 + */ +export const initGoogleDriveProxy = async (server: FastifyInstance) => { + server.get("/proxy/google-drive/:id", async (req, reply) => { + const { id } = req.params as { id: string }; + + try { + const driveService = GoogleDriveService.getInstance(); + if (!driveService.hasValidTokens()) { + return reply.status(401).send("Not authenticated with Google Drive"); + } + + // 获取 Token + // 这里我们需要更 hack 一点,因为 GoogleDriveService 本身的 oauth2Client 是私有的 + // 但我们可以通过 getStreamInfo 间接获取 token,或者扩展 GoogleDriveService + // 简单起见,我们直接用 GoogleDriveService 内部逻辑获取 token, + // 但最干净的方法是扩展 GoogleDriveService 提供一个 getToken() 方法。 + // 不过为了尽量少改动,我们直接在 server 里创建临时 client 或复用 service 方法。 + + // Update: 既然 Service 是单例且在 Main 进程,虽然 Server 也是在 Main 进程中初始化, + // 我们可以尝试扩展 GoogleDriveService 来获取 stream。 + + // 让 GoogleDriveService 提供一个方法返回 stream + const headersInit: Record = {}; + if (req.headers.range) { + headersInit["range"] = req.headers.range as string; + } + + const { data, headers, statusCode } = await driveService.getFileStream(id, headersInit); + + // 设置响应头 + Object.entries(headers).forEach(([key, value]) => { + reply.header(key, value); + }); + // 强制流媒体相关头 + reply.header("Accept-Ranges", "bytes"); + + // 设置状态码 + reply.status(statusCode); + + // 返回流 + return reply.send(data); + + } catch (error: any) { + serverLog.error(`❌ Google Drive Proxy Error [${id}]:`, error); + return reply.status(500).send(error.message || "Internal Proxy Error"); + } + }); +}; diff --git a/electron/server/index.ts b/electron/server/index.ts index 20f096ecd..ee0376f26 100644 --- a/electron/server/index.ts +++ b/electron/server/index.ts @@ -5,6 +5,7 @@ import { initNcmAPI } from "./netease"; import { initUnblockAPI } from "./unblock"; import { initControlAPI } from "./control"; import { initQQMusicAPI } from "./qqmusic"; +import { initGoogleDriveProxy } from "./google-drive"; import fastifyCookie from "@fastify/cookie"; import fastifyMultipart from "@fastify/multipart"; import fastifyStatic from "@fastify/static"; @@ -59,6 +60,8 @@ const initAppServer = async () => { server.register(initUnblockAPI, { prefix: "/api" }); server.register(initControlAPI, { prefix: "/api" }); server.register(initQQMusicAPI, { prefix: "/api" }); + // 注册 Google Drive 代理 + server.register(initGoogleDriveProxy, { prefix: "/api" }); // 启动端口 const port = Number(process.env["VITE_SERVER_PORT"] || 25884); await server.listen({ port }); diff --git a/package.json b/package.json index 28d7fa911..46260376d 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "get-port": "^7.1.0", "github-markdown-css": "^5.8.1", "got": "^14.6.5", + "googleapis": "^168.0.0", "js-cookie": "^3.0.5", "jss": "^10.10.0", "jss-preset-default": "^10.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9070205e..883501153 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: axios-retry: specifier: ^4.5.0 version: 4.5.0(axios@1.13.2) + basic-ftp: + specifier: ^5.1.0 + version: 5.1.0 better-sqlite3: specifier: ^12.5.0 version: 12.5.0 @@ -96,6 +99,9 @@ importers: github-markdown-css: specifier: ^5.8.1 version: 5.8.1 + googleapis: + specifier: ^168.0.0 + version: 168.0.0 got: specifier: ^14.6.5 version: 14.6.5 @@ -138,6 +144,9 @@ importers: sortablejs: specifier: ^1.15.6 version: 1.15.6 + webdav: + specifier: ^5.8.0 + version: 5.8.0 ws: specifier: ^8.18.3 version: 8.18.3 @@ -555,6 +564,9 @@ packages: '@borewit/text-codec@0.2.0': resolution: {integrity: sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==} + '@buttercup/fetch@0.2.1': + resolution: {integrity: sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==} + '@css-render/plugin-bem@0.15.14': resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} peerDependencies: @@ -2696,6 +2708,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2703,8 +2718,8 @@ packages: resolution: {integrity: sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==} hasBin: true - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + basic-ftp@5.1.0: + resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==} engines: {node: '>=10.0.0'} before-after-hook@4.0.0: @@ -2717,6 +2732,9 @@ packages: bezier-easing@2.1.0: resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2767,6 +2785,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2795,6 +2816,9 @@ packages: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} engines: {node: '>=20'} + byte-length@1.0.2: + resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3080,6 +3104,10 @@ packages: cwise-compiler@1.1.3: resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -3253,6 +3281,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3337,6 +3368,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3522,6 +3557,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -3566,6 +3604,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -3587,6 +3629,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3669,6 +3715,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3727,6 +3777,14 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3818,6 +3876,22 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@168.0.0: + resolution: {integrity: sha512-A1gQtli8/UXjuGnBAwvObwcsIs2xheV82OnmnrrqSOzgzU0gsu18VbOBLh6G8DiMsZze5hJmOJJM7qsVuqAe6g==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3836,6 +3910,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -3880,6 +3958,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + hot-patcher@2.0.1: + resolution: {integrity: sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==} + htm@3.1.1: resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} @@ -4126,6 +4207,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -4203,6 +4287,12 @@ packages: jss@10.10.0: resolution: {integrity: sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4212,6 +4302,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + layerr@3.0.0: + resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==} + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -4561,6 +4654,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nested-property@4.0.0: + resolution: {integrity: sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==} + netmask@2.0.2: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} @@ -4578,6 +4674,15 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -4736,6 +4841,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-posix@1.0.0: + resolution: {integrity: sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -4937,6 +5045,9 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5018,6 +5129,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5068,6 +5182,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rimraf@6.1.2: resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} engines: {node: 20 || >=22} @@ -5348,6 +5466,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -5634,9 +5755,19 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-polyfill@1.1.14: resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -5820,6 +5951,14 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webdav@5.8.0: + resolution: {integrity: sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==} + engines: {node: '>=14'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -6302,6 +6441,10 @@ snapshots: '@borewit/text-codec@0.2.0': {} + '@buttercup/fetch@0.2.1': + optionalDependencies: + node-fetch: 3.3.2 + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': dependencies: css-render: 0.15.14 @@ -8312,11 +8455,13 @@ snapshots: balanced-match@1.0.2: {} + base-64@1.0.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.4: {} - basic-ftp@5.0.5: {} + basic-ftp@5.1.0: {} before-after-hook@4.0.0: {} @@ -8327,6 +8472,8 @@ snapshots: bezier-easing@2.1.0: {} + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} binary-install@1.1.2: @@ -8408,6 +8555,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -8459,6 +8608,8 @@ snapshots: byte-counter@0.1.0: {} + byte-length@1.0.2: {} + bytes@3.1.2: {} cac@6.7.14: {} @@ -8755,6 +8906,8 @@ snapshots: dependencies: uniq: 1.0.1 + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} date-fns-tz@3.2.0(date-fns@4.1.0): @@ -8917,6 +9070,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} ejs@3.1.10: @@ -9040,6 +9197,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -9326,6 +9485,8 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: {} + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -9376,6 +9537,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + fastify-plugin@5.1.0: {} fastify@5.6.2: @@ -9408,6 +9573,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -9510,6 +9680,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -9569,6 +9743,23 @@ snapshots: fuse.js@7.1.0: {} + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -9610,7 +9801,7 @@ snapshots: get-uri@6.0.5: dependencies: - basic-ftp: 5.0.5 + basic-ftp: 5.1.0 data-uri-to-buffer: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -9689,6 +9880,37 @@ snapshots: gopd: 1.2.0 optional: true + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + qs: 6.14.0 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@168.0.0: + dependencies: + google-auth-library: 10.5.0 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} got@11.8.6: @@ -9724,6 +9946,13 @@ snapshots: graphemer@1.4.0: {} + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -9771,6 +10000,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + hot-patcher@2.0.1: {} + htm@3.1.1: {} html-void-elements@3.0.0: {} @@ -9976,6 +10207,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-schema-ref-resolver@3.0.0: @@ -10099,6 +10334,17 @@ snapshots: is-in-browser: 1.1.3 tiny-warning: 1.0.3 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -10109,6 +10355,8 @@ snapshots: kolorist@1.8.0: {} + layerr@3.0.0: {} + lazy-val@1.0.5: {} lcid@3.1.1: @@ -10455,6 +10703,8 @@ snapshots: negotiator@1.0.0: {} + nested-property@4.0.0: {} + netmask@2.0.2: {} node-abi@3.85.0: @@ -10471,6 +10721,14 @@ snapshots: dependencies: semver: 7.7.3 + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-forge@1.3.3: {} node-releases@2.0.27: {} @@ -10629,6 +10887,8 @@ snapshots: path-key@3.1.1: {} + path-posix@1.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -10839,6 +11099,8 @@ snapshots: quansync@0.2.11: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -10920,6 +11182,8 @@ snapshots: require-main-filename@2.0.0: {} + requires-port@1.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -10959,6 +11223,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rimraf@6.1.2: dependencies: glob: 13.0.0 @@ -11341,6 +11609,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@1.1.2: {} + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -11645,8 +11915,17 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@5.0.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + url-polyfill@1.1.14: {} + url-template@2.0.8: {} + url@0.11.4: dependencies: punycode: 1.4.1 @@ -11883,6 +12162,25 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} + + webdav@5.8.0: + dependencies: + '@buttercup/fetch': 0.2.1 + base-64: 1.0.0 + byte-length: 1.0.2 + entities: 6.0.1 + fast-xml-parser: 4.5.3 + hot-patcher: 2.0.1 + layerr: 3.0.0 + md5: 2.3.0 + minimatch: 9.0.5 + nested-property: 4.0.0 + node-fetch: 3.3.2 + path-posix: 1.0.0 + url-join: 5.0.0 + url-parse: 1.5.10 + webpack-virtual-modules@0.6.2: {} when-exit@2.1.5: {} diff --git a/src/components/Modal/AddRemoteFolderModal.vue b/src/components/Modal/AddRemoteFolderModal.vue new file mode 100644 index 000000000..16a17e67e --- /dev/null +++ b/src/components/Modal/AddRemoteFolderModal.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/components/Setting/LocalSetting.vue b/src/components/Setting/LocalSetting.vue index 3ffd88bcc..c27f94884 100644 --- a/src/components/Setting/LocalSetting.vue +++ b/src/components/Setting/LocalSetting.vue @@ -36,12 +36,19 @@ 本地歌曲目录 可在此增删本地歌曲目录,歌曲增删实时同步 - - - 添加 - + + + + 添加 + + +
+ 云盘配置 + +
+ Google Drive 播放模式 + 选择云盘歌曲的播放方式 +
+ +
+ +
+ 仅扫描音频文件 + 扫描时忽略非音频文件 +
+ +
+
缓存配置 @@ -338,6 +369,7 @@