Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
120 changes: 120 additions & 0 deletions drivers/emby/driver.go
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions drivers/emby/meta.go
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions drivers/emby/types.go
Original file line number Diff line number Diff line change
@@ -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 changes: 127 additions & 0 deletions drivers/emby/util.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading