diff --git a/README.md b/README.md index 7392684..f379ce6 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ ## 功能特性 -- **多后端支持** — 兼容 115 云盘、PikPak 云盘、OneDrive和本地存储 -- **零带宽消耗** — 云盘视频采用 302 重定向播放,不占用服务器带宽 +- **多后端支持** — 兼容 115 云盘、PikPak 云盘、OneDrive、Google Drive 和本地存储 +- **低带宽播放** — 支持 302 的云盘可直连播放;Google Drive 等需鉴权直链的来源走后端代理 - **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片 - **91 爬虫** — 内置爬虫,支持抓取 91 本月最热视频 - **双主题** — 黑黄经典主题 / 粉白清新主题,随时切换 diff --git a/backend/README.md b/backend/README.md index b45ae22..c5101e5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ 视频聚合站的 Go 后端。提供三件事: -1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘 / OneDrive / 本地存储) +1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘 / OneDrive / Google Drive / 本地存储) 2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成 3. REST API(前台)+ 管理后台 + 直链代理 4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力 @@ -21,6 +21,7 @@ internal/ pikpak/ PikPak(自己实现,参考 OpenList pikpak) wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go) onedrive/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口) + googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理) localstorage/ 本地目录扫描(服务器已有视频目录) scanner/ 扫目录 → 落库 preview/ ffmpeg 抽封面和生成多段 teaser @@ -109,6 +110,7 @@ go run ./cmd/server 后端 9192 | pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) | | wopan | `access_token`、`refresh_token`,可选 `family_id` | | onedrive | `refresh_token` | +| googledrive | `refresh_token` | | localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos`) | ### PikPak 速度说明 @@ -119,6 +121,8 @@ 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 白名单。 + ## 文件名约定 扫描器按以下顺序解析文件名,用于提取标题和作者: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a9c8713..67fa4ae 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -25,6 +25,7 @@ import ( "github.com/video-site/backend/internal/catalog" "github.com/video-site/backend/internal/config" "github.com/video-site/backend/internal/drives" + "github.com/video-site/backend/internal/drives/googledrive" "github.com/video-site/backend/internal/drives/localstorage" "github.com/video-site/backend/internal/drives/localupload" "github.com/video-site/backend/internal/drives/onedrive" @@ -634,6 +635,27 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error { _ = a.cat.UpsertDrive(ctx, d) }, }) + case googledrive.Kind: + drv = googledrive.New(googledrive.Config{ + ID: d.ID, + RootID: d.RootID, + AccessToken: d.Credentials["access_token"], + RefreshToken: d.Credentials["refresh_token"], + ClientID: d.Credentials["client_id"], + ClientSecret: d.Credentials["client_secret"], + UseOnlineAPI: parseBoolDefault(d.Credentials["use_online_api"], true), + RenewAPIURL: d.Credentials["api_url_address"], + OAuthURL: d.Credentials["oauth_url"], + APIBaseURL: d.Credentials["api_base_url"], + OnTokenUpdate: func(access, refresh string) { + if d.Credentials == nil { + d.Credentials = make(map[string]string) + } + d.Credentials["access_token"] = access + d.Credentials["refresh_token"] = refresh + _ = a.cat.UpsertDrive(ctx, d) + }, + }) case localstorage.Kind: drv = localstorage.New(localstorage.Config{ ID: d.ID, diff --git a/backend/config.example.yaml b/backend/config.example.yaml index ac27775..d2d0656 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -59,7 +59,7 @@ preview: width: 480 # 盘列表。上线后请通过管理后台添加,本文件可留空。 -# kind 支持 quark / p115 / pikpak / wopan / onedrive / localstorage。 +# kind 支持 quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage。 # OneDrive 示例: # - id: "my-onedrive" # kind: "onedrive" @@ -67,6 +67,13 @@ preview: # root_id: "root" # params: # refresh_token: "..." +# Google Drive 示例: +# - id: "my-google" +# kind: "googledrive" +# name: "我的 Google Drive" +# root_id: "root" +# params: +# refresh_token: "..." # 本地存储示例: # - id: "local-media" # kind: "localstorage" diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index ed42815..8f9c261 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -900,6 +900,8 @@ func driveKindLabel(kind string) string { return "联通沃盘" case "onedrive": return "OneDrive" + case "googledrive": + return "Google Drive" case localstorage.Kind: return "本地存储" case spider91.Kind: diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index c3d90a3..6f79aa1 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -64,7 +64,7 @@ CREATE INDEX IF NOT EXISTS idx_video_tags_video ON video_tags(video_id); -- 网盘账户 CREATE TABLE IF NOT EXISTS drives ( id TEXT PRIMARY KEY, - kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive / localstorage / spider91 + kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage / spider91 name TEXT NOT NULL, root_id TEXT NOT NULL DEFAULT '0', scan_root_id TEXT, -- 扫描起点(默认 root_id) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7816c9b..ccb9bba 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -202,7 +202,7 @@ type Nightly struct { // 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 type Drive struct { ID string `yaml:"id"` - Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan / onedrive / localstorage + Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage Name string `yaml:"name"` RootID string `yaml:"root_id"` Params map[string]string `yaml:"params,omitempty"` diff --git a/backend/internal/drives/googledrive/driver.go b/backend/internal/drives/googledrive/driver.go new file mode 100644 index 0000000..562b326 --- /dev/null +++ b/backend/internal/drives/googledrive/driver.go @@ -0,0 +1,505 @@ +package googledrive + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/video-site/backend/internal/drives" +) + +const ( + Kind = "googledrive" + defaultAPIBaseURL = "https://www.googleapis.com/drive/v3" + defaultOAuthURL = "https://www.googleapis.com/oauth2/v4/token" + defaultRenewAPIURL = "https://api.oplist.org/googleui/renewapi" + defaultListInterval = 1 * time.Second + defaultListCooldown = 5 * time.Minute + + filesListFields = "files(id,name,mimeType,size,modifiedTime,createdTime,thumbnailLink,shortcutDetails,md5Checksum,sha1Checksum,sha256Checksum),nextPageToken" + fileInfoFields = "id,name,mimeType,size,modifiedTime,createdTime,thumbnailLink,shortcutDetails,md5Checksum,sha1Checksum,sha256Checksum" +) + +type Driver struct { + id string + rootID string + refreshToken string + accessToken string + clientID string + clientSecret string + useOnlineAPI bool + renewAPIURL string + oauthURL string + apiBaseURL string + client *resty.Client + onTokenUpdate func(access, refresh string) + + listMu sync.Mutex + lastListAt time.Time + listInterval time.Duration + listCooldown time.Duration +} + +type Config struct { + ID string + RootID string + RefreshToken string + AccessToken string + ClientID string + ClientSecret string + UseOnlineAPI bool + RenewAPIURL string + OAuthURL string + APIBaseURL string + + OnTokenUpdate func(access, refresh string) +} + +func New(c Config) *Driver { + rootID := strings.TrimSpace(c.RootID) + if rootID == "" { + rootID = "root" + } + renewAPIURL := strings.TrimSpace(c.RenewAPIURL) + if renewAPIURL == "" { + renewAPIURL = defaultRenewAPIURL + } + oauthURL := strings.TrimSpace(c.OAuthURL) + if oauthURL == "" { + oauthURL = defaultOAuthURL + } + apiBaseURL := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/") + if apiBaseURL == "" { + apiBaseURL = defaultAPIBaseURL + } + return &Driver{ + id: c.ID, + rootID: rootID, + refreshToken: strings.TrimSpace(c.RefreshToken), + accessToken: strings.TrimSpace(c.AccessToken), + clientID: strings.TrimSpace(c.ClientID), + clientSecret: strings.TrimSpace(c.ClientSecret), + useOnlineAPI: c.UseOnlineAPI, + renewAPIURL: renewAPIURL, + oauthURL: oauthURL, + apiBaseURL: apiBaseURL, + onTokenUpdate: c.OnTokenUpdate, + client: resty.New(). + SetTimeout(30*time.Second). + SetHeader("Accept", "application/json, text/plain, */*"), + listInterval: defaultListInterval, + listCooldown: defaultListCooldown, + } +} + +func (d *Driver) Kind() string { return Kind } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +func (d *Driver) Init(ctx context.Context) error { + if d.refreshToken == "" { + return errors.New("googledrive init: refresh_token is required") + } + return d.refresh(ctx) +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + if dirID == "" { + dirID = d.rootID + } + d.listMu.Lock() + defer d.listMu.Unlock() + + pageToken := "" + out := make([]drives.Entry, 0) + for { + if err := d.waitForListSlotLocked(ctx); err != nil { + return nil, err + } + var resp filesResp + err := d.request(ctx, d.filesURL(), http.MethodGet, func(req *resty.Request) { + params := map[string]string{ + "fields": filesListFields, + "pageSize": "1000", + "q": fmt.Sprintf("'%s' in parents and trashed = false", strings.ReplaceAll(dirID, "'", "\\'")), + "orderBy": "folder,name,modifiedTime desc", + } + if pageToken != "" { + params["pageToken"] = pageToken + } + req.SetQueryParams(params) + }, &resp) + if err != nil { + if wait, ok := drives.RateLimitRetryAfter(err); ok { + if wait <= 0 { + wait = d.listCooldown + } + if sleepErr := sleepContext(ctx, wait); sleepErr != nil { + return nil, sleepErr + } + continue + } + return nil, fmt.Errorf("googledrive list: %w", err) + } + if err := d.fillShortcutFileMetadata(ctx, resp.Files); err != nil { + return nil, fmt.Errorf("googledrive shortcut metadata: %w", err) + } + for _, f := range resp.Files { + out = append(out, fileToEntry(f, dirID)) + } + pageToken = resp.NextPageToken + if pageToken == "" { + return out, nil + } + } +} + +func (d *Driver) waitForListSlotLocked(ctx context.Context) error { + if d.listInterval <= 0 || d.lastListAt.IsZero() { + d.lastListAt = time.Now() + return ctx.Err() + } + next := d.lastListAt.Add(d.listInterval) + now := time.Now() + if now.Before(next) { + if err := sleepContext(ctx, next.Sub(now)); err != nil { + return err + } + } + d.lastListAt = time.Now() + return ctx.Err() +} + +func sleepContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + var f driveFile + if err := d.request(ctx, d.fileURL(fileID), http.MethodGet, func(req *resty.Request) { + req.SetQueryParam("fields", fileInfoFields) + }, &f); err != nil { + return nil, fmt.Errorf("googledrive stat: %w", err) + } + e := fileToEntry(f, "") + return &e, nil +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + if fileID == "" { + return nil, errors.New("googledrive stream: empty file id") + } + if _, err := d.Stat(ctx, fileID); err != nil { + return nil, fmt.Errorf("googledrive stream: %w", err) + } + u := d.fileURL(fileID) + "?alt=media&acknowledgeAbuse=true&supportsAllDrives=true" + return &drives.StreamLink{ + URL: u, + Headers: http.Header{ + "Authorization": []string{"Bearer " + d.accessToken}, + }, + Expires: time.Now().Add(30 * time.Minute), + }, nil +} + +func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) { + return "", drives.ErrNotSupported +} + +func (d *Driver) EnsureDir(context.Context, string) (string, error) { + return "", drives.ErrNotSupported +} + +func (d *Driver) refresh(ctx context.Context) error { + if d.useOnlineAPI && d.renewAPIURL != "" { + var out tokenResp + res, err := d.client.R(). + SetContext(ctx). + SetQueryParams(map[string]string{ + "refresh_ui": d.refreshToken, + "server_use": "true", + "driver_txt": "googleui_go", + }). + SetResult(&out). + SetError(&out). + Get(d.renewAPIURL) + if err != nil { + return fmt.Errorf("googledrive refresh token: %w", err) + } + if err := tokenResponseError("googledrive refresh token", res, out, true); err != nil { + return err + } + d.applyToken(out) + return nil + } + if d.clientID == "" || d.clientSecret == "" { + return errors.New("googledrive refresh token: client_id and client_secret are required when online API is disabled") + } + var out tokenResp + res, err := d.client.R(). + SetContext(ctx). + SetFormData(map[string]string{ + "client_id": d.clientID, + "client_secret": d.clientSecret, + "refresh_token": d.refreshToken, + "grant_type": "refresh_token", + }). + SetResult(&out). + SetError(&out). + Post(d.oauthURL) + if err != nil { + return fmt.Errorf("googledrive refresh token: %w", err) + } + if err := tokenResponseError("googledrive refresh token", res, out, false); err != nil { + return err + } + d.applyToken(out) + return nil +} + +func (d *Driver) applyToken(out tokenResp) { + d.accessToken = out.AccessToken + if strings.TrimSpace(out.RefreshToken) != "" { + d.refreshToken = out.RefreshToken + } + if d.onTokenUpdate != nil { + d.onTokenUpdate(d.accessToken, d.refreshToken) + } +} + +func tokenResponseError(prefix string, res *resty.Response, out tokenResp, requireRefresh bool) error { + if out.Text != "" { + return fmt.Errorf("%s: %s", prefix, out.Text) + } + if out.Error != "" { + if out.ErrorDescription != "" { + return fmt.Errorf("%s: %s", prefix, out.ErrorDescription) + } + return fmt.Errorf("%s: %s", prefix, out.Error) + } + if res != nil && res.IsError() { + return fmt.Errorf("%s: status=%d body=%s", prefix, res.StatusCode(), strings.TrimSpace(res.String())) + } + if out.AccessToken == "" || (requireRefresh && out.RefreshToken == "") { + return fmt.Errorf("%s: empty token", prefix) + } + return nil +} + +func (d *Driver) request(ctx context.Context, rawURL, method string, configure func(*resty.Request), out any) error { + return d.requestOnce(ctx, rawURL, method, configure, out, true) +} + +func (d *Driver) requestOnce(ctx context.Context, rawURL, method string, configure func(*resty.Request), out any, retry bool) error { + req := d.client.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetQueryParam("includeItemsFromAllDrives", "true"). + SetQueryParam("supportsAllDrives", "true") + if configure != nil { + configure(req) + } + if out != nil { + req.SetResult(out) + } + var apiErr apiErrorResp + req.SetError(&apiErr) + res, err := req.Execute(method, rawURL) + if err != nil { + return err + } + if isGoogleRateLimit(res, apiErr.Error) { + return googleRateLimitError(res, apiErr.Error.Message) + } + if apiErr.Error.Code != 0 { + if apiErr.Error.Code == http.StatusUnauthorized && retry { + if err := d.refresh(ctx); err != nil { + return err + } + return d.requestOnce(ctx, rawURL, method, configure, out, false) + } + return googleAPIError(apiErr.Error) + } + if res.IsError() { + return fmt.Errorf("google drive api error: status=%d body=%s", res.StatusCode(), strings.TrimSpace(res.String())) + } + return nil +} + +func (d *Driver) fillShortcutFileMetadata(ctx context.Context, files []driveFile) error { + for i := range files { + f := &files[i] + if f.MimeType != "application/vnd.google-apps.shortcut" || + f.Shortcut.TargetID == "" || + f.Shortcut.TargetMimeType == "application/vnd.google-apps.folder" { + continue + } + var target driveFile + if err := d.request(ctx, d.fileURL(f.Shortcut.TargetID), http.MethodGet, func(req *resty.Request) { + req.SetQueryParam("fields", fileInfoFields) + }, &target); err != nil { + return err + } + if target.Size != "" { + f.Size = target.Size + } + if target.MD5Checksum != "" { + f.MD5Checksum = target.MD5Checksum + } + if target.SHA1Checksum != "" { + f.SHA1Checksum = target.SHA1Checksum + } + if target.SHA256Checksum != "" { + f.SHA256Checksum = target.SHA256Checksum + } + } + return nil +} + +func (d *Driver) filesURL() string { + return d.apiBaseURL + "/files" +} + +func (d *Driver) fileURL(fileID string) string { + return d.filesURL() + "/" + url.PathEscape(fileID) +} + +func fileToEntry(f driveFile, fallbackParentID string) drives.Entry { + id := f.ID + isDir := f.MimeType == "application/vnd.google-apps.folder" + if f.MimeType == "application/vnd.google-apps.shortcut" && f.Shortcut.TargetID != "" { + id = f.Shortcut.TargetID + isDir = f.Shortcut.TargetMimeType == "application/vnd.google-apps.folder" + } + size, _ := strconv.ParseInt(f.Size, 10, 64) + hash := f.MD5Checksum + if hash == "" { + hash = f.SHA1Checksum + } + if hash == "" { + hash = f.SHA256Checksum + } + return drives.Entry{ + ID: id, + Name: f.Name, + Size: size, + Hash: hash, + IsDir: isDir, + ParentID: fallbackParentID, + MimeType: mimeType(f), + ModTime: f.ModifiedTime, + ThumbnailURL: f.ThumbnailLink, + } +} + +func mimeType(f driveFile) string { + if f.MimeType != "" && f.MimeType != "application/vnd.google-apps.shortcut" { + return f.MimeType + } + if f.Shortcut.TargetMimeType != "" { + return f.Shortcut.TargetMimeType + } + ext := strings.ToLower(path.Ext(f.Name)) + switch ext { + case ".mp4": + return "video/mp4" + case ".mkv": + return "video/x-matroska" + case ".mov": + return "video/quicktime" + case ".webm": + return "video/webm" + case ".avi": + return "video/x-msvideo" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + default: + return "application/octet-stream" + } +} + +func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool { + if res != nil && res.StatusCode() == http.StatusTooManyRequests { + return true + } + if body.Code == http.StatusTooManyRequests { + return true + } + for _, e := range body.Errors { + reason := strings.ToLower(strings.TrimSpace(e.Reason)) + switch reason { + case "ratelimitexceeded", "userratelimitexceeded", "downloadquotaexceeded", "sharingratelimitexceeded": + return true + } + } + msg := strings.ToLower(body.Message) + return strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "quota exceeded") +} + +func googleRateLimitError(res *resty.Response, message string) error { + if strings.TrimSpace(message) == "" { + message = "google drive rate limited" + } + if res != nil && strings.TrimSpace(res.String()) != "" { + message = fmt.Sprintf("%s: status=%d body=%s", message, res.StatusCode(), strings.TrimSpace(res.String())) + } + return &drives.RateLimitError{ + Provider: Kind, + RetryAfter: parseRetryAfter(res), + Err: errors.New(message), + } +} + +func googleAPIError(body apiErrorBody) error { + if body.Message != "" { + return errors.New(body.Message) + } + if body.Code != 0 { + return fmt.Errorf("google drive api error: code=%d", body.Code) + } + return errors.New("google drive api error") +} + +func parseRetryAfter(res *resty.Response) time.Duration { + if res == nil { + return 0 + } + raw := strings.TrimSpace(res.Header().Get("Retry-After")) + if raw == "" { + return 0 + } + if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if when, err := http.ParseTime(raw); err == nil { + d := time.Until(when) + if d > 0 { + return d + } + } + return 0 +} + +var _ drives.Drive = (*Driver)(nil) diff --git a/backend/internal/drives/googledrive/driver_test.go b/backend/internal/drives/googledrive/driver_test.go new file mode 100644 index 0000000..15269f8 --- /dev/null +++ b/backend/internal/drives/googledrive/driver_test.go @@ -0,0 +1,190 @@ +package googledrive + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInitUsesOnlineRenewAPI(t *testing.T) { + var savedAccess, savedRefresh string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/renew" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if got := r.URL.Query().Get("refresh_ui"); got != "old-refresh" { + t.Fatalf("refresh_ui = %q", got) + } + if got := r.URL.Query().Get("server_use"); got != "true" { + t.Fatalf("server_use = %q", got) + } + if got := r.URL.Query().Get("driver_txt"); got != "googleui_go" { + t.Fatalf("driver_txt = %q", got) + } + writeTestJSON(w, tokenResp{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + }) + })) + defer srv.Close() + + d := New(Config{ + ID: "g", + RefreshToken: "old-refresh", + UseOnlineAPI: true, + RenewAPIURL: srv.URL + "/renew", + OnTokenUpdate: func(access, refresh string) { + savedAccess = access + savedRefresh = refresh + }, + }) + if err := d.Init(context.Background()); err != nil { + t.Fatalf("Init() error = %v", err) + } + if d.accessToken != "new-access" || d.refreshToken != "new-refresh" { + t.Fatalf("tokens not applied: access=%q refresh=%q", d.accessToken, d.refreshToken) + } + if savedAccess != "new-access" || savedRefresh != "new-refresh" { + t.Fatalf("tokens not persisted: access=%q refresh=%q", savedAccess, savedRefresh) + } +} + +func TestListMapsGoogleDriveFiles(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer access" { + t.Fatalf("Authorization = %q", got) + } + if r.URL.Path != "/drive/v3/files" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if !strings.Contains(r.URL.Query().Get("q"), "'root' in parents") { + t.Fatalf("unexpected q = %q", r.URL.Query().Get("q")) + } + writeTestJSON(w, filesResp{Files: []driveFile{ + {ID: "folder-1", Name: "Movies", MimeType: "application/vnd.google-apps.folder"}, + { + ID: "file-1", + Name: "clip.mp4", + MimeType: "video/mp4", + Size: "1234", + MD5Checksum: "abc", + ThumbnailLink: "https://thumb.example/1", + }, + }}) + })) + defer srv.Close() + + d := New(Config{ID: "g", RootID: "root", APIBaseURL: srv.URL + "/drive/v3"}) + d.accessToken = "access" + d.listInterval = -1 + + entries, err := d.List(context.Background(), "") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d", len(entries)) + } + if !entries[0].IsDir || entries[0].ID != "folder-1" { + t.Fatalf("folder entry = %+v", entries[0]) + } + if entries[1].ID != "file-1" || entries[1].Size != 1234 || entries[1].Hash != "abc" || entries[1].ThumbnailURL == "" { + t.Fatalf("file entry = %+v", entries[1]) + } +} + +func TestStreamURLReturnsAuthenticatedMediaLinkWithoutRedirectRequirement(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer access" { + t.Fatalf("Authorization = %q", got) + } + if r.URL.Path != "/drive/v3/files/file-1" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + writeTestJSON(w, driveFile{ + ID: "file-1", + Name: "clip.mp4", + MimeType: "video/mp4", + Size: "1234", + }) + })) + defer srv.Close() + + d := New(Config{ID: "g", APIBaseURL: srv.URL + "/drive/v3"}) + d.accessToken = "access" + + link, err := d.StreamURL(context.Background(), "file-1") + if err != nil { + t.Fatalf("StreamURL() error = %v", err) + } + if !strings.HasPrefix(link.URL, srv.URL+"/drive/v3/files/file-1?") { + t.Fatalf("link URL = %q", link.URL) + } + if !strings.Contains(link.URL, "alt=media") { + t.Fatalf("link URL missing alt=media: %q", link.URL) + } + if got := link.Headers.Get("Authorization"); got != "Bearer access" { + t.Fatalf("link Authorization = %q", got) + } +} + +func TestRequestRefreshesOnUnauthorized(t *testing.T) { + var fileCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/renew": + writeTestJSON(w, tokenResp{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + }) + case "/drive/v3/files/file-1": + fileCalls++ + if fileCalls == 1 { + writeTestJSONStatus(w, http.StatusUnauthorized, apiErrorResp{Error: apiErrorBody{ + Code: http.StatusUnauthorized, + Message: "Invalid Credentials", + }}) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer new-access" { + t.Fatalf("Authorization after refresh = %q", got) + } + writeTestJSON(w, driveFile{ID: "file-1", Name: "clip.mp4", Size: "1"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "g", + RefreshToken: "old-refresh", + UseOnlineAPI: true, + RenewAPIURL: srv.URL + "/renew", + APIBaseURL: srv.URL + "/drive/v3", + }) + d.accessToken = "old-access" + + if _, err := d.Stat(context.Background(), "file-1"); err != nil { + t.Fatalf("Stat() error = %v", err) + } + if fileCalls != 2 { + t.Fatalf("fileCalls = %d", fileCalls) + } + if d.accessToken != "new-access" || d.refreshToken != "new-refresh" { + t.Fatalf("tokens not refreshed: access=%q refresh=%q", d.accessToken, d.refreshToken) + } +} + +func writeTestJSON(w http.ResponseWriter, v any) { + writeTestJSONStatus(w, http.StatusOK, v) +} + +func writeTestJSONStatus(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/backend/internal/drives/googledrive/types.go b/backend/internal/drives/googledrive/types.go new file mode 100644 index 0000000..bcc87fb --- /dev/null +++ b/backend/internal/drives/googledrive/types.go @@ -0,0 +1,49 @@ +package googledrive + +import "time" + +type tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Text string `json:"text"` +} + +type filesResp struct { + NextPageToken string `json:"nextPageToken"` + Files []driveFile `json:"files"` + Error apiErrorBody `json:"error"` +} + +type driveFile struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + ModifiedTime time.Time `json:"modifiedTime"` + CreatedTime time.Time `json:"createdTime"` + Size string `json:"size"` + ThumbnailLink string `json:"thumbnailLink"` + MD5Checksum string `json:"md5Checksum"` + SHA1Checksum string `json:"sha1Checksum"` + SHA256Checksum string `json:"sha256Checksum"` + Shortcut struct { + TargetID string `json:"targetId"` + TargetMimeType string `json:"targetMimeType"` + } `json:"shortcutDetails"` +} + +type apiErrorResp struct { + Error apiErrorBody `json:"error"` +} + +type apiErrorBody struct { + Code int `json:"code"` + Message string `json:"message"` + Errors []struct { + Domain string `json:"domain"` + Reason string `json:"reason"` + Message string `json:"message"` + } `json:"errors"` +} diff --git a/backend/internal/drives/iface.go b/backend/internal/drives/iface.go index da8f439..0bac7d5 100644 --- a/backend/internal/drives/iface.go +++ b/backend/internal/drives/iface.go @@ -10,7 +10,7 @@ import ( // Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。 type Drive interface { - // Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan" / "onedrive" / "localstorage" + // Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage" Kind() string // ID 返回该盘在 catalog 中的唯一标识 diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index 6463e76..8efb6d9 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -26,6 +26,7 @@ const kindLabel: Record = { pikpak: "PikPak", wopan: "联通沃盘", onedrive: "OneDrive", + googledrive: "Google Drive", localstorage: "本地存储", spider91: "91 爬虫", }; @@ -905,6 +906,7 @@ function DriveForm({ + @@ -918,7 +920,7 @@ function DriveForm({ set("rootId", e.target.value)} - placeholder={form.kind === "pikpak" ? "留空表示根目录" : form.kind === "onedrive" ? "root" : "0"} + placeholder={form.kind === "pikpak" ? "留空表示根目录" : form.kind === "onedrive" || form.kind === "googledrive" ? "root" : "0"} />
@@ -1031,6 +1033,8 @@ function credentialHelp(kind: Kind, isEdit: boolean): string { return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴。${note}`; case "onedrive": return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`; + case "googledrive": + return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`; case "localstorage": return `把服务器上的一个已有目录作为视频来源扫描。填写绝对路径,例如 /mnt/videos;系统会读取该目录及子目录中的视频,并生成封面、Teaser 和指纹。${note}`; case "spider91": @@ -1114,6 +1118,16 @@ function credentialFields(kind: Kind): Array<{ required: true, }, ]; + case "googledrive": + return [ + { + key: "refresh_token", + label: "refresh_token", + placeholder: "OpenList Google Drive refresh_token", + multiline: true, + required: true, + }, + ]; case "localstorage": return [ { @@ -1132,6 +1146,7 @@ function credentialFields(kind: Kind): Array<{ function defaultRootId(kind: Kind): string { if (kind === "pikpak") return ""; if (kind === "onedrive") return "root"; + if (kind === "googledrive") return "root"; if (kind === "localstorage") return "/"; if (kind === "spider91") return "/"; return "0"; diff --git a/src/admin/api.ts b/src/admin/api.ts index b220671..37e928f 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -77,7 +77,7 @@ export function checkUpdate() { export type AdminDrive = { id: string; - kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "localstorage" | "spider91"; + kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; name: string; rootId: string; scanRootId: string; @@ -137,7 +137,7 @@ export function getDriveStorage() { export type UpsertDriveInput = { id: string; - kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "localstorage" | "spider91"; + kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; name: string; rootId: string; scanRootId: string; diff --git a/tests/adminDriveForm.test.ts b/tests/adminDriveForm.test.ts index cc00994..c52a37a 100644 --- a/tests/adminDriveForm.test.ts +++ b/tests/adminDriveForm.test.ts @@ -31,7 +31,7 @@ test("onedrive drive form only exposes required default-app fields", () => { ); const match = - /function credentialFields[\s\S]*?case "onedrive":\s*return \[([\s\S]*?)\];\s*case "spider91":/.exec( + /function credentialFields[\s\S]*?case "onedrive":\s*return \[([\s\S]*?)\];\s*case "googledrive":/.exec( drivesPageSource ); assert.ok(match, "onedrive credential field block should be present"); @@ -45,6 +45,23 @@ test("onedrive drive form only exposes required default-app fields", () => { assert.doesNotMatch(fields, /key: "site_id"/); }); +test("googledrive drive form only exposes refresh token", () => { + assert.match(drivesPageSource, /