From d4885a0e159032d4c175f45b6e5e18747f418623 Mon Sep 17 00:00:00 2001 From: Akizon77 Date: Thu, 26 Mar 2026 21:28:06 +0800 Subject: [PATCH 1/3] feat: add emby driver support with stream and download link methods --- drivers/all.go | 1 + drivers/emby/driver.go | 425 +++++++++++++++++++++++++++++++++++++++++ drivers/emby/meta.go | 31 +++ 3 files changed, 457 insertions(+) create mode 100644 drivers/emby/driver.go create mode 100644 drivers/emby/meta.go diff --git a/drivers/all.go b/drivers/all.go index fb68d0395..4af88dc00 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -32,6 +32,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_new" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox" + _ "github.com/OpenListTeam/OpenList/v4/drivers/emby" _ "github.com/OpenListTeam/OpenList/v4/drivers/febbox" _ "github.com/OpenListTeam/OpenList/v4/drivers/ftp" _ "github.com/OpenListTeam/OpenList/v4/drivers/github" diff --git a/drivers/emby/driver.go b/drivers/emby/driver.go new file mode 100644 index 000000000..c1a28851a --- /dev/null +++ b/drivers/emby/driver.go @@ -0,0 +1,425 @@ +package emby + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "regexp" + "strings" + "time" + + "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" +) + +type Emby struct { + model.Storage + Addition + + client *http.Client + token string + userID string +} + +type authReq struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +type authResp struct { + AccessToken string `json:"AccessToken"` + User struct { + ID string `json:"Id"` + } `json:"User"` +} + +type listResp struct { + Items []embyItem `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type embyItem struct { + Name string `json:"Name"` + ID string `json:"Id"` + Type string `json:"Type"` + Path string `json:"Path"` + SeriesName string `json:"SeriesName"` + IndexNumber int `json:"IndexNumber"` + ParentIndex int `json:"ParentIndexNumber"` + IsFolder bool `json:"IsFolder"` + Size int64 `json:"Size"` + DateCreated string `json:"DateCreated"` +} + +type itemDetailResp struct { + MediaSources []embyMediaSource `json:"MediaSources"` +} + +type embyMediaSource struct { + ID string `json:"Id"` + Container string `json:"Container"` + SupportsDirectStream bool `json:"SupportsDirectStream"` +} + +var episodeCodeRegexp = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,2}\b`) + +func (d *Emby) Config() driver.Config { + return config +} + +func (d *Emby) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Emby) Init(ctx context.Context) error { + d.URL = strings.TrimRight(strings.TrimSpace(d.URL), "/") + if d.URL == "" { + return fmt.Errorf("url is required") + } + + if strings.TrimSpace(d.RootFolderID) == "" { + d.RootFolderID = "1" + } + + d.client = base.HttpClient + d.token = strings.TrimSpace(d.ApiKey) + d.userID = strings.TrimSpace(d.UserID) + + if d.token != "" { + if d.userID == "" { + return fmt.Errorf("user_id is required when api_key is set") + } + op.MustSaveDriverStorage(d) + return nil + } + + if strings.TrimSpace(d.Username) == "" || strings.TrimSpace(d.Password) == "" { + return fmt.Errorf("please provide api_key+user_id or username+password") + } + + if err := d.login(ctx); err != nil { + return err + } + + d.ApiKey = d.token + d.UserID = d.userID + op.MustSaveDriverStorage(d) + return nil +} + +func (d *Emby) Drop(ctx context.Context) error { + return nil +} + +func (d *Emby) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + parentID := strings.TrimSpace(d.RootFolderID) + if dir != nil && strings.TrimSpace(dir.GetID()) != "" { + parentID = strings.TrimSpace(dir.GetID()) + } + + items, err := d.getItems(ctx, parentID) + if err != nil { + return nil, err + } + + parentPath := "/" + if dir != nil && strings.TrimSpace(dir.GetPath()) != "" { + parentPath = dir.GetPath() + } + + objs := make([]model.Obj, 0, len(items.Items)) + for _, it := range items.Items { + modified := time.Now() + if it.DateCreated != "" { + if t, parseErr := time.Parse(time.RFC3339Nano, it.DateCreated); parseErr == nil { + modified = t + } + } + + name := strings.TrimSpace(it.Name) + id := strings.TrimSpace(it.ID) + displayName := name + if name != "" && id != "" { + if it.IsFolder { + displayName = fmt.Sprintf("%s (ID%s)", name, id) + } else { + ext := path.Ext(strings.TrimSpace(it.Path)) + if ext == "" { + ext = path.Ext(name) + } + + base := strings.TrimSpace(strings.TrimSuffix(name, ext)) + episodeCode := "" + if m := episodeCodeRegexp.FindString(base); m != "" { + episodeCode = strings.ToUpper(m) + } else if it.ParentIndex > 0 && it.IndexNumber > 0 { + episodeCode = fmt.Sprintf("S%02dE%02d", it.ParentIndex, it.IndexNumber) + } + + title := strings.TrimSpace(base) + if episodeCode != "" { + title = strings.TrimSpace(episodeCodeRegexp.ReplaceAllString(title, "")) + title = strings.TrimSpace(strings.Trim(title, "-_:[]() ")) + } + + series := strings.TrimSpace(it.SeriesName) + if series == "" && episodeCode != "" { + if idx := strings.Index(title, " - "); idx > 0 { + series = strings.TrimSpace(title[:idx]) + title = strings.TrimSpace(title[idx+3:]) + } + } + + core := title + if series != "" { + if title == "" || strings.EqualFold(series, title) { + core = series + } else { + core = series + " " + title + } + } + if core == "" { + core = base + } + + if episodeCode != "" { + core = fmt.Sprintf("%s - [%s]", core, episodeCode) + } + if ext == "" { + displayName = fmt.Sprintf("%s (ID%s)", core, id) + } else { + displayName = fmt.Sprintf("%s (ID%s)%s", core, id, ext) + } + } + } + + obj := &model.Object{ + ID: it.ID, + Name: displayName, + Path: path.Join(parentPath, displayName), + Size: it.Size, + Modified: modified, + IsFolder: it.IsFolder, + } + if it.IsFolder { + obj.Size = 0 + } + objs = append(objs, obj) + } + return objs, nil +} + +func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, errs.NotFile + } + if strings.TrimSpace(file.GetID()) == "" { + return nil, fmt.Errorf("invalid file id") + } + + u, err := url.Parse(d.URL) + if err != nil { + return nil, err + } + linkMethod := strings.ToLower(strings.TrimSpace(d.LinkMethod)) + useDownload := linkMethod == "download" + + mediaSourceID := "" + mediaContainer := "" + if !useDownload { + detailURL, parseErr := url.Parse(d.URL + "/Users/" + d.userID + "/Items/" + file.GetID()) + if parseErr == nil { + q := detailURL.Query() + q.Set("Fields", "MediaSources") + q.Set("api_key", d.token) + detailURL.RawQuery = q.Encode() + + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, detailURL.String(), nil) + if reqErr == nil { + resp, doErr := d.client.Do(req) + if doErr == nil { + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + var detail itemDetailResp + if decodeErr := json.NewDecoder(resp.Body).Decode(&detail); decodeErr != nil || len(detail.MediaSources) == 0 { + return + } + for i := range detail.MediaSources { + if strings.TrimSpace(detail.MediaSources[i].ID) != "" && detail.MediaSources[i].SupportsDirectStream { + mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID) + mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container) + return + } + } + for i := range detail.MediaSources { + if strings.TrimSpace(detail.MediaSources[i].ID) != "" { + mediaSourceID = strings.TrimSpace(detail.MediaSources[i].ID) + mediaContainer = strings.TrimSpace(detail.MediaSources[i].Container) + return + } + } + }() + } + } + } + } + + if useDownload { + u.Path = path.Join(u.Path, "/Items", file.GetID(), "Download") + } else { + if mediaContainer != "" { + u.Path = path.Join(u.Path, "/Videos", file.GetID(), "stream."+mediaContainer) + } else { + u.Path = path.Join(u.Path, "/Videos", file.GetID(), "stream") + } + } + q := u.Query() + q.Set("api_key", d.token) + if mediaSourceID != "" { + q.Set("MediaSourceId", mediaSourceID) + } + if !useDownload { + q.Set("Static", "true") + } + u.RawQuery = q.Encode() + + return &model.Link{ + URL: u.String(), + Header: http.Header{ + "User-Agent": []string{base.UserAgent}, + }, + }, nil +} + +func (d *Emby) login(ctx context.Context) error { + payload, err := json.Marshal(authReq{ + Username: d.Username, + Pw: d.Password, + }) + if err != nil { + return err + } + + endpoint := d.URL + "/Users/AuthenticateByName" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="OpenList", Device="OpenList", DeviceId="openlist-emby", Version="1.0.0"`) + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("emby auth failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data authResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + if strings.TrimSpace(data.AccessToken) == "" || strings.TrimSpace(data.User.ID) == "" { + return fmt.Errorf("emby auth response missing access token or user id") + } + + d.token = data.AccessToken + d.userID = data.User.ID + return nil +} + +func (d *Emby) getItems(ctx context.Context, parentID string) (*listResp, error) { + u, err := url.Parse(d.URL + "/Users/" + d.userID + "/Items") + if err != nil { + return nil, err + } + q := u.Query() + q.Set("ParentId", parentID) + q.Set("Recursive", "false") + q.Set("Fields", "Path,Size,DateCreated,SeriesName,IndexNumber,ParentIndexNumber") + q.Set("api_key", d.token) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("emby list failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data listResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} + +func (d *Emby) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) Remove(ctx context.Context, obj model.Obj) error { + return errs.NotImplement +} + +func (d *Emby) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { + return nil, errs.NotImplement +} + +func (d *Emby) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { + return nil, errs.NotImplement +} + +func (d *Emby) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *Emby) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + return nil, errs.NotImplement +} + +var _ driver.Driver = (*Emby)(nil) diff --git a/drivers/emby/meta.go b/drivers/emby/meta.go new file mode 100644 index 000000000..62aa5f1b6 --- /dev/null +++ b/drivers/emby/meta.go @@ -0,0 +1,31 @@ +package emby + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + URL string `json:"url" required:"true"` + ApiKey string `json:"api_key"` + UserID string `json:"user_id"` + Username string `json:"username"` + Password string `json:"password"` + LinkMethod string `json:"link_method" type:"select" options:"stream,download" default:"stream"` +} + +var config = driver.Config{ + Name: "Emby", + LocalSort: true, + OnlyProxy: false, + NoUpload: true, + DefaultRoot: "1", + CheckStatus: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Emby{} + }) +} From 83c5808ef221ee44028de9710d59a91746353700 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Thu, 26 Mar 2026 23:35:29 +0800 Subject: [PATCH 2/3] refactor: organize codes into separate files Signed-off-by: MadDogOwner --- drivers/emby/driver.go | 165 ----------------------------------------- drivers/emby/meta.go | 1 - drivers/emby/types.go | 41 ++++++++++ drivers/emby/util.go | 91 +++++++++++++++++++++++ 4 files changed, 132 insertions(+), 166 deletions(-) create mode 100644 drivers/emby/types.go create mode 100644 drivers/emby/util.go diff --git a/drivers/emby/driver.go b/drivers/emby/driver.go index c1a28851a..c034f2928 100644 --- a/drivers/emby/driver.go +++ b/drivers/emby/driver.go @@ -1,15 +1,12 @@ package emby import ( - "bytes" "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "path" - "regexp" "strings" "time" @@ -29,48 +26,6 @@ type Emby struct { userID string } -type authReq struct { - Username string `json:"Username"` - Pw string `json:"Pw"` -} - -type authResp struct { - AccessToken string `json:"AccessToken"` - User struct { - ID string `json:"Id"` - } `json:"User"` -} - -type listResp struct { - Items []embyItem `json:"Items"` - TotalRecordCount int `json:"TotalRecordCount"` -} - -type embyItem struct { - Name string `json:"Name"` - ID string `json:"Id"` - Type string `json:"Type"` - Path string `json:"Path"` - SeriesName string `json:"SeriesName"` - IndexNumber int `json:"IndexNumber"` - ParentIndex int `json:"ParentIndexNumber"` - IsFolder bool `json:"IsFolder"` - Size int64 `json:"Size"` - DateCreated string `json:"DateCreated"` -} - -type itemDetailResp struct { - MediaSources []embyMediaSource `json:"MediaSources"` -} - -type embyMediaSource struct { - ID string `json:"Id"` - Container string `json:"Container"` - SupportsDirectStream bool `json:"SupportsDirectStream"` -} - -var episodeCodeRegexp = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,2}\b`) - func (d *Emby) Config() driver.Config { return config } @@ -302,124 +257,4 @@ func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* }, nil } -func (d *Emby) login(ctx context.Context) error { - payload, err := json.Marshal(authReq{ - Username: d.Username, - Pw: d.Password, - }) - if err != nil { - return err - } - - endpoint := d.URL + "/Users/AuthenticateByName" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="OpenList", Device="OpenList", DeviceId="openlist-emby", Version="1.0.0"`) - - resp, err := d.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("emby auth failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var data authResp - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return err - } - if strings.TrimSpace(data.AccessToken) == "" || strings.TrimSpace(data.User.ID) == "" { - return fmt.Errorf("emby auth response missing access token or user id") - } - - d.token = data.AccessToken - d.userID = data.User.ID - return nil -} - -func (d *Emby) getItems(ctx context.Context, parentID string) (*listResp, error) { - u, err := url.Parse(d.URL + "/Users/" + d.userID + "/Items") - if err != nil { - return nil, err - } - q := u.Query() - q.Set("ParentId", parentID) - q.Set("Recursive", "false") - q.Set("Fields", "Path,Size,DateCreated,SeriesName,IndexNumber,ParentIndexNumber") - q.Set("api_key", d.token) - u.RawQuery = q.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - - resp, err := d.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("emby list failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - var data listResp - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, err - } - return &data, nil -} - -func (d *Emby) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) Remove(ctx context.Context, obj model.Obj) error { - return errs.NotImplement -} - -func (d *Emby) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { - return nil, errs.NotImplement -} - -func (d *Emby) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { - return nil, errs.NotImplement -} - -func (d *Emby) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { - return nil, errs.NotImplement -} - -func (d *Emby) GetDetails(ctx context.Context) (*model.StorageDetails, error) { - return nil, errs.NotImplement -} - var _ driver.Driver = (*Emby)(nil) diff --git a/drivers/emby/meta.go b/drivers/emby/meta.go index 62aa5f1b6..632c98b7a 100644 --- a/drivers/emby/meta.go +++ b/drivers/emby/meta.go @@ -18,7 +18,6 @@ type Addition struct { var config = driver.Config{ Name: "Emby", LocalSort: true, - OnlyProxy: false, NoUpload: true, DefaultRoot: "1", CheckStatus: true, diff --git a/drivers/emby/types.go b/drivers/emby/types.go new file mode 100644 index 000000000..5f0515829 --- /dev/null +++ b/drivers/emby/types.go @@ -0,0 +1,41 @@ +package emby + +type authReq struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +type authResp struct { + AccessToken string `json:"AccessToken"` + User struct { + ID string `json:"Id"` + } `json:"User"` +} + +type listResp struct { + Items []embyItem `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type embyItem struct { + Name string `json:"Name"` + ID string `json:"Id"` + Type string `json:"Type"` + Path string `json:"Path"` + SeriesName string `json:"SeriesName"` + IndexNumber int `json:"IndexNumber"` + ParentIndex int `json:"ParentIndexNumber"` + IsFolder bool `json:"IsFolder"` + Size int64 `json:"Size"` + DateCreated string `json:"DateCreated"` +} + +type itemDetailResp struct { + MediaSources []embyMediaSource `json:"MediaSources"` +} + +type embyMediaSource struct { + ID string `json:"Id"` + Container string `json:"Container"` + SupportsDirectStream bool `json:"SupportsDirectStream"` +} diff --git a/drivers/emby/util.go b/drivers/emby/util.go new file mode 100644 index 000000000..788fb4be4 --- /dev/null +++ b/drivers/emby/util.go @@ -0,0 +1,91 @@ +package emby + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +var episodeCodeRegexp = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,2}\b`) + +func (d *Emby) login(ctx context.Context) error { + payload, err := json.Marshal(authReq{ + Username: d.Username, + Pw: d.Password, + }) + if err != nil { + return err + } + + endpoint := d.URL + "/Users/AuthenticateByName" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="OpenList", Device="OpenList", DeviceId="openlist-emby", Version="1.0.0"`) + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("emby auth failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data authResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + if strings.TrimSpace(data.AccessToken) == "" || strings.TrimSpace(data.User.ID) == "" { + return fmt.Errorf("emby auth response missing access token or user id") + } + + d.token = data.AccessToken + d.userID = data.User.ID + return nil +} + +func (d *Emby) getItems(ctx context.Context, parentID string) (*listResp, error) { + u, err := url.Parse(d.URL + "/Users/" + d.userID + "/Items") + if err != nil { + return nil, err + } + q := u.Query() + q.Set("ParentId", parentID) + q.Set("Recursive", "false") + q.Set("Fields", "Path,Size,DateCreated,SeriesName,IndexNumber,ParentIndexNumber") + q.Set("api_key", d.token) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("emby list failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data listResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} From e38336594f00cca6eb1e6e6e0d06e45bd05c7fc3 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Thu, 26 Mar 2026 23:51:32 +0800 Subject: [PATCH 3/3] fix: resolve fileID Signed-off-by: MadDogOwner --- drivers/emby/driver.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/drivers/emby/driver.go b/drivers/emby/driver.go index c034f2928..b2406aa6e 100644 --- a/drivers/emby/driver.go +++ b/drivers/emby/driver.go @@ -157,7 +157,7 @@ func (d *Emby) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([] } obj := &model.Object{ - ID: it.ID, + ID: id, Name: displayName, Path: path.Join(parentPath, displayName), Size: it.Size, @@ -176,7 +176,8 @@ func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* if file.IsDir() { return nil, errs.NotFile } - if strings.TrimSpace(file.GetID()) == "" { + fileID := strings.TrimSpace(file.GetID()) + if fileID == "" { return nil, fmt.Errorf("invalid file id") } @@ -190,7 +191,7 @@ func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* mediaSourceID := "" mediaContainer := "" if !useDownload { - detailURL, parseErr := url.Parse(d.URL + "/Users/" + d.userID + "/Items/" + file.GetID()) + detailURL, parseErr := url.Parse(d.URL + "/Users/" + d.userID + "/Items/" + fileID) if parseErr == nil { q := detailURL.Query() q.Set("Fields", "MediaSources") @@ -231,12 +232,12 @@ func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (* } if useDownload { - u.Path = path.Join(u.Path, "/Items", file.GetID(), "Download") + u.Path = path.Join(u.Path, "/Items", fileID, "Download") } else { if mediaContainer != "" { - u.Path = path.Join(u.Path, "/Videos", file.GetID(), "stream."+mediaContainer) + u.Path = path.Join(u.Path, "/Videos", fileID, "stream."+mediaContainer) } else { - u.Path = path.Join(u.Path, "/Videos", file.GetID(), "stream") + u.Path = path.Join(u.Path, "/Videos", fileID, "stream") } } q := u.Query()