components_tasks_QuickPlayTask.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiRead.bs"
import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/episodeQueue.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/quickplayLibrary.bs"
import "pkg:/source/utils/quickplayOutcome.bs"

' QuickPlayTask: Orchestrator task for all quickplay, Play All, Instant Mix,
' Trailer, and Photo Album flows that require API calls.
'
' Input AA fields:
'   action (string)       - dispatch key (for example "musicalbum", "playAllSeries", "instantMix")
'   id (string)           - primary item ID
'   seriesId (string)     - optional, for season/episode context
'   collectionType (string) - optional, for collection/userView dispatch
'   folderType (string)   - optional, for folder sub-dispatch
'   movieCount (integer)  - optional, for folder series vs movie heuristic
'   seriesCount (integer) - optional, for folder series vs movie heuristic
'   channelId (string)    - optional, for program-to-channel redirect
'   seasonId (string)     - optional, for season Play All
'
' Output AA fields:
'   action (string)       - "queue", "photo", or "trailer"
'   items (array)         - array of raw API item AAs (or node-like AAs)
'   shuffle (boolean)     - whether to shuffle after queuing
'   firstItemStartingPoint (LongInteger) - resume position for first item (0 = start)
'   startsFresh (boolean) - the first item starts from the beginning: nobody chose it to
'                           resume (QueueManager.startCurrentFresh). False only when a
'                           builder picked the first item to resume or continue it.
'   status (string)       - quickplayOutcome.STATUS_*: "ok", or why nothing arrived ("failed"
'                           when a read did not answer, "empty" when every read did)
'   kind (string)         - quickplayOutcome.KIND_*, which names the launch in the viewer's message
'
' Every queue gets one copy per episode (episodeQueue.collapseQueue) except playlists and
' collections, whose entries the viewer put there.

sub init()
  m.top.functionName = "executeQuickPlay"
  m.log = new log.Logger("QuickPlayTask")
end sub

sub executeQuickPlay()
  input = m.top.input
  if not isValid(input) or not isValidAndNotEmpty(input.action) then return

  action = LCase(input.action)
  ' Every read of this build, which remembers one that did not answer, so an empty result is
  ' not told as "nothing here".
  m.reader = new apiRead.Reader(m.log, "quick play")
  ' No branch builds this action, which no retry changes.
  unsupported = false
  items = []
  shouldShuffle = false
  outputAction = "queue"
  ' How the first item starts: resumes = a builder chose it to resume or continue it, at
  ' startingPoint (its own copy's position, or 0 to let the loader decide by version).
  launch = { startingPoint: 0&, resumes: false }
  keepsCopies = false

  ' Dispatch convention: the action key is the lowercased Jellyfin item type
  ' (for example "musicalbum", "boxset"), optionally with a literal operation prefix
  ' ("playall..."). Producers build it from the raw type (QueueManager.launchItem:
  ' LCase(type); ItemDetails: "playAll" + type), so keys must match Jellyfin's
  ' BaseItemKind exactly: there is no bare "album"/"artist" type, only "MusicAlbum"/"MusicArtist".
  ' The else branch below logs any unmatched action so drift can't fail silently.
  if action = "musicalbum"
    doAlbum(input, items)
  else if action = "musicartist"
    doArtist(input, items)
    shouldShuffle = true
  else if action = "boxset"
    doBoxset(input, items)
    keepsCopies = true
  else if action = "series"
    launch = doSeries(input, items)
  else if action = "season"
    launch = doSeason(input, items)
  else if action = "person"
    doPerson(input, items)
    shouldShuffle = true
  else if action = "playlist"
    doPlaylist(input, items)
    shouldShuffle = true
    keepsCopies = true
  else if action = "folder"
    result = doFolder(input)
    if isValid(result)
      items = result.items
      shouldShuffle = result.shuffle
      outputAction = result.action
      keepsCopies = (result.keepsCopies = true)
      if isValid(result.launch) then launch = result.launch
    end if
  else if action = "collectionfolder"
    result = doCollectionFolder(input)
    if isValid(result)
      items = result.items
      shouldShuffle = result.shuffle
      outputAction = result.action
      keepsCopies = (result.keepsCopies = true)
      if isValid(result.launch) then launch = result.launch
    end if
  else if action = "userview"
    result = doUserView(input)
    if isValid(result)
      items = result.items
      shouldShuffle = result.shuffle
      outputAction = result.action
      keepsCopies = (result.keepsCopies = true)
      if isValid(result.launch) then launch = result.launch
    end if
  else if action = "photoalbum"
    doPhotoAlbum(input, items)
    outputAction = "photo"
  else if action = "playallseries"
    doPlayAllSeries(input, items)
  else if action = "playallseason"
    doPlayAllSeason(input, items)
  else if action = "playallboxset"
    doPlayAllBoxset(input, items)
    keepsCopies = true
  else if action = "playallmusicalbum"
    doPlayAllAlbum(input, items)
  else if action = "playallmusicartist"
    doPlayAllArtist(input, items)
  else if action = "playallplaylist"
    doPlayAllPlaylist(input, items)
    keepsCopies = true
  else if action = "instantmix"
    doInstantMix(input, items)
  else if action = "loadtrailers"
    doLoadTrailers(input, items)
    outputAction = "trailer"
  else if action = "loadphotoalbum"
    doPhotoAlbum(input, items)
    outputAction = "photo"
  else
    ' No dispatch branch matched: a caller built an action string no handler key matches
    ' (for example "playAll" + itemType, the failure mode behind #629), or a remote Play
    ' named a type nothing here builds. Logged loudly so a type mismatch is debuggable, and
    ' told to the viewer as unsupported, since trying again cannot help.
    m.log.error("unhandled action — no dispatch branch matched", input.action)
    unsupported = true
  end if

  ' One copy per episode. A first item resumed at its own position stays exactly that copy.
  if outputAction = "queue" and not keepsCopies
    items = episodeQueue.collapseQueue(items, launch.startingPoint > 0, "qp_copies")
  end if

  m.top.output = {
    action: outputAction,
    items: items,
    shuffle: shouldShuffle,
    firstItemStartingPoint: launch.startingPoint,
    startsFresh: not launch.resumes,
    status: quickplayOutcome.statusOf(items.count(), m.reader.anyFailed, unsupported),
    kind: quickplayOutcome.kindOf(outputAction)
  }
end sub

' --- Requests ---

' m.reader.json for a read that searches a whole library, or every library for one person or
' artist, which a large library on a slow server answers long after an ordinary request's limit
' (timeouts.QUICKPLAY_LIBRARY_MS). Back stops this Task, which frees the request's pool slot at
' once (apiPool.bs, "Stopping a request nobody is waiting for").
function fetchLibraryJson(req as dynamic, requestId as string) as dynamic
  if isValid(req) then req.timeoutMs = timeouts.QUICKPLAY_LIBRARY_MS
  return m.reader.json(req, requestId)
end function

' --- QuickPlay actions (from quickplay.bs) ---

sub doAlbum(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
    "parentId": input.id,
    "imageTypeLimit": 1,
    "sortBy": "SortName",
    "limit": 500,
    "enableUserData": false,
    "EnableTotalRecordCount": false
  }), "qp_album")
  if isValid(data) and isValidAndNotEmpty(data.items)
    items.append(data.items)
  end if
end sub

sub doArtist(input as object, items as object)
  data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
    "artistIds": input.id,
    "includeItemTypes": "Audio",
    "sortBy": "Album",
    "limit": 500,
    "imageTypeLimit": 1,
    "Recursive": true,
    "enableUserData": false,
    "EnableTotalRecordCount": false
  }), "qp_artist")
  if isValid(data) and isValidAndNotEmpty(data.items)
    items.append(data.items)
  end if
end sub

sub doBoxset(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
    "parentid": input.id,
    "limit": 500,
    "EnableTotalRecordCount": false
  }), "qp_boxset")
  if isValid(data) and isValidAndNotEmpty(data.Items)
    items.append(data.Items)
  end if
end sub

' @return {object} { startingPoint, resumes } (see executeQuickPlay's launch)
function doSeries(input as object, items as object) as object
  launch = { startingPoint: 0&, resumes: true }
  localUser = m.global.user

  ' Check for a resumable (in-progress) episode first
  resumeData = m.reader.json(GetApi().BuildGetResumeItemsRequest({
    "parentId": input.id,
    "Limit": 1,
    "ImageTypeLimit": 1,
    "EnableTotalRecordCount": false
  }), "qp_seriesResume")
  ' A read that did not answer says nothing about where the viewer is, so the build stops
  ' rather than start some other episode (here and at Next Up below).
  if not isValid(resumeData) then return launch

  if isValidAndNotEmpty(resumeData.Items)
    ep = resumeData.Items[0]
    items.push(ep)
    if isValid(ep.UserData) and isValid(ep.UserData.PlaybackPositionTicks)
      launch.startingPoint = ep.UserData.PlaybackPositionTicks
    end if
  else
    ' Fall back to next unstarted episode
    data = m.reader.json(GetApi().BuildGetNextUpRequest({
      "seriesId": input.id,
      "Limit": 1,
      "ImageTypeLimit": 1,
      "EnableRewatching": localUser.settings.uiDetailsEnableRewatchingNextUp,
      "EnableTotalRecordCount": false
    }), "qp_seriesNextUp")
    if not isValid(data) then return launch

    if isValidAndNotEmpty(data.Items)
      items.push(data.Items[0])
    else
      ' All episodes fully watched and rewatching disabled: shuffle the whole series
      allEps = m.reader.json(GetApi().BuildGetEpisodesRequest(input.id, {
        "SortBy": "Random",
        "limit": 500,
        "EnableTotalRecordCount": false
      }), "qp_seriesShuffle")
      if isValid(allEps) and isValidAndNotEmpty(allEps.Items)
        items.append(allEps.Items)
      end if
      launch.resumes = false
    end if
  end if

  return launch
end function

' @return {object} { startingPoint, resumes } (see executeQuickPlay's launch)
function doSeason(input as object, items as object) as object
  launch = { startingPoint: 0&, resumes: true }
  seriesId = isValidAndNotEmpty(input.seriesId) ? input.seriesId : ""
  if seriesId = "" then return launch

  episodesData = m.reader.json(GetApi().BuildGetEpisodesRequest(seriesId, {
    "seasonId": input.id,
    "limit": 500,
    "EnableTotalRecordCount": false
  }), "qp_seasonEps")

  if isValid(episodesData) and isValidAndNotEmpty(episodesData.Items)
    ' Find the first unwatched EPISODE, not the first unwatched item: before 12.0 each copy
    ' of an episode is its own item with its own watched state, so an unwatched copy of a
    ' watched episode must not start the season over (episodeQueue). Tracks a position in
    ' the list rather than one derived from IndexNumber: the two only agree when a season
    ' holds episodes numbered 1..N with none missing; Jellyfin guarantees neither.
    groups = episodeQueue.groupByEpisode(episodesData.Items)
    firstUnwatched = -1
    for g = 0 to groups.count() - 1
      if not episodeQueue.isGroupPlayed(groups[g])
        firstUnwatched = g
        exit for
      end if
    end for

    if firstUnwatched >= 0
      ' Resume the copy the viewer is partway through, at its own position. Otherwise the
      ' episode's copies stay together and executeQuickPlay keeps one.
      firstQueued = firstUnwatched
      resumeCopy = episodeQueue.inProgressCopy(groups[firstUnwatched])
      if isValid(resumeCopy)
        items.push(resumeCopy)
        launch.startingPoint = resumeCopy.UserData.PlaybackPositionTicks
        firstQueued = firstUnwatched + 1
      end if
      for g = firstQueued to groups.count() - 1
        items.append(groups[g])
      end for
    else
      ' try to find a "continue watching" episode
      continueData = m.reader.json(GetApi().BuildGetResumeItemsRequest({
        "parentId": input.id,
        "EnableTotalRecordCount": false
      }), "qp_seasonContinue")
      ' Not answered: stop rather than start the season over (see doSeries).
      if not isValid(continueData) then return launch

      if isValidAndNotEmpty(continueData.Items)
        for each item in continueData.Items
          items.push(item)
        end for
        ' Set starting point for the first resumable episode
        firstEp = continueData.Items[0]
        if isValid(firstEp.UserData) and isValid(firstEp.UserData.PlaybackPositionTicks)
          launch.startingPoint = firstEp.UserData.PlaybackPositionTicks
        end if
      else
        ' play the whole season in order
        items.append(episodesData.Items)
        launch.resumes = false
      end if
    end if
  end if

  return launch
end function

sub doPerson(input as object, items as object)
  ' get movies by the person
  personMovies = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
    "personIds": input.id,
    "includeItemTypes": "Movie",
    "excludeItemTypes": "Season,Series",
    "recursive": true,
    "limit": 500
  }), "qp_personMovies")
  if isValid(personMovies) and isValidAndNotEmpty(personMovies.Items)
    items.append(personMovies.Items)
  end if

  ' get watched episodes by the person
  personEpisodes = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
    "personIds": input.id,
    "includeItemTypes": "Episode,Recording",
    "isPlayed": true,
    "excludeItemTypes": "Season,Series",
    "recursive": true,
    "limit": 500
  }), "qp_personEpisodes")
  if isValid(personEpisodes) and isValidAndNotEmpty(personEpisodes.Items)
    items.append(personEpisodes.Items)
  end if
end sub

sub doPlaylist(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetPlaylistItemsRequest(input.id, {
    "limit": 500
  }), "qp_playlist")
  if isValid(data) and isValidAndNotEmpty(data.Items)
    items.append(data.Items)
  end if
end sub

sub doPhotoAlbum(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
    "parentId": input.id,
    "includeItemTypes": "Photo",
    "sortBy": "Random",
    "Recursive": true
  }), "qp_photoAlbum")
  if isValid(data) and isValidAndNotEmpty(data.items)
    items.append(data.items)
  end if
end sub

' --- Folder / CollectionFolder / UserView (complex multi-call) ---

function doFolder(input as object) as dynamic
  items = []
  shouldShuffle = true
  outputAction = "queue"
  launch = { startingPoint: 0&, resumes: false }

  paramArray = {
    "includeItemTypes": "Episode,Recording,Movie,Video",
    "videoTypes": "VideoFile",
    "sortBy": "Random",
    "limit": 500,
    "imageTypeLimit": 1,
    "Recursive": true,
    "enableUserData": false,
    "EnableTotalRecordCount": false
  }

  folderType = LCase(isValidAndNotEmpty(input.folderType) ? input.folderType : "")
  seriesCount = isValid(input.seriesCount) ? input.seriesCount : 0
  ' A photo album opens as a slideshow. Set before the read, so an empty or failed one is told
  ' as a slideshow too (quickplayOutcome.kindOf).
  if folderType = "photoalbum" and seriesCount = 0 then outputAction = "photo"

  if folderType = "studio"
    paramArray["studioIds"] = input.id
  else if folderType = "genre"
    paramArray["genreIds"] = input.id
    if isValid(input.movieCount) and input.movieCount > 0
      paramArray["includeItemTypes"] = "Movie"
    end if
  else if folderType = "musicgenre"
    paramArray["genreIds"] = input.id
    paramArray.delete("videoTypes")
    paramArray["includeItemTypes"] = "Audio"
  else if folderType = "photoalbum"
    paramArray["parentId"] = input.id
    paramArray["includeItemTypes"] = "Photo"
    paramArray.delete("videoTypes")
    paramArray.delete("Recursive")
  else
    paramArray["parentId"] = input.id
  end if

  if seriesCount > 0
    paramArray["includeItemTypes"] = "Series"
    paramArray.Delete("videoTypes")
  end if

  folderData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest(paramArray), "qp_folder")

  if isValid(folderData) and isValidAndNotEmpty(folderData.items)
    if seriesCount > 0
      if seriesCount = 1
        ' Single series: delegate to series quickplay, and carry its whole launch: a folder
        ' holding one series plays it exactly as the series itself would, resume included.
        launch = doSeries({ id: folderData.items[0].Id }, items)
        shouldShuffle = false
      else
        doMultipleSeries(folderData.items, items)
      end if
    else
      items.append(folderData.items)
    end if
  end if

  return { action: outputAction, items: items, shuffle: shouldShuffle, launch: launch }
end function

function doCollectionFolder(input as object) as dynamic
  items = []
  shouldShuffle = true
  outputAction = "queue"
  keepsCopies = false

  collectionType = LCase(isValidAndNotEmpty(input.collectionType) ? input.collectionType : "")

  if collectionType = "movies"
    doVideoContainer(input, items)
    shouldShuffle = false
  else if collectionType = "music"
    data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "includeItemTypes": "Audio",
      "sortBy": "Album",
      "Recursive": true,
      "limit": 500,
      "imageTypeLimit": 1,
      "enableUserData": false,
      "EnableTotalRecordCount": false
    }), "qp_collectionMusic")
    if isValid(data) and isValidAndNotEmpty(data.items)
      items.append(data.Items)
    end if
  else if collectionType = "boxsets"
    ' pick a random boxset and play its contents
    boxsetData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "limit": 500,
      "imageTypeLimit": 0,
      "enableUserData": false,
      "EnableTotalRecordCount": false,
      "enableImages": false
    }), "qp_collectionBoxsets")
    if isValid(boxsetData) and isValidAndNotEmpty(boxsetData.items)
      arrayIndex = Rnd(boxsetData.items.count()) - 1
      myBoxset = boxsetData.items[arrayIndex]
      innerData = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
        "parentId": myBoxset.id,
        "EnableTotalRecordCount": false
      }), "qp_collectionBoxsetInner")
      if isValid(innerData) and isValidAndNotEmpty(innerData.items)
        items.append(innerData.Items)
      end if
    end if
    shouldShuffle = false
    keepsCopies = true
  else if collectionType = "tvshows"
    doVideoContainer(input, items)
    shouldShuffle = false
  else if collectionType = "" or collectionType = "collectionfolder"
    ' Mixed content: a library created with no type has no CollectionType.
    doMixedLibrary(input, items)
  else if collectionType = "musicvideos"
    data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "includeItemTypes": "MusicVideo",
      "sortBy": "Random",
      "Recursive": true,
      "limit": 500,
      "imageTypeLimit": 1,
      "enableUserData": false,
      "EnableTotalRecordCount": false
    }), "qp_collectionMusicVideos")
    if isValid(data) and isValidAndNotEmpty(data.items)
      items.append(data.Items)
    end if
    shouldShuffle = false
  else if collectionType = "homevideos"
    ' A slideshow, set before the read as in doFolder.
    outputAction = "photo"
    data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "includeItemTypes": "Photo",
      "sortBy": "Random",
      "Recursive": true,
      "limit": 500
    }), "qp_collectionPhotos")
    if isValid(data) and isValidAndNotEmpty(data.items)
      items.append(data.items)
    end if
  end if

  return { action: outputAction, items: items, shuffle: shouldShuffle, keepsCopies: keepsCopies }
end function

function doUserView(input as object) as dynamic
  items = []
  shouldShuffle = true
  outputAction = "queue"
  keepsCopies = false

  collectionType = LCase(isValidAndNotEmpty(input.collectionType) ? input.collectionType : "")

  if collectionType = "playlists"
    ' pick a random playlist and play its contents
    playlistData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "sortBy": "Random",
      "limit": 1,
      "imageTypeLimit": 0,
      "enableUserData": false,
      "EnableTotalRecordCount": false,
      "enableImages": false
    }), "qp_userViewPlaylists")
    if isValid(playlistData) and isValidAndNotEmpty(playlistData.items)
      myPlaylist = playlistData.items[0]
      playlistItems = m.reader.json(GetApi().BuildGetPlaylistItemsRequest(myPlaylist.id, {
        "EnableTotalRecordCount": false,
        "limit": 500
      }), "qp_userViewPlaylistItems")
      if isValid(playlistItems) and isValidAndNotEmpty(playlistItems.items)
        items.append(playlistItems.items)
      end if
    end if
    keepsCopies = true
  else if collectionType = "livetv"
    ' pick a random channel
    channelData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "includeItemTypes": "TVChannel",
      "sortBy": "Random",
      "limit": 1,
      "Recursive": true,
      "imageTypeLimit": 0,
      "enableUserData": false,
      "EnableTotalRecordCount": false,
      "enableImages": false
    }), "qp_userViewChannels")
    if isValid(channelData) and isValidAndNotEmpty(channelData.items)
      myChannel = channelData.items[0]
      ' Return channel as a queue item with TvChannel type
      myChannel.Type = "TvChannel"
      items.push(myChannel)
    end if
    shouldShuffle = false
  else if collectionType = "movies" or collectionType = "tvshows"
    doVideoContainer(input, items)
    shouldShuffle = false
  end if

  return { action: outputAction, items: items, shuffle: shouldShuffle, keepsCopies: keepsCopies }
end function

' Helper: video container logic (movies or tvshows collection)
sub doVideoContainer(input as object, items as object)
  collectionType = LCase(isValidAndNotEmpty(input.collectionType) ? input.collectionType : "")

  if collectionType = "movies"
    data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
      "parentId": input.id,
      "sortBy": "Random",
      "recursive": true,
      "includeItemTypes": "Movie,Video",
      "limit": 500
    }), "qp_videoContainerMovies")
    if isValid(data) and isValidAndNotEmpty(data.items)
      items.append(quickplayLibrary.withoutInProgress(data.items))
    end if
  else if collectionType = "tvshows"
    tvshowsData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest(quickplayLibrary.watchedEpisodesQuery(input.id)), "qp_videoContainerTvShows")
    if isValid(tvshowsData) and isValidAndNotEmpty(tvshowsData.items)
      items.append(tvshowsData.items)
    end if
  end if
end sub

' Helper for a mixed-content library: its movies and videos that are not in progress, and its
' watched episodes, the Movies and TV libraries' rules (quickplayLibrary), shuffled together by
' executeQuickPlay.
sub doMixedLibrary(input as object, items as object)
  videoData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
    "parentId": input.id,
    "includeItemTypes": "Movie,Video",
    "sortBy": "Random",
    "recursive": true,
    "limit": 500,
    "imageTypeLimit": 1,
    "EnableTotalRecordCount": false
  }), "qp_mixedVideos")
  if isValid(videoData) and isValidAndNotEmpty(videoData.items)
    items.append(quickplayLibrary.withoutInProgress(videoData.items))
  end if

  episodeData = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest(quickplayLibrary.watchedEpisodesQuery(input.id)), "qp_mixedEpisodes")
  if isValid(episodeData) and isValidAndNotEmpty(episodeData.items)
    items.append(episodeData.items)
  end if
end sub

' Helper: shuffle play watched episodes from multiple series
sub doMultipleSeries(tvshows as object, items as object)
  numTotal = 0
  numLimit = 500
  for each tvshow in tvshows
    showData = m.reader.json(GetApi().BuildGetEpisodesRequest(tvshow.id, {
      "SortBy": "Random",
      "imageTypeLimit": 0,
      "EnableTotalRecordCount": false,
      "enableImages": false
    }), "qp_multiSeries_" + tvshow.id)
    if isValid(showData) and isValidAndNotEmpty(showData.items)
      for each episode in showData.items
        if isValid(episode.userdata) and isValid(episode.userdata.Played)
          if episode.userdata.Played
            items.push(episode)
          end if
        end if
      end for
      numTotal = numTotal + showData.items.count()
      if numTotal >= numLimit then exit for
    end if
  end for
end sub

' --- Play All actions (from Main.bs button handlers) ---

sub doPlayAllSeries(input as object, items as object)
  allEpData = m.reader.json(GetApi().BuildGetEpisodesRequest(input.id, {
    SortBy: "IndexNumber,SortName",
    SortOrder: "Ascending"
  }), "qp_playAllSeries")
  if isValid(allEpData) and isValid(allEpData.Items)
    ' Exclude Season 0 (Specials) from Play All
    for each ep in allEpData.Items
      if isValid(ep.ParentIndexNumber) and ep.ParentIndexNumber > 0
        items.push(ep)
      end if
    end for
  end if
end sub

sub doPlayAllSeason(input as object, items as object)
  allEpData = m.reader.json(GetApi().BuildGetEpisodesRequest(input.seriesId, {
    SeasonId: input.id,
    SortBy: "IndexNumber,SortName",
    SortOrder: "Ascending"
  }), "qp_playAllSeason")
  if isValid(allEpData) and isValid(allEpData.Items)
    items.append(allEpData.Items)
  end if
end sub

sub doPlayAllBoxset(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
    "ParentId": input.id,
    "SortBy": "PremiereDate,ProductionYear,SortName",
    "SortOrder": "Ascending",
    "EnableTotalRecordCount": false
  }), "qp_playAllBoxset")
  if isValid(data) and isValid(data.Items)
    items.append(data.Items)
  end if
end sub

sub doPlayAllAlbum(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetItemsByQueryRequest({
    "parentId": input.id,
    "includeitemtypes": "Audio",
    "SortBy": "ParentIndexNumber,IndexNumber,SortName",
    "SortOrder": "Ascending",
    "EnableTotalRecordCount": false
  }), "qp_playAllAlbum")
  if isValid(data) and isValid(data.Items)
    items.append(data.Items)
  end if
end sub

sub doPlayAllArtist(input as object, items as object)
  data = fetchLibraryJson(GetApi().BuildGetItemsByQueryRequest({
    "ArtistIds": input.id,
    "Recursive": "true",
    "MediaTypes": "Audio",
    "Filters": "IsNotFolder",
    "SortBy": "SortName",
    "Limit": 300,
    "Fields": "Chapters",
    "ExcludeLocationTypes": "Virtual",
    "EnableTotalRecordCount": false,
    "CollapseBoxSetItems": false
  }), "qp_playAllArtist")
  if isValid(data) and isValid(data.Items)
    items.append(data.Items)
  end if
end sub

sub doPlayAllPlaylist(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetPlaylistItemsRequest(input.id, {
    "SortBy": "ListItemOrder",
    "SortOrder": "Ascending",
    "EnableTotalRecordCount": false,
    "limit": 500
  }), "qp_playAllPlaylist")
  if isValid(data) and isValid(data.Items)
    items.append(data.Items)
  end if
end sub

sub doInstantMix(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetInstantMixRequest(input.id, { "Limit": 201 }), "qp_instantMix")
  if isValid(data) and isValid(data.Items)
    items.append(data.Items)
  end if
end sub

sub doLoadTrailers(input as object, items as object)
  data = m.reader.json(GetApi().BuildGetLocalTrailersRequest(input.id), "qp_trailers")
  if isValid(data)
    items.append(data)
  end if
end sub