mirror of
https://github.com/alist-org/alist.git
synced 2026-07-21 21:14:07 +08:00
feat(driver): add emby driver (#9574)
Some checks failed
auto_lang / auto generate lang.json (1.25, ubuntu-latest) (push) Has been cancelled
beta release / Beta Release Changelog (1.21, ubuntu-latest) (push) Has been cancelled
build / Build (ubuntu-latest, android-arm64) (push) Has been cancelled
build / Build (ubuntu-latest, darwin-amd64) (push) Has been cancelled
build / Build (ubuntu-latest, darwin-arm64) (push) Has been cancelled
build / Build (ubuntu-latest, linux-amd64-musl) (push) Has been cancelled
build / Build (ubuntu-latest, linux-arm64-musl) (push) Has been cancelled
build / Build (ubuntu-latest, windows-amd64) (push) Has been cancelled
build / Build (ubuntu-latest, windows-arm64) (push) Has been cancelled
release_docker / Build Binaries for Docker Release (push) Has been cancelled
beta release / Beta Release (md5, !(*musl*|*windows-arm64*|*android*|*freebsd*)) (push) Has been cancelled
beta release / Beta Release (md5-android, android-*) (push) Has been cancelled
beta release / Beta Release (md5-freebsd, freebsd-*) (push) Has been cancelled
beta release / Beta Release (md5-linux-musl, linux-!(arm*)-musl*) (push) Has been cancelled
beta release / Beta Release (md5-linux-musl-arm, linux-arm*-musl*) (push) Has been cancelled
beta release / Beta Release (md5-windows-arm64, windows-arm64) (push) Has been cancelled
beta release / Beta Release Desktop (push) Has been cancelled
release_docker / Release Docker image (, latest, ) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_ARIA2=true, aria2, suffix=-aria2,onlatest=true) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_FFMPEG=true
INSTALL_ARIA2=true
, aio, suffix=-aio,onlatest=true) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_FFMPEG=true, ffmpeg, suffix=-ffmpeg,onlatest=true) (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
Some checks failed
auto_lang / auto generate lang.json (1.25, ubuntu-latest) (push) Has been cancelled
beta release / Beta Release Changelog (1.21, ubuntu-latest) (push) Has been cancelled
build / Build (ubuntu-latest, android-arm64) (push) Has been cancelled
build / Build (ubuntu-latest, darwin-amd64) (push) Has been cancelled
build / Build (ubuntu-latest, darwin-arm64) (push) Has been cancelled
build / Build (ubuntu-latest, linux-amd64-musl) (push) Has been cancelled
build / Build (ubuntu-latest, linux-arm64-musl) (push) Has been cancelled
build / Build (ubuntu-latest, windows-amd64) (push) Has been cancelled
build / Build (ubuntu-latest, windows-arm64) (push) Has been cancelled
release_docker / Build Binaries for Docker Release (push) Has been cancelled
beta release / Beta Release (md5, !(*musl*|*windows-arm64*|*android*|*freebsd*)) (push) Has been cancelled
beta release / Beta Release (md5-android, android-*) (push) Has been cancelled
beta release / Beta Release (md5-freebsd, freebsd-*) (push) Has been cancelled
beta release / Beta Release (md5-linux-musl, linux-!(arm*)-musl*) (push) Has been cancelled
beta release / Beta Release (md5-linux-musl-arm, linux-arm*-musl*) (push) Has been cancelled
beta release / Beta Release (md5-windows-arm64, windows-arm64) (push) Has been cancelled
beta release / Beta Release Desktop (push) Has been cancelled
release_docker / Release Docker image (, latest, ) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_ARIA2=true, aria2, suffix=-aria2,onlatest=true) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_FFMPEG=true
INSTALL_ARIA2=true
, aio, suffix=-aio,onlatest=true) (push) Has been cancelled
release_docker / Release Docker image (INSTALL_FFMPEG=true, ffmpeg, suffix=-ffmpeg,onlatest=true) (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled
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:
parent
167c98f7f2
commit
aead76e49a
@ -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
120
drivers/emby/driver.go
Normal 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
28
drivers/emby/meta.go
Normal 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
37
drivers/emby/types.go
Normal 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
127
drivers/emby/util.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user