diff --git a/backend/README.md b/backend/README.md index 1b6cc97..8a04191 100644 --- a/backend/README.md +++ b/backend/README.md @@ -109,7 +109,7 @@ go run ./cmd/server 后端 9192 | pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) | | wopan | `access_token`、`refresh_token`,可选 `family_id` | | onedrive | `refresh_token` | -| googledrive | `refresh_token` | +| googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` | | localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos`) | ### PikPak 速度说明 @@ -120,7 +120,7 @@ go run ./cmd/server 后端 9192 OneDrive 按 OpenList 默认应用方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token,不需要配置 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。后台新建 OneDrive 时只需要填 OpenList 代刷得到的 `refresh_token`;服务端会默认挂载根目录并自动回写新 token。 -Google Drive 按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。 +Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。如果不想依赖 OpenList 在线 API,可以关闭“使用 OpenList 在线续期 API”,并填写同一个 Google OAuth 客户端授权得到的 `refresh_token`、`client_id`、`client_secret`,服务端会直接请求 Google OAuth token 接口续期。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。 ## 文件名约定 diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 8318de2..0074c22 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -74,6 +74,11 @@ preview: # root_id: "root" # params: # refresh_token: "..." +# # 默认 use_online_api=true,会使用 OpenList 在线续期 API。 +# # 如需使用自己创建的 Google OAuth 客户端,取消下面三行注释: +# # use_online_api: "false" +# # client_id: "..." +# # client_secret: "..." # 本地存储示例: # - id: "local-media" # kind: "localstorage" diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 077d928..6fddaa4 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -425,6 +425,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { // 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。 Spider91Proxy string `json:"spider91Proxy,omitempty"` LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` + GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` @@ -488,6 +489,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { SkipDirIDs: append([]string{}, d.SkipDirIDs...), Spider91Proxy: spider91ProxyForDrive(d), LastCrawlAt: lastCrawlAt, + GoogleDriveUseOnlineAPI: googleDriveUseOnlineAPIForDrive(d), ScanGenerationStatus: generation.Scan, ThumbnailGenerationStatus: generation.Thumbnail, PreviewGenerationStatus: generation.Preview, @@ -547,6 +549,8 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) return } body.Credentials = credentials + } else if body.Kind == "googledrive" { + body.Credentials = mergeGoogleDriveCredentials(existing, body.Credentials) } else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 { body.Credentials = existing.Credentials } @@ -603,6 +607,47 @@ func spider91ProxyForDrive(d *catalog.Drive) string { return strings.TrimSpace(d.Credentials["proxy"]) } +func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool { + if d == nil || d.Kind != "googledrive" { + return nil + } + result := true + if d.Credentials == nil { + return &result + } + raw := strings.TrimSpace(d.Credentials["use_online_api"]) + if raw == "" { + return &result + } + v, err := strconv.ParseBool(raw) + if err != nil { + return &result + } + result = v + return &result +} + +func mergeGoogleDriveCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { + merged := map[string]string{} + if existing != nil { + for k, v := range existing.Credentials { + merged[k] = v + } + } + for k, v := range incoming { + key := strings.TrimSpace(k) + if key == "" { + continue + } + value := strings.TrimSpace(v) + if value == "" { + continue + } + merged[key] = value + } + return merged +} + func mergeSpider91Credentials(existing *catalog.Drive, incoming map[string]string) (map[string]string, error) { merged := map[string]string{} if existing != nil { diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index f73ac54..f8facb6 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -611,6 +611,67 @@ func TestHandleUpsertDriveReplacesExistingCredentialsWhenProvided(t *testing.T) } } +func TestHandleUpsertGoogleDriveMergesOAuthCredentials(t *testing.T) { + ctx := context.Background() + cat, err := catalog.Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { + if err := cat.Close(); err != nil { + t.Fatalf("close catalog: %v", err) + } + }) + + if err := cat.UpsertDrive(ctx, &catalog.Drive{ + ID: "google-main", + Kind: "googledrive", + Name: "Google Drive", + RootID: "root", + Credentials: map[string]string{ + "refresh_token": "existing-refresh", + "access_token": "existing-access", + "use_online_api": "true", + "api_url_address": "https://api.oplist.org/googleui/renewapi", + }, + Status: "ok", + }); err != nil { + t.Fatalf("seed drive: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", bytes.NewBufferString(`{ + "id": "google-main", + "kind": "googledrive", + "name": "Google Drive", + "rootId": "root", + "credentials": { + "use_online_api": "false", + "client_id": "google-client-id", + "client_secret": "google-client-secret" + } + }`)) + rr := httptest.NewRecorder() + + (&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + got, err := cat.GetDrive(ctx, "google-main") + if err != nil { + t.Fatalf("get drive: %v", err) + } + if got.Credentials["refresh_token"] != "existing-refresh" || got.Credentials["access_token"] != "existing-access" { + t.Fatalf("tokens were not preserved: %#v", got.Credentials) + } + if got.Credentials["use_online_api"] != "false" { + t.Fatalf("use_online_api = %q, want false", got.Credentials["use_online_api"]) + } + if got.Credentials["client_id"] != "google-client-id" || got.Credentials["client_secret"] != "google-client-secret" { + t.Fatalf("oauth client credentials = %#v, want saved", got.Credentials) + } +} + func TestHandleUpsertSpider91ProxyPreservesRuntimeCredentials(t *testing.T) { ctx := context.Background() cat, err := catalog.Open(t.TempDir() + "/catalog.db") @@ -905,6 +966,74 @@ func TestHandleListDrivesIncludesSpider91Proxy(t *testing.T) { } } +func TestHandleListDrivesIncludesGoogleDriveOnlineAPIMode(t *testing.T) { + ctx := context.Background() + cat, err := catalog.Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { + if err := cat.Close(); err != nil { + t.Fatalf("close catalog: %v", err) + } + }) + + for _, d := range []*catalog.Drive{ + { + ID: "google-legacy", + Kind: "googledrive", + Name: "Google Legacy", + RootID: "root", + Credentials: map[string]string{ + "refresh_token": "legacy-refresh", + }, + Status: "ok", + }, + { + ID: "google-oauth", + Kind: "googledrive", + Name: "Google OAuth", + RootID: "root", + Credentials: map[string]string{ + "refresh_token": "oauth-refresh", + "use_online_api": "false", + "client_id": "client-id", + "client_secret": "client-secret", + }, + Status: "ok", + }, + } { + if err := cat.UpsertDrive(ctx, d); err != nil { + t.Fatalf("seed drive %s: %v", d.ID, err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/admin/api/drives", nil) + rr := httptest.NewRecorder() + (&AdminServer{Catalog: cat}).handleListDrives(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var got []struct { + ID string `json:"id"` + GoogleDriveUseOnlineAPI bool `json:"googleDriveUseOnlineAPI"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + byID := map[string]bool{} + for _, d := range got { + byID[d.ID] = d.GoogleDriveUseOnlineAPI + } + if !byID["google-legacy"] { + t.Fatalf("legacy google drive use_online_api = false, want true") + } + if byID["google-oauth"] { + t.Fatalf("oauth google drive use_online_api = true, want false") + } +} + func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) { ctx := context.Background() cat, err := catalog.Open(t.TempDir() + "/catalog.db") diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index a16c721..5265b77 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -200,7 +200,12 @@ export function DrivesPage() { kind: d.kind, name: d.name, rootId: d.rootId, - creds: d.kind === "spider91" ? { proxy: d.spider91Proxy ?? "" } : {}, + creds: + d.kind === "spider91" + ? { proxy: d.spider91Proxy ?? "" } + : d.kind === "googledrive" + ? { use_online_api: (d.googleDriveUseOnlineAPI ?? true) ? "true" : "false" } + : {}, spider91UploadDriveId: settings?.spider91UploadDriveId ?? "", }; setForm(nextForm); diff --git a/src/admin/api.ts b/src/admin/api.ts index 1058a35..b929b18 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -95,6 +95,8 @@ export type AdminDrive = { lastCrawlAt?: number; // spider91 专用代理地址;仅后台管理接口返回,用于编辑表单回显。 spider91Proxy?: string; + // Google Drive 是否使用 OpenList 在线续期 API;未配置时后端按 true 返回。 + googleDriveUseOnlineAPI?: boolean; scanGenerationStatus?: DriveGenerationStatus; thumbnailGenerationStatus?: DriveGenerationStatus; previewGenerationStatus?: DriveGenerationStatus; diff --git a/src/admin/drive/DriveForm.tsx b/src/admin/drive/DriveForm.tsx index f57419b..f7d15e3 100644 --- a/src/admin/drive/DriveForm.tsx +++ b/src/admin/drive/DriveForm.tsx @@ -1,5 +1,5 @@ import { useId, useMemo, useState } from "react"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, ChevronDown } from "lucide-react"; import { P123QRCodeLogin } from "./P123QRCodeLogin"; import { Spider91UploadTargetField } from "./Spider91UploadTargetField"; import { @@ -49,7 +49,7 @@ export function DriveForm({ onBack?: () => void; }) { const idPrefix = useId(); - const fields = useMemo(() => credentialFields(form.kind), [form.kind]); + const fields = useMemo(() => credentialFields(form.kind, form.creds), [form.kind, form.creds]); const help = credentialHelp(form.kind, isEdit); const [step, setStep] = useState<"type" | "form">(isEdit ? "form" : "type"); const nameId = `${idPrefix}-drive-name`; @@ -180,25 +180,53 @@ export function DriveForm({ {fields.map((f) => (