From 0ce383f33ec42326f80dac39c39ab473363afbfd Mon Sep 17 00:00:00 2001 From: zaednasr <75589932+zaednasr@users.noreply.github.com> Date: Sun, 12 May 2024 23:34:54 +0200 Subject: [PATCH] 1.7.6 beta --- app/build.gradle | 9 +- app/src/main/AndroidManifest.xml | 65 ++ .../ytdl/database/dao/DownloadDao.kt | 10 +- .../deniscerri/ytdl/database/dao/ResultDao.kt | 3 + .../ytdl/database/models/AlreadyExistsItem.kt | 12 + .../database/repository/DownloadRepository.kt | 3 + .../database/repository/ResultRepository.kt | 8 +- .../database/viewmodel/DownloadViewModel.kt | 623 ++++-------------- .../viewmodel/ObserveSourcesViewModel.kt | 9 + .../viewmodel/SharedDownloadViewModel.kt | 616 +++++++++++++++++ .../receiver/CancelScheduleAlarmReceiver.kt | 34 + .../ytdl/receiver/ScheduleAlarmReceiver.kt | 38 ++ .../deniscerri/ytdl/receiver/ShareActivity.kt | 17 + .../ProcessDownloadsInBackgroundService.kt | 136 ++++ .../com/deniscerri/ytdl/ui/HomeFragment.kt | 135 ++-- .../ytdl/ui/adapter/AlreadyExistsAdapter.kt | 95 ++- .../ui/adapter/ScheduledDownloadAdapter.kt | 11 +- .../ui/adapter/SearchSuggestionsAdapter.kt | 6 + .../ui/adapter/SortableTextItemAdapter.kt | 38 ++ .../downloadcard/CutVideoBottomSheetDialog.kt | 301 ++++++--- .../ui/downloadcard/DownloadAudioFragment.kt | 3 + .../downloadcard/DownloadBottomSheetDialog.kt | 4 +- .../downloadcard/DownloadCommandFragment.kt | 2 +- .../DownloadMultipleBottomSheetDialog.kt | 212 ++++-- .../DownloadsAlreadyExistDialog.kt | 174 +++++ .../FormatSelectionBottomSheetDialog.kt | 7 +- .../ObserveSourcesBottomSheetDialog.kt | 42 +- .../downloadcard/SelectPlaylistItemsDialog.kt | 63 +- .../downloads/CancelledDownloadsFragment.kt | 19 +- .../ui/downloads/DownloadQueueMainFragment.kt | 52 +- .../ui/downloads/ErroredDownloadsFragment.kt | 20 +- .../ytdl/ui/downloads/HistoryFragment.kt | 22 +- .../ui/downloads/QueuedDownloadsFragment.kt | 2 +- .../ui/downloads/SavedDownloadsFragment.kt | 19 +- .../downloads/ScheduledDownloadsFragment.kt | 14 +- .../ytdl/ui/more/CommandTemplatesFragment.kt | 2 +- .../downloadLogs/DownloadLogListFragment.kt | 2 +- .../more/settings/DownloadSettingsFragment.kt | 63 +- .../settings/ProcessingSettingsFragment.kt | 129 +++- .../com/deniscerri/ytdl/util/Extensions.kt | 65 +- .../java/com/deniscerri/ytdl/util/FileUtil.kt | 5 +- .../java/com/deniscerri/ytdl/util/InfoUtil.kt | 16 +- .../deniscerri/ytdl/util/NotificationUtil.kt | 40 +- .../java/com/deniscerri/ytdl/util/UiUtil.kt | 174 +---- .../deniscerri/ytdl/work/AlarmScheduler.kt | 76 ++- .../work/CancelScheduledDownloadWorker.kt | 10 +- .../deniscerri/ytdl/work/DownloadWorker.kt | 16 +- .../ytdl/work/ObserveSourceWorker.kt | 8 + .../main/res/layout-land/fragment_home.xml | 80 ++- .../res/layout-sw600dp-land/fragment_home.xml | 80 ++- .../main/res/layout-sw600dp/fragment_home.xml | 80 ++- .../res/layout-sw950dp-land/fragment_home.xml | 80 ++- .../main/res/layout/adjust_cut_timestamp.xml | 129 ++++ .../main/res/layout/already_exists_item.xml | 106 +-- app/src/main/res/layout/cut_video_sheet.xml | 19 + .../layout/download_multiple_bottom_sheet.xml | 8 +- .../layout/extra_commands_bottom_sheet.xml | 75 +-- app/src/main/res/layout/filepath_card.xml | 17 +- app/src/main/res/layout/fragment_active.xml | 2 +- .../layout/fragment_already_exists_dialog.xml | 74 +++ .../res/layout/fragment_download_audio.xml | 1 + app/src/main/res/layout/fragment_home.xml | 60 +- .../history_item_details_bottom_sheet.xml | 15 + .../main/res/layout/queued_download_card.xml | 4 + .../main/res/layout/select_playlist_items.xml | 7 + .../main/res/layout/sortable_text_item.xml | 41 ++ app/src/main/res/menu/already_exists_menu.xml | 16 + .../menu/scheduled_downloads_menu_context.xml | 42 ++ app/src/main/res/navigation/nav_graph.xml | 10 + .../main/res/navigation/share_nav_graph.xml | 4 + app/src/main/res/values/arrays.xml | 67 +- app/src/main/res/values/strings.xml | 11 + .../main/res/xml/downloading_preferences.xml | 6 + app/src/main/res/xml/general_preferences.xml | 4 +- .../main/res/xml/processing_preferences.xml | 29 +- app/src/main/res/xml/updating_preferences.xml | 2 +- build.gradle | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 78 files changed, 3072 insertions(+), 1437 deletions(-) create mode 100644 app/src/main/java/com/deniscerri/ytdl/database/models/AlreadyExistsItem.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/database/viewmodel/SharedDownloadViewModel.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/receiver/CancelScheduleAlarmReceiver.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/services/ProcessDownloadsInBackgroundService.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/ui/adapter/SortableTextItemAdapter.kt create mode 100644 app/src/main/java/com/deniscerri/ytdl/ui/downloadcard/DownloadsAlreadyExistDialog.kt create mode 100644 app/src/main/res/layout/adjust_cut_timestamp.xml create mode 100644 app/src/main/res/layout/fragment_already_exists_dialog.xml create mode 100644 app/src/main/res/layout/sortable_text_item.xml create mode 100644 app/src/main/res/menu/already_exists_menu.xml create mode 100644 app/src/main/res/menu/scheduled_downloads_menu_context.xml diff --git a/app/build.gradle b/app/build.gradle index 237f29b1..c59aee13 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,13 +10,10 @@ plugins { def properties = new Properties() def versionMajor = 1 def versionMinor = 7 -def versionPatch = 5 +def versionPatch = 6 def versionBuild = 0 // bump for dogfood builds, public betas, etc. -def versionExt = "" - -if (versionBuild > 0){ - versionExt = ".${versionBuild}-beta" -} +def isBeta = true +def versionExt = isBeta ? ".${versionBuild}-beta" : "" android { namespace 'com.deniscerri.ytdl' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07e26678..3e875b9c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,8 +19,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -136,6 +161,15 @@ + + + + + + + + + @@ -181,6 +215,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -356,6 +417,8 @@ + + + + ) - @Query("UPDATE downloads SET status='Cancelled' WHERE status in('Queued','QueuedPaused','Active','ActivePaused')") + @Query("UPDATE downloads SET status='Cancelled' WHERE status in('Queued','QueuedPaused','Active','ActivePaused', 'Scheduled')") suspend fun cancelActiveQueued() @Query("DELETE FROM downloads WHERE status='Processing' AND id=:id") @@ -229,9 +230,12 @@ interface DownloadDao { @Query("Select url from downloads where id in (:ids)") fun getURLsByID(ids: List) : List - @Query("UPDATE downloads SET downloadStartTime=0 where id in (:list)") + @Query("UPDATE downloads SET downloadStartTime=0, status='Queued' where id in (:list)") suspend fun resetScheduleTimeForItems(list: List) + @Query("UPDATE downloads SET status='Queued' where status in ('QueuedPaused', 'ActivePaused')") + suspend fun resetPausedItems() + @Query("Update downloads SET status='Queued', downloadStartTime = 0 WHERE id in (:list)") suspend fun reQueueDownloadItems(list: List) diff --git a/app/src/main/java/com/deniscerri/ytdl/database/dao/ResultDao.kt b/app/src/main/java/com/deniscerri/ytdl/database/dao/ResultDao.kt index b72fe343..5da9d4cf 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/dao/ResultDao.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/dao/ResultDao.kt @@ -37,6 +37,9 @@ interface ResultDao { return insertMultiple(items.filter { getResultByURL(it.url) == null }) } + @Query("SELECT * FROM results WHERE id IN (:ids)") + fun getAllByIDs(ids: List) : List + @Update(onConflict = OnConflictStrategy.REPLACE) suspend fun update(item: ResultItem) diff --git a/app/src/main/java/com/deniscerri/ytdl/database/models/AlreadyExistsItem.kt b/app/src/main/java/com/deniscerri/ytdl/database/models/AlreadyExistsItem.kt new file mode 100644 index 00000000..cc021132 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/database/models/AlreadyExistsItem.kt @@ -0,0 +1,12 @@ +package com.deniscerri.ytdl.database.models + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AlreadyExistsItem( + var downloadItem: DownloadItem, + var historyID: Long? = null +) : Parcelable diff --git a/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt b/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt index c5a3408c..ee49a25d 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/repository/DownloadRepository.kt @@ -222,6 +222,9 @@ class DownloadRepository(private val downloadDao: DownloadDao) { val workConstraints = Constraints.Builder() if (!allowMeteredNetworks) workConstraints.setRequiredNetworkType(NetworkType.UNMETERED) + else { + workConstraints.setRequiredNetworkType(NetworkType.CONNECTED) + } val workRequest = OneTimeWorkRequestBuilder() .addTag("download") diff --git a/app/src/main/java/com/deniscerri/ytdl/database/repository/ResultRepository.kt b/app/src/main/java/com/deniscerri/ytdl/database/repository/ResultRepository.kt index be0dead5..a69e1319 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/repository/ResultRepository.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/repository/ResultRepository.kt @@ -129,6 +129,10 @@ class ResultRepository(private val resultDao: ResultDao, private val context: Co return resultDao.getResultByURL(url) } + fun getAllByIDs(ids: List) : List { + return resultDao.getAllByIDs(ids) + } + suspend fun getResultsFromSource(inputQuery: String, resetResults: Boolean, addToResults: Boolean = true, singleItem: Boolean = false) : ArrayList { return when(getQueryType(inputQuery)){ SourceType.YOUTUBE_VIDEO -> { @@ -151,9 +155,9 @@ class ResultRepository(private val resultDao: ResultDao, private val context: Co } - fun getQueryType(inputQuery: String) : SourceType { + private fun getQueryType(inputQuery: String) : SourceType { var type = SourceType.SEARCH_QUERY - val p = Pattern.compile("(^(https?)://(www.)?youtu(.be)?)|(^(https?)://(www.)?piped.video)") + val p = Pattern.compile("((^(https?)://)?(www.)?youtu(.be)?)|(^(https?)://(www.)?piped.video)") val m = p.matcher(inputQuery) if (m.find()) { type = SourceType.YOUTUBE_VIDEO diff --git a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt index c31f0a0c..13dddb62 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/DownloadViewModel.kt @@ -5,8 +5,11 @@ import android.app.Application import android.content.SharedPreferences import android.content.res.Configuration import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.Parcelable import android.util.DisplayMetrics -import android.util.Log +import android.widget.Toast import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData @@ -41,14 +44,18 @@ import com.deniscerri.ytdl.util.InfoUtil import com.deniscerri.ytdl.work.AlarmScheduler import com.deniscerri.ytdl.work.UpdatePlaylistFormatsWorker import com.google.gson.Gson +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize import java.io.File import java.util.Locale @@ -56,6 +63,7 @@ import java.util.Locale class DownloadViewModel(private val application: Application) : AndroidViewModel(application) { private val dbManager: DBManager val repository : DownloadRepository + private val sharedDownloadViewModel: SharedDownloadViewModel private val sharedPreferences: SharedPreferences private val commandTemplateDao: CommandTemplateDao private val infoUtil : InfoUtil @@ -78,6 +86,8 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel val scheduledDownloadsCount : Flow val pausedDownloadsCount: Flow + val alreadyExistsUiState: MutableStateFlow> + private var bestVideoFormat : Format private var bestAudioFormat : Format private var defaultVideoFormats : MutableList @@ -97,16 +107,6 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel private val historyRepository: HistoryRepository private val resultRepository: ResultRepository - data class AlreadyExistsUIState( - var historyItems: MutableList, - var downloadItems : MutableList - ) - - val alreadyExistsUiState: MutableStateFlow = MutableStateFlow(AlreadyExistsUIState( - historyItems = mutableListOf(), - downloadItems = mutableListOf() - )) - enum class Type { auto, audio, video, command } @@ -121,6 +121,8 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel dbManager = DBManager.getInstance(application) dao = dbManager.downloadDao repository = DownloadRepository(dao) + sharedDownloadViewModel = SharedDownloadViewModel(application) + alreadyExistsUiState = sharedDownloadViewModel.alreadyExistsUiState historyRepository = HistoryRepository(dbManager.historyDao) resultRepository = ResultRepository(dbManager.resultDao, application) sharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) @@ -168,7 +170,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel audioContainer = sharedPreferences.getString("audio_format", "mp3") bestAudioFormat = if (audioFormatIDPreference.isEmpty()){ - infoUtil.getGenericAudioFormats(resources).last() + infoUtil.getGenericAudioFormats(resources).first() }else{ Format( audioFormatIDPreference.first().split("+").first(), @@ -204,132 +206,24 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel return repository.getItemByID(id) } + fun getAllByIDs(ids: List) : List { + return repository.getAllItemsByIDs(ids) + } + fun getHistoryItemById(id: Long) : HistoryItem? { return historyRepository.getItem(id) } fun getDownloadType(t: Type? = null, url: String) : Type { - var type = t - - if (type == null){ - val preferredDownloadType = sharedPreferences.getString("preferred_download_type", Type.auto.toString()) - type = if (sharedPreferences.getBoolean("remember_download_type", false)){ - Type.valueOf(sharedPreferences.getString("last_used_download_type", - preferredDownloadType)!!) - }else{ - Type.valueOf(preferredDownloadType!!) - } - } - - return when(type){ - Type.auto -> { - if (urlsForAudioType.any { url.contains(it) }){ - Type.audio - }else{ - Type.video - } - } - - else -> type - } + return sharedDownloadViewModel.getDownloadType(t, url) } fun createDownloadItemFromResult(result: ResultItem?, url: String = "", givenType: Type) : DownloadItem { - val resultItem = result ?: createEmptyResultItem(url) - - val embedSubs = sharedPreferences.getBoolean("embed_subtitles", false) - val saveSubs = sharedPreferences.getBoolean("write_subtitles", false) - val saveAutoSubs = sharedPreferences.getBoolean("write_auto_subtitles", false) - val addChapters = sharedPreferences.getBoolean("add_chapters", false) - val saveThumb = sharedPreferences.getBoolean("write_thumbnail", false) - val embedThumb = sharedPreferences.getBoolean("embed_thumbnail", false) - val cropThumb = sharedPreferences.getBoolean("crop_thumbnail", false) - - var type = getDownloadType(givenType, resultItem.url) - if(type == Type.command && commandTemplateDao.getTotalNumber() == 0) type = Type.video - - val customFileNameTemplate = when(type) { - Type.audio -> sharedPreferences.getString("file_name_template_audio", "%(uploader)s - %(title)s") - Type.video -> sharedPreferences.getString("file_name_template", "%(uploader)s - %(title)s") - else -> "" - } - - val downloadPath = when(type){ - Type.audio -> sharedPreferences.getString("music_path", FileUtil.getDefaultAudioPath()) - Type.video -> sharedPreferences.getString("video_path", FileUtil.getDefaultVideoPath()) - else -> sharedPreferences.getString("command_path", FileUtil.getDefaultCommandPath()) - } - - val container = when(type){ - Type.audio -> sharedPreferences.getString("audio_format", "") - else -> sharedPreferences.getString("video_format", "") - } - - - val sponsorblock = sharedPreferences.getStringSet("sponsorblock_filters", emptySet()) - - val audioPreferences = AudioPreferences(embedThumb, cropThumb,false, ArrayList(sponsorblock!!)) - - - val preferredAudioFormats = getPreferredAudioFormats(resultItem.formats) - - val videoPreferences = VideoPreferences( - embedSubs, - addChapters, false, - ArrayList(sponsorblock), - saveSubs, - saveAutoSubs, - audioFormatIDs = preferredAudioFormats - ) - - val extraCommands = when(type){ - Type.audio -> extraCommandsForAudio - Type.video -> extraCommandsForVideo - else -> "" - } - - return DownloadItem(0, - resultItem.url, - resultItem.title, - resultItem.author, - resultItem.thumb, - resultItem.duration, - type, - getFormat(resultItem.formats, type), - container!!, - "", - resultItem.formats, - downloadPath!!, resultItem.website, - "", - resultItem.playlistTitle, - audioPreferences, - videoPreferences, - extraCommands, - customFileNameTemplate!!, - saveThumb, - DownloadRepository.Status.Queued.toString(), 0, null, playlistURL = resultItem.playlistURL, playlistIndex = resultItem.playlistIndex - ) - + return sharedDownloadViewModel.createDownloadItemFromResult(result, url, givenType) } fun createResultItemFromDownload(downloadItem: DownloadItem) : ResultItem { - return ResultItem( - 0, - downloadItem.url, - downloadItem.title, - downloadItem.author, - downloadItem.duration, - downloadItem.thumb, - downloadItem.website, - downloadItem.playlistTitle, - downloadItem.allFormats, - "", - arrayListOf(), - downloadItem.playlistURL, - downloadItem.playlistIndex, - System.currentTimeMillis() - ) - + return sharedDownloadViewModel.createResultItemFromDownload(downloadItem) } fun createResultItemFromHistory(downloadItem: HistoryItem) : ResultItem { @@ -353,23 +247,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel } fun createEmptyResultItem(url: String) : ResultItem { - return ResultItem( - 0, - url, - "", - "", - "", - "", - "", - "", - arrayListOf(), - "", - arrayListOf(), - "", - null, - System.currentTimeMillis() - ) - + return sharedDownloadViewModel.createEmptyResultItem(url) } fun switchDownloadType(list: List, type: Type) : List{ @@ -471,166 +349,140 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel fun getPreferredAudioRequirements(): MutableList<(Format) -> Int> { - val requirements: MutableList<(Format) -> Int> = mutableListOf() - requirements.add {it: Format -> if (audioFormatIDPreference.contains(it.format_id)) 10 else 0} - - sharedPreferences.getString("audio_language", "")?.apply { - if (this.isNotBlank()){ - requirements.add { it: Format -> if (it.lang?.contains(this) == true) 3 else 0 } - } - } - - requirements.add {it: Format -> if ("^(${audioCodec}).+$".toRegex(RegexOption.IGNORE_CASE).matches(it.acodec)) 2 else 0} - requirements.add {it: Format -> if (it.container == audioContainer) 1 else 0 } - return requirements + return sharedDownloadViewModel.getPreferredVideoRequirements() } //requirement and importance @SuppressLint("RestrictedApi") fun getPreferredVideoRequirements(): MutableList<(Format) -> Int> { - val requirements: MutableList<(Format) -> Int> = mutableListOf() - //format id - requirements.add { it: Format -> if (formatIDPreference.contains(it.format_id)) 20 else 0 } - //resolutions - application.getStringArray(R.array.video_formats_values) - .filter { it.contains("_") } - .map{ it.split("_")[0].dropLast(1) - }.toMutableList().apply { - when(videoQualityPreference) { - "worst" -> { - requirements.add { it: Format -> if (it.format_note.contains("worst", ignoreCase = true)) (15) else 0 } - } - "best" -> { - requirements.add { it: Format -> if (it.format_note.contains("best", ignoreCase = true)) (15) else 0 } - } - else -> { - val preferenceIndex = this.indexOfFirst { videoQualityPreference.contains(it) } - val preference = this[preferenceIndex] - for(i in 0..preferenceIndex){ - removeAt(0) - } - add(0, preference) - forEachIndexed { index, res -> - requirements.add { it: Format -> if (it.format_note.contains(res, ignoreCase = true)) (15 - index - 1) else 0 } - } - } - } - - } - requirements.add { it: Format -> if ("^(${videoCodec})(.+)?$".toRegex(RegexOption.IGNORE_CASE).matches(it.vcodec)) 5 else 0 } - requirements.add { it: Format -> if (it.acodec == "none" || it.acodec == "") 1 else 0 } - requirements.add { it: Format -> - if (videoContainer == "mp4") - if (it.container.equals("mpeg_4", true)) 1 else 0 - else - if (it.container.equals(videoContainer, true)) 1 else 0 - } - return requirements + return sharedDownloadViewModel.getPreferredVideoRequirements() } fun getFormat(formats: List, type: Type) : Format { - when(type) { - Type.audio -> { - return cloneFormat ( - try { - val theFormats = formats.filter { it.vcodec.isBlank() || it.vcodec == "none" } - val requirements = getPreferredAudioRequirements() - theFormats.maxByOrNull { f -> requirements.sumOf{ req -> req(f)} } ?: throw Exception() - }catch (e: Exception){ - bestAudioFormat - } - ) - - } - Type.video -> { - return cloneFormat( - try { - val theFormats = formats.filter { it.vcodec.isNotBlank() && it.vcodec != "none" }.ifEmpty { - defaultVideoFormats.sortedByDescending { it.filesize } - } - when (videoQualityPreference) { - "worst" -> { - theFormats.last() - } - else /*best*/ -> { - val requirements = getPreferredVideoRequirements() - theFormats.run { - if (sharedPreferences.getBoolean("prefer_smaller_formats", false)){ - sortedBy { it.filesize }.maxByOrNull { f -> requirements.sumOf { req -> req(f) } } ?: throw Exception() - }else{ - sortedByDescending { it.filesize }.maxByOrNull { f -> - val summ = requirements.sumOf { req -> req(f) } - summ - } ?: throw Exception() - } - } - } - } - }catch (e: Exception){ - bestVideoFormat - } - ) - } - else -> { - val lastUsedCommandTemplate = sharedPreferences.getString("lastCommandTemplateUsed", "")!! - val c = if (lastUsedCommandTemplate.isBlank()){ - commandTemplateDao.getFirst() ?: CommandTemplate(0,"","", useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false) - }else{ - commandTemplateDao.getTemplateByContent(lastUsedCommandTemplate) ?: CommandTemplate(0, "", lastUsedCommandTemplate, useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false) - } - return generateCommandFormat(c) - } - } + return sharedDownloadViewModel.getFormat(formats, type) } fun getPreferredAudioFormats(formats: List) : ArrayList{ - val preferredAudioFormats = arrayListOf() - for (f in formats.sortedBy { it.format_id }){ - val fId = audioFormatIDPreference.sorted().find { it.contains(f.format_id) } - if (fId != null) { - if (fId.split("+").all { formats.map { f-> f.format_id }.contains(it) }){ - preferredAudioFormats.addAll(fId.split("+")) - break - } - } - } - if (preferredAudioFormats.isEmpty()){ - val audioF = getFormat(formats, Type.audio) - if (!infoUtil.getGenericAudioFormats(resources).contains(audioF)){ - preferredAudioFormats.add(audioF.format_id) - } - } - return preferredAudioFormats + return sharedDownloadViewModel.getPreferredAudioFormats(formats) } fun generateCommandFormat(c: CommandTemplate) : Format { - return Format( - c.title, - c.id.toString(), - "", - "", - "", - 0, - c.content.replace("\n", " ") - ) + return sharedDownloadViewModel.generateCommandFormat(c) } + data class ProcessingItemsJob( + var jobID: Long = 0, + var job: Job? = null, + var itemType: String, + var itemIDs: List + ) - - fun getLatestCommandTemplateAsFormat() : Format { - val t = commandTemplateDao.getFirst()!! - return Format(t.title, "", "", "", "", 0, t.content) - } - - fun turnResultItemsToDownloadItems(items: List) : List { - val list : MutableList = mutableListOf() - items.forEach { - val preferredType = getDownloadType(url = it!!.url).toString() - list.add(createDownloadItemFromResult(result = it, givenType = Type.valueOf( - preferredType - ))) + val loadingProcessingItems: MutableStateFlow = MutableStateFlow(false) + var loadingProcessingDownloadsJobs: MutableStateFlow> = MutableStateFlow(listOf()) + private fun cancelAllLoadingProcessingDownloads() { + loadingProcessingDownloadsJobs.value.onEach { + it.job?.cancel(CancellationException()) } - return list + } + + fun getLoadingProcessingDownloadJob(jobID: Long) : ProcessingItemsJob? { + return loadingProcessingDownloadsJobs.value.firstOrNull { it.jobID == jobID } + } + + fun cancelLoadingProcessingDownloads(jobID: Long) { + loadingProcessingDownloadsJobs.value.firstOrNull {it.jobID == jobID }?.apply { + this.job?.cancel(CancellationException()) + } + } + + fun turnDownloadItemsToProcessingDownloads(itemIDs: List) : Long { + val jobID = System.currentTimeMillis() + val job = viewModelScope.launch(Dispatchers.IO) { + val insertedIDs = mutableListOf() + try { + loadingProcessingItems.emit(true) + + itemIDs.chunked(100).forEach { ids -> + val items = repository.getAllItemsByIDs(ids) + items.apply { + if (!isActive) { + throw CancellationException() + } + this.forEach { + it.id = 0 + it.status = DownloadRepository.Status.Processing.toString() + insertedIDs.add(repository.insert(it)) + } + } + } + + loadingProcessingItems.emit(false) + } catch (e: Exception) { + repository.deleteAllWithIDs(insertedIDs) + loadingProcessingItems.emit(false) + } + } + + viewModelScope.launch(Dispatchers.IO) { + val currentJobs = loadingProcessingDownloadsJobs.value.toMutableList() + currentJobs.add( + ProcessingItemsJob(jobID, job, DownloadItem::class.java.toString(), itemIDs) + ) + loadingProcessingDownloadsJobs.emit(currentJobs) + } + + return jobID + } + + + fun turnResultItemsToProcessingDownloads(itemIds: List, downloadNow: Boolean = false) : Long { + val jobID = System.currentTimeMillis() + val job = viewModelScope.launch(Dispatchers.IO) { + val insertedIds = mutableListOf() + try { + loadingProcessingItems.emit(true) + + itemIds.chunked(100).forEach { ids -> + val items = resultRepository.getAllByIDs(ids) + val downloadItems = items.map { + val preferredType = getDownloadType(url = it.url).toString() + val tmp = createDownloadItemFromResult(result = it, givenType = Type.valueOf( + preferredType + )) + tmp.status = DownloadRepository.Status.Processing.toString() + tmp + } + if (!isActive) { + throw CancellationException() + } + + if (downloadNow) { + downloadItems.forEach { + it.status = DownloadRepository.Status.Queued.toString() + } + queueDownloads(downloadItems) + }else{ + downloadItems.forEach { + insertedIds.add(repository.insert(it)) + } + } + } + loadingProcessingItems.emit(false) + }catch (e: Exception) { + repository.deleteAllWithIDs(insertedIds) + loadingProcessingItems.emit(false) + } + } + + viewModelScope.launch(Dispatchers.IO) { + val currentJobs = loadingProcessingDownloadsJobs.value.toMutableList() + currentJobs.add( + ProcessingItemsJob(jobID, job, ResultItem::class.java.toString(), itemIds) + ) + loadingProcessingDownloadsJobs.emit(currentJobs) + } + + return jobID } fun insert(item: DownloadItem) = viewModelScope.launch(Dispatchers.IO){ @@ -688,6 +540,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel } fun cancelActiveQueued() = viewModelScope.launch(Dispatchers.IO) { + cancelAllLoadingProcessingDownloads() repository.cancelActiveQueued() } @@ -706,11 +559,6 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel return dao.getSavedDownloadsList() } - private fun cloneFormat(item: Format) : Format { - val string = Gson().toJson(item, Format::class.java) - return Gson().fromJson(string, Format::class.java) - } - fun getActiveDownloads() : List{ return repository.getActiveDownloads() } @@ -725,6 +573,7 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel suspend fun resetScheduleTimeForItemsAndStartDownload(items: List) = CoroutineScope(Dispatchers.IO).launch { dbManager.downloadDao.resetScheduleTimeForItems(items) + dbManager.downloadDao.resetPausedItems() repository.startDownloadWorker(emptyList(), application) } @@ -789,173 +638,14 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel dao.updateDownloadID(-current, id) } - suspend fun reQueueDownloadItems(items: List) = CoroutineScope(Dispatchers.IO).launch { + fun reQueueDownloadItems(items: List) = viewModelScope.launch(Dispatchers.IO) { dbManager.downloadDao.reQueueDownloadItems(items) repository.startDownloadWorker(emptyList(), application) } - suspend fun queueDownloads(items: List, ign : Boolean = false) : Pair, List> { - val context = App.instance - val alarmScheduler = AlarmScheduler(context) - val activeAndQueuedDownloads = withContext(Dispatchers.IO){ - repository.getActiveAndQueuedDownloads() - } - val queuedItems = mutableListOf() - val existingDownloads = mutableListOf() - val existingHistory = mutableListOf() - - //if scheduler is on - val useScheduler = sharedPreferences.getBoolean("use_scheduler", false) - if (useScheduler && !alarmScheduler.isDuringTheScheduledTime()){ - alarmScheduler.schedule() - } - - if (items.any { it.playlistTitle.isEmpty() } && items.size > 1){ - items.forEachIndexed { index, it -> it.playlistTitle = "Various[${index+1}]" } - } - - val downloadArchive = runCatching { File(FileUtil.getDownloadArchivePath(application)).useLines { it.toList() } }.getOrElse { listOf() } - .map { it.split(" ")[1] } - items.forEach { - if (! listOf(DownloadRepository.Status.ActivePaused, DownloadRepository.Status.Scheduled).toListString().contains(it.status)) - it.status = DownloadRepository.Status.Queued.toString() - var alreadyExists = false - - val checkDuplicate = sharedPreferences.getString("prevent_duplicate_downloads", "")!! - if (checkDuplicate.isNotEmpty() && !ign){ - when(checkDuplicate){ - "download_archive" -> { - if (downloadArchive.any { d -> it.url.contains(d) }){ - alreadyExists = true - if (it.id == 0L) { - it.status = DownloadRepository.Status.Processing.toString() - val id = runBlocking { - repository.insert(it) - } - it.id = id - } - existingDownloads.add(it.id) - } - } - "url_type" -> { - val existingDownload = activeAndQueuedDownloads.firstOrNull{d -> - d.id = 0 - d.logID = null - d.customFileNameTemplate = it.customFileNameTemplate - d.status = DownloadRepository.Status.Queued.toString() - d.toString() == it.toString() - } - - if (existingDownload != null){ - it.status = DownloadRepository.Status.Processing.toString() - val id = runBlocking { - repository.insert(it) - } - alreadyExists = true - existingDownloads.add(id) - }else{ - //check if downloaded and file exists - val history = withContext(Dispatchers.IO){ - historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } } - } - - val existingHistoryItem = history.firstOrNull { - h -> h.type == it.type - } - - if (existingHistoryItem != null){ - alreadyExists = true - existingHistory.add(existingHistoryItem.id) - } - } - } - "config" -> { - val currentCommand = infoUtil.buildYoutubeDLRequest(it) - val parsedCurrentCommand = infoUtil.parseYTDLRequestString(currentCommand) - val existingDownload = activeAndQueuedDownloads.firstOrNull{d -> - d.id = 0 - d.logID = null - d.customFileNameTemplate = it.customFileNameTemplate - d.status = DownloadRepository.Status.Queued.toString() - d.toString() == it.toString() - } - - if (existingDownload != null){ - it.status = DownloadRepository.Status.Processing.toString() - val id = runBlocking { - repository.insert(it) - } - alreadyExists = true - existingDownloads.add(id) - }else{ - //check if downloaded and file exists - val history = withContext(Dispatchers.IO){ - historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } } - } - - val existingHistoryItem = history.firstOrNull { - h -> h.command.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "") == parsedCurrentCommand.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "") - } - - if (existingHistoryItem != null){ - alreadyExists = true - existingHistory.add(existingHistoryItem.id) - } - } - } - } - } - - if (!alreadyExists){ - if (it.id == 0L){ - val id = runBlocking { - repository.insert(it) - } - it.id = id - }else if (listOf(DownloadRepository.Status.Queued, DownloadRepository.Status.Scheduled).toListString().contains(it.status)){ - withContext(Dispatchers.IO){ - repository.update(it) - } - } - - queuedItems.add(it) - } - - } - - if (existingDownloads.isNotEmpty() || existingHistory.isNotEmpty()){ - alreadyExistsUiState.update { u -> u.copy(existingHistory, existingDownloads) } - } - - if (queuedItems.isNotEmpty()){ - if (!useScheduler || alarmScheduler.isDuringTheScheduledTime()){ - repository.startDownloadWorker(queuedItems, context) - - if(!useScheduler){ - queuedItems.filter { it.downloadStartTime != 0L && (it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty()) }.forEach { - CoroutineScope(Dispatchers.IO).launch { - runCatching { - resultRepository.updateDownloadItem(it)?.apply { - repository.updateWithoutUpsert(this) - } - } - } - } - }else{ - queuedItems.filter { it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty() }.forEach { - CoroutineScope(Dispatchers.IO).launch { - runCatching { - resultRepository.updateDownloadItem(it)?.apply { - repository.updateWithoutUpsert(this) - } - } - } - } - } - } - } - - return Pair(existingDownloads, existingHistory) + suspend fun queueDownloads(items: List, ign : Boolean = false) : List { + val ids = sharedDownloadViewModel.queueDownloads(items, ign) + return ids } fun getQueuedCollectedFileSize() : Long { @@ -978,19 +668,6 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel dao.updateProcessingtoSavedStatus() } - suspend fun downloadProcessingDownloads(timeInMillis: Long = 0){ - repository.getProcessingDownloads().apply { - if (timeInMillis > 0){ - this.forEach { - it.status = DownloadRepository.Status.Scheduled.toString() - it.downloadStartTime = timeInMillis - } - } - - queueDownloads(this) - } - } - suspend fun updateProcessingFormat(selectedFormats: List): List { val items = repository.getProcessingDownloads() items.forEachIndexed { index, i -> @@ -1098,16 +775,6 @@ class DownloadViewModel(private val application: Application) : AndroidViewModel val first = dao.getFirstProcessingDownload() } - suspend fun addDownloadsToProcessing(ids: List) { - repository.deleteProcessing() - val items = repository.getAllItemsByIDs(ids) - items.forEach { - it.id = 0 - it.status = DownloadRepository.Status.Processing.toString() - insert(it) - } - } - fun getURLsByStatus(list: List) : List { return dao.getURLsByStatus(list.map { it.toString() }) } diff --git a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/ObserveSourcesViewModel.kt b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/ObserveSourcesViewModel.kt index 1738ba14..681a6e77 100644 --- a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/ObserveSourcesViewModel.kt +++ b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/ObserveSourcesViewModel.kt @@ -10,6 +10,7 @@ import androidx.preference.PreferenceManager import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequestBuilder @@ -124,7 +125,15 @@ class ObserveSourcesViewModel(private val application: Application) : AndroidVie } //schedule for next time + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) + val allowMeteredNetworks = sharedPreferences.getBoolean("metered_networks", true) + val workConstraints = Constraints.Builder() + if (!allowMeteredNetworks) workConstraints.setRequiredNetworkType(NetworkType.UNMETERED) + else { + workConstraints.setRequiredNetworkType(NetworkType.CONNECTED) + } + val workRequest = OneTimeWorkRequestBuilder() .addTag("observeSources") .addTag(it.id.toString()) diff --git a/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/SharedDownloadViewModel.kt b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/SharedDownloadViewModel.kt new file mode 100644 index 00000000..533ab203 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/database/viewmodel/SharedDownloadViewModel.kt @@ -0,0 +1,616 @@ +package com.deniscerri.ytdl.database.viewmodel + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.Parcelable +import android.util.DisplayMetrics +import android.widget.Toast +import androidx.preference.PreferenceManager +import com.afollestad.materialdialogs.utils.MDUtil.getStringArray +import com.deniscerri.ytdl.App +import com.deniscerri.ytdl.R +import com.deniscerri.ytdl.database.DBManager +import com.deniscerri.ytdl.database.dao.CommandTemplateDao +import com.deniscerri.ytdl.database.dao.DownloadDao +import com.deniscerri.ytdl.database.models.AudioPreferences +import com.deniscerri.ytdl.database.models.CommandTemplate +import com.deniscerri.ytdl.database.models.DownloadItem +import com.deniscerri.ytdl.database.models.Format +import com.deniscerri.ytdl.database.models.ResultItem +import com.deniscerri.ytdl.database.models.VideoPreferences +import com.deniscerri.ytdl.database.repository.DownloadRepository +import com.deniscerri.ytdl.database.repository.HistoryRepository +import com.deniscerri.ytdl.database.repository.ResultRepository +import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel.Type +import com.deniscerri.ytdl.util.Extensions.toListString +import com.deniscerri.ytdl.util.FileUtil +import com.deniscerri.ytdl.util.InfoUtil +import com.deniscerri.ytdl.work.AlarmScheduler +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import java.io.File +import java.util.Locale + + +class SharedDownloadViewModel(private val context: Context) { + private val dbManager: DBManager = DBManager.getInstance(context) + val repository : DownloadRepository + private val sharedPreferences: SharedPreferences + private val commandTemplateDao: CommandTemplateDao + private val infoUtil : InfoUtil + + private var bestVideoFormat : Format + private var bestAudioFormat : Format + private var defaultVideoFormats : MutableList + + private val videoQualityPreference: String + private val formatIDPreference: List + private val audioFormatIDPreference: List + private val resources : Resources + private var extraCommandsForAudio: String = "" + private var extraCommandsForVideo: String = "" + + private var audioContainer: String? + private var videoContainer: String? + private var videoCodec: String? + private var audioCodec: String? + private val dao: DownloadDao + private val historyRepository: HistoryRepository + private val resultRepository: ResultRepository + + @Parcelize + data class AlreadyExistsIDs( + var downloadItemID: Long, + var historyItemID : Long? + ) : Parcelable + + val alreadyExistsUiState: MutableStateFlow> = MutableStateFlow( + mutableListOf() + ) + + private val urlsForAudioType = listOf( + "music", + "audio", + "soundcloud" + ) + + init { + dao = dbManager.downloadDao + repository = DownloadRepository(dao) + historyRepository = HistoryRepository(dbManager.historyDao) + resultRepository = ResultRepository(dbManager.resultDao, context) + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + commandTemplateDao = DBManager.getInstance(context).commandTemplateDao + infoUtil = InfoUtil(context) + + CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { + if (sharedPreferences.getBoolean("use_extra_commands", false)){ + extraCommandsForAudio = commandTemplateDao.getAllTemplatesAsExtraCommandsForAudio().joinToString(" ") + extraCommandsForVideo = commandTemplateDao.getAllTemplatesAsExtraCommandsForVideo().joinToString(" ") + } + } + + videoQualityPreference = sharedPreferences.getString("video_quality", "best").toString() + formatIDPreference = sharedPreferences.getString("format_id", "").toString().split(",").filter { it.isNotEmpty() } + audioFormatIDPreference = sharedPreferences.getString("format_id_audio", "").toString().split(",").filter { it.isNotEmpty() } + + val confTmp = Configuration(context.resources.configuration) + confTmp.setLocale(Locale(sharedPreferences.getString("app_language", "en")!!)) + val metrics = DisplayMetrics() + resources = Resources(context.assets, metrics, confTmp) + + + videoContainer = sharedPreferences.getString("video_format", "Default") + defaultVideoFormats = infoUtil.getGenericVideoFormats(resources) + bestVideoFormat = defaultVideoFormats.first() + + audioContainer = sharedPreferences.getString("audio_format", "mp3") + bestAudioFormat = if (audioFormatIDPreference.isEmpty()){ + infoUtil.getGenericAudioFormats(resources).first() + }else{ + Format( + audioFormatIDPreference.first().split("+").first(), + audioContainer!!, + "", + "", + "", + 0, + audioFormatIDPreference.first().split("+").first() + ) + } + + videoCodec = sharedPreferences.getString("video_codec", "") + audioCodec = sharedPreferences.getString("audio_codec", "") + } + + + fun getDownloadType(t: Type? = null, url: String) : Type { + var type = t + + if (type == null){ + val preferredDownloadType = sharedPreferences.getString("preferred_download_type", Type.auto.toString()) + type = if (sharedPreferences.getBoolean("remember_download_type", false)){ + Type.valueOf(sharedPreferences.getString("last_used_download_type", + preferredDownloadType)!!) + }else{ + Type.valueOf(preferredDownloadType!!) + } + } + + return when(type){ + Type.auto -> { + if (urlsForAudioType.any { url.contains(it) }){ + Type.audio + }else{ + Type.video + } + } + + else -> type + } + } + + fun createDownloadItemFromResult(result: ResultItem?, url: String = "", givenType: Type) : DownloadItem { + val resultItem = result ?: createEmptyResultItem(url) + + val embedSubs = sharedPreferences.getBoolean("embed_subtitles", false) + val saveSubs = sharedPreferences.getBoolean("write_subtitles", false) + val saveAutoSubs = sharedPreferences.getBoolean("write_auto_subtitles", false) + val addChapters = sharedPreferences.getBoolean("add_chapters", false) + val saveThumb = sharedPreferences.getBoolean("write_thumbnail", false) + val embedThumb = sharedPreferences.getBoolean("embed_thumbnail", false) + val cropThumb = sharedPreferences.getBoolean("crop_thumbnail", false) + + var type = getDownloadType(givenType, resultItem.url) + if(type == Type.command && commandTemplateDao.getTotalNumber() == 0) type = Type.video + + val customFileNameTemplate = when(type) { + Type.audio -> sharedPreferences.getString("file_name_template_audio", "%(uploader)s - %(title)s") + Type.video -> sharedPreferences.getString("file_name_template", "%(uploader)s - %(title)s") + else -> "" + } + + val downloadPath = when(type){ + Type.audio -> sharedPreferences.getString("music_path", FileUtil.getDefaultAudioPath()) + Type.video -> sharedPreferences.getString("video_path", FileUtil.getDefaultVideoPath()) + else -> sharedPreferences.getString("command_path", FileUtil.getDefaultCommandPath()) + } + + val container = when(type){ + Type.audio -> sharedPreferences.getString("audio_format", "") + else -> sharedPreferences.getString("video_format", "") + } + + + val sponsorblock = sharedPreferences.getStringSet("sponsorblock_filters", emptySet()) + + val audioPreferences = AudioPreferences(embedThumb, cropThumb,false, ArrayList(sponsorblock!!)) + + + val preferredAudioFormats = getPreferredAudioFormats(resultItem.formats) + + val videoPreferences = VideoPreferences( + embedSubs, + addChapters, false, + ArrayList(sponsorblock), + saveSubs, + saveAutoSubs, + audioFormatIDs = preferredAudioFormats + ) + + val extraCommands = when(type){ + Type.audio -> extraCommandsForAudio + Type.video -> extraCommandsForVideo + else -> "" + } + + return DownloadItem(0, + resultItem.url, + resultItem.title, + resultItem.author, + resultItem.thumb, + resultItem.duration, + type, + getFormat(resultItem.formats, type), + container!!, + "", + resultItem.formats, + downloadPath!!, resultItem.website, + "", + resultItem.playlistTitle, + audioPreferences, + videoPreferences, + extraCommands, + customFileNameTemplate!!, + saveThumb, + DownloadRepository.Status.Queued.toString(), 0, null, playlistURL = resultItem.playlistURL, playlistIndex = resultItem.playlistIndex + ) + + } + + fun createResultItemFromDownload(downloadItem: DownloadItem) : ResultItem { + return ResultItem( + 0, + downloadItem.url, + downloadItem.title, + downloadItem.author, + downloadItem.duration, + downloadItem.thumb, + downloadItem.website, + downloadItem.playlistTitle, + downloadItem.allFormats, + "", + arrayListOf(), + downloadItem.playlistURL, + downloadItem.playlistIndex, + System.currentTimeMillis() + ) + + } + + fun createEmptyResultItem(url: String) : ResultItem { + return ResultItem( + 0, + url, + "", + "", + "", + "", + "", + "", + arrayListOf(), + "", + arrayListOf(), + "", + null, + System.currentTimeMillis() + ) + + } + + private fun getPreferredAudioRequirements(): MutableList<(Format) -> Int> { + val requirements: MutableList<(Format) -> Int> = mutableListOf() + requirements.add {it: Format -> if (audioFormatIDPreference.contains(it.format_id)) 10 else 0} + + sharedPreferences.getString("audio_language", "")?.apply { + if (this.isNotBlank()){ + requirements.add { it: Format -> if (it.lang?.contains(this) == true) 3 else 0 } + } + } + + requirements.add {it: Format -> if ("^(${audioCodec}).+$".toRegex(RegexOption.IGNORE_CASE).matches(it.acodec)) 2 else 0} + requirements.add {it: Format -> if (it.container == audioContainer) 1 else 0 } + return requirements + } + + //requirement and importance + @SuppressLint("RestrictedApi") + fun getPreferredVideoRequirements(): MutableList<(Format) -> Int> { + val requirements: MutableList<(Format) -> Int> = mutableListOf() + //format id + requirements.add { it: Format -> if (formatIDPreference.contains(it.format_id)) 20 else 0 } + //resolutions + context.getStringArray(R.array.video_formats_values) + .filter { it.contains("_") } + .map{ it.split("_")[0].dropLast(1) + }.toMutableList().apply { + when(videoQualityPreference) { + "worst" -> { + requirements.add { it: Format -> if (it.format_note.contains("worst", ignoreCase = true)) (15) else 0 } + } + "best" -> { + requirements.add { it: Format -> if (it.format_note.contains("best", ignoreCase = true)) (15) else 0 } + } + else -> { + val preferenceIndex = this.indexOfFirst { videoQualityPreference.contains(it) } + val preference = this[preferenceIndex] + for(i in 0..preferenceIndex){ + removeAt(0) + } + add(0, preference) + forEachIndexed { index, res -> + requirements.add { it: Format -> if (it.format_note.contains(res, ignoreCase = true)) (15 - index - 1) else 0 } + } + } + } + + } + requirements.add { it: Format -> if ("^(${videoCodec})(.+)?$".toRegex(RegexOption.IGNORE_CASE).matches(it.vcodec)) 5 else 0 } + requirements.add { it: Format -> if (it.acodec == "none" || it.acodec == "") 1 else 0 } + requirements.add { it: Format -> + if (videoContainer == "mp4") + if (it.container.equals("mpeg_4", true)) 1 else 0 + else + if (it.container.equals(videoContainer, true)) 1 else 0 + } + return requirements + } + + fun getFormat(formats: List, type: Type) : Format { + when(type) { + Type.audio -> { + return cloneFormat ( + try { + val theFormats = formats.filter { it.vcodec.isBlank() || it.vcodec == "none" } + val requirements = getPreferredAudioRequirements() + theFormats.maxByOrNull { f -> requirements.sumOf{ req -> req(f)} } ?: throw Exception() + }catch (e: Exception){ + bestAudioFormat + } + ) + + } + Type.video -> { + return cloneFormat( + try { + val theFormats = formats.filter { it.vcodec.isNotBlank() && it.vcodec != "none" }.ifEmpty { + defaultVideoFormats.sortedByDescending { it.filesize } + } + when (videoQualityPreference) { + "worst" -> { + theFormats.last() + } + else /*best*/ -> { + val requirements = getPreferredVideoRequirements() + theFormats.run { + if (sharedPreferences.getBoolean("prefer_smaller_formats", false)){ + sortedBy { it.filesize }.maxByOrNull { f -> requirements.sumOf { req -> req(f) } } ?: throw Exception() + }else{ + sortedByDescending { it.filesize }.maxByOrNull { f -> + val summ = requirements.sumOf { req -> req(f) } + summ + } ?: throw Exception() + } + } + } + } + }catch (e: Exception){ + bestVideoFormat + } + ) + } + else -> { + val lastUsedCommandTemplate = sharedPreferences.getString("lastCommandTemplateUsed", "")!! + val c = if (lastUsedCommandTemplate.isBlank()){ + commandTemplateDao.getFirst() ?: CommandTemplate(0,"","", useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false) + }else{ + commandTemplateDao.getTemplateByContent(lastUsedCommandTemplate) ?: CommandTemplate(0, "", lastUsedCommandTemplate, useAsExtraCommand = false, useAsExtraCommandAudio = false, useAsExtraCommandVideo = false) + } + return generateCommandFormat(c) + } + } + } + + fun getPreferredAudioFormats(formats: List) : ArrayList{ + val preferredAudioFormats = arrayListOf() + for (f in formats.sortedBy { it.format_id }){ + val fId = audioFormatIDPreference.sorted().find { it.contains(f.format_id) } + if (fId != null) { + if (fId.split("+").all { formats.map { f-> f.format_id }.contains(it) }){ + preferredAudioFormats.addAll(fId.split("+")) + break + } + } + } + if (preferredAudioFormats.isEmpty()){ + val audioF = getFormat(formats, Type.audio) + if (!infoUtil.getGenericAudioFormats(resources).contains(audioF)){ + preferredAudioFormats.add(audioF.format_id) + } + } + return preferredAudioFormats + } + + fun generateCommandFormat(c: CommandTemplate) : Format { + return Format( + c.title, + c.id.toString(), + "", + "", + "", + 0, + c.content.replace("\n", " ") + ) + } + + private fun cloneFormat(item: Format) : Format { + val string = Gson().toJson(item, Format::class.java) + return Gson().fromJson(string, Format::class.java) + } + + suspend fun queueDownloads(items: List, ign : Boolean = false) : List { + val context = App.instance + val alarmScheduler = AlarmScheduler(context) + val queuedItems = mutableListOf() + //download id, history item id + //history item id if the existing item is already downloaded + val existingItemIDs = mutableListOf() + + if (items.any { it.playlistTitle.isEmpty() } && items.size > 1){ + items.forEachIndexed { index, it -> it.playlistTitle = "Various[${index+1}]" } + } + + val downloadArchive = runCatching { File(FileUtil.getDownloadArchivePath(context)).useLines { it.toList() } }.getOrElse { listOf() } + .map { it.split(" ")[1] } + items.forEach { + if (! listOf(DownloadRepository.Status.ActivePaused, DownloadRepository.Status.Scheduled).toListString().contains(it.status)) + it.status = DownloadRepository.Status.Queued.toString() + var alreadyExists = false + + val checkDuplicate = sharedPreferences.getString("prevent_duplicate_downloads", "")!! + if (checkDuplicate.isNotEmpty() && !ign){ + when(checkDuplicate){ + "download_archive" -> { + if (downloadArchive.any { d -> it.url.contains(d) }){ + alreadyExists = true + if (it.id == 0L) { + it.status = DownloadRepository.Status.Processing.toString() + val id = runBlocking { + repository.insert(it) + } + it.id = id + } + existingItemIDs.add(AlreadyExistsIDs(it.id, null)) + } + } + "url_type" -> { + val activeAndQueuedDownloads = withContext(Dispatchers.IO){ + repository.getActiveAndQueuedDownloads() + } + val existingDownload = activeAndQueuedDownloads.firstOrNull{d -> + d.id = 0 + d.logID = null + d.customFileNameTemplate = it.customFileNameTemplate + d.status = DownloadRepository.Status.Queued.toString() + d.toString() == it.toString() + } + + if (existingDownload != null){ + it.status = DownloadRepository.Status.Processing.toString() + val id = runBlocking { + repository.insert(it) + } + it.id = id + alreadyExists = true + existingItemIDs.add(AlreadyExistsIDs(it.id, null)) + }else{ + //check if downloaded and file exists + val history = withContext(Dispatchers.IO){ + historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } } + } + + val existingHistoryItem = history.firstOrNull { + h -> h.type == it.type + } + + if (existingHistoryItem != null){ + alreadyExists = true + it.status = DownloadRepository.Status.Processing.toString() + val id = runBlocking { + repository.insert(it) + } + existingItemIDs.add(AlreadyExistsIDs(id, existingHistoryItem.id)) + } + } + } + "config" -> { + val currentCommand = infoUtil.buildYoutubeDLRequest(it) + val parsedCurrentCommand = infoUtil.parseYTDLRequestString(currentCommand) + val activeAndQueuedDownloads = withContext(Dispatchers.IO){ + repository.getActiveAndQueuedDownloads() + } + val existingDownload = activeAndQueuedDownloads.firstOrNull{d -> + d.id = 0 + d.logID = null + d.customFileNameTemplate = it.customFileNameTemplate + d.status = DownloadRepository.Status.Queued.toString() + d.toString() == it.toString() + } + + if (existingDownload != null){ + it.status = DownloadRepository.Status.Processing.toString() + val id = runBlocking { + repository.insert(it) + } + alreadyExists = true + existingItemIDs.add(AlreadyExistsIDs(id, null)) + }else{ + //check if downloaded and file exists + val history = withContext(Dispatchers.IO){ + historyRepository.getAllByURL(it.url).filter { item -> item.downloadPath.any { path -> FileUtil.exists(path) } } + } + + val existingHistoryItem = history.firstOrNull { + h -> h.command.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "") == parsedCurrentCommand.replace("(-P \"(.*?)\")|(--trim-filenames \"(.*?)\")".toRegex(), "") + } + + if (existingHistoryItem != null){ + alreadyExists = true + it.status = DownloadRepository.Status.Processing.toString() + val id = runBlocking { + repository.insert(it) + } + existingItemIDs.add(AlreadyExistsIDs(id, existingHistoryItem.id)) + } + } + } + } + } + + if (!alreadyExists){ + if (it.id == 0L){ + val id = runBlocking { + repository.insert(it) + } + it.id = id + }else if (listOf(DownloadRepository.Status.Queued, DownloadRepository.Status.Scheduled).toListString().contains(it.status)){ + withContext(Dispatchers.IO){ + repository.update(it) + } + } + + queuedItems.add(it) + } + + } + + if (existingItemIDs.isNotEmpty()){ + alreadyExistsUiState.value = existingItemIDs.toList() + } + + + //if scheduler is on + val useScheduler = sharedPreferences.getBoolean("use_scheduler", false) + if (useScheduler && !alarmScheduler.isDuringTheScheduledTime()){ + if (alarmScheduler.canSchedule()){ + alarmScheduler.schedule() + }else{ + sharedPreferences.edit().putBoolean("use_scheduler", false).apply() + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, context.getString(R.string.enable_alarm_permission), Toast.LENGTH_LONG).show() + } + } + }else{ + if (queuedItems.isNotEmpty()){ + repository.startDownloadWorker(queuedItems, context) + + if(!useScheduler){ + queuedItems.filter { it.downloadStartTime != 0L && (it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty()) }.forEach { + CoroutineScope(Dispatchers.IO).launch { + runCatching { + resultRepository.updateDownloadItem(it)?.apply { + repository.updateWithoutUpsert(this) + } + } + } + } + }else{ + queuedItems.filter { it.title.isEmpty() || it.author.isEmpty() || it.thumb.isEmpty() }.forEach { + CoroutineScope(Dispatchers.IO).launch { + runCatching { + resultRepository.updateDownloadItem(it)?.apply { + repository.updateWithoutUpsert(this) + } + } + } + } + } + } + } + + return existingItemIDs + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/receiver/CancelScheduleAlarmReceiver.kt b/app/src/main/java/com/deniscerri/ytdl/receiver/CancelScheduleAlarmReceiver.kt new file mode 100644 index 00000000..590bf6eb --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/receiver/CancelScheduleAlarmReceiver.kt @@ -0,0 +1,34 @@ +package com.deniscerri.ytdl.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.deniscerri.ytdl.work.CancelScheduledDownloadWorker +import com.deniscerri.ytdl.work.DownloadWorker +import java.util.concurrent.TimeUnit + +class CancelScheduleAlarmReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context?, p1: Intent?) { + ctx?.apply { + val workConstraints = Constraints.Builder() + val workRequest2 = OneTimeWorkRequestBuilder() + .addTag("cancelScheduledDownload") + .setConstraints(workConstraints.build()) + .setInitialDelay( 0L, TimeUnit.MILLISECONDS) + + WorkManager.getInstance(this).enqueueUniqueWork( + System.currentTimeMillis().toString(), + ExistingWorkPolicy.REPLACE, + workRequest2.build() + ) + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt b/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt new file mode 100644 index 00000000..01e45b4f --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/receiver/ScheduleAlarmReceiver.kt @@ -0,0 +1,38 @@ +package com.deniscerri.ytdl.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.deniscerri.ytdl.work.DownloadWorker +import java.util.concurrent.TimeUnit + +class ScheduleAlarmReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context?, p1: Intent?) { + ctx?.apply { + val workConstraints = Constraints.Builder() + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val allowMeteredNetworks = preferences.getBoolean("metered_networks", true) + if (!allowMeteredNetworks) workConstraints.setRequiredNetworkType(NetworkType.UNMETERED) + + + val workRequest = OneTimeWorkRequestBuilder() + .addTag("scheduledDownload") + .addTag("download") + .setConstraints(workConstraints.build()) + .setInitialDelay(0L, TimeUnit.MILLISECONDS) + + WorkManager.getInstance(this).enqueueUniqueWork( + System.currentTimeMillis().toString(), + ExistingWorkPolicy.REPLACE, + workRequest.build() + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/receiver/ShareActivity.kt b/app/src/main/java/com/deniscerri/ytdl/receiver/ShareActivity.kt index cfb1598e..911d8252 100644 --- a/app/src/main/java/com/deniscerri/ytdl/receiver/ShareActivity.kt +++ b/app/src/main/java/com/deniscerri/ytdl/receiver/ShareActivity.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.WindowManager import androidx.core.app.ActivityCompat +import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModelProvider @@ -34,9 +35,11 @@ import com.deniscerri.ytdl.MainActivity import com.deniscerri.ytdl.R import com.deniscerri.ytdl.database.viewmodel.CookieViewModel import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel +import com.deniscerri.ytdl.database.viewmodel.HistoryViewModel import com.deniscerri.ytdl.database.viewmodel.ResultViewModel import com.deniscerri.ytdl.ui.BaseActivity import com.deniscerri.ytdl.util.ThemeUtil +import com.deniscerri.ytdl.util.UiUtil import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -52,6 +55,7 @@ class ShareActivity : BaseActivity() { lateinit var context: Context private lateinit var resultViewModel: ResultViewModel + private lateinit var historyViewModel: HistoryViewModel private lateinit var downloadViewModel: DownloadViewModel private lateinit var cookieViewModel: CookieViewModel private lateinit var sharedPreferences: SharedPreferences @@ -115,6 +119,7 @@ class ShareActivity : BaseActivity() { context = baseContext resultViewModel = ViewModelProvider(this)[ResultViewModel::class.java] + historyViewModel = ViewModelProvider(this)[HistoryViewModel::class.java] downloadViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] cookieViewModel = ViewModelProvider(this)[CookieViewModel::class.java] sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) @@ -208,6 +213,18 @@ class ShareActivity : BaseActivity() { } this@ShareActivity.finish() } + + downloadViewModel.alreadyExistsUiState.collectLatest { res -> + if (res.isNotEmpty()){ + withContext(Dispatchers.Main){ + val bundle = bundleOf( + Pair("duplicates", res) + ) + navController.navigate(R.id.downloadsAlreadyExistDialog2, bundle) + } + downloadViewModel.alreadyExistsUiState.value = mutableListOf() + } + } } } } diff --git a/app/src/main/java/com/deniscerri/ytdl/services/ProcessDownloadsInBackgroundService.kt b/app/src/main/java/com/deniscerri/ytdl/services/ProcessDownloadsInBackgroundService.kt new file mode 100644 index 00000000..cff70e42 --- /dev/null +++ b/app/src/main/java/com/deniscerri/ytdl/services/ProcessDownloadsInBackgroundService.kt @@ -0,0 +1,136 @@ +package com.deniscerri.ytdl.services + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import androidx.core.content.IntentCompat +import com.deniscerri.ytdl.database.DBManager +import com.deniscerri.ytdl.database.models.DownloadItem +import com.deniscerri.ytdl.database.models.ResultItem +import com.deniscerri.ytdl.database.repository.DownloadRepository +import com.deniscerri.ytdl.database.repository.ResultRepository +import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel +import com.deniscerri.ytdl.database.viewmodel.SharedDownloadViewModel +import com.deniscerri.ytdl.util.NotificationUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + + +class ProcessDownloadsInBackgroundService : Service() { + + private val binder: IBinder = LocalBinder() + private val queueProcessingDownloadsJobList = mutableListOf() + private lateinit var repository: DownloadRepository + private lateinit var resultRepository: ResultRepository + private lateinit var downloadViewModel: SharedDownloadViewModel + inner class LocalBinder: Binder() { + val service: ProcessDownloadsInBackgroundService + get() = this@ProcessDownloadsInBackgroundService + } + + override fun onCreate() { + super.onCreate() + val dbManager = DBManager.getInstance(this) + repository = DownloadRepository(dbManager.downloadDao) + resultRepository = ResultRepository(dbManager.resultDao, this) + downloadViewModel = SharedDownloadViewModel(this) + } + + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val notificationUtil = NotificationUtil(this) + startForeground(System.currentTimeMillis().toInt(), notificationUtil.createProcessingDownloads()) + + val itemType = intent.getStringExtra("itemType") ?: "" + val itemIDs = intent.getLongArrayExtra("itemIDs") ?: longArrayOf() + val jobData = DownloadViewModel.ProcessingItemsJob( + itemType = itemType, + itemIDs = itemIDs.toList() + ) + val timeInMillis = intent.getLongExtra("timeInMillis", 0) + CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { + runJob(jobData, timeInMillis) + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + private fun DownloadItem.setAsScheduling(timeInMillis: Long) { + status = DownloadRepository.Status.Scheduled.toString() + downloadStartTime = timeInMillis + } + + private suspend fun runJob(jobData: DownloadViewModel.ProcessingItemsJob, timeInMillis: Long = 0) { + val job = CoroutineScope(SupervisorJob()).launch(Dispatchers.IO) { + if (jobData.itemType != "") { + val itemIDS = jobData.itemIDs + val processingType = jobData.itemType + when(processingType) { + ResultItem::class.java.toString() -> { + itemIDS.chunked(100).map { ids -> + resultRepository.getAllByIDs(ids).map { + downloadViewModel.createDownloadItemFromResult( + result = it, givenType = DownloadViewModel.Type.valueOf( + downloadViewModel.getDownloadType(url = it.url).toString() + ) + ) + }.apply { + if (timeInMillis > 0) { + this.forEach { + it.setAsScheduling(timeInMillis) + } + } + downloadViewModel.queueDownloads(this) + } + } + } + + DownloadItem::class.java.toString() -> { + itemIDS.chunked(100).map { ids -> + repository.getAllItemsByIDs(ids).apply { + if (timeInMillis > 0) { + this.forEach { + it.setAsScheduling(timeInMillis) + } + } + downloadViewModel.queueDownloads(this) + } + } + } + } + }else { + repository.getProcessingDownloads().apply { + if (timeInMillis > 0){ + this.forEach { + it.setAsScheduling(timeInMillis) + } + } + + downloadViewModel.queueDownloads(this) + } + } + } + job.invokeOnCompletion { + queueProcessingDownloadsJobList.remove(job) + if (queueProcessingDownloadsJobList.isEmpty()){ + stopForeground(true) + stopSelf() + } + } + queueProcessingDownloadsJobList.add(job) + } + + fun cancelAllProcessingJobs(){ + queueProcessingDownloadsJobList.onEach { it.cancel(CancellationException()) } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/HomeFragment.kt b/app/src/main/java/com/deniscerri/ytdl/ui/HomeFragment.kt index 65424e31..06d6a4a3 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/HomeFragment.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/HomeFragment.kt @@ -20,6 +20,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.constraintlayout.widget.ConstraintLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.os.bundleOf import androidx.core.view.children import androidx.core.view.forEach import androidx.core.view.isVisible @@ -59,12 +60,14 @@ import com.google.android.material.search.SearchBar import com.google.android.material.search.SearchView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.* +import kotlin.collections.ArrayList class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggestionsAdapter.OnItemClickListener, OnClickListener { @@ -76,7 +79,7 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti private var downloadSelectedFab: ExtendedFloatingActionButton? = null private var downloadAllFab: ExtendedFloatingActionButton? = null private var clipboardFab: ExtendedFloatingActionButton? = null - private var homeFabs: CoordinatorLayout? = null + private var homeFabs: LinearLayout? = null private var infoUtil: InfoUtil? = null private var downloadQueue: ArrayList? = null @@ -216,12 +219,6 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti inputQueries!!.addAll(argList) } - if(arguments?.getBoolean("search") == true){ - requireView().post { - searchBar?.performClick() - } - } - if (inputQueries != null) { lifecycleScope.launch(Dispatchers.IO){ resultViewModel.deleteAll() @@ -276,22 +273,15 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti lifecycleScope.launch { launch{ downloadViewModel.alreadyExistsUiState.collectLatest { res -> - if (res.downloadItems.isNotEmpty() || res.historyItems.isNotEmpty()) { + if (res.isNotEmpty()){ withContext(Dispatchers.Main){ - kotlin.runCatching { - UiUtil.handleExistingDownloadsResponse( - requireActivity(), - requireActivity().lifecycleScope, - requireActivity().supportFragmentManager, - res, - downloadViewModel, - historyViewModel) - } + val bundle = bundleOf( + Pair("duplicates", ArrayList(res)) + ) + delay(500) + findNavController().navigate(R.id.downloadsAlreadyExistDialog, bundle) } - downloadViewModel.alreadyExistsUiState.value = DownloadViewModel.AlreadyExistsUIState( - mutableListOf(), - mutableListOf() - ) + downloadViewModel.alreadyExistsUiState.value = mutableListOf() } } } @@ -313,15 +303,24 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti arguments?.remove("showDownloadsWithUpdatedFormats") CoroutineScope(Dispatchers.IO).launch { val ids = arguments?.getLongArray("downloadIds") ?: return@launch - downloadViewModel.updateItemsWithIdsToProcessingStatus(ids.toList()) + val jobID = downloadViewModel.turnDownloadItemsToProcessingDownloads(ids.toList()) withContext(Dispatchers.Main){ - findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2) + findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2, bundleOf( + Pair("processingDownloadsJobID", jobID) + )) } } } + + if(arguments?.getBoolean("search") == true){ + arguments?.remove("search") + requireView().post { + searchBar?.performClick() + } + } if (searchView?.currentTransitionState == SearchView.TransitionState.SHOWN){ - updateSearchViewItems(searchView?.editText?.text) + updateSearchViewItems(searchView?.editText?.text.toString()) } requireView().post { @@ -405,13 +404,13 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti chipGroupDivider?.visibility = VISIBLE } - updateSearchViewItems(searchView!!.editText.text) + updateSearchViewItems(searchView!!.editText.text.toString()) } } searchView!!.editText.doAfterTextChanged { if (searchView!!.currentTransitionState != SearchView.TransitionState.SHOWN) return@doAfterTextChanged - updateSearchViewItems(it) + updateSearchViewItems(it.toString()) } searchView!!.editText.setOnTouchListener(OnTouchListener { _, event -> @@ -454,7 +453,11 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti searchBar!!.setOnMenuItemClickListener { m: MenuItem -> when (m.itemId) { R.id.delete_results -> { - resultViewModel.cancelParsingQueries() + lifecycleScope.launch { + withContext(Dispatchers.IO){ + resultViewModel.cancelParsingQueries() + } + } resultViewModel.getTrending() selectedObjects = ArrayList() searchBar!!.setText("") @@ -479,7 +482,7 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti } @SuppressLint("InflateParams") - private fun updateSearchViewItems(searchQuery: Editable?) = lifecycleScope.launch(Dispatchers.Main) { + private fun updateSearchViewItems(searchQuery: String) = lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch { if (searchView!!.editText.text.isEmpty()){ searchView!!.editText.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) @@ -490,13 +493,13 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti val combinedList = mutableListOf() val history = withContext(Dispatchers.IO){ - resultViewModel.getSearchHistory().map { it.query }.filter { it.contains(searchQuery!!) } + resultViewModel.getSearchHistory().map { it.query }.filter { it.contains(searchQuery) } }.map { SearchSuggestionItem(it, SearchSuggestionType.HISTORY) } val suggestions = if (sharedPreferences!!.getBoolean("search_suggestions", false)){ withContext(Dispatchers.IO){ - infoUtil!!.getSearchSuggestions(searchQuery.toString()) + infoUtil!!.getSearchSuggestions(searchQuery) } }else{ emptyList() @@ -509,12 +512,12 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti val url = checkClipboard() url?.apply { - if (this.isNotEmpty()){ + var alreadyHasThem = this.all { queriesChipGroup?.children?.any { c -> (c as Chip).text.contains(it) } == true } + if (this.isNotEmpty() && !alreadyHasThem){ combinedList.add(0, SearchSuggestionItem(this.joinToString("\n"), SearchSuggestionType.CLIPBOARD)) } } - searchSuggestionsAdapter?.submitList(combinedList) // history.forEach { s -> @@ -729,21 +732,12 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti } catch (e: Exception) {""} if (viewIdName.isNotEmpty()) { if (viewIdName == "downloadAll") { - lifecycleScope.launch { - val downloadList = withContext(Dispatchers.IO){ - downloadViewModel.turnResultItemsToDownloadItems(resultsList!!) - } - if (sharedPreferences!!.getBoolean("download_card", true)) { - CoroutineScope(Dispatchers.IO).launch { - downloadViewModel.insertToProcessing(downloadList) - } - - findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2) - } else { - downloadList.chunked(100).forEach { - downloadViewModel.queueDownloads(it) - } - } + val showDownloadCard = sharedPreferences!!.getBoolean("download_card", true) + val jobID = downloadViewModel.turnResultItemsToProcessingDownloads(resultsList!!.map { it!!.id }, downloadNow = !showDownloadCard) + if (showDownloadCard){ + findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2, bundleOf( + Pair("processingDownloadsJobID", jobID) + )) } } } @@ -806,24 +800,18 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti } R.id.download -> { lifecycleScope.launch { - if (sharedPreferences!!.getBoolean("download_card", true) && selectedObjects.size == 1) { + val showDownloadCard = sharedPreferences!!.getBoolean("download_card", true) + if (showDownloadCard && selectedObjects.size == 1) { showSingleDownloadSheet( selectedObjects[0], downloadViewModel.getDownloadType(url = selectedObjects[0].url) ) }else{ - val downloadList = withContext(Dispatchers.IO){ - downloadViewModel.turnResultItemsToDownloadItems(selectedObjects) - } - - if (sharedPreferences!!.getBoolean("download_card", true)) { - CoroutineScope(Dispatchers.IO).launch { - downloadViewModel.insertToProcessing(downloadList) - } - - findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2) - } else { - downloadViewModel.queueDownloads(downloadList) + val jobID = downloadViewModel.turnResultItemsToProcessingDownloads(selectedObjects.map { it.id }, downloadNow = !showDownloadCard) + if (showDownloadCard){ + findNavController().navigate(R.id.downloadMultipleBottomSheetDialog2, bundleOf( + Pair("processingDownloadsJobID", jobID) + )) } } clearCheckedItems() @@ -835,7 +823,7 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti homeAdapter?.checkAll(resultsList) selectedObjects.clear() resultsList?.forEach { selectedObjects.add(it!!) } - mode?.title = getString(R.string.all_items_selected) + mode?.title = "(${selectedObjects.size}) ${resources.getString(R.string.all_items_selected)}" true } R.id.invert_selected -> { @@ -905,14 +893,14 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti } - override fun onSearchSuggestionAdd(t: String) { - val items = t.split("\n") + override fun onSearchSuggestionAdd(text: String) { + val items = text.split("\n") - items.forEach {text -> - val present = queriesChipGroup!!.children.firstOrNull { (it as Chip).text.toString() == text } + items.forEach {t -> + val present = queriesChipGroup!!.children.firstOrNull { (it as Chip).text.toString() == t } if (present == null) { val chip = layoutinflater!!.inflate(R.layout.input_chip, queriesChipGroup, false) as Chip - chip.text = text + chip.text = t chip.chipBackgroundColor = ColorStateList.valueOf(MaterialColors.getColor(requireContext(), R.attr.colorSecondaryContainer, Color.BLACK)) chip.setOnClickListener { if (queriesChipGroup!!.childCount == 1) queriesConstraint!!.visibility = View.GONE @@ -924,17 +912,14 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti } searchView!!.editText.setText("") - if (queriesChipGroup!!.childCount == 0) queriesConstraint!!.visibility = View.GONE - else queriesConstraint!!.visibility = View.VISIBLE + queriesConstraint?.isVisible = queriesChipGroup?.childCount!! > 0 - val clipBoardItem = searchSuggestionsRecyclerView?.layoutManager?.findViewByPosition(0) - clipBoardItem?.apply { - if ((this as ConstraintLayout).findViewById(R.id.suggestion_text).text == getString(R.string.link_you_copied)){ - searchSuggestionsAdapter?.notifyItemRemoved(0) + searchSuggestionsAdapter?.getList()?.apply { + if (this.first().type == SearchSuggestionType.CLIPBOARD){ + val newList = this.toMutableList().drop(1) + searchSuggestionsAdapter?.submitList(newList) } } - - } override fun onSearchSuggestionLongClick(text: String, position: Int) { @@ -943,7 +928,7 @@ class HomeFragment : Fragment(), HomeAdapter.OnItemClickListener, SearchSuggesti deleteDialog.setNegativeButton(getString(R.string.cancel)) { dialogInterface: DialogInterface, _: Int -> dialogInterface.cancel() } deleteDialog.setPositiveButton(getString(R.string.ok)) { _: DialogInterface?, _: Int -> resultViewModel.removeSearchQueryFromHistory(text) - updateSearchViewItems(searchView!!.editText.text) + updateSearchViewItems(searchView!!.editText.text.toString()) } deleteDialog.show() } diff --git a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AlreadyExistsAdapter.kt b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AlreadyExistsAdapter.kt index 0fa2593a..d429e2a5 100644 --- a/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AlreadyExistsAdapter.kt +++ b/app/src/main/java/com/deniscerri/ytdl/ui/adapter/AlreadyExistsAdapter.kt @@ -2,27 +2,34 @@ package com.deniscerri.ytdl.ui.adapter import android.app.Activity import android.content.SharedPreferences +import android.os.Build import android.os.Handler import android.os.Looper import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView +import android.widget.PopupMenu import android.widget.TextView +import androidx.core.view.isVisible import androidx.preference.PreferenceManager import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.deniscerri.ytdl.R +import com.deniscerri.ytdl.database.models.AlreadyExistsItem import com.deniscerri.ytdl.database.models.DownloadItem +import com.deniscerri.ytdl.database.viewmodel.DownloadViewModel import com.deniscerri.ytdl.util.Extensions.loadThumbnail import com.deniscerri.ytdl.util.Extensions.popup import com.deniscerri.ytdl.util.FileUtil +import com.deniscerri.ytdl.util.UiUtil import com.google.android.material.card.MaterialCardView -class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: Activity) : ListAdapter, AlreadyExistsAdapter.ViewHolder>(AsyncDifferConfig.Builder( +class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: Activity) : ListAdapter(AsyncDifferConfig.Builder( DIFF_CALLBACK ).build()) { private val onItemClickListener: OnItemClickListener @@ -39,7 +46,7 @@ class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: A val cardView: MaterialCardView init { - cardView = itemView as MaterialCardView + cardView = itemView.findViewById(R.id.download_card_view) } } @@ -51,12 +58,9 @@ class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: A override fun onBindViewHolder(holder: ViewHolder, position: Int) { val alreadyExistsItem = getItem(position) ?: return - val item = alreadyExistsItem.first - val historyID = alreadyExistsItem.second - val card = holder.cardView - card.tag = item.id.toString() - card.popup() + card.tag = alreadyExistsItem.downloadItem.id.toString() + val item = alreadyExistsItem.downloadItem val uiHandler = Handler(Looper.getMainLooper()) val thumbnail = card.findViewById(R.id.downloads_image_view) @@ -76,6 +80,20 @@ class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: A } itemTitle.text = title.ifEmpty { item.url } + //DOWNLOAD TYPE ----------------------------- + val type = card.findViewById(R.id.download_type) + when(item.type){ + DownloadViewModel.Type.audio -> type.setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_music_formatcard, 0,0,0 + ) + DownloadViewModel.Type.video -> type.setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_video_formatcard, 0,0,0 + ) + else -> type.setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_terminal_formatcard, 0,0,0 + ) + } + val formatNote = card.findViewById(R.id.format_note) if (item.format.format_note == "?" || item.format.format_note == "") formatNote!!.visibility = View.GONE @@ -102,45 +120,64 @@ class AlreadyExistsAdapter(onItemClickListener: OnItemClickListener, activity: A if (fileSizeReadable == "?") fileSize.visibility = View.GONE else fileSize.text = fileSizeReadable - val editBtn = card.findViewById