Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
60c206f
feat: add sync server support to instance settings and simplify settings
Bnyro Apr 25, 2026
9ea4c28
refactor: create abstract UserDataRepository containing all user-data…
Bnyro Apr 25, 2026
bf53085
refactor: simplify Piped API authentication header and error handling
Bnyro Apr 25, 2026
e0fb75f
feat: add api stubs for LibreTube sync API
Bnyro Apr 25, 2026
708d8b8
feat: add support for logging in, registering and deleting account to…
Bnyro Apr 25, 2026
8a23648
feat: add support for subscription syncing via LibreTube sync server
Bnyro Apr 25, 2026
25f52f2
feat: add support for playlist syncing via LibreTube sync server
Bnyro Apr 25, 2026
b5c98ad
fix: crash when subscribing/unsubscribing a channel fails
Bnyro Apr 26, 2026
c09758a
fix: add dialog title to DeleteAccountDialog
Bnyro Apr 26, 2026
324633b
feat: forward HTTP error messages to caller in LibreTube sync server …
Bnyro Apr 26, 2026
0f8be7a
feat: add button group preference (single selection as a material but…
Bnyro Apr 26, 2026
b42c5b0
feat: use button group preference in instance settings for better UX
Bnyro Apr 26, 2026
111eb30
refactor: move dialog to select an instance into a custom component
Bnyro Apr 26, 2026
f6c5b60
feat: add welcome activity to inform user about data sync options
Bnyro Apr 26, 2026
cc557a5
chore: switch to v1 api path of LibreTube sync server
Bnyro Apr 29, 2026
e9650fe
feat: add LT sync API stubs for playlist bookmarks, watch history and…
Bnyro May 3, 2026
3a90044
feat: allow changing options bottom sheet options while sheet is alre…
Bnyro May 3, 2026
0428fec
feat: extend user data repository by subscription groups, watch histo…
Bnyro May 3, 2026
f6e9477
feat: add support for syncing subscription groups
Bnyro May 3, 2026
a891073
feat: delegate all playlist bookmarks/subscription groups/watch histo…
Bnyro May 3, 2026
bfba5fd
feat: add support for syncing playlist bookmarks
Bnyro May 3, 2026
a962a35
feat: add support for syncing watch history
Bnyro May 3, 2026
ce6dbb0
fix(player): remove periodic watch position store timer
Bnyro May 4, 2026
a257732
refactor: remove legacy local playlist type and forward known playlis…
Bnyro May 4, 2026
71c12c5
fix: show proper error message in delete account dialog
Bnyro May 4, 2026
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
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
android:label="@string/settings"
android:screenOrientation="locked" />

<activity
android:name=".ui.activities.WelcomeActivity"
android:label="@string/settings"
android:screenOrientation="locked" />

<activity
android:name=".ui.activities.HelpActivity"
android:label="@string/settings"
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ interface MediaServiceRepository {
val instance: MediaServiceRepository
get() = when {
PlayerHelper.fullLocalMode -> NewPipeMediaServiceRepository()
PlayerHelper.localStreamExtraction -> LocalStreamsExtractionPipedMediaServiceRepository()
else -> PipedMediaServiceRepository()
}
}
Expand Down
17 changes: 2 additions & 15 deletions app/src/main/java/com/github/libretube/api/PipedAuthApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import com.github.libretube.api.obj.Subscription
import com.github.libretube.api.obj.Token
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
Expand All @@ -29,7 +28,6 @@ interface PipedAuthApi {

@POST("user/delete")
suspend fun deleteAccount(
@Header("Authorization") token: String,
@Body password: DeleteUserRequest
)

Expand All @@ -45,11 +43,10 @@ interface PipedAuthApi {
@GET("subscribed")
suspend fun isSubscribed(
@Query("channelId") channelId: String,
@Header("Authorization") token: String
): Subscribed

@GET("subscriptions")
suspend fun subscriptions(@Header("Authorization") token: String): List<Subscription>
suspend fun subscriptions(): List<Subscription>

@GET("subscriptions/unauthenticated")
suspend fun unauthenticatedSubscriptions(
Expand All @@ -61,65 +58,55 @@ interface PipedAuthApi {

@POST("subscribe")
suspend fun subscribe(
@Header("Authorization") token: String,
@Body subscribe: Subscribe
): Message

@POST("unsubscribe")
suspend fun unsubscribe(
@Header("Authorization") token: String,
@Body subscribe: Subscribe
): Message

@POST("import")
suspend fun importSubscriptions(
@Query("override") override: Boolean,
@Header("Authorization") token: String,
@Body channels: List<String>
): Message

@POST("import/playlist")
suspend fun clonePlaylist(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): EditPlaylistBody

@GET("user/playlists")
suspend fun getUserPlaylists(@Header("Authorization") token: String): List<Playlists>
suspend fun getUserPlaylists(): List<Playlists>

@POST("user/playlists/rename")
suspend fun renamePlaylist(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message

@PATCH("user/playlists/description")
suspend fun changePlaylistDescription(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message

@POST("user/playlists/delete")
suspend fun deletePlaylist(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message

@POST("user/playlists/create")
suspend fun createPlaylist(
@Header("Authorization") token: String,
@Body name: Playlists
): EditPlaylistBody

@POST("user/playlists/add")
suspend fun addToPlaylist(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message

@POST("user/playlists/remove")
suspend fun removeFromPlaylist(
@Header("Authorization") token: String,
@Body editPlaylistBody: EditPlaylistBody
): Message

Expand Down
61 changes: 21 additions & 40 deletions app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
Original file line number Diff line number Diff line change
@@ -1,36 +1,27 @@
package com.github.libretube.api

import androidx.core.text.isDigitsOnly
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlaylistType
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.repo.LocalPlaylistsRepository
import com.github.libretube.repo.PipedPlaylistRepository
import com.github.libretube.repo.PlaylistRepository
import com.github.libretube.repo.UserDataRepository
import com.github.libretube.repo.UserDataRepositoryHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext

object PlaylistsHelper {
private val pipedPlaylistRegex =
"[\\da-fA-F]{8}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{4}-[\\da-fA-F]{12}".toRegex()
const val MAX_CONCURRENT_IMPORT_CALLS = 5

private val token get() = PreferenceHelper.getToken()
val loggedIn: Boolean get() = token.isNotEmpty()
private val playlistsRepository: PlaylistRepository
get() = when {
loggedIn -> PipedPlaylistRepository()
else -> LocalPlaylistsRepository()
}
@Suppress("DEPRECATION")
private val userDataRepository: UserDataRepository get() = UserDataRepositoryHelper.userDataRepository

suspend fun getPlaylists(): List<Playlists> = withContext(Dispatchers.IO) {
val playlists = playlistsRepository.getPlaylists()
val playlists = userDataRepository.getPlaylists()
sortPlaylists(playlists)
}

Expand All @@ -48,56 +39,46 @@ object PlaylistsHelper {
}
}

suspend fun getPlaylist(playlistId: String): Playlist {
suspend fun getPlaylist(playlistId: String, playlistType: PlaylistType): Playlist {
// load locally stored playlists with the auth api
return when (getPlaylistType(playlistId)) {
return when (playlistType) {
PlaylistType.PUBLIC -> MediaServiceRepository.instance.getPlaylist(playlistId)
else -> playlistsRepository.getPlaylist(playlistId)
else -> userDataRepository.getPlaylist(playlistId)
}
}

suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> {
return withContext(Dispatchers.IO) {
(playlistIds ?: getPlaylists().map { it.id!! })
.map { async { getPlaylist(it) } }
.map { async { getPlaylist(it, getPlaylistType(it)) } }
.awaitAll()
}
}

suspend fun createPlaylist(playlistName: String) =
playlistsRepository.createPlaylist(playlistName)
userDataRepository.createPlaylist(playlistName)

suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) =
withContext(Dispatchers.IO) {
playlistsRepository.addToPlaylist(playlistId, *videos)
userDataRepository.addToPlaylist(playlistId, *videos)
}

suspend fun renamePlaylist(playlistId: String, newName: String) =
playlistsRepository.renamePlaylist(playlistId, newName)
userDataRepository.renamePlaylist(playlistId, newName)

suspend fun changePlaylistDescription(playlistId: String, newDescription: String) =
playlistsRepository.changePlaylistDescription(playlistId, newDescription)
userDataRepository.changePlaylistDescription(playlistId, newDescription)

suspend fun removeFromPlaylist(playlistId: String, index: Int) =
playlistsRepository.removeFromPlaylist(playlistId, index)
suspend fun removeFromPlaylist(playlistId: String, videoId: String, index: Int) =
userDataRepository.removeFromPlaylist(playlistId, videoId, index)

suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) =
playlistsRepository.importPlaylists(playlists)

suspend fun clonePlaylist(playlistId: String) = playlistsRepository.clonePlaylist(playlistId)
suspend fun deletePlaylist(playlistId: String) = playlistsRepository.deletePlaylist(playlistId)
userDataRepository.importPlaylists(playlists)

fun getPrivatePlaylistType(): PlaylistType {
return if (loggedIn) PlaylistType.PRIVATE else PlaylistType.LOCAL
}
suspend fun clonePlaylist(playlistId: String) = userDataRepository.clonePlaylist(playlistId)
suspend fun deletePlaylist(playlistId: String) = userDataRepository.deletePlaylist(playlistId)

fun getPlaylistType(playlistId: String): PlaylistType {
return if (playlistId.isDigitsOnly()) {
PlaylistType.LOCAL
} else if (playlistId.matches(pipedPlaylistRegex)) {
PlaylistType.PRIVATE
} else {
PlaylistType.PUBLIC
}
}
// TODO: remove this and pass the type information down instead
private fun isYouTubePlaylist(playlistId: String) = playlistId.startsWith("PL") && playlistId.length == 34
fun getPlaylistType(playlistId: String) = if (isYouTubePlaylist(playlistId)) PlaylistType.PUBLIC else PlaylistType.PRIVATE
}
71 changes: 50 additions & 21 deletions app/src/main/java/com/github/libretube/api/RetrofitInstance.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.github.libretube.api

import com.github.libretube.BuildConfig
import com.github.libretube.api.ltsync.LibreTubeSyncServerApi
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.helpers.PreferenceHelper
import okhttp3.MediaType.Companion.toMediaType
Expand All @@ -10,43 +11,68 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.create

typealias HeadersAccessor = () -> Map<String, String>

object RetrofitInstance {
const val PIPED_API_URL = "https://pipedapi.kavin.rocks"
private const val LIBRETUBE_SYNC_SERVER_URL = "https://sync.libretube.dev"

val authUrl
get() = if (
PreferenceHelper.getBoolean(
PreferenceKeys.AUTH_INSTANCE_TOGGLE,
false
)
) {
PreferenceHelper.getString(
PreferenceKeys.AUTH_INSTANCE,
PIPED_API_URL
)
} else {
PipedMediaServiceRepository.apiUrl
}
val pipedAuthUrl
get() = PreferenceHelper.getString(
PreferenceKeys.AUTH_INSTANCE,
PIPED_API_URL
)

private val libretubeSyncServerUrl
get() = PreferenceHelper.getString(
PreferenceKeys.LIBRETUBE_SYNC_SERVER_URL,
LIBRETUBE_SYNC_SERVER_URL
)

val apiLazyMgr = resettableManager()
val kotlinxConverterFactory = JsonHelper.json
.asConverterFactory("application/json".toMediaType())

val httpClient by lazy { buildClient() }
val pipedAuthApi by resettableLazy(apiLazyMgr) {
buildRetrofitInstance<PipedAuthApi>(
pipedAuthUrl,
headersAccessor = { mapOf("Authorization" to PreferenceHelper.getToken()) }
)
}

val authApi by resettableLazy(apiLazyMgr) {
buildRetrofitInstance<PipedAuthApi>(authUrl)
val libretubeSyncServerApi by resettableLazy(apiLazyMgr) {
buildRetrofitInstance<LibreTubeSyncServerApi>(
libretubeSyncServerUrl,
headersAccessor = { mapOf("Authorization" to PreferenceHelper.getToken()) }
)
}

// the url provided here isn't actually used anywhere in the external api
val externalApi = buildRetrofitInstance<ExternalApi>(PIPED_API_URL)

private fun buildClient(): OkHttpClient {
/**
* Build a new [OkHttpClient] with logging support.
*
* @param headersAccessor Method that returns a list of headers to inject into each request. Can
* e.g. be used for injecting authorization tokens to the client.
*/
fun buildClient(headersAccessor: HeadersAccessor = { mapOf() }): OkHttpClient {
val httpClient = OkHttpClient().newBuilder()

// add provided headers to all requests
httpClient.addInterceptor { interceptorChain ->
val request = interceptorChain.request()
.newBuilder()
for ((key, value) in headersAccessor()) {
request.addHeader(key, value)
}

interceptorChain.proceed(request.build())
}

if (BuildConfig.DEBUG) {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.HEADERS
}

httpClient.addInterceptor(loggingInterceptor)
Expand All @@ -55,9 +81,12 @@ object RetrofitInstance {
return httpClient.build()
}

inline fun <reified T: Any> buildRetrofitInstance(apiUrl: String): T = Retrofit.Builder()
inline fun <reified T : Any> buildRetrofitInstance(
apiUrl: String,
noinline headersAccessor: HeadersAccessor = { mapOf() }
): T = Retrofit.Builder()
.baseUrl(apiUrl)
.client(httpClient)
.client(buildClient(headersAccessor))
.addConverterFactory(kotlinxConverterFactory)
.build()
.create<T>()
Expand Down
Loading
Loading