Start working on QuickPicks screen

This commit is contained in:
vfsfitvnm
2022-09-28 21:46:56 +02:00
parent 7a3c0ca110
commit 33778b33dd
37 changed files with 1354 additions and 272 deletions

View File

@@ -20,13 +20,16 @@ import it.vfsfitvnm.youtubemusic.models.BrowseResponse
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
import it.vfsfitvnm.youtubemusic.models.GetQueueResponse
import it.vfsfitvnm.youtubemusic.models.GetSearchSuggestionsResponse
import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import it.vfsfitvnm.youtubemusic.models.NextResponse
import it.vfsfitvnm.youtubemusic.models.PlayerResponse
import it.vfsfitvnm.youtubemusic.models.Runs
import it.vfsfitvnm.youtubemusic.models.SearchResponse
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
import it.vfsfitvnm.youtubemusic.models.ThumbnailRenderer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@@ -36,7 +39,7 @@ import kotlinx.serialization.json.Json
object YouTube {
private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
val client = HttpClient(OkHttp) {
private val client = HttpClient(OkHttp) {
BrowserUserAgent()
expectSuccess = true
@@ -162,37 +165,34 @@ object YouTube {
}
data class Info<T : NavigationEndpoint.Endpoint>(
val name: String,
val name: String?,
val endpoint: T?
) {
companion object {
inline fun <reified T : NavigationEndpoint.Endpoint> from(run: Runs.Run): Info<T> {
return Info(
name = run.text,
endpoint = run.navigationEndpoint?.endpoint as T?
)
}
}
@Suppress("UNCHECKED_CAST")
constructor(run: Runs.Run) : this(
name = run.text,
endpoint = run.navigationEndpoint?.endpoint as T?
)
}
sealed class Item {
abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
abstract val key: String?
abstract val key: String
data class Song(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.videoId
override val key: String
get() = info!!.endpoint!!.videoId!!
companion object : FromMusicShelfRendererContent<Song> {
companion object {
val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Song {
fun from(content: MusicShelfRenderer.Content): Song? {
val (mainRuns, otherRuns) = content.runs
// Possible configurations:
@@ -210,21 +210,22 @@ object YouTube {
?.browseEndpoint
?.type == "MUSIC_PAGE_TYPE_ALBUM"
}
?.let(Info.Companion::from)
?.let(::Info)
return Song(
info = Info.from(mainRuns.first()),
info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
?.map(Info.Companion::from)
?: emptyList(),
?.map(::Info),
album = album,
durationText = otherRuns
.lastOrNull()
?.firstOrNull()?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.videoId != null }
}
fun from(renderer: MusicResponsiveListItemRenderer): Song? {
@@ -236,15 +237,15 @@ object YouTube {
?.text
?.runs
?.getOrNull(0)
?.let { Info.from(it) } ?: return null,
?.let(::Info),
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Info.from<NavigationEndpoint.Endpoint.Browse>(it) }
?.takeIf { it.isNotEmpty() },
?.map<Runs.Run, Info<NavigationEndpoint.Endpoint.Browse>>(::Info)
?.takeIf(List<Any>::isNotEmpty),
durationText = renderer
.fixedColumns
?.getOrNull(0)
@@ -260,53 +261,55 @@ object YouTube {
?.text
?.runs
?.firstOrNull()
?.let { Info.from(it) },
?.let(::Info),
thumbnail = renderer
.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}
data class Video(
val info: Info<NavigationEndpoint.Endpoint.Watch>,
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val viewsText: String?,
val durationText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.videoId
override val key: String
get() = info!!.endpoint!!.videoId!!
val isOfficialMusicVideo: Boolean
get() = info
.endpoint
?.endpoint
?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
val isUserGeneratedContent: Boolean
get() = info
.endpoint
?.endpoint
?.watchEndpointMusicSupportedConfigs
?.watchEndpointMusicConfig
?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC"
companion object : FromMusicShelfRendererContent<Video> {
companion object {
val Filter = Filter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Video {
fun from(content: MusicShelfRenderer.Content): Video? {
val (mainRuns, otherRuns) = content.runs
return Video(
info = Info.from(mainRuns.first()),
info = mainRuns
.firstOrNull()
?.let(::Info),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 2)
?.map(Info.Companion::from),
?.map(::Info),
viewsText = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.firstOrNull()
@@ -317,31 +320,31 @@ object YouTube {
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}
data class Album(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Album> {
companion object {
val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Album {
fun from(content: MusicShelfRenderer.Content): Album? {
val (mainRuns, otherRuns) = content.runs
return Album(
info = Info(
name = mainRuns
.first()
.text,
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
.navigationEndpoint
@@ -349,37 +352,59 @@ object YouTube {
),
authors = otherRuns
.getOrNull(otherRuns.lastIndex - 1)
?.map(Info.Companion::from),
?.map(::Info),
year = otherRuns
.getOrNull(otherRuns.lastIndex)
?.firstOrNull()
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Album? {
return Album(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
authors = null,
year = renderer
.subtitle
.runs
.lastOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
data class Artist(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val subscribersCountText: String?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Artist> {
companion object {
val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
override fun from(content: MusicShelfRenderer.Content): Artist {
fun from(content: MusicShelfRenderer.Content): Artist? {
val (mainRuns, otherRuns) = content.runs
return Artist(
info = Info(
name = mainRuns
.first()
.text,
.firstOrNull()
?.text,
endpoint = content
.musicResponsiveListItemRenderer
.navigationEndpoint
@@ -391,22 +416,43 @@ object YouTube {
?.text,
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Artist? {
return Artist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
subscribersCountText = renderer
.subtitle
.runs
.firstOrNull()
?.text,
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
data class Playlist(
val info: Info<NavigationEndpoint.Endpoint.Browse>,
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
val songCount: Int?,
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?
) : Item() {
override val key: String?
get() = info.endpoint?.browseId
override val key: String
get() = info!!.endpoint!!.browseId!!
companion object : FromMusicShelfRendererContent<Playlist> {
override fun from(content: MusicShelfRenderer.Content): Playlist {
companion object {
fun from(content: MusicShelfRenderer.Content): Playlist? {
val (mainRuns, otherRuns) = content.runs
return Playlist(
@@ -422,7 +468,7 @@ object YouTube {
channel = otherRuns
.firstOrNull()
?.firstOrNull()
?.let { Info.from(it) },
?.let(::Info),
songCount = otherRuns
.lastOrNull()
?.firstOrNull()
@@ -432,7 +478,36 @@ object YouTube {
?.toIntOrNull(),
thumbnail = content
.thumbnail
)
).takeIf { it.info?.endpoint?.browseId != null }
}
fun from(renderer: MusicTwoRowItemRenderer): Playlist? {
return Playlist(
info = renderer
.title
.runs
.firstOrNull()
?.let(::Info),
channel = renderer
.subtitle
.runs
.getOrNull(2)
?.let(::Info),
songCount = renderer
.subtitle
.runs
.getOrNull(4)
?.text
?.split(' ')
?.firstOrNull()
?.toIntOrNull(),
thumbnail = renderer
.thumbnailRenderer
.musicThumbnailRenderer
.thumbnail
.thumbnails
.firstOrNull()
).takeIf { it.info?.endpoint?.browseId != null }
}
}
}
@@ -445,15 +520,11 @@ object YouTube {
val Filter = Filter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
}
interface FromMusicShelfRendererContent<out T : Item> {
fun from(content: MusicShelfRenderer.Content): T
}
@JvmInline
value class Filter(val value: String)
}
class SearchResult(val items: List<Item>, val continuation: String?)
class SearchResult(val items: List<Item>?, val continuation: String?)
suspend fun search(
query: String,
@@ -495,7 +566,7 @@ object YouTube {
SearchResult(
items = musicShelfRenderer
?.contents
?.map(
?.mapNotNull(
when (filter) {
Item.Song.Filter.value -> Item.Song.Companion::from
Item.Album.Filter.value -> Item.Album.Companion::from
@@ -505,7 +576,7 @@ object YouTube {
Item.FeaturedPlaylist.Filter.value -> Item.Playlist.Companion::from
else -> error("Unknown filter: $filter")
}
) ?: emptyList(),
),
continuation = musicShelfRenderer
?.continuations
?.firstOrNull()
@@ -623,7 +694,7 @@ object YouTube {
info = Info(
name = renderer
.title
?.text ?: return@let null,
?.text,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
@@ -632,14 +703,13 @@ object YouTube {
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { Info.from(it) }
?: emptyList(),
?.map(::Info),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { Info.from(it) },
?.let(::Info),
thumbnail = renderer
.thumbnail
.thumbnails
@@ -647,7 +717,7 @@ object YouTube {
durationText = renderer
.lengthText
?.text
)
).takeIf { it.info?.endpoint?.videoId != null }
}
}
}.recoverIfCancelled()
@@ -663,16 +733,6 @@ object YouTube {
)?.map { it?.firstOrNull() }
}
suspend fun queue(playlistId: String): Result<List<Item.Song>?>? {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = null,
playlistId = playlistId
)
)
}
suspend fun next(
videoId: String?,
playlistId: String?,
@@ -759,7 +819,7 @@ object YouTube {
info = Info(
name = renderer
.title
?.text ?: return@mapNotNull null,
?.text,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
@@ -768,14 +828,13 @@ object YouTube {
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { run -> Info.from(run) }
?: emptyList(),
?.map(::Info),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { run -> Info.from(run) },
?.let(::Info),
thumbnail = renderer
.thumbnail
.thumbnails
@@ -783,24 +842,14 @@ object YouTube {
durationText = renderer
.lengthText
?.text
)
).takeIf { it.info?.endpoint?.videoId != null }
},
lyrics = NextResult.Lyrics(
browseId = tabs
.getOrNull(1)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
),
related = NextResult.Related(
browseId = tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
)
lyricsBrowseId = tabs
.getOrNull(1)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId,
)
}.recoverIfCancelled()
}
@@ -811,32 +860,23 @@ object YouTube {
val params: String? = null,
val playlistSetVideoId: String? = null,
val items: List<Item.Song>?,
val lyrics: Lyrics?,
val related: Related?,
val lyricsBrowseId: String?
) {
class Lyrics(
val browseId: String?,
) {
suspend fun text(): Result<String?>? {
return if (browseId == null) {
Result.success(null)
} else {
browse(browseId)?.map { body ->
body.contents
.sectionListRenderer
?.contents
?.first()
?.musicDescriptionShelfRenderer
?.description
?.text
}
suspend fun lyrics(): Result<String?>? {
return if (lyricsBrowseId == null) {
Result.success(null)
} else {
browse(lyricsBrowseId)?.map { body ->
body.contents
.sectionListRenderer
?.contents
?.first()
?.musicDescriptionShelfRenderer
?.description
?.text
}
}
}
class Related(
val browseId: String?,
)
}
suspend fun browse(browseId: String): Result<BrowseResponse>? {
@@ -875,12 +915,14 @@ object YouTube {
parameter("continuation", continuation)
}.body<ContinuationResponse>().let { continuationResponse ->
copy(
songs = songs?.plus(continuationResponse
.continuationContents
.musicShelfContinuation
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()),
songs = songs?.plus(
continuationResponse
.continuationContents
.musicShelfContinuation
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from) ?: emptyList()
),
continuation = continuationResponse
.continuationContents
.musicShelfContinuation
@@ -897,7 +939,7 @@ object YouTube {
suspend fun album(browseId: String): Result<PlaylistOrAlbum>? {
return playlistOrAlbum(browseId)?.map { album ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
playlistOrAlbum("VL$playlistId")?.getOrNull()?.let { playlist ->
album.copy(songs = playlist.songs)
}
@@ -950,7 +992,7 @@ object YouTube {
?.subtitle
?.splitBySeparator()
?.getOrNull(1)
?.map { Info.from(it) },
?.map(::Info),
year = body
.header
?.musicDetailHeaderRenderer
@@ -972,9 +1014,7 @@ object YouTube {
?.musicShelfRenderer
?.contents
?.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(Item.Song.Companion::from)
// ?.filter { it.info.endpoint != null }
,
?.mapNotNull(Item.Song.Companion::from),
url = body
.microformat
?.microformatDataRenderer
@@ -999,7 +1039,7 @@ object YouTube {
}
data class Artist(
val name: String,
val name: String?,
val description: String?,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
@@ -1013,7 +1053,7 @@ object YouTube {
.header
?.musicImmersiveHeaderRenderer
?.title
?.text ?: "Unknown",
?.text,
description = body
.header
?.musicImmersiveHeaderRenderer
@@ -1045,4 +1085,100 @@ object YouTube {
)
}
}
data class Related(
val songs: List<Item.Song>? = null,
val playlists: List<Item.Playlist>? = null,
val albums: List<Item.Album>? = null,
val artists: List<Item.Artist>? = null,
)
suspend fun related(videoId: String): Result<Related?>? {
return runCatching {
val body = client.post("/youtubei/v1/next") {
contentType(ContentType.Application.Json)
setBody(
NextBody(
context = Context.DefaultWeb,
videoId = videoId,
playlistId = null,
isAudioOnly = true,
tunerSettingValue = "AUTOMIX_SETTING_NORMAL",
watchEndpointMusicSupportedConfigs = NextBody.WatchEndpointMusicSupportedConfigs(
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
),
index = 0,
playlistSetVideoId = null,
params = null,
continuation = null
)
)
parameter("key", Key)
parameter("prettyPrint", false)
}.body<NextResponse>()
body
.contents
.singleColumnMusicWatchNextResultsRenderer
.tabbedRenderer
.watchNextTabbedResultsRenderer
.tabs
.getOrNull(2)
?.tabRenderer
?.endpoint
?.browseEndpoint
?.browseId
?.let { browseId ->
browse(browseId)?.getOrThrow()?.let { browseResponse ->
browseResponse
.contents
.sectionListRenderer
?.contents
?.mapNotNull(SectionListRenderer.Content::musicCarouselShelfRenderer)
?.map(MusicCarouselShelfRenderer::contents)
}
}?.let { contents ->
Related(
songs = contents.find { items ->
items.firstOrNull()?.musicResponsiveListItemRenderer != null
}?.mapNotNull { content ->
Item.Song.from(content.musicResponsiveListItemRenderer!!)
},
playlists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Playlist.Companion::from),
albums = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ALBUM"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Album.Companion::from),
artists = contents.find { items ->
items.firstOrNull()
?.musicTwoRowItemRenderer
?.navigationEndpoint
?.browseEndpoint
?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig
?.pageType == "MUSIC_PAGE_TYPE_ARTIST"
}
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Item.Artist.Companion::from),
)
}
}.recoverIfCancelled()
}
}

View File

@@ -1,9 +1,7 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header,
@@ -12,7 +10,8 @@ data class MusicCarouselShelfRenderer(
@Serializable
data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
)
@Serializable

View File

@@ -93,7 +93,7 @@ data class NavigationEndpoint(
@Serializable
data class Browse(
val params: String?,
val browseId: String,
val browseId: String?,
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?,
) : Endpoint() {
val type: String?