source_utils_episodeQueue.bs
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/utils/mediaSources.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/versionPick.bs"
' One entry per episode in a queue the app builds from a list of episodes — the next episodes
' behind the one playing, Play All, quick play and shuffle of a series, season, person or folder.
'
' Before 12.0 the server lists every file of an episode as its own episode (same series, season
' and episode number, one MediaSource each — the same on 10.7 through 10.11), so a plain queue
' plays both copies back to back. Copies are grouped and one is kept, chosen like a version:
' the viewer's explicit pick when a copy matches it, else the best for the device
' (versionPick). A 12.0 server merges them into one item with alternate versions, so its
' lists have no copies and nothing here costs a request.
'
' Playlists and collections are left alone: the viewer put those entries there.
'
' Everything but collapseCopies() / collapseQueue() is pure, so the rules are unit-tested
' without a network seam.
namespace episodeQueue
' episodeKey: "<seriesId>/S<season>E<episode>" for a single numbered episode, "" otherwise.
'
' "" means "never group": anything that is not an episode (a movie, a recording, a track —
' which has disc and track numbers), an episode without a series or numbers, and a
' multi-episode file (IndexNumberEnd past IndexNumber), which covers more than the episode
' its first number names. The series is part of the key so a list mixing shows never merges
' S1E1 of one with S1E1 of another.
'
' @param {dynamic} item - a raw item (BaseItemDto)
' @return {string}
function episodeKey(item as dynamic) as string
if not isValid(item) then return ""
if not isValidAndNotEmpty(item.Type) or LCase(item.Type) <> "episode" then return ""
if not isValidAndNotEmpty(item.SeriesId) then return ""
if not isValid(item.ParentIndexNumber) or not isValid(item.IndexNumber) then return ""
if isValid(item.IndexNumberEnd) and item.IndexNumberEnd <> item.IndexNumber then return ""
return item.SeriesId + "/S" + item.ParentIndexNumber.toStr() + "E" + item.IndexNumber.toStr()
end function
' groupByEpisode: The items as groups of copies of one episode, in the order each episode
' first appears. An item that cannot be keyed is a group of its own.
'
' @param {dynamic} items - raw items, in play order
' @param {string} skipKey - an episode to leave out entirely: the one already playing, whose
' other copies would otherwise play it a second time
' @return {object} array of arrays
function groupByEpisode(items as dynamic, skipKey = "" as string) as object
groups = []
if not isValid(items) then return groups
groupIndexByKey = {}
for each item in items
key = episodeKey(item)
if key = ""
groups.push([item])
else if key = skipKey
continue for
else if groupIndexByKey.DoesExist(key)
groups[groupIndexByKey[key]].push(item)
else
groupIndexByKey[key] = groups.count()
groups.push([item])
end if
end for
return groups
end function
' copyIds: The ids of every item that has another copy in its group — the only items whose
' MediaSources are needed to choose between them.
function copyIds(groups as object) as object
ids = []
for each group in groups
if group.count() > 1
for each item in group
if isValidAndNotEmpty(item.Id) then ids.push(item.Id)
end for
end if
end for
return ids
end function
' isGroupPlayed: Whether the viewer has watched this episode — on ANY copy. Before 12.0 each
' copy keeps its own watched state, so an unwatched copy of a watched episode is not a reason
' to play it again.
'
' @param {object} group - copies of one episode, from groupByEpisode()
' @return {boolean}
function isGroupPlayed(group as object) as boolean
for each item in group
if isValid(item.UserData) and item.UserData.Played = true then return true
end for
return false
end function
' inProgressCopy: The copy the viewer is partway through, or invalid. With more than one, the
' one played most recently (LastPlayedDate is ISO 8601, so it compares as text).
'
' A resume must come from this copy: its position belongs to its own file, and copies of one
' episode can differ in runtime.
'
' @param {object} group - copies of one episode, from groupByEpisode()
' @return {dynamic} the copy, or invalid
function inProgressCopy(group as object) as dynamic
best = invalid
bestPlayed = ""
for each item in group
userData = item.UserData
if not isValid(userData) or userData.Played = true then continue for
if not isValid(userData.PlaybackPositionTicks) or userData.PlaybackPositionTicks <= 0 then continue for
played = ""
if isValidAndNotEmpty(userData.LastPlayedDate) then played = userData.LastPlayedDate
if not isValid(best) or played > bestPlayed
best = item
bestPlayed = played
end if
end for
return best
end function
' chooseCopy: The one copy of an episode to queue, by the same rule as a version.
'
' A copy's own source is its FIRST MediaSource: before 12.0 each copy is a separate item
' with its file as its only source. When any copy's sources could not be read, the server's
' first copy is kept — a choice made on partial data would be a guess.
'
' @param {object} group - copies of one episode, from groupByEpisode()
' @param {object} sourcesById - item id -> its MediaSources
' @param {dynamic} preference - from versionPick.preferenceFor(), or invalid
' @param {dynamic} deviceCapabilities - for tests; invalid reads the device
' @return {object} the item to queue
function chooseCopy(group as object, sourcesById as object, preference as dynamic, deviceCapabilities = invalid as dynamic) as object
if group.count() = 1 then return group[0]
candidates = []
for each item in group
sources = invalid
if isValidAndNotEmpty(item.Id) then sources = sourcesById[item.Id]
if not isValidAndNotEmpty(sources) then return group[0]
candidates.push(sources[0])
end for
return group[versionPick.sourceIndexFor(preference, candidates, deviceCapabilities)]
end function
' collapseCopies: The items with one copy per episode, in play order.
'
' The imperative shell around the rules above; runs on a Task thread. MediaSources are
' fetched only when copies exist — mediaSources.byIds() chunks them — so a 12.0 list
' (never any copies) costs nothing.
'
' @param {dynamic} items - raw items in play order
' @param {string} skipKey - episodeKey() of the episode already playing, or ""
' @param {string} requestId - for the pool
' @return {object} array of raw items
function collapseCopies(items as dynamic, skipKey as string, requestId as string) as object
groups = groupByEpisode(items, skipKey)
result = []
sourcesById = {}
preference = invalid
ids = copyIds(groups)
if ids.count() > 0
preference = m.global.queueManager.callFunc("getVersionPreference")
sourcesById = mediaSources.byIds(ids, requestId)
end if
for each group in groups
result.push(chooseCopy(group, sourcesById, preference))
end for
return result
end function
' collapseQueue: A whole queue with one copy per episode.
'
' @param {dynamic} items - raw items in play order
' @param {boolean} keepFirst - true when the builder chose the first item to resume: that
' exact copy stays first (its position is its own), and its
' episode's other copies are dropped
' @param {string} requestId - for the pool
' @return {object} array of raw items
function collapseQueue(items as dynamic, keepFirst as boolean, requestId as string) as object
if not isValidAndNotEmpty(items) then return []
if not keepFirst then return collapseCopies(items, "", requestId)
rest = []
for i = 1 to items.count() - 1
rest.push(items[i])
end for
result = [items[0]]
result.append(collapseCopies(rest, episodeKey(items[0]), requestId))
return result
end function
end namespace