Repository navigation
feat(drivers): add Lsky Pro driver #3180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| package lsky_pro | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "github.com/OpenListTeam/OpenList/v4/drivers/base" | ||
| "github.com/OpenListTeam/OpenList/v4/internal/driver" | ||
| "github.com/OpenListTeam/OpenList/v4/internal/errs" | ||
| "github.com/OpenListTeam/OpenList/v4/internal/model" | ||
| "github.com/OpenListTeam/OpenList/v4/internal/op" | ||
| "github.com/go-resty/resty/v2" | ||
| log "github.com/sirupsen/logrus" | ||
| ) | ||
|
|
||
| // LskyPro maps a Lsky Pro (v2, API v1) gallery to a two level tree: | ||
| // the root holds albums (folders) and the images that belong to no album, | ||
| // each album holds its images. | ||
| type LskyPro struct { | ||
| model.Storage | ||
| Addition | ||
| client *resty.Client | ||
| } | ||
|
|
||
| func (d *LskyPro) Config() driver.Config { return config } | ||
| func (d *LskyPro) GetAddition() driver.Additional { return &d.Addition } | ||
|
|
||
| func (d *LskyPro) Init(ctx context.Context) error { | ||
| d.Address = strings.TrimRight(d.Address, "/") | ||
| d.client = base.NewRestyClient(). | ||
| SetBaseURL(d.Address). | ||
| SetHeader("Accept", "application/json") | ||
|
|
||
| if d.Token == "" { | ||
| if d.Email == "" || d.Password == "" { | ||
| return fmt.Errorf("token, or email and password, is required") | ||
| } | ||
| var data TokenData | ||
| err := d.request(ctx, http.MethodPost, "/tokens", func(req *resty.Request) { | ||
| req.SetFormData(map[string]string{"email": d.Email, "password": d.Password}) | ||
| }, &data) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| d.Token = data.Token | ||
| op.MustSaveDriverStorage(d) | ||
| } | ||
| d.client.SetAuthToken(strings.TrimPrefix(d.Token, "Bearer ")) | ||
|
|
||
| // connectivity check | ||
| return d.request(ctx, http.MethodGet, "/profile", nil, nil) | ||
| } | ||
|
|
||
| func (d *LskyPro) Drop(ctx context.Context) error { return nil } | ||
|
|
||
| func (d *LskyPro) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { | ||
| var objs []model.Obj | ||
| albumID := dir.GetID() | ||
| params := map[string]string{"order": "earliest"} | ||
| if albumID == "0" || albumID == "" { | ||
| albums, err := listAll[Album](ctx, d, "/albums", nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for _, a := range albums { | ||
| objs = append(objs, a.toObj()) | ||
| } | ||
| } else { | ||
| params["album_id"] = albumID | ||
| } | ||
| images, err := listAll[Image](ctx, d, "/images", params) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for _, i := range images { | ||
| objs = append(objs, i.toObj()) | ||
| } | ||
| uniqueNames(objs) | ||
| return objs, nil | ||
| } | ||
|
|
||
| func (d *LskyPro) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { | ||
| if img, ok := file.(*imageObj); ok && img.url != "" { | ||
| return &model.Link{URL: img.url}, nil | ||
| } | ||
| return nil, errs.NotSupport | ||
| } | ||
|
|
||
| func (d *LskyPro) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { | ||
| if id := parentDir.GetID(); id != "0" && id != "" { | ||
| return nil, errs.NotSupport // albums cannot be nested | ||
| } | ||
| var album Album | ||
| err := d.request(ctx, http.MethodPost, "/albums", func(req *resty.Request) { | ||
| req.SetFormData(map[string]string{"name": dirName}) | ||
| }, &album) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return album.toObj(), nil | ||
| } | ||
|
|
||
| func (d *LskyPro) Remove(ctx context.Context, obj model.Obj) error { | ||
| path := "/images/" + obj.GetID() | ||
| if obj.IsDir() { | ||
| path = "/albums/" + obj.GetID() | ||
| } | ||
| return d.request(ctx, http.MethodDelete, path, nil, nil) | ||
| } | ||
|
|
||
| func (d *LskyPro) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { | ||
| // album_id=0 asks Lsky Pro to skip the account's default album, so a root | ||
| // upload really lands in the root (sites without the patch ignore it) | ||
| fields := map[string]string{"album_id": "0"} | ||
| if id := dstDir.GetID(); id != "0" && id != "" { | ||
| fields["album_id"] = id | ||
| } | ||
| if d.StrategyID != "" { | ||
| fields["strategy_id"] = d.StrategyID | ||
| } | ||
| var img Image | ||
| err := d.request(ctx, http.MethodPost, "/upload", func(req *resty.Request) { | ||
| req.SetMultipartFormData(fields).SetFileReader("file", file.GetName(), | ||
| driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ | ||
| Reader: file, | ||
| UpdateProgress: up, | ||
| })) | ||
| }, &img) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if img.OriginName == "" { | ||
| img.OriginName = file.GetName() | ||
| } | ||
| // Lsky Pro keeps same-named images side by side, so replace the old one | ||
| // when OpenList is overwriting an existing file. | ||
| if old := file.GetExist(); old != nil && old.GetID() != img.Key { | ||
| if err := d.Remove(ctx, old); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the gallery already contains two Useful? React with 👍 / 👎. |
||
| // keep a single record per name: drop the new upload and fail | ||
| if rerr := d.Remove(ctx, img.toObj()); rerr != nil { | ||
| log.Warnf("lsky_pro: failed to clean up image %s: %v", img.Key, rerr) | ||
| } | ||
| return nil, fmt.Errorf("lsky pro: failed to replace the existing image: %w", err) | ||
| } | ||
| } | ||
| return img.toObj(), nil | ||
| } | ||
|
|
||
| var ( | ||
| _ driver.Driver = (*LskyPro)(nil) | ||
| _ driver.MkdirResult = (*LskyPro)(nil) | ||
| _ driver.PutResult = (*LskyPro)(nil) | ||
| _ driver.Remove = (*LskyPro)(nil) | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package lsky_pro | ||
|
|
||
| import ( | ||
| "github.com/OpenListTeam/OpenList/v4/internal/driver" | ||
| "github.com/OpenListTeam/OpenList/v4/internal/op" | ||
| ) | ||
|
|
||
| type Addition struct { | ||
| driver.RootID | ||
| Address string `json:"address" required:"true" help:"Lsky Pro site address, e.g. https://img.example.com"` | ||
| Token string `json:"token" help:"API token, e.g. 1|xxxx. If empty, email and password are used to request one"` | ||
| Email string `json:"email" help:"Used to request a token when the token is empty"` | ||
| Password string `json:"password" help:"Used to request a token when the token is empty"` | ||
| StrategyID string `json:"strategy_id" help:"Storage strategy ID used for uploads, empty for the default strategy"` | ||
| } | ||
|
|
||
| var config = driver.Config{ | ||
| Name: "LskyPro", | ||
| LocalSort: true, | ||
| OnlyProxy: false, | ||
| DefaultRoot: "0", | ||
| } | ||
|
|
||
| func init() { | ||
| op.RegisterDriver(func() driver.Driver { return &LskyPro{} }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package lsky_pro | ||
|
|
||
| import ( | ||
| "time" | ||
|
|
||
| "github.com/OpenListTeam/OpenList/v4/internal/model" | ||
| ) | ||
|
|
||
| // Resp is the common response envelope of the Lsky Pro API. | ||
| type Resp[T any] struct { | ||
| Status bool `json:"status"` | ||
| Message string `json:"message"` | ||
| Data T `json:"data"` | ||
| } | ||
|
|
||
| type Page[T any] struct { | ||
| CurrentPage int `json:"current_page"` | ||
| LastPage int `json:"last_page"` | ||
| Data []T `json:"data"` | ||
| } | ||
|
|
||
| type Album struct { | ||
| ID int `json:"id"` | ||
| Name string `json:"name"` | ||
| Intro string `json:"intro"` | ||
| ImageNum int `json:"image_num"` | ||
| } | ||
|
|
||
| type Links struct { | ||
| URL string `json:"url"` | ||
| ThumbnailURL string `json:"thumbnail_url"` | ||
| } | ||
|
|
||
| type Image struct { | ||
| Key string `json:"key"` | ||
| Name string `json:"name"` | ||
| OriginName string `json:"origin_name"` | ||
| Size float64 `json:"size"` // KB | ||
| Mimetype string `json:"mimetype"` | ||
| MD5 string `json:"md5"` | ||
| SHA1 string `json:"sha1"` | ||
| Date string `json:"date"` | ||
| Links Links `json:"links"` | ||
| } | ||
|
|
||
| type TokenData struct { | ||
| Token string `json:"token"` | ||
| } | ||
|
|
||
| // imageObj carries the public url returned by the list/upload API. | ||
| type imageObj struct { | ||
| model.ObjThumb | ||
| url string | ||
| } | ||
|
|
||
| func (a Album) toObj() model.Obj { | ||
| return &model.Object{ | ||
| ID: itoa(a.ID), | ||
| Name: a.Name, | ||
| IsFolder: true, | ||
| Modified: time.Now(), | ||
| } | ||
| } | ||
|
|
||
| func (i Image) toObj() model.Obj { | ||
| name := i.OriginName | ||
| if name == "" { | ||
| name = i.Name | ||
| } | ||
| t, err := time.ParseInLocation("2006-01-02 15:04:05", i.Date, time.Local) | ||
| if err != nil { | ||
| t = time.Now() | ||
| } | ||
| return &imageObj{ | ||
| ObjThumb: model.ObjThumb{ | ||
| Object: model.Object{ | ||
| ID: i.Key, | ||
| Name: name, | ||
| Size: int64(i.Size * 1024), | ||
| Modified: t, | ||
| Ctime: t, | ||
| }, | ||
| Thumbnail: model.Thumbnail{Thumbnail: i.Links.ThumbnailURL}, | ||
| }, | ||
| url: i.Links.URL, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package lsky_pro | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "path" | ||
| "strconv" | ||
|
|
||
| "github.com/OpenListTeam/OpenList/v4/internal/model" | ||
|
|
||
| "github.com/go-resty/resty/v2" | ||
| ) | ||
|
|
||
| const apiPrefix = "/api/v1" | ||
|
|
||
| func itoa(i int) string { return strconv.Itoa(i) } | ||
|
|
||
| // request calls the Lsky Pro API, checks the response envelope and | ||
| // unmarshals its data field into out (when out is not nil). | ||
| func (d *LskyPro) request(ctx context.Context, method, path string, callback func(*resty.Request), out any) error { | ||
| req := d.client.R().SetContext(ctx) | ||
| if callback != nil { | ||
| callback(req) | ||
| } | ||
| res, err := req.Execute(method, apiPrefix+path) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| var env Resp[json.RawMessage] | ||
| if err := json.Unmarshal(res.Body(), &env); err != nil { | ||
| return fmt.Errorf("lsky pro: unexpected response (HTTP %d)", res.StatusCode()) | ||
| } | ||
| if res.StatusCode() == http.StatusUnauthorized { | ||
| return fmt.Errorf("lsky pro: unauthorized, please check the token") | ||
| } | ||
| if !env.Status { | ||
| if env.Message == "" { | ||
| return fmt.Errorf("lsky pro: request failed (HTTP %d)", res.StatusCode()) | ||
| } | ||
| return fmt.Errorf("lsky pro: %s", env.Message) | ||
| } | ||
| if out == nil || len(env.Data) == 0 { | ||
| return nil | ||
| } | ||
| return json.Unmarshal(env.Data, out) | ||
| } | ||
|
|
||
| // listAll walks every page of a paginated endpoint. | ||
| func listAll[T any](ctx context.Context, d *LskyPro, path string, params map[string]string) ([]T, error) { | ||
| var all []T | ||
| for page := 1; ; page++ { | ||
| var p Page[T] | ||
| err := d.request(ctx, http.MethodGet, path, func(req *resty.Request) { | ||
| req.SetQueryParams(params).SetQueryParam("page", strconv.Itoa(page)) | ||
| }, &p) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| all = append(all, p.Data...) | ||
| if page >= p.LastPage || len(p.Data) == 0 { | ||
| return all, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // uniqueNames makes names in one listing unique. Lsky Pro allows several | ||
| // images (or albums) with the same name, but OpenList resolves a path to the | ||
| // first object with that name. Later duplicates get their id inserted before | ||
| // the extension, e.g. "a.png" -> "a (Le5rYe).png". | ||
| func uniqueNames(objs []model.Obj) { | ||
| seen := make(map[string]struct{}, len(objs)) | ||
| for _, o := range objs { | ||
| name := o.GetName() | ||
| if _, dup := seen[name]; dup { | ||
| ext := path.Ext(name) | ||
| name = name[:len(name)-len(ext)] + " (" + o.GetID() + ")" + ext | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a real filename already equals a suffix synthesized later—for example objects ordered as Useful? React with 👍 / 👎. |
||
| switch v := o.(type) { | ||
| case *imageObj: | ||
| v.Name = name | ||
| case *model.Object: | ||
| v.Name = name | ||
| } | ||
| } | ||
| seen[name] = struct{}{} | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package lsky_pro | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/OpenListTeam/OpenList/v4/internal/model" | ||
| ) | ||
|
|
||
| func TestUniqueNames(t *testing.T) { | ||
| objs := []model.Obj{ | ||
| Album{ID: 1, Name: "pics"}.toObj(), | ||
| Album{ID: 2, Name: "pics"}.toObj(), | ||
| Image{Key: "aaa", OriginName: "a.png"}.toObj(), | ||
| Image{Key: "bbb", OriginName: "a.png"}.toObj(), | ||
| Image{Key: "ccc", OriginName: "a.png"}.toObj(), | ||
| Image{Key: "ddd", OriginName: "noext"}.toObj(), | ||
| Image{Key: "eee", OriginName: "noext"}.toObj(), | ||
| } | ||
| uniqueNames(objs) | ||
| want := []string{"pics", "pics (2)", "a.png", "a (bbb).png", "a (ccc).png", "noext", "noext (eee)"} | ||
| for i, o := range objs { | ||
| if o.GetName() != want[i] { | ||
| t.Errorf("objs[%d] = %q, want %q", i, o.GetName(), want[i]) | ||
| } | ||
| } | ||
| // ids are untouched, so Link/Remove still hit the right record | ||
| if objs[3].GetID() != "bbb" { | ||
| t.Errorf("id changed: %s", objs[3].GetID()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the mounted root has ID
0,paramscontains onlyorder; Lsky Pro API v1 interprets an omittedalbum_idas no album filter, so this call appends every album image at the root and then exposes it again inside its album. Deleting or overwriting the apparent root copy therefore mutates the same image in the album; this branch needs to send the API's explicit no-album/root filter.Useful? React with 👍 / 👎.