Build and publish / Test and build (darwin) (push) Successful in 1m49s
Build and publish / Test and build (linux) (push) Successful in 2m16s
Build and publish / Test and build (windows) (push) Successful in 2m55s
Build and publish / Build and publish Docker image (push) Successful in 1m53s
DLNA/Cast-bridged renderers can advertise SetSubtitleStreamIndex / SetAudioStreamIndex yet silently drop the command. After sending the GeneralCommand, poll PlayState briefly; if the requested index never appears, restart playback at the current position with the stream index in the PlayNow request.
515 lines
19 KiB
Go
515 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gordon/emby-mcp/internal/emby"
|
|
"github.com/gordon/emby-mcp/internal/state"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// chunkedItems emits items as a JSON chunkResult, storing the remainder in the
|
|
// search state for retrieve_next_search_chunk when over the chunk limit.
|
|
// Shared by the browse tools with search_for_item's semantics.
|
|
func chunkedItems(st *state.State, items []emby.MediaItem) *mcp.CallToolResult {
|
|
total := len(items)
|
|
maxChunk := st.MaxChunkSize
|
|
st.ClearSearch()
|
|
if maxChunk > 0 && maxChunk < total {
|
|
st.SetSearch(&state.SearchChunk{
|
|
SearchID: uuid.NewString(),
|
|
TotalItems: total,
|
|
ChunkSize: maxChunk,
|
|
Items: items,
|
|
})
|
|
return textResult(nextChunkJSON(st))
|
|
}
|
|
return jsonResult(chunkResult{
|
|
SearchID: uuid.NewString(),
|
|
TotalNumberOfItems: total,
|
|
ChunkSize: min(maxChunk, total),
|
|
ChunkNumber: 1,
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
func registerBrowseTools(s *mcp.Server, st *state.State) {
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "retrieve_item_children",
|
|
Description: `Retrieve the direct children of an item on the Emby media server in JSON format.
|
|
Use to browse a series' seasons, a season's episodes, an album's tracks, or a folder's contents.
|
|
|
|
Args:
|
|
item_id (str): The ID of the parent item (series, season, album or folder)
|
|
|
|
Returns:
|
|
JSON search result with the same shape as search_for_item, including an 'item_type' field
|
|
(e.g. 'Series', 'Season', 'Episode', 'Audio', 'Movie') on each item.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
ItemID string `json:"item_id" jsonschema:"The ID of the parent item (series, season, album or folder)"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
if in.ItemID == "" {
|
|
return textResult("ERROR: no item_id was supplied. Supply the item_id of a series, season, album or folder"), nil, nil
|
|
}
|
|
items, err := st.Client.GetChildren(ctx, st.UserID, in.ItemID)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve item children because: %v", err)), nil, nil
|
|
}
|
|
return chunkedItems(st, items), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "retrieve_season_list",
|
|
Description: `Retrieve the seasons of a TV series on the Emby media server in JSON format.
|
|
Find the series' item_id via tool search_for_item with item_types 'Series'.
|
|
|
|
Args:
|
|
series_id (str): The ID of the series
|
|
|
|
Returns:
|
|
JSON list of seasons; each item's 'item_id' can be passed to retrieve_episode_list as 'season_id'.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SeriesID string `json:"series_id" jsonschema:"The ID of the series, obtained via search_for_item with item_types 'Series'"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
if in.SeriesID == "" {
|
|
return textResult("ERROR: no series_id was supplied. Find the series via tool search_for_item with item_types 'Series'"), nil, nil
|
|
}
|
|
items, err := st.Client.GetSeasons(ctx, st.UserID, in.SeriesID)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve season list because: %v", err)), nil, nil
|
|
}
|
|
return chunkedItems(st, items), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "retrieve_episode_list",
|
|
Description: `Retrieve the episodes of a TV series on the Emby media server in JSON format,
|
|
optionally restricted to one season. Episodes include watch state ('played', 'played_percentage',
|
|
'resume_position_milliseconds') so played/unplayed questions can be answered directly.
|
|
|
|
Args:
|
|
series_id (str): The ID of the series, obtained via search_for_item with item_types 'Series'
|
|
season_id (str, optional): Restrict to one season (season item_id from retrieve_season_list)
|
|
|
|
Returns:
|
|
JSON search result with the same shape as search_for_item; 'disk_number' is the season number
|
|
and 'track_number' the episode number.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SeriesID string `json:"series_id" jsonschema:"The ID of the series"`
|
|
SeasonID string `json:"season_id" jsonschema:"Optional season item_id to restrict to one season, or empty for all"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
if in.SeriesID == "" {
|
|
return textResult("ERROR: no series_id was supplied. Find the series via tool search_for_item with item_types 'Series'"), nil, nil
|
|
}
|
|
items, err := st.Client.GetEpisodes(ctx, st.UserID, in.SeriesID, in.SeasonID)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve episode list because: %v", err)), nil, nil
|
|
}
|
|
return chunkedItems(st, items), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "retrieve_next_episode",
|
|
Description: `Retrieve the episode of a TV series to play next on the Emby media server in JSON format.
|
|
Mode 'next_unplayed' (default) returns the user's next-up episode: the first episode not fully
|
|
played, including a partially-watched episode to resume (see 'resume_position_milliseconds').
|
|
Mode 'latest' returns the most recently premiered episode present on the server.
|
|
Supply the returned 'item_id' to tool control_media_player with command 'PlayNow' to play it.
|
|
|
|
Args:
|
|
series_name (str): The name of the series (e.g. 'Family Guy'), or empty if supplying series_id
|
|
series_id (str): The ID of the series if known, or empty to resolve by name
|
|
mode (str): 'next_unplayed' (default) or 'latest'
|
|
|
|
Returns:
|
|
Dict: a single item in search_for_item format plus 'mode' and 'series_id', or an error.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SeriesName string `json:"series_name" jsonschema:"The name of the series, or empty if supplying series_id"`
|
|
SeriesID string `json:"series_id" jsonschema:"The ID of the series if known, or empty to resolve by name"`
|
|
Mode string `json:"mode" jsonschema:"'next_unplayed' (default) or 'latest'"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
seriesID := in.SeriesID
|
|
seriesName := in.SeriesName
|
|
if seriesID == "" {
|
|
if seriesName == "" {
|
|
return textResult("ERROR: supply either 'series_name' or 'series_id'"), nil, nil
|
|
}
|
|
exact, candidates, err := st.Client.FindSeries(ctx, st.UserID, seriesName)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to find series because: %v", err)), nil, nil
|
|
}
|
|
if exact == nil {
|
|
if len(candidates) == 0 {
|
|
return textResult(errf("ERROR: no series found matching %q", seriesName)), nil, nil
|
|
}
|
|
var names []string
|
|
for _, c := range candidates {
|
|
names = append(names, fmt.Sprintf("%s (item_id %s)", c.Title, c.ItemID))
|
|
}
|
|
return textResult(errf("ERROR: no exact series match for %q. Candidates: %s. Retry with 'series_id'.", seriesName, strings.Join(names, ", "))), nil, nil
|
|
}
|
|
seriesID = exact.ItemID
|
|
seriesName = exact.Title
|
|
}
|
|
var ep *emby.MediaItem
|
|
var err error
|
|
mode := strings.ToLower(in.Mode)
|
|
if mode == "" {
|
|
mode = "next_unplayed"
|
|
}
|
|
switch mode {
|
|
case "next_unplayed":
|
|
ep, err = st.Client.GetNextUp(ctx, st.UserID, seriesID)
|
|
case "latest":
|
|
ep, err = st.Client.GetLatestEpisode(ctx, st.UserID, seriesID)
|
|
default:
|
|
return textResult("ERROR: invalid mode. Valid modes are 'next_unplayed' and 'latest'"), nil, nil
|
|
}
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve next episode because: %v", err)), nil, nil
|
|
}
|
|
if ep == nil {
|
|
return textResult(errf("ERROR: no episode available for %q in mode %q", seriesName, mode)), nil, nil
|
|
}
|
|
return jsonResult(struct {
|
|
emby.MediaItem
|
|
Mode string `json:"mode"`
|
|
SeriesID string `json:"series_id"`
|
|
}{*ep, mode, seriesID}), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "retrieve_now_playing",
|
|
Description: `Retrieve details of the item currently playing on a media player session in JSON
|
|
format, including the available audio and subtitle tracks.
|
|
|
|
Args:
|
|
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
|
|
|
|
Returns:
|
|
Dict: as JSON with keys: item_id, title, series_name, position_milliseconds, can_seek,
|
|
audio_stream_index, subtitle_stream_index, audio_streams, subtitle_streams (each stream has
|
|
index, language, codec, title, display_title, is_default, is_forced, is_external, is_selected),
|
|
supported_commands.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SessionID string `json:"session_id" jsonschema:"The ID of the player session to query, obtained from tool retrieve_player_list"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
if in.SessionID == "" {
|
|
return textResult("ERROR: no session_id was supplied. Obtain session_id from tool retrieve_player_list"), nil, nil
|
|
}
|
|
session, err := st.Client.GetSession(ctx, in.SessionID)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve session because: %v", err)), nil, nil
|
|
}
|
|
if session == nil {
|
|
return textResult("ERROR: session not found. Obtain a fresh session_id from tool retrieve_player_list"), nil, nil
|
|
}
|
|
if session.NowPlayingItem == nil {
|
|
return textResult("ERROR: nothing is currently playing on this session"), nil, nil
|
|
}
|
|
item := session.NowPlayingItem
|
|
if len(item.MediaSources) == 0 {
|
|
full, ferr := st.Client.GetItem(ctx, st.UserID, item.ID)
|
|
if ferr == nil && len(full.MediaSources) > 0 {
|
|
item = full
|
|
}
|
|
}
|
|
return jsonResult(nowPlayingResult(session, item)), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "set_subtitle",
|
|
Description: `Enable or change the subtitle track on a media player session in JSON format.
|
|
Identify the track by 'track' as a stream index, a language name (e.g. 'english'), or part of the
|
|
track title. Use 'off' or 'none' to disable subtitles. Use tool retrieve_now_playing to list the
|
|
available tracks.
|
|
If the player does not support switching tracks during playback, or the switch cannot be confirmed, playback is restarted with the
|
|
selected track at the current position.
|
|
|
|
Args:
|
|
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
|
|
track (str): Stream index, language, or title of the subtitle track; 'off'/'none' to disable
|
|
|
|
Returns:
|
|
Str: success messsage or error message.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SessionID string `json:"session_id" jsonschema:"The ID of the player session to control"`
|
|
Track string `json:"track" jsonschema:"Stream index, language, or title of the subtitle track; 'off'/'none' to disable"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
return setStreamTrack(ctx, st, in.SessionID, in.Track, "Subtitle"), nil, nil
|
|
})
|
|
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "set_audio_track",
|
|
Description: `Change the audio track on a media player session in JSON format.
|
|
Identify the track by 'track' as a stream index, a language name (e.g. 'english'), or part of the
|
|
track title. Use tool retrieve_now_playing to list the available tracks.
|
|
If the player does not support switching tracks during playback, or the switch cannot be confirmed, playback is restarted with the
|
|
selected track at the current position.
|
|
|
|
Args:
|
|
session_id (str): The ID of the player session, obtained from tool retrieve_player_list
|
|
track (str): Stream index, language, or title of the audio track
|
|
|
|
Returns:
|
|
Str: success messsage or error message.`,
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
|
|
SessionID string `json:"session_id" jsonschema:"The ID of the player session to control"`
|
|
Track string `json:"track" jsonschema:"Stream index, language, or title of the audio track"`
|
|
}) (*mcp.CallToolResult, any, error) {
|
|
return setStreamTrack(ctx, st, in.SessionID, in.Track, "Audio"), nil, nil
|
|
})
|
|
}
|
|
|
|
// streamDescriptor is one audio/subtitle track in retrieve_now_playing output.
|
|
type streamDescriptor struct {
|
|
Index int `json:"index"`
|
|
Language string `json:"language,omitempty"`
|
|
Codec string `json:"codec,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
DisplayTitle string `json:"display_title,omitempty"`
|
|
IsDefault bool `json:"is_default"`
|
|
IsForced bool `json:"is_forced"`
|
|
IsExternal bool `json:"is_external"`
|
|
IsSelected bool `json:"is_selected"`
|
|
}
|
|
|
|
// nowPlayingResult is the output shape of retrieve_now_playing.
|
|
type nowPlaying struct {
|
|
ItemID string `json:"item_id"`
|
|
Title string `json:"title"`
|
|
SeriesName string `json:"series_name,omitempty"`
|
|
MediaType string `json:"media_type"`
|
|
PositionMilliseconds int64 `json:"position_milliseconds"`
|
|
CanSeek bool `json:"can_seek"`
|
|
AudioStreamIndex any `json:"audio_stream_index"`
|
|
SubtitleStreamIndex any `json:"subtitle_stream_index"`
|
|
AudioStreams []streamDescriptor `json:"audio_streams"`
|
|
SubtitleStreams []streamDescriptor `json:"subtitle_streams"`
|
|
SupportedCommands []string `json:"supported_commands"`
|
|
}
|
|
|
|
func nowPlayingResult(session *emby.SessionInfo, item *emby.BaseItemDto) nowPlaying {
|
|
var audioIdx, subIdx *int
|
|
var pos int64
|
|
var canSeek bool
|
|
if ps := session.PlayState; ps != nil {
|
|
audioIdx, subIdx = ps.AudioStreamIndex, ps.SubtitleStreamIndex
|
|
pos = ticksToMSLocal(ps.PositionTicks)
|
|
canSeek = ps.CanSeek
|
|
}
|
|
var audio, subs []streamDescriptor
|
|
for _, ms := range item.MediaSources {
|
|
for _, st := range ms.MediaStreams {
|
|
d := streamDescriptor{
|
|
Index: st.Index,
|
|
Language: st.Language,
|
|
Codec: st.Codec,
|
|
Title: st.Title,
|
|
DisplayTitle: st.DisplayTitle,
|
|
IsDefault: st.IsDefault,
|
|
IsForced: st.IsForced,
|
|
IsExternal: st.IsExternal,
|
|
}
|
|
switch st.Type {
|
|
case "Audio":
|
|
d.IsSelected = audioIdx != nil && *audioIdx == st.Index
|
|
audio = append(audio, d)
|
|
case "Subtitle":
|
|
d.IsSelected = subIdx != nil && *subIdx == st.Index
|
|
subs = append(subs, d)
|
|
}
|
|
}
|
|
}
|
|
return nowPlaying{
|
|
ItemID: item.ID,
|
|
Title: item.Name,
|
|
SeriesName: item.SeriesName,
|
|
MediaType: item.MediaType,
|
|
PositionMilliseconds: pos,
|
|
CanSeek: canSeek,
|
|
AudioStreamIndex: intPtrOrEmpty(audioIdx),
|
|
SubtitleStreamIndex: intPtrOrEmpty(subIdx),
|
|
AudioStreams: audio,
|
|
SubtitleStreams: subs,
|
|
SupportedCommands: session.SupportedCommands,
|
|
}
|
|
}
|
|
|
|
func ticksToMSLocal(ticks int64) int64 { return ticks / 10_000 }
|
|
|
|
func intPtrOrEmpty(p *int) any {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|
|
|
|
// setStreamTrack implements set_subtitle / set_audio_track: resolve the track,
|
|
// switch mid-playback when the session supports it, else restart playback at
|
|
// the current position with the stream selected.
|
|
func setStreamTrack(ctx context.Context, st *state.State, sessionID, track, streamType string) *mcp.CallToolResult {
|
|
if sessionID == "" {
|
|
return textResult("ERROR: no session_id was supplied. Obtain session_id from tool retrieve_player_list")
|
|
}
|
|
if track == "" {
|
|
return textResult("ERROR: no track was supplied. Use tool retrieve_now_playing to list the available tracks")
|
|
}
|
|
session, err := st.Client.GetSession(ctx, sessionID)
|
|
if err != nil {
|
|
return textResult(errf("ERROR: failed to retrieve session because: %v", err))
|
|
}
|
|
if session == nil {
|
|
return textResult("ERROR: session not found. Obtain a fresh session_id from tool retrieve_player_list")
|
|
}
|
|
item := session.NowPlayingItem
|
|
if item == nil {
|
|
return textResult("ERROR: nothing is currently playing on this session")
|
|
}
|
|
if len(item.MediaSources) == 0 {
|
|
full, ferr := st.Client.GetItem(ctx, st.UserID, item.ID)
|
|
if ferr == nil {
|
|
item = full
|
|
}
|
|
}
|
|
|
|
var command, fieldName string
|
|
var streams []emby.MediaStream
|
|
switch streamType {
|
|
case "Subtitle":
|
|
command, fieldName = "SetSubtitleStreamIndex", "SubtitleStreamIndex"
|
|
case "Audio":
|
|
command, fieldName = "SetAudioStreamIndex", "AudioStreamIndex"
|
|
}
|
|
for _, ms := range item.MediaSources {
|
|
for _, sm := range ms.MediaStreams {
|
|
if sm.Type == streamType {
|
|
streams = append(streams, sm)
|
|
}
|
|
}
|
|
}
|
|
|
|
index, resolveErr := resolveStreamIndex(streams, track, streamType == "Subtitle")
|
|
if resolveErr != "" {
|
|
return textResult(resolveErr)
|
|
}
|
|
|
|
if session.SupportsCommand(command) {
|
|
args := map[string]string{"Index": strconv.Itoa(index)}
|
|
if err := st.Client.SendGeneralCommand(ctx, sessionID, command, args, st.UserID); err != nil {
|
|
return textResult(errf("ERROR: failed to set %s track because: %v", streamType, err))
|
|
}
|
|
// Some renderers (DLNA/Cast bridges) advertise the command but silently
|
|
// drop it — confirm the selection landed in PlayState before declaring
|
|
// success, otherwise fall through to the restart path.
|
|
for range 3 {
|
|
time.Sleep(400 * time.Millisecond)
|
|
s2, verr := st.Client.GetSession(ctx, sessionID)
|
|
if verr == nil && s2 != nil {
|
|
session = s2
|
|
if streamIndexMatches(session.PlayState, fieldName, index) {
|
|
return textResult("Success")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: restart playback with the stream selected, resuming position.
|
|
opts := emby.PlayOptions{}
|
|
idx := index
|
|
if fieldName == "SubtitleStreamIndex" {
|
|
opts.SubtitleStreamIndex = &idx
|
|
} else {
|
|
opts.AudioStreamIndex = &idx
|
|
}
|
|
pos := int64(0)
|
|
if ps := session.PlayState; ps != nil {
|
|
pos = ps.PositionTicks
|
|
}
|
|
if pos > 0 {
|
|
opts.StartPositionTicks = &pos
|
|
}
|
|
if err := st.Client.PlayNowWithOptions(ctx, sessionID, item.ID, st.UserID, opts); err != nil {
|
|
return textResult(errf("ERROR: player does not support switching tracks mid-playback; failed to restart playback because: %v", err))
|
|
}
|
|
return textResult("Success (playback restarted with selected track)")
|
|
}
|
|
|
|
// streamIndexMatches reports whether PlayState reflects the requested stream
|
|
// index. A negative index (subtitles off) matches a nil or negative PlayState
|
|
// value.
|
|
func streamIndexMatches(ps *emby.PlayState, fieldName string, index int) bool {
|
|
var cur *int
|
|
if ps != nil {
|
|
if fieldName == "SubtitleStreamIndex" {
|
|
cur = ps.SubtitleStreamIndex
|
|
} else {
|
|
cur = ps.AudioStreamIndex
|
|
}
|
|
}
|
|
if index < 0 {
|
|
return cur == nil || *cur < 0
|
|
}
|
|
return cur != nil && *cur == index
|
|
}
|
|
|
|
// resolveStreamIndex maps a user-supplied track selector to a stream index:
|
|
// a numeric stream index, a language name, or a title/display-title substring.
|
|
// "off"/"none" (subtitles only) resolves to -1. Returns a non-empty error
|
|
// string on failure.
|
|
func resolveStreamIndex(streams []emby.MediaStream, track string, allowOff bool) (int, string) {
|
|
if allowOff && (strings.EqualFold(track, "off") || strings.EqualFold(track, "none")) {
|
|
return -1, ""
|
|
}
|
|
if n, err := strconv.Atoi(track); err == nil {
|
|
return n, ""
|
|
}
|
|
needle := strings.ToLower(track)
|
|
var matches []emby.MediaStream
|
|
for _, s := range streams {
|
|
if s.Language != "" && strings.EqualFold(s.Language, track) {
|
|
matches = append(matches, s)
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(s.Title), needle) ||
|
|
strings.Contains(strings.ToLower(s.DisplayTitle), needle) {
|
|
matches = append(matches, s)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 1:
|
|
return matches[0].Index, ""
|
|
case 0:
|
|
var avail []string
|
|
for _, s := range streams {
|
|
avail = append(avail, fmt.Sprintf("%d: %s", s.Index, streamLabel(s)))
|
|
}
|
|
return 0, fmt.Sprintf("ERROR: no matching track for %q. Available tracks: %s", track, strings.Join(avail, "; "))
|
|
default:
|
|
var avail []string
|
|
for _, s := range matches {
|
|
avail = append(avail, fmt.Sprintf("%d: %s", s.Index, streamLabel(s)))
|
|
}
|
|
return 0, fmt.Sprintf("ERROR: multiple tracks match %q: %s. Supply a stream index.", track, strings.Join(avail, "; "))
|
|
}
|
|
}
|
|
|
|
func streamLabel(s emby.MediaStream) string {
|
|
label := s.DisplayTitle
|
|
if label == "" {
|
|
label = s.Title
|
|
}
|
|
if label == "" {
|
|
label = s.Language
|
|
}
|
|
if label == "" {
|
|
label = s.Codec
|
|
}
|
|
return label
|
|
}
|