diff --git a/drivers/all.go b/drivers/all.go index 9848ff287d1..b7b534c0e94 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -33,6 +33,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/doubao_new" _ "github.com/alist-org/alist/v3/drivers/doubao_share" _ "github.com/alist-org/alist/v3/drivers/dropbox" + _ "github.com/alist-org/alist/v3/drivers/emby" _ "github.com/alist-org/alist/v3/drivers/febbox" _ "github.com/alist-org/alist/v3/drivers/ftp" _ "github.com/alist-org/alist/v3/drivers/ftps" diff --git a/drivers/emby/driver.go b/drivers/emby/driver.go new file mode 100644 index 00000000000..6fc6fa7e1df --- /dev/null +++ b/drivers/emby/driver.go @@ -0,0 +1,120 @@ +package emby + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/model" +) + +type Emby struct { + model.Storage + Addition + + token string + userID string + canDownload bool + loginMutex sync.Mutex +} + +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 { + return d.relogin(ctx) +} + +func (d *Emby) Drop(ctx context.Context) error { + return nil +} + +func (d *Emby) GetRoot(ctx context.Context) (model.Obj, error) { + return &model.Object{ + ID: d.RootFolderID, + Path: "/", + Name: "root", + IsFolder: true, + }, nil +} + +func (d *Emby) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + if err := d.ensureLogin(ctx); err != nil { + return nil, err + } + + // root without a root_folder_id: list library views + if dir.GetID() == "" { + var resp ItemsResp + if err := d.request(ctx, "/Users/"+d.userID+"/Views", nil, &resp); err != nil { + return nil, err + } + objs := make([]model.Obj, 0, len(resp.Items)) + for _, item := range resp.Items { + objs = append(objs, d.toObj(item)) + } + return objs, nil + } + + const pageSize = 1000 + var objs []model.Obj + for start := 0; ; start += pageSize { + var resp ItemsResp + err := d.request(ctx, "/Users/"+d.userID+"/Items", map[string]string{ + "ParentId": dir.GetID(), + "Fields": "DateCreated,Path,MediaSources", + "StartIndex": strconv.Itoa(start), + "Limit": strconv.Itoa(pageSize), + }, &resp) + if err != nil { + return nil, err + } + for _, item := range resp.Items { + objs = append(objs, d.toObj(item)) + } + if start+len(resp.Items) >= resp.TotalRecordCount || len(resp.Items) == 0 { + break + } + } + return objs, nil +} + +func (d *Emby) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if err := d.ensureLogin(ctx); err != nil { + return nil, err + } + if d.canDownload { + return &model.Link{ + URL: fmt.Sprintf("%s/Items/%s/Download?api_key=%s", d.baseURL(), file.GetID(), d.token), + }, nil + } + // no download permission: fall back to the direct-play stream of the original file + var resp ItemsResp + err := d.request(ctx, "/Users/"+d.userID+"/Items", map[string]string{"Ids": file.GetID()}, &resp) + if err != nil { + return nil, err + } + if len(resp.Items) > 0 { + switch resp.Items[0].MediaType { + case "Video": + return &model.Link{ + URL: fmt.Sprintf("%s/Videos/%s/stream?static=true&api_key=%s", d.baseURL(), file.GetID(), d.token), + }, nil + case "Audio": + return &model.Link{ + URL: fmt.Sprintf("%s/Audio/%s/stream?static=true&api_key=%s", d.baseURL(), file.GetID(), d.token), + }, nil + } + } + return nil, fmt.Errorf("emby user has no download permission and item is not streamable") +} + +var _ driver.Driver = (*Emby)(nil) +var _ driver.GetRooter = (*Emby)(nil) diff --git a/drivers/emby/meta.go b/drivers/emby/meta.go new file mode 100644 index 00000000000..c75c0961008 --- /dev/null +++ b/drivers/emby/meta.go @@ -0,0 +1,28 @@ +package emby + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + // Leave root_folder_id empty to list all library views + driver.RootID + Address string `json:"address" required:"true" help:"Emby/Jellyfin server address, e.g. http://192.168.1.1:8096"` + Username string `json:"username" required:"true"` + Password string `json:"password"` +} + +var config = driver.Config{ + Name: "Emby", + LocalSort: true, + NoUpload: true, + DefaultRoot: "", + Alert: "info|Direct links contain your Emby access token. Enable proxy for this storage if it is shared publicly.", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Emby{} + }) +} diff --git a/drivers/emby/types.go b/drivers/emby/types.go new file mode 100644 index 00000000000..b6fe0862eee --- /dev/null +++ b/drivers/emby/types.go @@ -0,0 +1,37 @@ +package emby + +import "time" + +type AuthResp struct { + User struct { + Id string `json:"Id"` + Policy struct { + EnableContentDownloading bool `json:"EnableContentDownloading"` + } `json:"Policy"` + } `json:"User"` + AccessToken string `json:"AccessToken"` +} + +type MediaSource struct { + Path string `json:"Path"` + Size int64 `json:"Size"` +} + +type Item struct { + Id string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + MediaType string `json:"MediaType"` + IsFolder bool `json:"IsFolder"` + Path string `json:"Path"` + Container string `json:"Container"` + Size int64 `json:"Size"` + DateCreated time.Time `json:"DateCreated"` + MediaSources []MediaSource `json:"MediaSources"` + ImageTags map[string]string `json:"ImageTags"` +} + +type ItemsResp struct { + Items []Item `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} diff --git a/drivers/emby/util.go b/drivers/emby/util.go new file mode 100644 index 00000000000..cf5bd23818c --- /dev/null +++ b/drivers/emby/util.go @@ -0,0 +1,127 @@ +package emby + +import ( + "context" + "fmt" + "net/http" + "path" + "strings" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/model" + "github.com/go-resty/resty/v2" +) + +const authHeader = `MediaBrowser Client="AList", Device="AList", DeviceId="AList", Version="3.0.0"` + +func (d *Emby) baseURL() string { + return strings.TrimRight(d.Address, "/") +} + +func (d *Emby) login(ctx context.Context) error { + var resp AuthResp + res, err := base.RestyClient.R(). + SetContext(ctx). + SetHeader("X-Emby-Authorization", authHeader). + SetBody(map[string]string{ + "Username": d.Username, + "Pw": d.Password, + }). + SetResult(&resp). + Post(d.baseURL() + "/Users/AuthenticateByName") + if err != nil { + return err + } + if res.StatusCode() != http.StatusOK { + return fmt.Errorf("emby login failed: %s: %s", res.Status(), res.String()) + } + if resp.AccessToken == "" || resp.User.Id == "" { + return fmt.Errorf("emby login failed: empty token or user id in response") + } + d.token = resp.AccessToken + d.userID = resp.User.Id + d.canDownload = resp.User.Policy.EnableContentDownloading + return nil +} + +func (d *Emby) ensureLogin(ctx context.Context) error { + d.loginMutex.Lock() + defer d.loginMutex.Unlock() + if d.token != "" { + return nil + } + return d.login(ctx) +} + +func (d *Emby) relogin(ctx context.Context) error { + d.loginMutex.Lock() + defer d.loginMutex.Unlock() + return d.login(ctx) +} + +// request sends an authenticated GET request, re-logging in once on 401 +func (d *Emby) request(ctx context.Context, path string, query map[string]string, out interface{}) error { + if err := d.ensureLogin(ctx); err != nil { + return err + } + do := func() (*resty.Response, error) { + return base.RestyClient.R(). + SetContext(ctx). + SetHeader("X-Emby-Token", d.token). + SetQueryParams(query). + SetResult(out). + Get(d.baseURL() + path) + } + res, err := do() + if err != nil { + return err + } + if res.StatusCode() == http.StatusUnauthorized { + if err = d.relogin(ctx); err != nil { + return err + } + res, err = do() + if err != nil { + return err + } + } + if res.StatusCode() != http.StatusOK { + return fmt.Errorf("emby request failed: %s: %s", res.Status(), res.String()) + } + return nil +} + +// toObj converts an emby item to an alist object +func (d *Emby) toObj(item Item) model.Obj { + name := item.Name + size := item.Size + if !item.IsFolder { + // prefer the real filename so the extension is kept for players + if item.Path != "" { + name = path.Base(strings.ReplaceAll(item.Path, "\\", "/")) + } else if len(item.MediaSources) > 0 && item.MediaSources[0].Path != "" { + name = path.Base(strings.ReplaceAll(item.MediaSources[0].Path, "\\", "/")) + } else if item.Container != "" && !strings.Contains(item.Container, ",") { + name = item.Name + "." + item.Container + } + if size == 0 && len(item.MediaSources) > 0 { + size = item.MediaSources[0].Size + } + } + name = strings.ReplaceAll(name, "/", "/") + obj := model.ObjThumb{ + Object: model.Object{ + ID: item.Id, + Name: name, + Size: size, + Modified: item.DateCreated, + Ctime: item.DateCreated, + IsFolder: item.IsFolder, + }, + } + if tag, ok := item.ImageTags["Primary"]; ok && tag != "" { + obj.Thumbnail.Thumbnail = fmt.Sprintf("%s/Items/%s/Images/Primary?maxWidth=400&quality=90&tag=%s", + d.baseURL(), item.Id, tag) + } + return &obj +}