Validate add-ons / YAML and layout (pull_request) Failing after 35s
Validate add-ons / emby-mcp Go (pull_request) Failing after 3m55s
Validate add-ons / openai-codex-proxy (pull_request) Successful in 13s
Validate add-ons / n95-mqtt-bridge pin and upstream tests (pull_request) Failing after 39s
Gitea ubuntu-latest is PEP 668 so pip install pyyaml fails. gofmt -l was dirty on main.go, items_test.go, tools_browse.go.
515 lines
19 KiB
Go
515 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
|
"git.i3omb.com/gronod/emby-mcp/internal/state"
|
|
"github.com/google/uuid"
|
|
"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
|
|
}
|