Merge pull request #6938 from Bnyro/master

feat: support for local feed extraction
This commit is contained in:
Bnyro 2025-01-10 17:33:00 +01:00 committed by GitHub
commit 205ac4d94b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1250 additions and 265 deletions

View File

@ -0,0 +1,663 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "74afbaf921a81ccd97447002122d5077",
"entities": [
{
"tableName": "watchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, `isShort` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isShort",
"columnName": "isShort",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "watchPosition",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "searchHistoryItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "customInstance",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "apiUrl",
"columnName": "apiUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "frontendUrl",
"columnName": "frontendUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "localSubscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))",
"fields": [
{
"fieldPath": "channelId",
"columnName": "channelId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"channelId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlistBookmark",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `videos` INTEGER NOT NULL, PRIMARY KEY(`playlistId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistName",
"columnName": "playlistName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "videos",
"columnName": "videos",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `description` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "LocalPlaylistItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `duration` INTEGER DEFAULT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "NULL"
},
{
"fieldPath": "uploadDate",
"columnName": "uploadDate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailPath",
"columnName": "thumbnailPath",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "downloadItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `language` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "format",
"columnName": "format",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quality",
"columnName": "quality",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadSize",
"columnName": "downloadSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_downloadItem_path",
"unique": true,
"columnNames": [
"path"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)"
}
],
"foreignKeys": [
{
"table": "download",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"videoId"
],
"referencedColumns": [
"videoId"
]
}
]
},
{
"tableName": "downloadChapters",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoId` TEXT NOT NULL, `name` TEXT NOT NULL, `start` INTEGER NOT NULL, `thumbnailUrl` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "subscriptionGroups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `channels` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "channels",
"columnName": "channels",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "feedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `thumbnail` TEXT, `uploaderName` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `duration` INTEGER, `views` INTEGER, `uploaderVerified` INTEGER NOT NULL, `uploaded` INTEGER NOT NULL, `shortDescription` TEXT, `isShort` INTEGER NOT NULL, PRIMARY KEY(`videoId`))",
"fields": [
{
"fieldPath": "videoId",
"columnName": "videoId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnail",
"columnName": "thumbnail",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderName",
"columnName": "uploaderName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploaderUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploaderAvatar",
"columnName": "uploaderAvatar",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "views",
"columnName": "views",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "uploaderVerified",
"columnName": "uploaderVerified",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uploaded",
"columnName": "uploaded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isShort",
"columnName": "isShort",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"videoId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74afbaf921a81ccd97447002122d5077')"
]
}
}

View File

@ -5,6 +5,7 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NewPipeExtractorInstance
import com.github.libretube.helpers.NotificationHelper import com.github.libretube.helpers.NotificationHelper
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
@ -55,6 +56,8 @@ class LibreTubeApp : Application() {
* Dynamically create App Shortcuts * Dynamically create App Shortcuts
*/ */
ShortcutHelper.createShortcuts(this) ShortcutHelper.createShortcuts(this)
NewPipeExtractorInstance.init()
} }
/** /**

View File

@ -1,16 +1,16 @@
package com.github.libretube.api package com.github.libretube.api
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.github.libretube.api.obj.EditPlaylistBody
import com.github.libretube.api.obj.Message
import com.github.libretube.api.obj.Playlist import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlaylistType import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.PipedImportPlaylist 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.util.deArrow import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -24,14 +24,14 @@ object PlaylistsHelper {
private val token get() = PreferenceHelper.getToken() private val token get() = PreferenceHelper.getToken()
val loggedIn: Boolean get() = token.isNotEmpty() val loggedIn: Boolean get() = token.isNotEmpty()
private fun Message.isOk() = this.message == "ok" private val playlistsRepository: PlaylistRepository
get() = when {
loggedIn -> PipedPlaylistRepository()
else -> LocalPlaylistsRepository()
}
suspend fun getPlaylists(): List<Playlists> = withContext(Dispatchers.IO) { suspend fun getPlaylists(): List<Playlists> = withContext(Dispatchers.IO) {
val playlists = if (loggedIn) { val playlists = playlistsRepository.getPlaylists()
RetrofitInstance.authApi.getUserPlaylists(token)
} else {
LocalPlaylistsRepository.getPlaylists()
}
sortPlaylists(playlists) sortPlaylists(playlists)
} }
@ -52,109 +52,41 @@ object PlaylistsHelper {
suspend fun getPlaylist(playlistId: String): Playlist { suspend fun getPlaylist(playlistId: String): Playlist {
// load locally stored playlists with the auth api // load locally stored playlists with the auth api
return when (getPrivatePlaylistType(playlistId)) { return when (getPrivatePlaylistType(playlistId)) {
PlaylistType.PRIVATE -> RetrofitInstance.authApi.getPlaylist(playlistId)
PlaylistType.PUBLIC -> RetrofitInstance.api.getPlaylist(playlistId) PlaylistType.PUBLIC -> RetrofitInstance.api.getPlaylist(playlistId)
PlaylistType.LOCAL -> LocalPlaylistsRepository.getPlaylist(playlistId) else -> playlistsRepository.getPlaylist(playlistId)
}.apply { }.apply {
relatedStreams = relatedStreams.deArrow() relatedStreams = relatedStreams.deArrow()
} }
} }
suspend fun createPlaylist(playlistName: String): String? { suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> {
return if (!loggedIn) { return withContext(Dispatchers.IO) {
LocalPlaylistsRepository.createPlaylist(playlistName)
} else {
RetrofitInstance.authApi.createPlaylist(
token,
Playlists(name = playlistName)
).playlistId
}
}
suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.addToPlaylist(playlistId, *videos)
return true
}
val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() })
return RetrofitInstance.authApi.addToPlaylist(token, playlist).isOk()
}
suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.renamePlaylist(playlistId, newName)
return true
}
val playlist = EditPlaylistBody(playlistId, newName = newName)
return RetrofitInstance.authApi.renamePlaylist(token, playlist).isOk()
}
suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.changePlaylistDescription(playlistId, newDescription)
return true
}
val playlist = EditPlaylistBody(playlistId, description = newDescription)
return RetrofitInstance.authApi.changePlaylistDescription(token, playlist).isOk()
}
suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.removeFromPlaylist(playlistId, index)
return true
}
return RetrofitInstance.authApi.removeFromPlaylist(
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId = playlistId, index = index)
).isOk()
}
suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) =
withContext(Dispatchers.IO) {
if (!loggedIn) return@withContext LocalPlaylistsRepository.importPlaylists(playlists)
for (playlist in playlists) {
val playlistId = createPlaylist(playlist.name!!) ?: return@withContext
val streams = playlist.videos.map { StreamItem(url = it) }
addToPlaylist(playlistId, *streams.toTypedArray())
}
}
suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> =
withContext(Dispatchers.IO) {
(playlistIds ?: getPlaylists().map { it.id!! }) (playlistIds ?: getPlaylists().map { it.id!! })
.map { async { getPlaylist(it) } } .map { async { getPlaylist(it) } }
.awaitAll() .awaitAll()
} }
suspend fun clonePlaylist(playlistId: String): String? {
if (!loggedIn) {
return LocalPlaylistsRepository.clonePlaylist(playlistId)
}
return RetrofitInstance.authApi.clonePlaylist(
token,
EditPlaylistBody(playlistId)
).playlistId
} }
suspend fun deletePlaylist(playlistId: String, playlistType: PlaylistType): Boolean { suspend fun createPlaylist(playlistName: String) =
if (playlistType == PlaylistType.LOCAL) { playlistsRepository.createPlaylist(playlistName)
LocalPlaylistsRepository.deletePlaylist(playlistId)
return true
}
return runCatching { suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) =
RetrofitInstance.authApi.deletePlaylist( playlistsRepository.addToPlaylist(playlistId, *videos)
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId) suspend fun renamePlaylist(playlistId: String, newName: String) =
).isOk() playlistsRepository.renamePlaylist(playlistId, newName)
}.getOrDefault(false)
} suspend fun changePlaylistDescription(playlistId: String, newDescription: String) =
playlistsRepository.changePlaylistDescription(playlistId, newDescription)
suspend fun removeFromPlaylist(playlistId: String, index: Int) =
playlistsRepository.removeFromPlaylist(playlistId, 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)
fun getPrivatePlaylistType(): PlaylistType { fun getPrivatePlaylistType(): PlaylistType {
return if (loggedIn) PlaylistType.PRIVATE else PlaylistType.LOCAL return if (loggedIn) PlaylistType.PRIVATE else PlaylistType.LOCAL

View File

@ -11,15 +11,13 @@ import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle import com.github.libretube.api.obj.Subtitle
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.util.NewPipeDownloaderImpl import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import kotlinx.datetime.toKotlinInstant import kotlinx.datetime.toKotlinInstant
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.extractor.stream.VideoStream
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
import java.lang.Exception
fun VideoStream.toPipedStream(): PipedStream = PipedStream( fun VideoStream.toPipedStream(): PipedStream = PipedStream(
url = content, url = content,
@ -39,26 +37,18 @@ fun VideoStream.toPipedStream(): PipedStream = PipedStream(
) )
object StreamsExtractor { object StreamsExtractor {
// val npe by lazy {
// NewPipe.getService(ServiceList.YouTube.serviceId)
// }
init {
NewPipe.init(NewPipeDownloaderImpl())
}
suspend fun extractStreams(videoId: String): Streams { suspend fun extractStreams(videoId: String): Streams {
if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) { if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) {
return RetrofitInstance.api.getStreams(videoId) return RetrofitInstance.api.getStreams(videoId)
} }
val resp = StreamInfo.getInfo("https://www.youtube.com/watch?v=$videoId") val resp = StreamInfo.getInfo("${YOUTUBE_FRONTEND_URL}/watch?v=$videoId")
return Streams( return Streams(
title = resp.name, title = resp.name,
description = resp.description.content, description = resp.description.content,
uploader = resp.uploaderName, uploader = resp.uploaderName,
uploaderAvatar = resp.uploaderAvatars.maxBy { it.height }.url, uploaderAvatar = resp.uploaderAvatars.maxBy { it.height }.url,
uploaderUrl = resp.uploaderUrl.replace("https://www.youtube.com", ""), uploaderUrl = resp.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""),
uploaderVerified = resp.isUploaderVerified, uploaderVerified = resp.isUploaderVerified,
uploaderSubscriberCount = resp.uploaderSubscriberCount, uploaderSubscriberCount = resp.uploaderSubscriberCount,
category = resp.category, category = resp.category,
@ -86,12 +76,12 @@ object StreamsExtractor {
thumbnailUrl = resp.thumbnails.maxBy { it.height }.url, thumbnailUrl = resp.thumbnails.maxBy { it.height }.url,
relatedStreams = resp.relatedItems.filterIsInstance<StreamInfoItem>().map { relatedStreams = resp.relatedItems.filterIsInstance<StreamInfoItem>().map {
StreamItem( StreamItem(
it.url.replace("https://www.youtube.com", ""), it.url.replace(YOUTUBE_FRONTEND_URL, ""),
StreamItem.TYPE_STREAM, StreamItem.TYPE_STREAM,
it.name, it.name,
it.thumbnails.maxBy { image -> image.height }.url, it.thumbnails.maxBy { image -> image.height }.url,
it.uploaderName, it.uploaderName,
it.uploaderUrl.replace("https://www.youtube.com", ""), it.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""),
it.uploaderAvatars.maxBy { image -> image.height }.url, it.uploaderAvatars.maxBy { image -> image.height }.url,
it.textualUploadDate, it.textualUploadDate,
it.duration, it.duration,

View File

@ -1,16 +1,16 @@
package com.github.libretube.api package com.github.libretube.api
import android.content.Context import android.content.Context
import android.util.Log
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Subscribe
import com.github.libretube.api.obj.Subscription
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.extensions.TAG
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.repo.AccountSubscriptionsRepository
import com.github.libretube.repo.FeedRepository
import com.github.libretube.repo.LocalFeedRepository
import com.github.libretube.repo.LocalSubscriptionsRepository
import com.github.libretube.repo.PipedAccountFeedRepository
import com.github.libretube.repo.PipedNoAccountFeedRepository
import com.github.libretube.repo.SubscriptionsRepository
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -19,28 +19,26 @@ object SubscriptionHelper {
* The maximum number of channel IDs that can be passed via a GET request for fetching * The maximum number of channel IDs that can be passed via a GET request for fetching
* the subscriptions list and the feed * the subscriptions list and the feed
*/ */
private const val GET_SUBSCRIPTIONS_LIMIT = 100 const val GET_SUBSCRIPTIONS_LIMIT = 100
private val token get() = PreferenceHelper.getToken() private val token get() = PreferenceHelper.getToken()
private val subscriptionsRepository: SubscriptionsRepository get() = when {
suspend fun subscribe(channelId: String) { token.isNotEmpty() -> AccountSubscriptionsRepository()
if (token.isNotEmpty()) { else -> LocalSubscriptionsRepository()
runCatching { }
RetrofitInstance.authApi.subscribe(token, Subscribe(channelId)) private val feedRepository: FeedRepository get() = when {
} PreferenceHelper.getBoolean(PreferenceKeys.LOCAL_FEED_EXTRACTION, false) -> LocalFeedRepository()
} else { token.isNotEmpty() -> PipedAccountFeedRepository()
Database.localSubscriptionDao().insert(LocalSubscription(channelId)) else -> PipedNoAccountFeedRepository()
}
} }
suspend fun unsubscribe(channelId: String) { suspend fun subscribe(channelId: String) = subscriptionsRepository.subscribe(channelId)
if (token.isNotEmpty()) { suspend fun unsubscribe(channelId: String) = subscriptionsRepository.unsubscribe(channelId)
runCatching { suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId)
RetrofitInstance.authApi.unsubscribe(token, Subscribe(channelId)) suspend fun importSubscriptions(newChannels: List<String>) = subscriptionsRepository.importSubscriptions(newChannels)
} suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions()
} else { suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds()
Database.localSubscriptionDao().delete(LocalSubscription(channelId)) suspend fun getFeed(forceRefresh: Boolean) = feedRepository.getFeed(forceRefresh)
}
}
fun handleUnsubscribe( fun handleUnsubscribe(
context: Context, context: Context,
@ -68,62 +66,4 @@ object SubscriptionHelper {
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
suspend fun isSubscribed(channelId: String): Boolean? {
if (token.isNotEmpty()) {
val isSubscribed = try {
RetrofitInstance.authApi.isSubscribed(channelId, token)
} catch (e: Exception) {
Log.e(TAG(), e.toString())
return null
}
return isSubscribed.subscribed
} else {
return Database.localSubscriptionDao().includes(channelId)
}
}
suspend fun importSubscriptions(newChannels: List<String>) {
if (token.isNotEmpty()) {
runCatching {
RetrofitInstance.authApi.importSubscriptions(false, token, newChannels)
}
} else {
Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) })
}
}
suspend fun getSubscriptions(): List<Subscription> {
return if (token.isNotEmpty()) {
RetrofitInstance.authApi.subscriptions(token)
} else {
val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId }
when {
subscriptions.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.unauthenticatedSubscriptions(subscriptions)
else -> RetrofitInstance.authApi.unauthenticatedSubscriptions(
subscriptions.joinToString(",")
)
}
}
}
suspend fun getFeed(): List<StreamItem> {
return if (token.isNotEmpty()) {
RetrofitInstance.authApi.getFeed(token)
} else {
val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId }
when {
subscriptions.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.getUnauthenticatedFeed(subscriptions)
else -> RetrofitInstance.authApi.getUnauthenticatedFeed(
subscriptions.joinToString(",")
)
}
}
}
} }

View File

@ -2,6 +2,7 @@ package com.github.libretube.api.obj
import android.os.Parcelable import android.os.Parcelable
import com.github.libretube.db.obj.LocalPlaylistItem import com.github.libretube.db.obj.LocalPlaylistItem
import com.github.libretube.db.obj.SubscriptionsFeedItem
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -40,6 +41,21 @@ data class StreamItem(
) )
} }
fun toFeedItem() = SubscriptionsFeedItem(
videoId = url!!.toID(),
title = title,
thumbnail = thumbnail,
uploaderName = uploaderName,
uploaded = uploaded,
uploaderAvatar = uploaderAvatar,
uploaderUrl = uploaderUrl,
duration = duration,
uploaderVerified = uploaderVerified ?: false,
shortDescription = shortDescription,
views = views,
isShort = isShort
)
companion object { companion object {
const val TYPE_STREAM = "stream" const val TYPE_STREAM = "stream"
const val TYPE_CHANNEL = "channel" const val TYPE_CHANNEL = "channel"

View File

@ -3,12 +3,11 @@ package com.github.libretube.api.obj
import android.os.Parcelable import android.os.Parcelable
import com.github.libretube.db.obj.DownloadItem import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType import com.github.libretube.enums.FileType
import com.github.libretube.extensions.toLocalDate
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.json.SafeInstantSerializer import com.github.libretube.json.SafeInstantSerializer
import com.github.libretube.parcelable.DownloadData import com.github.libretube.parcelable.DownloadData
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -100,8 +99,7 @@ data class Streams(
uploaderName = uploader, uploaderName = uploader,
uploaderUrl = uploaderUrl, uploaderUrl = uploaderUrl,
uploaderAvatar = uploaderAvatar, uploaderAvatar = uploaderAvatar,
uploadedDate = uploadTimestamp?.toLocalDateTime(TimeZone.currentSystemDefault())?.date uploadedDate = uploadTimestamp?.toLocalDate()?.toString(),
?.toString(),
uploaded = uploaded ?: uploadTimestamp?.toEpochMilliseconds() ?: 0, uploaded = uploaded ?: uploadTimestamp?.toEpochMilliseconds() ?: 0,
duration = duration, duration = duration,
views = views, views = views,
@ -111,6 +109,6 @@ data class Streams(
} }
companion object { companion object {
const val categoryMusic = "Music" const val CATEGORY_MUSIC = "Music"
} }
} }

View File

@ -113,6 +113,8 @@ object PreferenceKeys {
const val HIDE_WATCHED_FROM_FEED = "hide_watched_from_feed" const val HIDE_WATCHED_FROM_FEED = "hide_watched_from_feed"
const val SELECTED_FEED_FILTERS = "filter_feed" const val SELECTED_FEED_FILTERS = "filter_feed"
const val FEED_SORT_ORDER = "sort_oder_feed" const val FEED_SORT_ORDER = "sort_oder_feed"
const val LOCAL_FEED_EXTRACTION = "local_feed_extraction"
const val LAST_FEED_REFRESH_TIMESTAMP_MILLIS = "last_feed_refresh_timestamp_millis"
// Advanced // Advanced
const val AUTOMATIC_UPDATE_CHECKS = "automatic_update_checks" const val AUTOMATIC_UPDATE_CHECKS = "automatic_update_checks"

View File

@ -11,6 +11,7 @@ import com.github.libretube.db.dao.LocalSubscriptionDao
import com.github.libretube.db.dao.PlaylistBookmarkDao import com.github.libretube.db.dao.PlaylistBookmarkDao
import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.SearchHistoryDao
import com.github.libretube.db.dao.SubscriptionGroupsDao import com.github.libretube.db.dao.SubscriptionGroupsDao
import com.github.libretube.db.dao.SubscriptionsFeedDao
import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchHistoryDao
import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.dao.WatchPositionDao
import com.github.libretube.db.obj.CustomInstance import com.github.libretube.db.obj.CustomInstance
@ -23,6 +24,7 @@ import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.SubscriptionGroup import com.github.libretube.db.obj.SubscriptionGroup
import com.github.libretube.db.obj.SubscriptionsFeedItem
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.db.obj.WatchPosition import com.github.libretube.db.obj.WatchPosition
@ -39,15 +41,17 @@ import com.github.libretube.db.obj.WatchPosition
Download::class, Download::class,
DownloadItem::class, DownloadItem::class,
DownloadChapter::class, DownloadChapter::class,
SubscriptionGroup::class SubscriptionGroup::class,
SubscriptionsFeedItem::class
], ],
version = 18, version = 19,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 7, to = 8), AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11), AutoMigration(from = 10, to = 11),
AutoMigration(from = 16, to = 17) AutoMigration(from = 16, to = 17),
AutoMigration(from = 18, to = 19)
] ]
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -96,4 +100,6 @@ abstract class AppDatabase : RoomDatabase() {
* Subscription groups * Subscription groups
*/ */
abstract fun subscriptionGroupsDao(): SubscriptionGroupsDao abstract fun subscriptionGroupsDao(): SubscriptionGroupsDao
abstract fun feedDao(): SubscriptionsFeedDao
} }

View File

@ -3,7 +3,6 @@ package com.github.libretube.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.github.libretube.api.JsonHelper import com.github.libretube.api.JsonHelper
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.toLocalDate
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.Path import kotlin.io.path.Path
@ -13,7 +12,7 @@ object Converters {
fun localDateToString(localDate: LocalDate?) = localDate?.toString() fun localDateToString(localDate: LocalDate?) = localDate?.toString()
@TypeConverter @TypeConverter
fun stringToLocalDate(string: String?) = string?.toLocalDate() fun stringToLocalDate(string: String?) = string?.let { LocalDate.parse(it) }
@TypeConverter @TypeConverter
fun pathToString(path: Path?) = path?.toString() fun pathToString(path: Path?) = path?.toString()

View File

@ -8,13 +8,11 @@ import com.github.libretube.db.obj.SearchHistoryItem
import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem
import com.github.libretube.enums.ContentFilter import com.github.libretube.enums.ContentFilter
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toLocalDate
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
object DatabaseHelper { object DatabaseHelper {
private const val MAX_SEARCH_HISTORY_SIZE = 20 private const val MAX_SEARCH_HISTORY_SIZE = 20
@ -28,8 +26,7 @@ object DatabaseHelper {
val watchHistoryItem = WatchHistoryItem( val watchHistoryItem = WatchHistoryItem(
videoId, videoId,
stream.title, stream.title,
Instant.fromEpochMilliseconds(stream.uploaded) stream.uploaded.toLocalDate(),
.toLocalDateTime(TimeZone.currentSystemDefault()).date,
stream.uploaderName, stream.uploaderName,
stream.uploaderUrl?.toID(), stream.uploaderUrl?.toID(),
stream.uploaderAvatar, stream.uploaderAvatar,

View File

@ -66,7 +66,6 @@ object DatabaseHolder {
MIGRATION_15_16, MIGRATION_15_16,
MIGRATION_17_18 MIGRATION_17_18
) )
.fallbackToDestructiveMigration()
.build() .build()
} }
} }

View File

@ -0,0 +1,22 @@
package com.github.libretube.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.libretube.db.obj.SubscriptionsFeedItem
@Dao
interface SubscriptionsFeedDao {
@Query("SELECT * FROM feedItem ORDER BY uploaded DESC")
suspend fun getAll(): List<SubscriptionsFeedItem>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedItems: List<SubscriptionsFeedItem>)
@Query("DELETE FROM feedItem WHERE uploaded < :olderThan")
suspend fun cleanUpOlderThan(olderThan: Long)
@Query("DELETE FROM feedItem")
suspend fun deleteAll()
}

View File

@ -0,0 +1,40 @@
package com.github.libretube.db.obj
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.extensions.toLocalDate
@Entity(tableName = "feedItem")
data class SubscriptionsFeedItem(
@PrimaryKey
val videoId: String,
val title: String? = null,
val thumbnail: String? = null,
val uploaderName: String? = null,
val uploaderUrl: String? = null,
val uploaderAvatar: String? = null,
val duration: Long? = null,
val views: Long? = null,
val uploaderVerified: Boolean,
val uploaded: Long = 0,
val shortDescription: String? = null,
val isShort: Boolean = false
) {
fun toStreamItem() = StreamItem(
url = videoId,
type = StreamItem.TYPE_STREAM,
title = title,
thumbnail = thumbnail,
uploaderName = uploaderName,
uploaded = uploaded,
uploadedDate = uploaded.toLocalDate().toString(),
uploaderAvatar = uploaderAvatar,
uploaderUrl = uploaderUrl,
duration = duration,
uploaderVerified = uploaderVerified,
shortDescription = shortDescription,
views = views,
isShort = isShort
)
}

View File

@ -30,6 +30,7 @@ data class WatchHistoryItem(
thumbnail = thumbnailUrl, thumbnail = thumbnailUrl,
uploaderName = uploader, uploaderName = uploader,
uploaded = uploadDate?.toMillis() ?: 0, uploaded = uploadDate?.toMillis() ?: 0,
uploadedDate = uploadDate?.toString(),
uploaderAvatar = uploaderAvatar, uploaderAvatar = uploaderAvatar,
uploaderUrl = uploaderUrl, uploaderUrl = uploaderUrl,
duration = duration, duration = duration,

View File

@ -1,7 +1,19 @@
package com.github.libretube.extensions package com.github.libretube.extensions
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
fun LocalDate.toMillis() = this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() fun LocalDate.toMillis() = this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
fun Long.toLocalDateTime() =
Instant.fromEpochMilliseconds(this).toLocalDateTime()
fun Long.toLocalDate() =
Instant.fromEpochMilliseconds(this).toLocalDate()
fun Instant.toLocalDateTime() = this.toLocalDateTime(TimeZone.currentSystemDefault())
fun Instant.toLocalDate() = this.toLocalDateTime().date

View File

@ -0,0 +1,16 @@
package com.github.libretube.helpers
import com.github.libretube.util.NewPipeDownloaderImpl
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.StreamingService
object NewPipeExtractorInstance {
val extractor: StreamingService by lazy {
NewPipe.getService(ServiceList.YouTube.serviceId)
}
fun init() {
NewPipe.init(NewPipeDownloaderImpl())
}
}

View File

@ -3,6 +3,7 @@ package com.github.libretube.json
import android.util.Log import android.util.Log
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
@ -19,10 +20,10 @@ object SafeInstantSerializer : KSerializer<Instant> {
override fun deserialize(decoder: Decoder): Instant { override fun deserialize(decoder: Decoder): Instant {
val string = decoder.decodeString() val string = decoder.decodeString()
return try { return try {
string.toInstant() Instant.parse(string)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Log.e(TAG(), "Error parsing date '$string'", e) Log.e(TAG(), "Error parsing date '$string'", e)
string.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()) LocalDate.parse(string).atStartOfDayIn(TimeZone.currentSystemDefault())
} }
} }

View File

@ -0,0 +1,41 @@
package com.github.libretube.repo
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.Subscribe
import com.github.libretube.api.obj.Subscription
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
class AccountSubscriptionsRepository: SubscriptionsRepository {
private val token get() = PreferenceHelper.getToken()
override suspend fun subscribe(channelId: String) {
runCatching {
RetrofitInstance.authApi.subscribe(token, Subscribe(channelId))
}
}
override suspend fun unsubscribe(channelId: String) {
runCatching {
RetrofitInstance.authApi.unsubscribe(token, Subscribe(channelId))
}
}
override suspend fun isSubscribed(channelId: String): Boolean? {
return runCatching {
RetrofitInstance.authApi.isSubscribed(channelId, token)
}.getOrNull()?.subscribed
}
override suspend fun importSubscriptions(newChannels: List<String>) {
RetrofitInstance.authApi.importSubscriptions(false, token, newChannels)
}
override suspend fun getSubscriptions(): List<Subscription> {
return RetrofitInstance.authApi.subscriptions(token)
}
override suspend fun getSubscriptionChannelIds(): List<String> {
return getSubscriptions().map { it.url.toID() }
}
}

View File

@ -0,0 +1,7 @@
package com.github.libretube.repo
import com.github.libretube.api.obj.StreamItem
interface FeedRepository {
suspend fun getFeed(forceRefresh: Boolean): List<StreamItem>
}

View File

@ -0,0 +1,103 @@
package com.github.libretube.repo
import android.util.Log
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.SubscriptionsFeedItem
import com.github.libretube.extensions.parallelMap
import com.github.libretube.helpers.NewPipeExtractorInstance
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import java.time.Duration
import java.time.Instant
class LocalFeedRepository : FeedRepository {
private val relevantTabs =
arrayOf(ChannelTabs.LIVESTREAMS, ChannelTabs.VIDEOS, ChannelTabs.SHORTS)
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> {
val nowMillis = Instant.now().toEpochMilli()
if (!forceRefresh) {
val feed = DatabaseHolder.Database.feedDao().getAll()
val oneDayAgo = nowMillis - Duration.ofDays(1).toMillis()
// only refresh if feed is empty or last refresh was more than a day ago
val lastRefresh =
PreferenceHelper.getLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, 0)
if (feed.isNotEmpty() && lastRefresh > oneDayAgo) {
return DatabaseHolder.Database.feedDao().getAll()
.map(SubscriptionsFeedItem::toStreamItem)
}
}
val minimumDateMillis = nowMillis - Duration.ofDays(MAX_FEED_AGE_DAYS).toMillis()
DatabaseHolder.Database.feedDao().cleanUpOlderThan(minimumDateMillis)
refreshFeed(minimumDateMillis)
PreferenceHelper.putLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, nowMillis)
return DatabaseHolder.Database.feedDao().getAll().map(SubscriptionsFeedItem::toStreamItem)
}
private suspend fun refreshFeed(minimumDateMillis: Long) {
val channelIds = SubscriptionHelper.getSubscriptionChannelIds()
for (channelIdChunk in channelIds.chunked(CHUNK_SIZE)) {
val collectedFeedItems = channelIdChunk.parallelMap { channelId ->
try {
getRelatedStreams(channelId)
} catch (e: Exception) {
Log.e(channelId, e.stackTraceToString())
null
}
}.filterNotNull().flatten().map(StreamItem::toFeedItem)
.filter { it.uploaded > minimumDateMillis }
DatabaseHolder.Database.feedDao().insertAll(collectedFeedItems)
}
}
private suspend fun getRelatedStreams(channelId: String): List<StreamItem> {
val channelInfo = ChannelInfo.getInfo("$YOUTUBE_FRONTEND_URL/channel/${channelId}")
val relevantInfoTabs = channelInfo.tabs.filter { tab ->
relevantTabs.any { tab.contentFilters.contains(it) }
}
val related = relevantInfoTabs.parallelMap { tab ->
runCatching {
ChannelTabInfo.getInfo(NewPipeExtractorInstance.extractor, tab).relatedItems
}.getOrElse { emptyList() }
}.flatten().filterIsInstance<StreamInfoItem>()
return related.map { item ->
StreamItem(
type = StreamItem.TYPE_STREAM,
url = item.url.replace(YOUTUBE_FRONTEND_URL, ""),
title = item.name,
uploaded = item.uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: 0,
uploadedDate = item.uploadDate?.offsetDateTime()?.toLocalDateTime()?.toLocalDate()
?.toString(),
uploaderName = item.uploaderName,
uploaderUrl = item.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""),
uploaderAvatar = channelInfo.avatars.maxByOrNull { it.height }?.url,
thumbnail = item.thumbnails.maxByOrNull { it.height }?.url,
duration = item.duration,
views = item.viewCount,
uploaderVerified = item.isUploaderVerified,
shortDescription = item.shortDescription
)
}
}
companion object {
private const val CHUNK_SIZE = 2
private const val MAX_FEED_AGE_DAYS = 30L // 30 days
}
}

View File

@ -1,6 +1,9 @@
package com.github.libretube.api package com.github.libretube.repo
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.Playlist import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.StreamItem
@ -10,8 +13,8 @@ import com.github.libretube.extensions.parallelMap
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PipedImportPlaylist import com.github.libretube.obj.PipedImportPlaylist
object LocalPlaylistsRepository { class LocalPlaylistsRepository: PlaylistRepository {
suspend fun getPlaylist(playlistId: String): Playlist { override suspend fun getPlaylist(playlistId: String): Playlist {
val relation = DatabaseHolder.Database.localPlaylistsDao().getAll() val relation = DatabaseHolder.Database.localPlaylistsDao().getAll()
.first { it.playlist.id.toString() == playlistId } .first { it.playlist.id.toString() == playlistId }
@ -24,7 +27,7 @@ object LocalPlaylistsRepository {
) )
} }
suspend fun getPlaylists(): List<Playlists> { override suspend fun getPlaylists(): List<Playlists> {
return DatabaseHolder.Database.localPlaylistsDao().getAll() return DatabaseHolder.Database.localPlaylistsDao().getAll()
.map { .map {
Playlists( Playlists(
@ -37,7 +40,7 @@ object LocalPlaylistsRepository {
} }
} }
suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) { override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
val localPlaylist = DatabaseHolder.Database.localPlaylistsDao().getAll() val localPlaylist = DatabaseHolder.Database.localPlaylistsDao().getAll()
.first { it.playlist.id.toString() == playlistId } .first { it.playlist.id.toString() == playlistId }
@ -59,25 +62,31 @@ object LocalPlaylistsRepository {
} }
} }
} }
return true
} }
suspend fun renamePlaylist(playlistId: String, newName: String) { override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll() val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll()
.first { it.playlist.id.toString() == playlistId }.playlist .first { it.playlist.id.toString() == playlistId }.playlist
playlist.name = newName playlist.name = newName
DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist) DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist)
return true
} }
suspend fun changePlaylistDescription(playlistId: String, newDescription: String) { override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll() val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll()
.first { it.playlist.id.toString() == playlistId }.playlist .first { it.playlist.id.toString() == playlistId }.playlist
playlist.description = newDescription playlist.description = newDescription
DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist) DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist)
return true
} }
suspend fun clonePlaylist(playlistId: String): String? { override suspend fun clonePlaylist(playlistId: String): String {
val playlist = RetrofitInstance.api.getPlaylist(playlistId) val playlist = RetrofitInstance.api.getPlaylist(playlistId)
val newPlaylist = createPlaylist(playlist.name ?: "Unknown name") ?: return null val newPlaylist = createPlaylist(playlist.name ?: "Unknown name")
PlaylistsHelper.addToPlaylist(newPlaylist, *playlist.relatedStreams.toTypedArray()) PlaylistsHelper.addToPlaylist(newPlaylist, *playlist.relatedStreams.toTypedArray())
@ -93,7 +102,7 @@ object LocalPlaylistsRepository {
return playlistId return playlistId
} }
suspend fun removeFromPlaylist(playlistId: String, index: Int) { override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
val transaction = DatabaseHolder.Database.localPlaylistsDao().getAll() val transaction = DatabaseHolder.Database.localPlaylistsDao().getAll()
.first { it.playlist.id.toString() == playlistId } .first { it.playlist.id.toString() == playlistId }
DatabaseHolder.Database.localPlaylistsDao().removePlaylistVideo( DatabaseHolder.Database.localPlaylistsDao().removePlaylistVideo(
@ -105,11 +114,13 @@ object LocalPlaylistsRepository {
transaction.videos.getOrNull(1)?.thumbnailUrl.orEmpty() transaction.videos.getOrNull(1)?.thumbnailUrl.orEmpty()
} }
DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(transaction.playlist) DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(transaction.playlist)
return true
} }
suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) { override suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) {
for (playlist in playlists) { for (playlist in playlists) {
val playlistId = createPlaylist(playlist.name!!) ?: return val playlistId = createPlaylist(playlist.name!!)
// if not logged in, all video information needs to become fetched manually // if not logged in, all video information needs to become fetched manually
// Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues // Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues
@ -125,13 +136,15 @@ object LocalPlaylistsRepository {
} }
} }
suspend fun createPlaylist(playlistName: String): String { override suspend fun createPlaylist(playlistName: String): String {
val playlist = LocalPlaylist(name = playlistName, thumbnailUrl = "") val playlist = LocalPlaylist(name = playlistName, thumbnailUrl = "")
return DatabaseHolder.Database.localPlaylistsDao().createPlaylist(playlist).toString() return DatabaseHolder.Database.localPlaylistsDao().createPlaylist(playlist).toString()
} }
suspend fun deletePlaylist(playlistId: String) { override suspend fun deletePlaylist(playlistId: String): Boolean {
DatabaseHolder.Database.localPlaylistsDao().deletePlaylistById(playlistId) DatabaseHolder.Database.localPlaylistsDao().deletePlaylistById(playlistId)
DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId) DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId)
return true
} }
} }

View File

@ -0,0 +1,43 @@
package com.github.libretube.repo
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT
import com.github.libretube.api.obj.Subscription
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.LocalSubscription
class LocalSubscriptionsRepository: SubscriptionsRepository {
override suspend fun subscribe(channelId: String) {
Database.localSubscriptionDao().insert(LocalSubscription(channelId))
}
override suspend fun unsubscribe(channelId: String) {
Database.localSubscriptionDao().delete(LocalSubscription(channelId))
}
override suspend fun isSubscribed(channelId: String): Boolean {
return Database.localSubscriptionDao().includes(channelId)
}
override suspend fun importSubscriptions(newChannels: List<String>) {
Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) })
}
override suspend fun getSubscriptions(): List<Subscription> {
val channelIds = getSubscriptionChannelIds()
return when {
channelIds.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.unauthenticatedSubscriptions(channelIds)
else -> RetrofitInstance.authApi.unauthenticatedSubscriptions(
channelIds.joinToString(",")
)
}
}
override suspend fun getSubscriptionChannelIds(): List<String> {
return Database.localSubscriptionDao().getAll().map { it.channelId }
}
}

View File

@ -0,0 +1,13 @@
package com.github.libretube.repo
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.helpers.PreferenceHelper
class PipedAccountFeedRepository: FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> {
val token = PreferenceHelper.getToken()
return RetrofitInstance.authApi.getFeed(token)
}
}

View File

@ -0,0 +1,22 @@
package com.github.libretube.repo
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT
import com.github.libretube.api.obj.StreamItem
class PipedNoAccountFeedRepository: FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> {
val channelIds = SubscriptionHelper.getSubscriptionChannelIds()
return when {
channelIds.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.getUnauthenticatedFeed(channelIds)
else -> RetrofitInstance.authApi.getUnauthenticatedFeed(
channelIds.joinToString(",")
)
}
}
}

View File

@ -0,0 +1,81 @@
package com.github.libretube.repo
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.EditPlaylistBody
import com.github.libretube.api.obj.Message
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.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.PipedImportPlaylist
class PipedPlaylistRepository: PlaylistRepository {
private fun Message.isOk() = this.message == "ok"
private val token get() = PreferenceHelper.getToken()
override suspend fun getPlaylist(playlistId: String): Playlist {
return RetrofitInstance.authApi.getPlaylist(playlistId)
}
override suspend fun getPlaylists(): List<Playlists> {
return RetrofitInstance.authApi.getUserPlaylists(token)
}
override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() })
return RetrofitInstance.authApi.addToPlaylist(token, playlist).isOk()
}
override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
val playlist = EditPlaylistBody(playlistId, newName = newName)
return RetrofitInstance.authApi.renamePlaylist(token, playlist).isOk()
}
override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
val playlist = EditPlaylistBody(playlistId, description = newDescription)
return RetrofitInstance.authApi.changePlaylistDescription(token, playlist).isOk()
}
override suspend fun clonePlaylist(playlistId: String): String? {
return RetrofitInstance.authApi.clonePlaylist(
token,
EditPlaylistBody(playlistId)
).playlistId
}
override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
return RetrofitInstance.authApi.removeFromPlaylist(
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId = playlistId, index = index)
).isOk()
}
override suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) {
for (playlist in playlists) {
val playlistId = PlaylistsHelper.createPlaylist(playlist.name!!) ?: return
val streams = playlist.videos.map { StreamItem(url = it) }
PlaylistsHelper.addToPlaylist(playlistId, *streams.toTypedArray())
}
}
override suspend fun createPlaylist(playlistName: String): String? {
return RetrofitInstance.authApi.createPlaylist(
token,
Playlists(name = playlistName)
).playlistId
}
override suspend fun deletePlaylist(playlistId: String): Boolean {
return runCatching {
RetrofitInstance.authApi.deletePlaylist(
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId)
).isOk()
}.getOrDefault(false)
}
}

View File

@ -0,0 +1,19 @@
package com.github.libretube.repo
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.obj.PipedImportPlaylist
interface PlaylistRepository {
suspend fun getPlaylist(playlistId: String): Playlist
suspend fun getPlaylists(): List<Playlists>
suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean
suspend fun renamePlaylist(playlistId: String, newName: String): Boolean
suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean
suspend fun clonePlaylist(playlistId: String): String?
suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean
suspend fun importPlaylists(playlists: List<PipedImportPlaylist>)
suspend fun createPlaylist(playlistName: String): String?
suspend fun deletePlaylist(playlistId: String): Boolean
}

View File

@ -0,0 +1,12 @@
package com.github.libretube.repo
import com.github.libretube.api.obj.Subscription
interface SubscriptionsRepository {
suspend fun subscribe(channelId: String)
suspend fun unsubscribe(channelId: String)
suspend fun isSubscribed(channelId: String): Boolean?
suspend fun importSubscriptions(newChannels: List<String>)
suspend fun getSubscriptions(): List<Subscription>
suspend fun getSubscriptionChannelIds(): List<String>
}

View File

@ -33,6 +33,7 @@ import com.github.libretube.enums.NotificationId
import com.github.libretube.extensions.formatAsFileSize import com.github.libretube.extensions.formatAsFileSize
import com.github.libretube.extensions.getContentLength import com.github.libretube.extensions.getContentLength
import com.github.libretube.extensions.parcelableExtra import com.github.libretube.extensions.parcelableExtra
import com.github.libretube.extensions.toLocalDate
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.helpers.DownloadHelper import com.github.libretube.helpers.DownloadHelper
@ -56,8 +57,6 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -159,7 +158,7 @@ class DownloadService : LifecycleService() {
streams.description, streams.description,
streams.uploader, streams.uploader,
streams.duration, streams.duration,
streams.uploadTimestamp?.toLocalDateTime(TimeZone.currentSystemDefault())?.date, streams.uploadTimestamp?.toLocalDate(),
thumbnailTargetPath thumbnailTargetPath
) )
Database.downloadDao().insertDownload(download) Database.downloadDao().insertDownload(download)

View File

@ -10,8 +10,6 @@ import androidx.lifecycle.lifecycleScope
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.serializable
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -21,14 +19,14 @@ import kotlinx.coroutines.withContext
class DeletePlaylistDialog : DialogFragment() { class DeletePlaylistDialog : DialogFragment() {
private lateinit var playlistId: String private lateinit var playlistId: String
private lateinit var playlistType: PlaylistType
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
arguments?.let { arguments?.let {
playlistId = it.getString(IntentData.playlistId)!! playlistId = it.getString(IntentData.playlistId)!!
playlistType = it.serializable(IntentData.playlistType)!!
} }
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireContext()) return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.deletePlaylist) .setTitle(R.string.deletePlaylist)
@ -39,7 +37,7 @@ class DeletePlaylistDialog : DialogFragment() {
.apply { .apply {
getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val success = PlaylistsHelper.deletePlaylist(playlistId, playlistType) val success = PlaylistsHelper.deletePlaylist(playlistId)
context.toastFromMainDispatcher( context.toastFromMainDispatcher(
if (success) R.string.success else R.string.fail if (success) R.string.success else R.string.fail
) )

View File

@ -1059,7 +1059,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// set media source and resolution in the beginning // set media source and resolution in the beginning
updateResolution(commonPlayerViewModel.isFullscreen.value == true) updateResolution(commonPlayerViewModel.isFullscreen.value == true)
if (streams.category == Streams.categoryMusic) { if (streams.category == Streams.CATEGORY_MUSIC) {
playerController.setPlaybackSpeed(1f) playerController.setPlaybackSpeed(1f)
} }
} }

View File

@ -118,7 +118,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment() {
binding.subProgress.isVisible = true binding.subProgress.isVisible = true
if (viewModel.videoFeed.value == null) { if (viewModel.videoFeed.value == null) {
viewModel.fetchFeed(requireContext()) viewModel.fetchFeed(requireContext(), forceRefresh = false)
} }
if (viewModel.subscriptions.value == null) { if (viewModel.subscriptions.value == null) {
viewModel.fetchSubscriptions(requireContext()) viewModel.fetchSubscriptions(requireContext())
@ -134,7 +134,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment() {
binding.subRefresh.setOnRefreshListener { binding.subRefresh.setOnRefreshListener {
viewModel.fetchSubscriptions(requireContext()) viewModel.fetchSubscriptions(requireContext())
viewModel.fetchFeed(requireContext()) viewModel.fetchFeed(requireContext(), forceRefresh = true)
} }
binding.toggleSubs.isVisible = true binding.toggleSubs.isVisible = true

View File

@ -18,7 +18,6 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.room.withTransaction import androidx.room.withTransaction
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentWatchHistoryBinding import com.github.libretube.databinding.FragmentWatchHistoryBinding
import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHelper
@ -180,18 +179,7 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment() {
binding.playAll.setOnClickListener { binding.playAll.setOnClickListener {
PlayingQueue.resetToDefaults() PlayingQueue.resetToDefaults()
PlayingQueue.add( PlayingQueue.add(
*watchHistory.reversed().map { *watchHistory.reversed().map(WatchHistoryItem::toStreamItem).toTypedArray()
StreamItem(
url = "/watch?v=${it.videoId}",
title = it.title,
thumbnail = it.thumbnailUrl,
uploaderName = it.uploader,
uploaderUrl = it.uploaderUrl,
uploaderAvatar = it.uploaderAvatar,
uploadedDate = it.uploadDate?.toString(),
duration = it.duration
)
}.toTypedArray()
) )
NavigationHelper.navigateVideo( NavigationHelper.navigateVideo(
requireContext(), requireContext(),

View File

@ -122,7 +122,7 @@ class HomeViewModel : ViewModel() {
private suspend fun tryLoadFeed(subscriptionsViewModel: SubscriptionsViewModel): List<StreamItem> { private suspend fun tryLoadFeed(subscriptionsViewModel: SubscriptionsViewModel): List<StreamItem> {
subscriptionsViewModel.videoFeed.value?.let { return it } subscriptionsViewModel.videoFeed.value?.let { return it }
val feed = SubscriptionHelper.getFeed() val feed = SubscriptionHelper.getFeed(forceRefresh = false)
subscriptionsViewModel.videoFeed.postValue(feed) subscriptionsViewModel.videoFeed.postValue(feed)
return if (hideWatched) feed.filterWatched() else feed return if (hideWatched) feed.filterWatched() else feed

View File

@ -21,10 +21,10 @@ class SubscriptionsViewModel : ViewModel() {
var subscriptions = MutableLiveData<List<Subscription>?>() var subscriptions = MutableLiveData<List<Subscription>?>()
fun fetchFeed(context: Context) { fun fetchFeed(context: Context, forceRefresh: Boolean) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val videoFeed = try { val videoFeed = try {
SubscriptionHelper.getFeed() SubscriptionHelper.getFeed(forceRefresh = forceRefresh)
} catch (e: Exception) { } catch (e: Exception) {
context.toastFromMainDispatcher(R.string.server_error) context.toastFromMainDispatcher(R.string.server_error)
Log.e(TAG(), e.toString()) Log.e(TAG(), e.toString())

View File

@ -123,8 +123,7 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
R.string.deletePlaylist -> { R.string.deletePlaylist -> {
val newDeletePlaylistDialog = DeletePlaylistDialog() val newDeletePlaylistDialog = DeletePlaylistDialog()
newDeletePlaylistDialog.arguments = bundleOf( newDeletePlaylistDialog.arguments = bundleOf(
IntentData.playlistId to playlistId, IntentData.playlistId to playlistId
IntentData.playlistType to playlistType
) )
newDeletePlaylistDialog.show(mFragmentManager, null) newDeletePlaylistDialog.show(mFragmentManager, null)
} }

View File

@ -9,10 +9,9 @@ import androidx.core.text.isDigitsOnly
import com.github.libretube.BuildConfig import com.github.libretube.BuildConfig
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.toLocalDate
import com.google.common.math.IntMath.pow import com.google.common.math.IntMath.pow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@ -51,9 +50,7 @@ object TextUtils {
} }
fun localizeInstant(instant: kotlinx.datetime.Instant): String { fun localizeInstant(instant: kotlinx.datetime.Instant): String {
val date = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date return localizeDate(instant.toLocalDate())
return localizeDate(date)
} }
/** /**

View File

@ -82,7 +82,7 @@ class NotificationWorker(appContext: Context, parameters: WorkerParameters) :
// fetch the users feed // fetch the users feed
val videoFeed = try { val videoFeed = try {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
SubscriptionHelper.getFeed() SubscriptionHelper.getFeed(forceRefresh = true)
} }
} catch (e: Exception) { } catch (e: Exception) {
return false return false

View File

@ -526,6 +526,8 @@
<string name="dialog_play_offline_body">Would you like to play the video from the download folder?</string> <string name="dialog_play_offline_body">Would you like to play the video from the download folder?</string>
<string name="view_count">%1$s views</string> <string name="view_count">%1$s views</string>
<string name="delete_only_watched_videos">Only delete already watched videos</string> <string name="delete_only_watched_videos">Only delete already watched videos</string>
<string name="local_feed_extraction">Local feed extraction</string>
<string name="local_feed_extraction_summary">Directly fetch the feed from YouTube. This may be significantly slower.</string>
<!-- Notification channel strings --> <!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string> <string name="download_channel_name">Download Service</string>

View File

@ -91,4 +91,15 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory app:title="@string/subscriptions">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_region"
android:summary="@string/local_feed_extraction_summary"
android:title="@string/local_feed_extraction"
app:key="local_feed_extraction" />
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>