Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import com.maxrave.common.SELECTED_LANGUAGE
import com.maxrave.common.SUPPORTED_LANGUAGE
import com.maxrave.common.SponsorBlockType
import com.maxrave.domain.data.model.cookie.YouTubeSession
import com.maxrave.domain.data.model.network.ProxyConfiguration
import com.maxrave.domain.data.player.ReverbPreset
import com.maxrave.domain.manager.DataStoreManager
Expand All @@ -28,6 +29,7 @@ import com.maxrave.logger.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -196,6 +198,16 @@ internal class DataStoreManagerImpl(
preferences[AUTH_USER] ?: 0
}

override val youTubeSession: Flow<YouTubeSession> =
settingsDataStore.data
.map { preferences ->
YouTubeSession(
cookie = preferences[COOKIE] ?: "",
pageId = preferences[PAGE_ID] ?: "",
authUser = preferences[AUTH_USER] ?: 0,
)
}.distinctUntilChanged()

override suspend fun setCookie(
cookie: String,
pageId: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ internal class AccountRepositoryImpl(

override fun getAccountInfo(cookie: String): Flow<List<AccountInfo>> =
flow {
youTube.cookie = cookie
delay(1000)
youTube
.getAccountListWithPageId(cookie)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,42 +70,35 @@ internal class CommonRepositoryImpl(
)
}
}
val ytCookieJob =
// cookie, pageId and authUser are written by DataStoreManager.setCookie in one
// DataStore edit; youTubeSession is one flow over that snapshot, so the scraper
// never gets a new pageId with a stale authUser or cookie.
val sessionJob =
launch {
dataStoreManager.cookie.distinctUntilChanged().collectLatest { cookie ->
if (cookie.isNotEmpty()) {
youTube.cookie = cookie
var lastCookie: String? = null
dataStoreManager.youTubeSession.collectLatest { s ->
youTube.setSession(
cookie = s.cookie.ifEmpty { null },
pageId = s.pageId.ifEmpty { null },
authUser = s.authUser,
)
// Same trigger as the old cookie-only collector: a channel switch on the
// same cookie must not refetch visitorData.
if (s.cookie.isNotEmpty() && s.cookie != lastCookie) {
youTube.visitorData()?.let {
youTube.visitorData = it
lastCookie = s.cookie
}
} else {
youTube.cookie = null
lastCookie = s.cookie
}
Logger.d("YouTube", "New cookie")
Logger.d("YouTube", "New session")
localDataSource.getUsedGoogleAccount()?.netscapeCookie?.let {
writeTextToFile(it, cookiePath)
Logger.w("YouTube", "Wrote cookie to file")
}
}
}
val pageIdJob =
launch {
dataStoreManager.pageId.distinctUntilChanged().collectLatest { pageId ->
youTube.pageId = pageId.ifEmpty { null }
Logger.d("YouTube", "New pageId")
localDataSource.getUsedGoogleAccount()?.netscapeCookie?.let {
writeTextToFile(it, cookiePath)
Logger.w("YouTube", "Wrote cookie to file")
}
}
}
val authUserJob =
launch {
dataStoreManager.authUser.distinctUntilChanged().collectLatest { authUser ->
youTube.authUser = authUser
Logger.d("YouTube", "New authUser")
}
}
val usingProxy =
launch {
combine(
Expand Down Expand Up @@ -269,9 +262,7 @@ internal class CommonRepositoryImpl(
}

localeJob.join()
ytCookieJob.join()
pageIdJob.join()
authUserJob.join()
sessionJob.join()
usingProxy.join()
dataSyncIdJob.join()
visitorDataJob.join()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.maxrave.domain.data.model.cookie

/**
* The three values the YouTube scraper needs together: a brand channel's pageId is only
* valid with the authuser that owns it, and both only with the cookie they came from.
* Read as one DataStore snapshot so no consumer ever sees a mixed pair.
*/
data class YouTubeSession(
val cookie: String,
val pageId: String,
val authUser: Int,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.maxrave.domain.manager

import com.maxrave.domain.data.model.cookie.YouTubeSession
import com.maxrave.domain.data.model.network.ProxyConfiguration
import com.maxrave.domain.data.player.ReverbPreset
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -64,6 +65,7 @@ interface DataStoreManager {
val cookie: Flow<String>
val pageId: Flow<String>
val authUser: Flow<Int>
val youTubeSession: Flow<YouTubeSession>

suspend fun setCookie(
cookie: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,23 +169,17 @@ class YouTube {
/**
* Set cookie and authentication header for client (for log in option)
*/
var cookie: String?
get() = ytMusic.cookie
set(value) {
ytMusic.cookie = value
}
val cookie: String? get() = ytMusic.cookie

var pageId: String?
get() = ytMusic.pageId
set(value) {
ytMusic.pageId = value
}
val pageId: String? get() = ytMusic.pageId

var authUser: Int
get() = ytMusic.authUser
set(value) {
ytMusic.authUser = value
}
val authUser: Int get() = ytMusic.authUser

fun setSession(
cookie: String?,
pageId: String?,
authUser: Int,
) = ytMusic.setSession(cookie, pageId, authUser)

/**
* TIDAL credentials, backed by [Ytmusic]. Set by the data layer from cached remote config.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM
import okio.buffer
import okio.use
import kotlin.concurrent.Volatile
import kotlin.time.ExperimentalTime

private const val TAG = "YouTubeScraperClient"
Expand Down Expand Up @@ -122,29 +123,42 @@ class Ytmusic {
var visitorData: String? = null
var dataSyncId: String? = null
private var poTokenChallengeRequestKey = "O43z0dpjhgX20SCx4KAo"
var cookie: String? = null
set(value) {
field = value
cookieMap = if (value == null) emptyMap() else parseCookieString(value)
extractor.logIn(value)
}
// The three request-identity values are swapped as ONE immutable snapshot and read once
// per request (see ytClient / getAuthorizationHeader): a request must never see the
// pageId of one account with the authuser or cookie of another.
private data class Session(
val cookie: String?,
val pageId: String?,
val authUser: Int,
)

var pageId: String? = null
@Volatile
private var session = Session(cookie = null, pageId = null, authUser = 0)

val cookie: String? get() = session.cookie
val pageId: String? get() = session.pageId

// Index of the Google account inside the browser session the cookie came from
// (the `authuser` query param on youtube.com). A brand channel's pageId is only
// valid together with the authuser that owns it; sending 0 for a channel of the
// second signed-in account makes YouTube answer as if not logged in.
var authUser: Int = 0
val authUser: Int get() = session.authUser

fun setSession(
cookie: String?,
pageId: String?,
authUser: Int,
) {
session = Session(cookie, pageId, authUser)
extractor.logIn(cookie)
}

// TIDAL credentials. Empty until CommonRepositoryImpl pushes the values fetched from the
// remote config (cached in DataStore). Deliberately NOT hard-coded in source — while
// empty, TIDAL metadata lookups fail silently until the first successful fetch.
var tidalClientId: String = ""
var tidalClientSecret: String = ""

private var cookieMap = emptyMap<String, String>()

var proxy: ProxyConfig? = null
set(value) {
field = value
Expand Down Expand Up @@ -214,26 +228,28 @@ class Ytmusic {
isUsingReferer: Boolean = true,
customCookie: String? = null,
) {
val s = session
contentType(ContentType.Application.Json)
headers {
append("X-Goog-Api-Format-Version", "1")
append("X-YouTube-Client-Name", "${client.xClientName ?: 1}")
append("X-YouTube-Client-Version", client.clientVersion)
append("X-Goog-Authuser", authUser.toString())
pageId?.let {
append("X-Goog-Authuser", s.authUser.toString())
s.pageId?.let {
append("X-Goog-Pageid", it)
}
append("x-origin", "https://music.youtube.com")
if (client.referer != null && isUsingReferer) {
append("Referer", client.referer)
}
if (setLogin) {
val cookie = customCookie ?: this@Ytmusic.cookie
val cookie = customCookie ?: s.cookie
cookie?.let { cookie ->
append("Cookie", cookie)
if ("SAPISID" !in cookieMap || "__Secure-3PAPISID" !in cookieMap) return@let
val parsedCookies = parseCookieString(cookie)
if ("SAPISID" !in parsedCookies || "__Secure-3PAPISID" !in parsedCookies) return@let
val currentTime = now().toInstant(TimeZone.currentSystemDefault()).epochSeconds / 1000
val sapisidCookie = cookieMap["SAPISID"] ?: cookieMap["__Secure-3PAPISID"]
val sapisidCookie = parsedCookies["SAPISID"] ?: parsedCookies["__Secure-3PAPISID"]
val sapisidHash = sha1("$currentTime $sapisidCookie https://music.youtube.com")
Logger.d(TAG, "SAPI SID Hash: SAPISIDHASH ${currentTime}_$sapisidHash")
append("Authorization", "SAPISIDHASH ${currentTime}_$sapisidHash")
Expand All @@ -245,15 +261,18 @@ class Ytmusic {
}

@OptIn(ExperimentalTime::class)
internal fun getAuthorizationHeader(): String? =
cookie?.let { cookie ->
if ("SAPISID" !in cookieMap || "__Secure-3PAPISID" !in cookieMap) null
internal fun getAuthorizationHeader(): String? {
val s = session
return s.cookie?.let { cookie ->
val parsedCookies = parseCookieString(cookie)
if ("SAPISID" !in parsedCookies || "__Secure-3PAPISID" !in parsedCookies) return@let null
val currentTime = now().toInstant(TimeZone.currentSystemDefault()).epochSeconds / 1000
val sapisidCookie = cookieMap["SAPISID"] ?: cookieMap["__Secure-3PAPISID"]
val sapisidCookie = parsedCookies["SAPISID"] ?: parsedCookies["__Secure-3PAPISID"]
val sapisidHash = sha1("$currentTime $sapisidCookie https://music.youtube.com")
Logger.d(TAG, "SAPI SID Hash: SAPISIDHASH ${currentTime}_$sapisidHash")
"SAPISIDHASH ${currentTime}_$sapisidHash"
}
}

fun getNewPipePlayer(videoId: String): List<Pair<Int, String>> = extractor.newPipePlayer(videoId)

Expand Down