Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add playback rewrite queue system #2209

Merged
merged 2 commits into from
Oct 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions playback/core/src/main/kotlin/queue/EmptyQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.jellyfin.playback.core.queue

import org.jellyfin.playback.core.queue.item.QueueEntry

object EmptyQueue : Queue {
override suspend fun getItem(index: Int): QueueEntry? = null
}
27 changes: 27 additions & 0 deletions playback/core/src/main/kotlin/queue/PagedQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.jellyfin.playback.core.queue

import org.jellyfin.playback.core.queue.item.QueueEntry

abstract class PagedQueue(
private val pageSize: Int = 10,
) : Queue {
private val buffer: MutableList<QueueEntry> = mutableListOf()

override suspend fun getItem(index: Int): QueueEntry? {
require(index in 0 until SequenceQueue.MAX_SIZE)

var page: Collection<QueueEntry>
var pageOffset = buffer.size
do {
if (buffer.size > index) return buffer[index]
page = loadPage(pageOffset, pageSize)
pageOffset += page.size

for (item in page) buffer.add(item)
} while (page.isNotEmpty())

return null
}

abstract suspend fun loadPage(offset: Int, size: Int): Collection<QueueEntry>
}
12 changes: 12 additions & 0 deletions playback/core/src/main/kotlin/queue/Queue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.jellyfin.playback.core.queue

import org.jellyfin.playback.core.queue.item.QueueEntry

/**
* A queue contains all items in the current playback session. This includes already played items,
* the currently playing item and future items.
*/
interface Queue {
// Item retrieval
suspend fun getItem(index: Int): QueueEntry?
}
38 changes: 38 additions & 0 deletions playback/core/src/main/kotlin/queue/QueueManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.jellyfin.playback.core.queue

import org.jellyfin.playback.core.queue.item.QueueEntry

class QueueManager {
companion object {
const val POSITION_NONE = -1
}

private var currentQueue: Queue? = null

private var currentItem: QueueEntry? = null
private var currentItemPosition = POSITION_NONE

fun updateQueue(queue: Queue) {
currentItem = null
currentItemPosition = POSITION_NONE
currentQueue = queue
}

suspend fun jumpTo(index: Int): QueueEntry? {
require(index >= 0)

currentItemPosition = index
currentItem = currentQueue?.getItem(currentItemPosition)
if (currentItem == null) currentItemPosition = POSITION_NONE

return currentItem
}

suspend fun next(): QueueEntry? = jumpTo(currentItemPosition + 1)
suspend fun peekNext(): QueueEntry? = currentQueue?.getItem(currentItemPosition + 1)
suspend fun peek(amount: Int): Collection<QueueEntry> = Array(amount) { i ->
currentQueue?.getItem(currentItemPosition + i + 1)
}.filterNotNull()

suspend fun previous(): QueueEntry? = jumpTo(currentItemPosition - 1)
}
26 changes: 26 additions & 0 deletions playback/core/src/main/kotlin/queue/SequenceQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.jellyfin.playback.core.queue

import org.jellyfin.playback.core.queue.item.QueueEntry

abstract class SequenceQueue : Queue {
companion object {
const val MAX_SIZE = 100
}

protected abstract val items: Sequence<QueueEntry>
private val itemIterator by lazy { items.iterator() }
private val buffer = mutableListOf<QueueEntry>()

override suspend fun getItem(index: Int): QueueEntry? {
require(index in 0 until MAX_SIZE)

do {
// Buffer contains the requested item
if (buffer.size > index) return buffer[index]
// Requested item is too big
if (!itemIterator.hasNext()) return null
// Add next item to buffer
buffer.add(itemIterator.next())
} while (true)
}
}
36 changes: 36 additions & 0 deletions playback/core/src/main/kotlin/queue/item/QueueEntry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.jellyfin.playback.core.queue.item

/**
* The QueueEntry is a single entry in a queue. It can represent any supported media type.
* Implementations normally extend one of it's subclasses [BrandedQueueEntry] or [UserQueueEntry].
*/
interface QueueEntry {
/**
* Whether this entry can be skipped and seeked or not. Normally set to `true`.
*/
val skippable: Boolean

/**
* The metadata for this item.
*/
val metadata: QueueEntryMetadata
}

/**
* Branded queue entries are used for intros, trailers, advertisements and other related videos. It
* is not possible to seek or skip these items and are normally invisible when showing a queue's
* contents.
*/
open class BrandedQueueEntry : QueueEntry {
override val skippable = false
override val metadata = QueueEntryMetadata.Empty
}
thornbill marked this conversation as resolved.
Show resolved Hide resolved

/**
* User queue entries are used for regular media like music tracks, movies and series episodes. This
* is the entry type used most often.
*/
open class UserQueueEntry : QueueEntry {
override val skippable = true
override val metadata = QueueEntryMetadata.Empty
}
31 changes: 31 additions & 0 deletions playback/core/src/main/kotlin/queue/item/QueueEntryMetadata.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.jellyfin.playback.core.queue.item

data class QueueEntryMetadata(
val album: String? = null,
val albumArtist: String? = null,
val albumArtUri: String? = null,
val artist: String? = null,
val artUri: String? = null,
val author: String? = null,
val compilation: String? = null,
val composer: String? = null,
val date: String? = null,
val discNumber: Long? = null,
val displayDescription: String? = null,
val displayIconUri: String? = null,
val displaySubtitle: String? = null,
val displayTitle: String? = null,
val duration: Long? = null,
val genre: String? = null,
val mediaId: String? = null,
val mediaUri: String? = null,
val numTracks: Long? = null,
val title: String? = null,
val trackNumber: Long? = null,
val writer: String? = null,
val year: Long? = null,
) {
companion object {
val Empty = QueueEntryMetadata()
}
}
35 changes: 35 additions & 0 deletions playback/jellyfin/src/main/kotlin/queue/AudioAlbumQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.jellyfin.playback.jellyfin.queue

import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.jellyfin.queue.item.BaseItemDtoUserQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.constant.MediaType

class AudioAlbumQueue(
private val album: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
init {
require(album.type == BaseItemKind.MUSIC_ALBUM)
}

override suspend fun loadPage(offset: Int, size: Int): Collection<QueueEntry> {
val result by api.itemsApi.getItemsByUserId(
parentId = album.id,
recursive = true,
mediaTypes = listOf(MediaType.Audio),
includeItemTypes = listOf(BaseItemKind.AUDIO),
sortBy = listOf(ItemFields.SORT_NAME.name),
fields = listOf(ItemFields.MEDIA_SOURCES),
// Pagination
startIndex = offset,
limit = size,
)
return result.items.orEmpty().map { BaseItemDtoUserQueueEntry.build(api, it) }
}
}
44 changes: 44 additions & 0 deletions playback/jellyfin/src/main/kotlin/queue/AudioInstantMixQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.jellyfin.playback.jellyfin.queue

import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.jellyfin.queue.item.BaseItemDtoUserQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.instantMixApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields

class AudioInstantMixQueue(
private val item: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
companion object {
val instantMixableItems = arrayOf(
BaseItemKind.MUSIC_GENRE,
BaseItemKind.PLAYLIST,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.AUDIO,
BaseItemKind.FOLDER,
)
}

init {
require(item.type in instantMixableItems)
}

override suspend fun loadPage(offset: Int, size: Int): Collection<QueueEntry> {
// API doesn't support paging for instant mix
if (offset > 0) return emptyList()

val result by api.instantMixApi.getInstantMixFromItem(
id = item.id,
userId = api.userId,
fields = listOf(ItemFields.MEDIA_SOURCES),
// Pagination
limit = size,
)
return result.items.orEmpty().map { BaseItemDtoUserQueueEntry.build(api, it) }
}
}
24 changes: 24 additions & 0 deletions playback/jellyfin/src/main/kotlin/queue/AudioTrackQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.jellyfin.playback.jellyfin.queue

import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.jellyfin.queue.item.BaseItemDtoUserQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind

class AudioTrackQueue(
private val item: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
init {
require(item.type == BaseItemKind.AUDIO)
}

override suspend fun loadPage(offset: Int, size: Int): Collection<QueueEntry> {
// We only have a single item
if (offset > 0) return emptyList()

return listOf(BaseItemDtoUserQueueEntry.build(api, item))
}
}
36 changes: 36 additions & 0 deletions playback/jellyfin/src/main/kotlin/queue/EpisodeQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.jellyfin.playback.jellyfin.queue

import org.jellyfin.playback.core.queue.PagedQueue
import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.jellyfin.queue.item.BaseItemDtoUserQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.constant.MediaType

class EpisodeQueue(
private val episode: BaseItemDto,
private val api: ApiClient,
) : PagedQueue() {
init {
require(episode.type == BaseItemKind.EPISODE)
}

override suspend fun loadPage(offset: Int, size: Int): Collection<QueueEntry> {
val result by api.itemsApi.getItemsByUserId(
parentId = episode.parentId,
parentIndexNumber = episode.parentIndexNumber,
recursive = true,
mediaTypes = listOf(MediaType.Video),
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemFields.SORT_NAME.name),
fields = listOf(ItemFields.MEDIA_SOURCES),
// Pagination
startIndex = offset,
limit = size,
)
return result.items.orEmpty().map { BaseItemDtoUserQueueEntry.build(api, it) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.jellyfin.playback.jellyfin.queue.item

import org.jellyfin.playback.core.queue.item.QueueEntry
import org.jellyfin.playback.core.queue.item.QueueEntryMetadata
import org.jellyfin.playback.core.queue.item.UserQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.nanoseconds

class BaseItemDtoUserQueueEntry private constructor(
val baseItem: BaseItemDto,
override val metadata: QueueEntryMetadata,
base: QueueEntry,
) : QueueEntry by base {
companion object {
fun build(api: ApiClient, baseItem: BaseItemDto): BaseItemDtoUserQueueEntry {
val metadata = QueueEntryMetadata(
mediaId = baseItem.id.toString(),
title = baseItem.name,
artist = baseItem.albumArtist,
album = baseItem.album,
date = baseItem.dateCreated?.toString(),
displayDescription = baseItem.overview,
displayTitle = baseItem.name,
duration = baseItem.runTimeTicks?.ticks?.inWholeMilliseconds,
genre = baseItem.genres?.joinToString(", "),
year = baseItem.productionYear?.toLong(),
artUri = baseItem.imageTags?.get(ImageType.PRIMARY)?.let { tag ->
api.imageApi.getItemImageUrl(
itemId = baseItem.id,
imageType = ImageType.PRIMARY,
tag = tag,
)
},
albumArtUri = baseItem.albumPrimaryImageTag?.let { tag ->
api.imageApi.getItemImageUrl(
itemId = baseItem.albumId ?: baseItem.id,
imageType = ImageType.PRIMARY,
tag = tag,
)
}
)

return BaseItemDtoUserQueueEntry(baseItem, metadata, UserQueueEntry())
}

/**
* Convert ticks to duration
*/
private val Long.ticks get() = div(100L).nanoseconds
}
}