feat(driver): add emby driver

Mount an Emby/Jellyfin media server as a storage:
- username/password login with automatic re-login on 401
- library views as root, ID-based hierarchical browsing with pagination
- real filenames/sizes resolved from item Path and MediaSources
- primary image as thumbnail
- direct download link, falling back to static stream when the
  user has no download permission

Close #9573
This commit is contained in:
okatu-loli 2026-07-09 01:49:43 +08:00
parent 44ccd33165
commit 02201769aa
5 changed files with 313 additions and 0 deletions

View File

@ -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"

120
drivers/emby/driver.go Normal file
View File

@ -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)

28
drivers/emby/meta.go Normal file
View File

@ -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{}
})
}

37
drivers/emby/types.go Normal file
View File

@ -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"`
}

127
drivers/emby/util.go Normal file
View File

@ -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
}