Publish current app and server
This commit is contained in:
@@ -33,6 +33,15 @@ type Credentials struct {
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"Id"`
|
||||
ReportedDeviceID string `json:"ReportedDeviceId"`
|
||||
}
|
||||
|
||||
type DevicesResult struct {
|
||||
Items []Device `json:"Items"`
|
||||
}
|
||||
|
||||
type ItemsResult struct {
|
||||
Items []json.RawMessage `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
@@ -57,10 +66,15 @@ type User struct {
|
||||
|
||||
// Summary is the minimal view of an item the gateway needs for its own logic.
|
||||
type Summary struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
UserData struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Overview string `json:"Overview"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
UserData struct {
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
} `json:"UserData"`
|
||||
}
|
||||
@@ -71,10 +85,13 @@ type PlaybackInfo struct {
|
||||
}
|
||||
|
||||
type MediaSourceInfo struct {
|
||||
ID string `json:"Id"`
|
||||
MediaStreams []MediaStream `json:"MediaStreams"`
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
TranscodingURL string `json:"TranscodingUrl"`
|
||||
ID string `json:"Id"`
|
||||
MediaStreams []MediaStream `json:"MediaStreams"`
|
||||
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
||||
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
||||
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
TranscodingURL string `json:"TranscodingUrl"`
|
||||
}
|
||||
|
||||
type MediaStream struct {
|
||||
@@ -152,6 +169,38 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
// DeleteDevice removes the persistent Emby device record whose client-supplied ID
|
||||
// matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately
|
||||
// keeps the device in its dashboard until the record itself is deleted.
|
||||
func (c *Client) DeleteDevice(
|
||||
ctx context.Context,
|
||||
cred Credentials,
|
||||
reportedDeviceID string,
|
||||
) error {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var devices DevicesResult
|
||||
if err := c.do(req, &devices); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, device := range devices.Items {
|
||||
if device.ReportedDeviceID != reportedDeviceID || device.ID == "" {
|
||||
continue
|
||||
}
|
||||
params := url.Values{"Id": {device.ID}}
|
||||
req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.do(req, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Users returns the household accounts visible to an administrative/service token.
|
||||
// It is used only by the background For You builder, never on a television request.
|
||||
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
|
||||
@@ -225,6 +274,8 @@ func (c *Client) PlaybackInfo(
|
||||
startTicks int64,
|
||||
subtitleStreamIndex *int,
|
||||
currentPlaySessionID string,
|
||||
forceTranscode bool,
|
||||
capabilities PlaybackCapabilities,
|
||||
) (*PlaybackInfo, error) {
|
||||
params := url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
@@ -232,39 +283,10 @@ func (c *Client) PlaybackInfo(
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
|
||||
"StartTimeTicks": startTicks,
|
||||
"DeviceProfile": map[string]any{
|
||||
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
|
||||
"DirectPlayProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
|
||||
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
|
||||
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
|
||||
"Type": "Video",
|
||||
},
|
||||
},
|
||||
"TranscodingProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
|
||||
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
|
||||
},
|
||||
},
|
||||
"SubtitleProfiles": []map[string]string{
|
||||
{"Format": "srt", "Method": "External"},
|
||||
{"Format": "subrip", "Method": "External"},
|
||||
{"Format": "ass", "Method": "External"},
|
||||
{"Format": "ssa", "Method": "External"},
|
||||
{"Format": "vtt", "Method": "External"},
|
||||
{"Format": "webvtt", "Method": "External"},
|
||||
{"Format": "mov_text", "Method": "External"},
|
||||
{"Format": "tx3g", "Method": "External"},
|
||||
{"Format": "pgs", "Method": "Encode"},
|
||||
{"Format": "pgssub", "Method": "Encode"},
|
||||
{"Format": "sup", "Method": "Encode"},
|
||||
{"Format": "vobsub", "Method": "Encode"},
|
||||
{"Format": "dvdsub", "Method": "Encode"},
|
||||
},
|
||||
},
|
||||
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
|
||||
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
|
||||
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
|
||||
"DeviceProfile": androidTVDeviceProfile(capabilities),
|
||||
})
|
||||
var requestBody map[string]any
|
||||
if err == nil {
|
||||
@@ -276,6 +298,10 @@ func (c *Client) PlaybackInfo(
|
||||
if currentPlaySessionID != "" {
|
||||
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
|
||||
}
|
||||
if forceTranscode {
|
||||
profile := requestBody["DeviceProfile"].(map[string]any)
|
||||
profile["DirectPlayProfiles"] = []map[string]string{}
|
||||
}
|
||||
if err == nil {
|
||||
body, err = json.Marshal(requestBody)
|
||||
}
|
||||
@@ -301,6 +327,13 @@ func (c *Client) PlaybackInfo(
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func directPlayVideoCodecs(supportsHEVC bool) string {
|
||||
if supportsHEVC {
|
||||
return "h264,hevc"
|
||||
}
|
||||
return "h264"
|
||||
}
|
||||
|
||||
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package emby
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) {
|
||||
if got := directPlayVideoCodecs(false); got != "h264" {
|
||||
t.Fatalf("baseline codecs = %q", got)
|
||||
}
|
||||
if got := directPlayVideoCodecs(true); got != "h264,hevc" {
|
||||
t.Fatalf("HEVC codecs = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
|
||||
profile := androidTVDeviceProfile(PlaybackCapabilities{
|
||||
H264Profiles: []string{"baseline", "main", "high"},
|
||||
H264Level: 52, H264MaxWidth: 3840, H264MaxHeight: 2160,
|
||||
HEVC: true, HEVCMain: true, HEVCMain10: true,
|
||||
HEVCMainLevel: 153, HEVCMain10Level: 153,
|
||||
HEVCMaxWidth: 3840, HEVCMaxHeight: 2160,
|
||||
})
|
||||
direct := profile["DirectPlayProfiles"].([]map[string]string)
|
||||
if direct[0]["VideoCodec"] != "h264,hevc" {
|
||||
t.Fatalf("direct video codecs = %q", direct[0]["VideoCodec"])
|
||||
}
|
||||
transcode := profile["TranscodingProfiles"].([]map[string]string)
|
||||
if transcode[0]["VideoCodec"] != "h264,hevc" {
|
||||
t.Fatalf("stream-copy codecs = %q", transcode[0]["VideoCodec"])
|
||||
}
|
||||
codecProfiles := profile["CodecProfiles"].([]map[string]any)
|
||||
if len(codecProfiles) < 6 {
|
||||
t.Fatalf("codec profiles = %#v", codecProfiles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Playback profile generation is adapted from Wholphin's DeviceProfileUtils.kt,
|
||||
// itself derived from Jellyfin Android TV.
|
||||
//
|
||||
// Wholphin: https://github.com/damontecres/Wholphin
|
||||
// Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
|
||||
//
|
||||
// Modifications Copyright (C) 2026 Memby contributors
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package emby
|
||||
|
||||
import "strconv"
|
||||
|
||||
// PlaybackCapabilities is the Android decoder evidence captured for one TV session.
|
||||
// Zero values deliberately describe the legacy H.264-safe profile.
|
||||
type PlaybackCapabilities struct {
|
||||
H264Profiles []string
|
||||
H264Level int
|
||||
H264High10Level int
|
||||
H264MaxWidth int
|
||||
H264MaxHeight int
|
||||
HEVC bool
|
||||
HEVCMain bool
|
||||
HEVCMain10 bool
|
||||
HEVCMainLevel int
|
||||
HEVCMain10Level int
|
||||
HEVCMaxWidth int
|
||||
HEVCMaxHeight int
|
||||
HEVCHDR10 bool
|
||||
HEVCHDR10Plus bool
|
||||
HEVCDolbyVision bool
|
||||
}
|
||||
|
||||
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
|
||||
videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
|
||||
return map[string]any{
|
||||
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
|
||||
"DirectPlayProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
|
||||
"AudioCodec": "aac,mp3", "Type": "Video",
|
||||
},
|
||||
},
|
||||
"TranscodingProfiles": []map[string]string{
|
||||
{
|
||||
// AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and
|
||||
// convert only incompatible audio or subtitles into an HLS stream.
|
||||
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": "aac",
|
||||
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
|
||||
},
|
||||
},
|
||||
"CodecProfiles": videoCodecProfiles(capabilities),
|
||||
"SubtitleProfiles": androidTVSubtitleProfiles(),
|
||||
}
|
||||
}
|
||||
|
||||
func androidTVSubtitleProfiles() []map[string]string {
|
||||
return []map[string]string{
|
||||
{"Format": "srt", "Method": "External"},
|
||||
{"Format": "subrip", "Method": "External"},
|
||||
{"Format": "ass", "Method": "External"},
|
||||
{"Format": "ssa", "Method": "External"},
|
||||
{"Format": "vtt", "Method": "External"},
|
||||
{"Format": "webvtt", "Method": "External"},
|
||||
{"Format": "mov_text", "Method": "External"},
|
||||
{"Format": "tx3g", "Method": "External"},
|
||||
{"Format": "pgs", "Method": "Encode"},
|
||||
{"Format": "pgssub", "Method": "Encode"},
|
||||
{"Format": "sup", "Method": "Encode"},
|
||||
{"Format": "vobsub", "Method": "Encode"},
|
||||
{"Format": "dvdsub", "Method": "Encode"},
|
||||
}
|
||||
}
|
||||
|
||||
func videoCodecProfiles(capabilities PlaybackCapabilities) []map[string]any {
|
||||
profiles := []map[string]any{}
|
||||
if len(capabilities.H264Profiles) > 0 {
|
||||
profiles = append(profiles, codecProfile("h264",
|
||||
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(capabilities.H264Profiles))}, nil))
|
||||
}
|
||||
profiles = appendLevelProfile(
|
||||
profiles, "h264", capabilities.H264Level, "baseline|constrained baseline|main|high",
|
||||
)
|
||||
profiles = appendLevelProfile(profiles, "h264", capabilities.H264High10Level, "high 10")
|
||||
profiles = appendResolutionProfile(
|
||||
profiles, "h264", capabilities.H264MaxWidth, capabilities.H264MaxHeight,
|
||||
)
|
||||
if !capabilities.HEVC {
|
||||
return profiles
|
||||
}
|
||||
|
||||
hevcProfiles := []string{}
|
||||
if capabilities.HEVCMain {
|
||||
hevcProfiles = append(hevcProfiles, "main")
|
||||
}
|
||||
if capabilities.HEVCMain10 {
|
||||
hevcProfiles = append(hevcProfiles, "main 10")
|
||||
}
|
||||
if len(hevcProfiles) > 0 {
|
||||
profiles = append(profiles, codecProfile("hevc",
|
||||
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(hevcProfiles))}, nil))
|
||||
}
|
||||
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMainLevel, "main")
|
||||
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMain10Level, "main 10")
|
||||
profiles = appendResolutionProfile(
|
||||
profiles, "hevc", capabilities.HEVCMaxWidth, capabilities.HEVCMaxHeight,
|
||||
)
|
||||
return profiles
|
||||
}
|
||||
|
||||
func appendLevelProfile(profiles []map[string]any, codec string, level int, applyTo string) []map[string]any {
|
||||
if level <= 0 {
|
||||
return profiles
|
||||
}
|
||||
condition := "Equals"
|
||||
if containsPipe(applyTo) {
|
||||
condition = "EqualsAny"
|
||||
}
|
||||
return append(profiles, codecProfile(codec,
|
||||
[]map[string]any{profileCondition("LessThanEqual", "VideoLevel", strconv.Itoa(level))},
|
||||
[]map[string]any{profileCondition(condition, "VideoProfile", applyTo)}))
|
||||
}
|
||||
|
||||
func appendResolutionProfile(profiles []map[string]any, codec string, width, height int) []map[string]any {
|
||||
if width <= 0 || height <= 0 {
|
||||
return profiles
|
||||
}
|
||||
return append(profiles, codecProfile(codec, []map[string]any{
|
||||
profileCondition("LessThanEqual", "Width", strconv.Itoa(width)),
|
||||
profileCondition("LessThanEqual", "Height", strconv.Itoa(height)),
|
||||
}, nil))
|
||||
}
|
||||
|
||||
func codecProfile(codec string, conditions, applyConditions []map[string]any) map[string]any {
|
||||
profile := map[string]any{
|
||||
"Type": "Video", "Codec": codec, "Conditions": conditions,
|
||||
}
|
||||
if len(applyConditions) > 0 {
|
||||
profile["ApplyConditions"] = applyConditions
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func profileCondition(condition, property, value string) map[string]any {
|
||||
return map[string]any{
|
||||
"Condition": condition, "Property": property, "Value": value, "IsRequired": false,
|
||||
}
|
||||
}
|
||||
|
||||
func joinProfiles(values []string) string {
|
||||
result := ""
|
||||
for _, value := range values {
|
||||
if result != "" {
|
||||
result += "|"
|
||||
}
|
||||
result += value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsPipe(value string) bool {
|
||||
for _, char := range value {
|
||||
if char == '|' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user