mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-12-05 01:10:43 +00:00
Compare commits
83 Commits
fix-timest
...
03e963952c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e963952c | ||
|
|
e5ed0b529f | ||
|
|
0131bb227f | ||
|
|
b06b7c35ca | ||
|
|
0a0f28e801 | ||
|
|
4a8592c5ba | ||
|
|
8f91f21f27 | ||
|
|
2ba87f7979 | ||
|
|
f4d138d06f | ||
|
|
6c95c9c7dd | ||
|
|
153e4820e7 | ||
|
|
93f03bab87 | ||
|
|
9702189be4 | ||
|
|
85bd7c3351 | ||
|
|
b7bf07d5e4 | ||
|
|
89a68d0789 | ||
|
|
eaafdb2570 | ||
|
|
f836f5e75d | ||
|
|
4826e5b3c5 | ||
|
|
97e7272151 | ||
|
|
7c76791db3 | ||
|
|
995a92b7a4 | ||
|
|
05b9ff49a2 | ||
|
|
4422b55ab4 | ||
|
|
2fadaffb98 | ||
|
|
1314a21f71 | ||
|
|
650b51ffec | ||
|
|
c03f405f8c | ||
|
|
0a89276b7a | ||
|
|
300afde83d | ||
|
|
d311faea58 | ||
|
|
71aa6d52d3 | ||
|
|
88eb32be3a | ||
|
|
2a9c6f0538 | ||
|
|
c81148ae0a | ||
|
|
ecd3e85d49 | ||
|
|
f3ca5f659d | ||
|
|
300f5abc70 | ||
|
|
729702b420 | ||
|
|
42f909936b | ||
|
|
c8e294b1a3 | ||
|
|
c4e6e4d4c4 | ||
|
|
41981902ab | ||
|
|
56a09220ee | ||
|
|
c4bfc119df | ||
|
|
c49e44443c | ||
|
|
1014dd563f | ||
|
|
ee01ba3209 | ||
|
|
3516667671 | ||
|
|
22ee01bcfb | ||
|
|
1bef2fdc25 | ||
|
|
061ce870ac | ||
|
|
15089245bb | ||
|
|
d99435c4ad | ||
|
|
320c693636 | ||
|
|
09e4bea205 | ||
|
|
fbc664d0da | ||
|
|
2dd4509b75 | ||
|
|
eee1172e8a | ||
|
|
0ebd01e65e | ||
|
|
fd4f4737c2 | ||
|
|
965eea2124 | ||
|
|
59dfdda95e | ||
|
|
3a2d427a46 | ||
|
|
c25f83da6c | ||
|
|
e2026dc378 | ||
|
|
00f6203904 | ||
|
|
980e8f3951 | ||
|
|
4e9a480fdd | ||
|
|
aa2b4821e2 | ||
|
|
92a07a3445 | ||
|
|
eed09f8a1d | ||
|
|
fd3f030d0b | ||
|
|
45c22c0db8 | ||
|
|
2b7c72eb69 | ||
|
|
89c4eb5237 | ||
|
|
803aba4935 | ||
|
|
1723bf0e8a | ||
|
|
21e24c9e34 | ||
|
|
83a0abddcc | ||
|
|
e1888ede87 | ||
|
|
2c35db7a07 | ||
|
|
9282cce6a8 |
44
.editorconfig
Normal file
44
.editorconfig
Normal file
@@ -0,0 +1,44 @@
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
|
||||
root = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_standard_annotation = disabled
|
||||
ktlint_standard_argument-list-wrapping = disabled
|
||||
ktlint_standard_backing-property-naming = disabled
|
||||
ktlint_standard_blank-line-before-declaration = disabled
|
||||
ktlint_standard_blank-line-between-when-conditions = disabled
|
||||
ktlint_standard_chain-method-continuation = disabled
|
||||
ktlint_standard_class-signature = disabled
|
||||
ktlint_standard_comment-wrapping = disabled
|
||||
ktlint_standard_enum-wrapping = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-literal = disabled
|
||||
ktlint_standard_function-signature = disabled
|
||||
ktlint_standard_indent = disabled
|
||||
ktlint_standard_kdoc = disabled
|
||||
ktlint_standard_max-line-length = disabled
|
||||
ktlint_standard_mixed-condition-operators = disabled
|
||||
ktlint_standard_multiline-expression-wrapping = disabled
|
||||
ktlint_standard_multiline-if-else = disabled
|
||||
ktlint_standard_no-blank-line-in-list = disabled
|
||||
ktlint_standard_no-consecutive-comments = disabled
|
||||
ktlint_standard_no-empty-first-line-in-class-body = disabled
|
||||
ktlint_standard_no-empty-first-line-in-method-block = disabled
|
||||
ktlint_standard_no-line-break-after-else = disabled
|
||||
ktlint_standard_no-semi = disabled
|
||||
ktlint_standard_no-single-line-block-comment = disabled
|
||||
ktlint_standard_package-name = disabled
|
||||
ktlint_standard_parameter-list-wrapping = disabled
|
||||
ktlint_standard_property-naming = disabled
|
||||
ktlint_standard_spacing-between-declarations-with-annotations = disabled
|
||||
ktlint_standard_spacing-between-declarations-with-comments = disabled
|
||||
ktlint_standard_statement-wrapping = disabled
|
||||
ktlint_standard_string-template-indent = disabled
|
||||
ktlint_standard_trailing-comma-on-call-site = disabled
|
||||
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
||||
ktlint_standard_try-catch-finally-spacing = disabled
|
||||
ktlint_standard_when-entry-bracing = disabled
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
#### What is it?
|
||||
- [ ] Bugfix (user facing)
|
||||
- [ ] Feature (user facing)
|
||||
- [ ] Feature (user facing) ⚠️ **Your PR must target the [`refactor`](https://github.com/TeamNewPipe/NewPipe/tree/refactor) branch**
|
||||
- [ ] Codebase improvement (dev facing)
|
||||
- [ ] Meta improvement to the project (dev facing)
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ captures/
|
||||
*.iml
|
||||
*~
|
||||
.weblate
|
||||
.kotlin
|
||||
*.class
|
||||
app/debug/
|
||||
app/release/
|
||||
|
||||
360
app/build.gradle
360
app/build.gradle
@@ -1,360 +0,0 @@
|
||||
import com.android.tools.profgen.ArtProfileKt
|
||||
import com.android.tools.profgen.ArtProfileSerializer
|
||||
import com.android.tools.profgen.DexFile
|
||||
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "kotlin-kapt"
|
||||
id "kotlin-parcelize"
|
||||
id "checkstyle"
|
||||
id "org.sonarqube" version "4.0.0.2929"
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 36
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 21
|
||||
targetSdk 35
|
||||
if (System.properties.containsKey('versionCodeOverride')) {
|
||||
versionCode System.getProperty('versionCodeOverride') as Integer
|
||||
} else {
|
||||
versionCode 1005
|
||||
}
|
||||
versionName "0.28.0"
|
||||
if (System.properties.containsKey('versionNameSuffix')) {
|
||||
versionNameSuffix System.getProperty('versionNameSuffix')
|
||||
}
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
|
||||
// suffix the app id and the app name with git branch name
|
||||
def workingBranch = getGitWorkingBranch()
|
||||
def normalizedWorkingBranch = workingBranch.replaceFirst("^[^A-Za-z]+", "").replaceAll("[^0-9A-Za-z]+", "")
|
||||
if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") {
|
||||
// default values when branch name could not be determined or is master or dev
|
||||
applicationIdSuffix ".debug"
|
||||
resValue "string", "app_name", "NewPipe Debug"
|
||||
} else {
|
||||
applicationIdSuffix ".debug." + normalizedWorkingBranch
|
||||
resValue "string", "app_name", "NewPipe " + workingBranch
|
||||
archivesBaseName = 'NewPipe_' + normalizedWorkingBranch
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
if (System.properties.containsKey('packageSuffix')) {
|
||||
applicationIdSuffix System.getProperty('packageSuffix')
|
||||
resValue "string", "app_name", "NewPipe " + System.getProperty('packageSuffix')
|
||||
archivesBaseName = 'NewPipe_' + System.getProperty('packageSuffix')
|
||||
}
|
||||
minifyEnabled true
|
||||
shrinkResources false // disabled to fix F-Droid's reproducible build
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
archivesBaseName = 'app'
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds false
|
||||
// Or, if you prefer, you can continue to check for errors in release builds,
|
||||
// but continue the build even when errors are found:
|
||||
abortOnError false
|
||||
// suppress false warning ("Resource IDs will be non-final in Android Gradle Plugin version
|
||||
// 5.0, avoid using them in switch case statements"), which affects only library projects
|
||||
disable 'NonConstantResourceId'
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// Flag to enable support for the new language APIs
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
encoding 'utf-8'
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
// remove two files which belong to jsoup
|
||||
// no idea how they ended up in the META-INF dir...
|
||||
excludes += ['META-INF/README.md', 'META-INF/CHANGES',
|
||||
// 'COPYRIGHT' belongs to RxJava...
|
||||
'META-INF/COPYRIGHT']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
checkstyleVersion = '10.12.1'
|
||||
|
||||
androidxLifecycleVersion = '2.6.2'
|
||||
androidxRoomVersion = '2.6.1'
|
||||
androidxWorkVersion = '2.8.1'
|
||||
|
||||
stateSaverVersion = '1.4.1'
|
||||
exoPlayerVersion = '2.18.7'
|
||||
googleAutoServiceVersion = '1.1.1'
|
||||
groupieVersion = '2.10.1'
|
||||
markwonVersion = '4.6.2'
|
||||
|
||||
leakCanaryVersion = '2.12'
|
||||
stethoVersion = '1.6.0'
|
||||
}
|
||||
|
||||
configurations {
|
||||
checkstyle
|
||||
ktlint
|
||||
}
|
||||
|
||||
checkstyle {
|
||||
getConfigDirectory().set(rootProject.file("checkstyle"))
|
||||
ignoreFailures false
|
||||
showViolations true
|
||||
toolVersion = checkstyleVersion
|
||||
}
|
||||
|
||||
tasks.register('runCheckstyle', Checkstyle) {
|
||||
source 'src'
|
||||
include '**/*.java'
|
||||
exclude '**/gen/**'
|
||||
exclude '**/R.java'
|
||||
exclude '**/BuildConfig.java'
|
||||
exclude 'main/java/us/shandian/giga/**'
|
||||
|
||||
classpath = configurations.checkstyle
|
||||
|
||||
showViolations true
|
||||
|
||||
reports {
|
||||
xml.getRequired().set(true)
|
||||
html.getRequired().set(true)
|
||||
}
|
||||
}
|
||||
|
||||
def outputDir = "${project.buildDir}/reports/ktlint/"
|
||||
def inputFiles = project.fileTree(dir: "src", include: "**/*.kt")
|
||||
|
||||
tasks.register('runKtlint', JavaExec) {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "src/**/*.kt"
|
||||
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.register('formatKtlint', JavaExec) {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.ktlint
|
||||
args "-F", "src/**/*.kt"
|
||||
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
if (!System.properties.containsKey('skipFormatKtlint')) {
|
||||
preDebugBuild.dependsOn formatKtlint
|
||||
}
|
||||
preDebugBuild.dependsOn runCheckstyle, runKtlint
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property "sonar.projectKey", "TeamNewPipe_NewPipe"
|
||||
property "sonar.organization", "teamnewpipe"
|
||||
property "sonar.host.url", "https://sonarcloud.io"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Desugaring **/
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
|
||||
|
||||
/** NewPipe libraries **/
|
||||
// You can use a local version by uncommenting a few lines in settings.gradle
|
||||
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
|
||||
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
|
||||
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
|
||||
// If there’s already a git hash, just add more of it to the end (or remove a letter)
|
||||
// to cause jitpack to regenerate the artifact.
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:0023b22095a2d62a60cdfc87f4b5cd85c8b266c3'
|
||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
ktlint 'com.pinterest:ktlint:0.45.2'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
|
||||
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.preference:preference:1.2.1'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||
kapt "androidx.room:room-compiler:${androidxRoomVersion}"
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
// Newer version specified to prevent accessibility regressions with RecyclerView, see:
|
||||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation "androidx.webkit:webkit:1.9.0"
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
implementation 'com.github.livefront:bridge:v2.0.2'
|
||||
implementation "com.evernote:android-state:$stateSaverVersion"
|
||||
kapt "com.evernote:android-state-processor:$stateSaverVersion"
|
||||
|
||||
// HTML parser
|
||||
implementation "org.jsoup:jsoup:1.17.2"
|
||||
|
||||
// HTTP client
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
|
||||
// Media player
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-dash:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-database:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-datasource:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-hls:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:exoplayer-ui:${exoPlayerVersion}"
|
||||
implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}"
|
||||
|
||||
// Metadata generator for service descriptors
|
||||
compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}"
|
||||
kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}"
|
||||
|
||||
// Manager for complex RecyclerView layouts
|
||||
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||
|
||||
// Image loading
|
||||
//noinspection NewerVersionAvailable,GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
implementation "com.squareup.picasso:picasso:2.8"
|
||||
|
||||
// Markdown library for Android
|
||||
implementation "io.noties.markwon:core:${markwonVersion}"
|
||||
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
||||
|
||||
// Crash reporting
|
||||
implementation "ch.acra:acra-core:5.11.3"
|
||||
|
||||
// Properly restarting
|
||||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||
|
||||
// Reactive extensions for Java VM
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.1.8"
|
||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
|
||||
// RxJava binding APIs for Android UI widgets
|
||||
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
|
||||
|
||||
// Date and time formatting
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:leakcanary-android-core:${leakCanaryVersion}"
|
||||
// Debug bridge for Android
|
||||
debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
|
||||
debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
|
||||
|
||||
/** Testing **/
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.6.0'
|
||||
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.5"
|
||||
androidTestImplementation "androidx.test:runner:1.5.2"
|
||||
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
|
||||
androidTestImplementation "org.assertj:assertj-core:3.24.2"
|
||||
}
|
||||
|
||||
static String getGitWorkingBranch() {
|
||||
try {
|
||||
def gitProcess = "git rev-parse --abbrev-ref HEAD".execute()
|
||||
gitProcess.waitFor()
|
||||
if (gitProcess.exitValue() == 0) {
|
||||
return gitProcess.text.trim()
|
||||
} else {
|
||||
// not a git repository
|
||||
return ""
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// git was not found
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// fix reproducible builds
|
||||
project.afterEvaluate {
|
||||
tasks.compileReleaseArtProfile.doLast {
|
||||
outputs.files.each { file ->
|
||||
if (file.toString().endsWith(".profm")) {
|
||||
println("Sorting ${file} ...")
|
||||
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||
def profile = ArtProfileKt.ArtProfile(file)
|
||||
def keys = new ArrayList(profile.profileData.keySet())
|
||||
def sortedData = new LinkedHashMap()
|
||||
Collections.sort keys, new DexFile.Companion()
|
||||
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||
new FileOutputStream(file).with {
|
||||
write(version.magicBytes$profgen)
|
||||
write(version.versionBytes$profgen)
|
||||
version.write$profgen(it, sortedData, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
308
app/build.gradle.kts
Normal file
308
app/build.gradle.kts
Normal file
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.jetbrains.kotlin.kapt)
|
||||
alias(libs.plugins.google.ksp)
|
||||
alias(libs.plugins.jetbrains.kotlin.parcelize)
|
||||
alias(libs.plugins.sonarqube)
|
||||
checkstyle
|
||||
}
|
||||
|
||||
val gitWorkingBranch = providers.exec {
|
||||
commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}.standardOutput.asText.map { it.trim() }
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
// TODO: Drop annotation default target when it is stable
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xannotation-default-target=param-property"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "org.schabi.newpipe"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.schabi.newpipe"
|
||||
resValue("string", "app_name", "NewPipe")
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
|
||||
versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1005
|
||||
|
||||
versionName = "0.28.0"
|
||||
System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
|
||||
// suffix the app id and the app name with git branch name
|
||||
val defaultBranches = listOf("master", "dev")
|
||||
val workingBranch = gitWorkingBranch.getOrElse("")
|
||||
val normalizedWorkingBranch = workingBranch
|
||||
.replaceFirst("^[^A-Za-z]+".toRegex(), "")
|
||||
.replace("[^0-9A-Za-z]+".toRegex(), "")
|
||||
|
||||
if (normalizedWorkingBranch.isEmpty() || workingBranch in defaultBranches) {
|
||||
// default values when branch name could not be determined or is master or dev
|
||||
applicationIdSuffix = ".debug"
|
||||
resValue("string", "app_name", "NewPipe Debug")
|
||||
} else {
|
||||
applicationIdSuffix = ".debug.$normalizedWorkingBranch"
|
||||
resValue("string", "app_name", "NewPipe $workingBranch")
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
System.getProperty("packageSuffix")?.let { suffix ->
|
||||
applicationIdSuffix = suffix
|
||||
resValue("string", "app_name", "NewPipe $suffix")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = false // disabled to fix F-Droid"s reproducible build
|
||||
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
// Or, if you prefer, you can continue to check for errors in release builds,
|
||||
// but continue the build even when errors are found:
|
||||
abortOnError = false
|
||||
// suppress false warning ("Resource IDs will be non-final in Android Gradle Plugin version
|
||||
// 5.0, avoid using them in switch case statements"), which affects only library projects
|
||||
disable += "NonConstantResourceId"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// Flag to enable support for the new language APIs
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
encoding = "utf-8"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDir("$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// remove two files which belong to jsoup
|
||||
// no idea how they ended up in the META-INF dir...
|
||||
excludes += setOf(
|
||||
"META-INF/README.md",
|
||||
"META-INF/CHANGES",
|
||||
"META-INF/COPYRIGHT" // "COPYRIGHT" belongs to RxJava...
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
|
||||
// Custom dependency configuration for ktlint
|
||||
val ktlint by configurations.creating
|
||||
|
||||
checkstyle {
|
||||
configDirectory = rootProject.file("checkstyle")
|
||||
isIgnoreFailures = false
|
||||
isShowViolations = true
|
||||
toolVersion = libs.versions.checkstyle.get()
|
||||
}
|
||||
|
||||
tasks.register<Checkstyle>("runCheckstyle") {
|
||||
source("src")
|
||||
include("**/*.java")
|
||||
exclude("**/gen/**")
|
||||
exclude("**/R.java")
|
||||
exclude("**/BuildConfig.java")
|
||||
exclude("main/java/us/shandian/giga/**")
|
||||
|
||||
classpath = configurations.getByName("checkstyle")
|
||||
|
||||
isShowViolations = true
|
||||
|
||||
reports {
|
||||
xml.required = true
|
||||
html.required = true
|
||||
}
|
||||
}
|
||||
|
||||
val outputDir = project.layout.buildDirectory.dir("reports/ktlint/")
|
||||
val inputFiles = fileTree("src") { include("**/*.kt") }
|
||||
|
||||
tasks.register<JavaExec>("runKtlint") {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.getByName("ktlint")
|
||||
args = listOf("--editorconfig=../.editorconfig", "src/**/*.kt")
|
||||
jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.register<JavaExec>("formatKtlint") {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.getByName("ktlint")
|
||||
args = listOf("--editorconfig=../.editorconfig", "-F", "src/**/*.kt")
|
||||
jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.register<CheckDependenciesOrder>("checkDependenciesOrder") {
|
||||
tomlFile = layout.projectDirectory.file("../gradle/libs.versions.toml")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preDebugBuild").configure {
|
||||
if (!System.getProperties().containsKey("skipFormatKtlint")) {
|
||||
dependsOn("formatKtlint")
|
||||
}
|
||||
dependsOn("runCheckstyle", "runKtlint", "checkDependenciesOrder")
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "TeamNewPipe_NewPipe")
|
||||
property("sonar.organization", "teamnewpipe")
|
||||
property("sonar.host.url", "https://sonarcloud.io")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Desugaring **/
|
||||
coreLibraryDesugaring(libs.android.desugar)
|
||||
|
||||
/** NewPipe libraries **/
|
||||
implementation(libs.newpipe.nanojson)
|
||||
implementation(libs.newpipe.extractor)
|
||||
implementation(libs.newpipe.filepicker)
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle(libs.puppycrawl.checkstyle)
|
||||
ktlint(libs.pinterest.ktlint)
|
||||
|
||||
/** AndroidX **/
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.lifecycle.livedata)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.localbroadcastmanager)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.rxjava3)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.androidx.work.rxjava3)
|
||||
implementation(libs.google.android.material)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
/** Third-party libraries **/
|
||||
implementation(libs.livefront.bridge)
|
||||
implementation(libs.evernote.statesaver.core)
|
||||
kapt(libs.evernote.statesaver.compiler)
|
||||
|
||||
// HTML parser
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// HTTP client
|
||||
implementation(libs.squareup.okhttp)
|
||||
|
||||
// Media player
|
||||
implementation(libs.google.exoplayer.core)
|
||||
implementation(libs.google.exoplayer.dash)
|
||||
implementation(libs.google.exoplayer.database)
|
||||
implementation(libs.google.exoplayer.datasource)
|
||||
implementation(libs.google.exoplayer.hls)
|
||||
implementation(libs.google.exoplayer.mediasession)
|
||||
implementation(libs.google.exoplayer.smoothstreaming)
|
||||
implementation(libs.google.exoplayer.ui)
|
||||
|
||||
// Metadata generator for service descriptors
|
||||
compileOnly(libs.google.autoservice.annotations)
|
||||
ksp(libs.google.autoservice.compiler)
|
||||
|
||||
// Manager for complex RecyclerView layouts
|
||||
implementation(libs.lisawray.groupie.core)
|
||||
implementation(libs.lisawray.groupie.viewbinding)
|
||||
|
||||
// Image loading
|
||||
implementation(libs.squareup.picasso)
|
||||
|
||||
// Markdown library for Android
|
||||
implementation(libs.noties.markwon.core)
|
||||
implementation(libs.noties.markwon.linkify)
|
||||
|
||||
// Crash reporting
|
||||
implementation(libs.acra.core)
|
||||
|
||||
// Properly restarting
|
||||
implementation(libs.jakewharton.phoenix)
|
||||
|
||||
// Reactive extensions for Java VM
|
||||
implementation(libs.reactivex.rxjava)
|
||||
implementation(libs.reactivex.rxandroid)
|
||||
// RxJava binding APIs for Android UI widgets
|
||||
implementation(libs.jakewharton.rxbinding)
|
||||
|
||||
// Date and time formatting
|
||||
implementation(libs.ocpsoft.prettytime)
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
debugImplementation(libs.squareup.leakcanary.watcher)
|
||||
debugImplementation(libs.squareup.leakcanary.plumber)
|
||||
debugImplementation(libs.squareup.leakcanary.core)
|
||||
// Debug bridge for Android
|
||||
debugImplementation(libs.facebook.stetho.core)
|
||||
debugImplementation(libs.facebook.stetho.okhttp3)
|
||||
|
||||
/** Testing **/
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockito.core)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.runner)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.assertj.core)
|
||||
}
|
||||
@@ -458,7 +458,7 @@
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"fieldPath": "orderingName",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
|
||||
@@ -129,7 +129,7 @@ class DatabaseMigrationTest {
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().getAll().blockingFirst()
|
||||
|
||||
// Only expect 2, the one with the null url will be ignored
|
||||
assertEquals(2, listFromDB.size)
|
||||
@@ -217,7 +217,7 @@ class DatabaseMigrationTest {
|
||||
)
|
||||
|
||||
val migratedDatabaseV8 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
|
||||
val listFromDB = migratedDatabaseV8.searchHistoryDAO().getAll().blockingFirst()
|
||||
|
||||
assertEquals(2, listFromDB.size)
|
||||
assertEquals("abc", listFromDB[0].search)
|
||||
@@ -283,8 +283,8 @@ class DatabaseMigrationTest {
|
||||
)
|
||||
|
||||
val migratedDatabaseV9 = getMigratedDatabase()
|
||||
var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
|
||||
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
|
||||
var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
|
||||
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
|
||||
|
||||
assertEquals(1, localListFromDB.size)
|
||||
assertEquals(localUid2, localListFromDB[0].uid)
|
||||
@@ -294,17 +294,27 @@ class DatabaseMigrationTest {
|
||||
assertEquals(-1, remoteListFromDB[0].displayIndex)
|
||||
|
||||
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
|
||||
PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
|
||||
PlaylistEntity(
|
||||
name = "${DEFAULT_NAME}3",
|
||||
isThumbnailPermanent = false,
|
||||
thumbnailStreamId = -1,
|
||||
displayIndex = -1
|
||||
)
|
||||
)
|
||||
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
|
||||
PlaylistRemoteEntity(
|
||||
DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
|
||||
DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
|
||||
serviceId = DEFAULT_THIRD_SERVICE_ID,
|
||||
orderingName = DEFAULT_NAME,
|
||||
url = DEFAULT_THIRD_URL,
|
||||
thumbnailUrl = DEFAULT_THUMBNAIL,
|
||||
uploader = DEFAULT_UPLOADER_NAME,
|
||||
displayIndex = -1,
|
||||
streamCount = 10
|
||||
)
|
||||
)
|
||||
|
||||
localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
|
||||
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
|
||||
localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
|
||||
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
|
||||
assertEquals(2, localListFromDB.size)
|
||||
assertEquals(localUid3, localListFromDB[1].uid)
|
||||
assertEquals(-1, localListFromDB[1].displayIndex)
|
||||
|
||||
@@ -41,7 +41,7 @@ class HistoryRecordManagerTest {
|
||||
// For some reason the Flowable returned by getAll() never completes, so we can't assert
|
||||
// that the number of Lists it returns is exactly 1, we can only check if the first List is
|
||||
// correct. Why on earth has a Flowable been used instead of a Single for getAll()?!?
|
||||
val entities = database.searchHistoryDAO().all.blockingFirst()
|
||||
val entities = database.searchHistoryDAO().getAll().blockingFirst()
|
||||
assertThat(entities).hasSize(1)
|
||||
assertThat(entities[0].id).isEqualTo(1)
|
||||
assertThat(entities[0].serviceId).isEqualTo(0)
|
||||
@@ -51,50 +51,50 @@ class HistoryRecordManagerTest {
|
||||
@Test
|
||||
fun deleteSearchHistory() {
|
||||
val entries = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(1), 0, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 2, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 1, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 0, "B"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 0, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 1, search = "B"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B"),
|
||||
)
|
||||
|
||||
// make sure all 4 were inserted
|
||||
database.searchHistoryDAO().insertAll(entries)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
|
||||
assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries)
|
||||
|
||||
// try to delete only "A" entries, "B" entries should be untouched
|
||||
manager.deleteSearchHistory("A").test().await().assertValue(2)
|
||||
val entities = database.searchHistoryDAO().all.blockingFirst()
|
||||
val entities = database.searchHistoryDAO().getAll().blockingFirst()
|
||||
assertThat(entities).hasSize(2)
|
||||
assertThat(entities).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
|
||||
.containsExactly(*entries.subList(2, 4).toTypedArray())
|
||||
|
||||
// assert that nothing happens if we delete a search query that does exist in the db
|
||||
manager.deleteSearchHistory("A").test().await().assertValue(0)
|
||||
val entities2 = database.searchHistoryDAO().all.blockingFirst()
|
||||
val entities2 = database.searchHistoryDAO().getAll().blockingFirst()
|
||||
assertThat(entities2).hasSize(2)
|
||||
assertThat(entities2).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
|
||||
.containsExactly(*entries.subList(2, 4).toTypedArray())
|
||||
|
||||
// delete all remaining entries
|
||||
manager.deleteSearchHistory("B").test().await().assertValue(2)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
|
||||
assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteCompleteSearchHistory() {
|
||||
val entries = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 2, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 0, "C"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "B"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C"),
|
||||
)
|
||||
|
||||
// make sure all 3 were inserted
|
||||
database.searchHistoryDAO().insertAll(entries)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
|
||||
assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries)
|
||||
|
||||
// should remove everything
|
||||
manager.deleteCompleteSearchHistory().test().await().assertValue(entries.size)
|
||||
assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
|
||||
assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty()
|
||||
}
|
||||
|
||||
private fun insertShuffledRelatedSearches(relatedSearches: Collection<SearchHistoryEntry>) {
|
||||
@@ -107,7 +107,7 @@ class HistoryRecordManagerTest {
|
||||
// make sure all entries were inserted
|
||||
assertEquals(
|
||||
relatedSearches.size,
|
||||
database.searchHistoryDAO().all.blockingFirst().size
|
||||
database.searchHistoryDAO().getAll().blockingFirst().size
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,19 +127,18 @@ class HistoryRecordManagerTest {
|
||||
|
||||
@Test
|
||||
fun getRelatedSearches_emptyQuery_manyDuplicates() {
|
||||
insertShuffledRelatedSearches(
|
||||
listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(9), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(8), 3, "AB"),
|
||||
SearchHistoryEntry(time.minusSeconds(7), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(6), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(5), 3, "BA"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 0, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(1), 2, "AA"),
|
||||
)
|
||||
val relatedSearches = listOf(
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(9), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(8), serviceId = 3, search = "AB"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 3, search = "BA"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA"),
|
||||
)
|
||||
insertShuffledRelatedSearches(relatedSearches)
|
||||
|
||||
val searches = manager.getRelatedSearches("", 9, 3).blockingFirst()
|
||||
assertThat(searches).containsExactly("AA", "A", "BA")
|
||||
@@ -166,13 +165,13 @@ class HistoryRecordManagerTest {
|
||||
private val time = OffsetDateTime.of(LocalDateTime.of(2000, 1, 1, 1, 1), ZoneOffset.UTC)
|
||||
|
||||
private val RELATED_SEARCHES_ENTRIES = listOf(
|
||||
SearchHistoryEntry(time.minusSeconds(7), 2, "AC"),
|
||||
SearchHistoryEntry(time.minusSeconds(6), 0, "ABC"),
|
||||
SearchHistoryEntry(time.minusSeconds(5), 1, "BA"),
|
||||
SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
|
||||
SearchHistoryEntry(time.minusSeconds(2), 0, "B"),
|
||||
SearchHistoryEntry(time.minusSeconds(3), 2, "AA"),
|
||||
SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 2, search = "AC"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 0, search = "ABC"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 1, search = "BA"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "B"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 2, search = "AA"),
|
||||
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,6 @@ class LocalPlaylistManagerTest {
|
||||
val result = manager.createPlaylist("name", listOf(stream, upserted))
|
||||
|
||||
result.test().await().assertComplete()
|
||||
database.streamDAO().all.test().awaitCount(1).assertValue(listOf(stream, upserted))
|
||||
database.streamDAO().getAll().test().awaitCount(1).assertValue(listOf(stream, upserted))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
<data android:scheme="https" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="m.soundcloud.com" />
|
||||
<data android:host="on.soundcloud.com" />
|
||||
<data android:host="www.soundcloud.com" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -65,6 +65,8 @@ public class App extends Application {
|
||||
private static final String TAG = App.class.toString();
|
||||
|
||||
private boolean isFirstRun = false;
|
||||
private boolean notificationsRequested = false;
|
||||
|
||||
private static App app;
|
||||
|
||||
@NonNull
|
||||
@@ -72,6 +74,14 @@ public class App extends Application {
|
||||
return app;
|
||||
}
|
||||
|
||||
public boolean getNotificationsRequested() {
|
||||
return notificationsRequested;
|
||||
}
|
||||
|
||||
public void setNotificationsRequested() {
|
||||
notificationsRequested = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(base);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.Room;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
private NewPipeDatabase() {
|
||||
//no instance
|
||||
}
|
||||
|
||||
private static AppDatabase getDatabase(final Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
|
||||
.build();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static AppDatabase getInstance(@NonNull final Context context) {
|
||||
AppDatabase result = databaseInstance;
|
||||
if (result == null) {
|
||||
synchronized (NewPipeDatabase.class) {
|
||||
result = databaseInstance;
|
||||
if (result == null) {
|
||||
databaseInstance = getDatabase(context);
|
||||
result = databaseInstance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void checkpoint() {
|
||||
if (databaseInstance == null) {
|
||||
throw new IllegalStateException("database is not initialized");
|
||||
}
|
||||
final Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null);
|
||||
if (c.moveToFirst() && c.getInt(0) == 1) {
|
||||
throw new RuntimeException("Checkpoint was blocked from completing");
|
||||
}
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
if (databaseInstance != null) {
|
||||
synchronized (NewPipeDatabase.class) {
|
||||
if (databaseInstance != null) {
|
||||
databaseInstance.close();
|
||||
databaseInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
Normal file
80
app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room.databaseBuilder
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_3_4
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_4_5
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_5_6
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_6_7
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_7_8
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_8_9
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
object NewPipeDatabase {
|
||||
|
||||
@Volatile
|
||||
private var databaseInstance: AppDatabase? = null
|
||||
|
||||
private fun getDatabase(context: Context): AppDatabase {
|
||||
return databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.Companion.DATABASE_NAME
|
||||
).addMigrations(
|
||||
MIGRATION_1_2,
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9
|
||||
).build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
var result = databaseInstance
|
||||
if (result == null) {
|
||||
synchronized(NewPipeDatabase::class.java) {
|
||||
result = databaseInstance
|
||||
if (result == null) {
|
||||
databaseInstance = getDatabase(context)
|
||||
result = databaseInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun checkpoint() {
|
||||
checkNotNull(databaseInstance) { "database is not initialized" }
|
||||
val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null)
|
||||
if (c.moveToFirst() && c.getInt(0) == 1) {
|
||||
throw RuntimeException("Checkpoint was blocked from completing")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun close() {
|
||||
if (databaseInstance != null) {
|
||||
synchronized(NewPipeDatabase::class.java) {
|
||||
if (databaseInstance != null) {
|
||||
databaseInstance!!.close()
|
||||
databaseInstance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_9;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
|
||||
import org.schabi.newpipe.database.feed.dao.FeedDAO;
|
||||
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity;
|
||||
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
import org.schabi.newpipe.database.stream.dao.StreamDAO;
|
||||
import org.schabi.newpipe.database.stream.dao.StreamStateDAO;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
SubscriptionEntity.class, SearchHistoryEntry.class,
|
||||
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
|
||||
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
|
||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_9
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
||||
public abstract SearchHistoryDAO searchHistoryDAO();
|
||||
|
||||
public abstract StreamDAO streamDAO();
|
||||
|
||||
public abstract StreamHistoryDAO streamHistoryDAO();
|
||||
|
||||
public abstract StreamStateDAO streamStateDAO();
|
||||
|
||||
public abstract PlaylistDAO playlistDAO();
|
||||
|
||||
public abstract PlaylistStreamDAO playlistStreamDAO();
|
||||
|
||||
public abstract PlaylistRemoteDAO playlistRemoteDAO();
|
||||
|
||||
public abstract FeedDAO feedDAO();
|
||||
|
||||
public abstract FeedGroupDAO feedGroupDAO();
|
||||
|
||||
public abstract SubscriptionDAO subscriptionDAO();
|
||||
}
|
||||
68
app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
Normal file
68
app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import org.schabi.newpipe.database.feed.dao.FeedDAO
|
||||
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
|
||||
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.dao.StreamDAO
|
||||
import org.schabi.newpipe.database.stream.dao.StreamStateDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
@Database(
|
||||
version = Migrations.DB_VER_9,
|
||||
entities = [
|
||||
SubscriptionEntity::class,
|
||||
SearchHistoryEntry::class,
|
||||
StreamEntity::class,
|
||||
StreamHistoryEntity::class,
|
||||
StreamStateEntity::class,
|
||||
PlaylistEntity::class,
|
||||
PlaylistStreamEntity::class,
|
||||
PlaylistRemoteEntity::class,
|
||||
FeedEntity::class,
|
||||
FeedGroupEntity::class,
|
||||
FeedGroupSubscriptionEntity::class,
|
||||
FeedLastUpdatedEntity::class
|
||||
]
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun feedDAO(): FeedDAO
|
||||
abstract fun feedGroupDAO(): FeedGroupDAO
|
||||
abstract fun playlistDAO(): PlaylistDAO
|
||||
abstract fun playlistRemoteDAO(): PlaylistRemoteDAO
|
||||
abstract fun playlistStreamDAO(): PlaylistStreamDAO
|
||||
abstract fun searchHistoryDAO(): SearchHistoryDAO
|
||||
abstract fun streamDAO(): StreamDAO
|
||||
abstract fun streamHistoryDAO(): StreamHistoryDAO
|
||||
abstract fun streamStateDAO(): StreamStateDAO
|
||||
abstract fun subscriptionDAO(): SubscriptionDAO
|
||||
|
||||
companion object {
|
||||
const val DATABASE_NAME: String = "newpipe.db"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Delete;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.Update;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
@Dao
|
||||
public interface BasicDAO<Entity> {
|
||||
/* Inserts */
|
||||
@Insert
|
||||
long insert(Entity entity);
|
||||
|
||||
@Insert
|
||||
List<Long> insertAll(Collection<Entity> entities);
|
||||
|
||||
/* Searches */
|
||||
Flowable<List<Entity>> getAll();
|
||||
|
||||
Flowable<List<Entity>> listByService(int serviceId);
|
||||
|
||||
/* Deletes */
|
||||
@Delete
|
||||
void delete(Entity entity);
|
||||
|
||||
int deleteAll();
|
||||
|
||||
/* Updates */
|
||||
@Update
|
||||
int update(Entity entity);
|
||||
|
||||
@Update
|
||||
void update(Collection<Entity> entities);
|
||||
}
|
||||
42
app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
Normal file
42
app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2022 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Update
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
|
||||
@Dao
|
||||
interface BasicDAO<Entity> {
|
||||
|
||||
/* Inserts */
|
||||
@Insert
|
||||
fun insert(entity: Entity): Long
|
||||
|
||||
@Insert
|
||||
fun insertAll(entities: Collection<Entity>): List<Long>
|
||||
|
||||
/* Searches */
|
||||
fun getAll(): Flowable<List<Entity>>
|
||||
|
||||
fun listByService(serviceId: Int): Flowable<List<Entity>>
|
||||
|
||||
/* Deletes */
|
||||
@Delete
|
||||
fun delete(entity: Entity)
|
||||
|
||||
fun deleteAll(): Int
|
||||
|
||||
/* Updates */
|
||||
@Update
|
||||
fun update(entity: Entity): Int
|
||||
|
||||
@Update
|
||||
fun update(entities: Collection<Entity>)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
public interface LocalItem {
|
||||
LocalItemType getLocalItemType();
|
||||
|
||||
enum LocalItemType {
|
||||
PLAYLIST_LOCAL_ITEM,
|
||||
PLAYLIST_REMOTE_ITEM,
|
||||
|
||||
PLAYLIST_STREAM_ITEM,
|
||||
STATISTIC_STREAM_ITEM,
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
Normal file
19
app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2020 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
interface LocalItem {
|
||||
val localItemType: LocalItemType
|
||||
|
||||
enum class LocalItemType {
|
||||
PLAYLIST_LOCAL_ITEM,
|
||||
PLAYLIST_REMOTE_ITEM,
|
||||
|
||||
PLAYLIST_STREAM_ITEM,
|
||||
STATISTIC_STREAM_ITEM,
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.migration.Migration;
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
|
||||
public final class Migrations {
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// Test new migrations manually by importing a database from daily usage //
|
||||
// and checking if the migration works (Use the Database Inspector //
|
||||
// https://developer.android.com/studio/inspect/database). //
|
||||
// If you add a migration point it out in the pull request, so that //
|
||||
// others remember to test it themselves. //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public static final int DB_VER_1 = 1;
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
public static final int DB_VER_4 = 4;
|
||||
public static final int DB_VER_5 = 5;
|
||||
public static final int DB_VER_6 = 6;
|
||||
public static final int DB_VER_7 = 7;
|
||||
public static final int DB_VER_8 = 8;
|
||||
public static final int DB_VER_9 = 9;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
||||
public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Start migrating database");
|
||||
}
|
||||
/*
|
||||
* Unfortunately these queries must be hardcoded due to the possibility of
|
||||
* schema and names changing at a later date, thus invalidating the older migration
|
||||
* scripts if they are not hardcoded.
|
||||
* */
|
||||
|
||||
// Not much we can do about this, since room doesn't create tables before migration.
|
||||
// It's either this or blasting the entire database anew.
|
||||
database.execSQL("CREATE INDEX `index_search_history_search` "
|
||||
+ "ON `search_history` (`search`)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `streams` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, "
|
||||
+ "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, "
|
||||
+ "`thumbnail_url` TEXT)");
|
||||
database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` "
|
||||
+ "ON `streams` (`service_id`, `url`)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` "
|
||||
+ "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, "
|
||||
+ "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), "
|
||||
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE )");
|
||||
database.execSQL("CREATE INDEX `index_stream_history_stream_id` "
|
||||
+ "ON `stream_history` (`stream_id`)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` "
|
||||
+ "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, "
|
||||
+ "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) "
|
||||
+ "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`name` TEXT, `thumbnail_url` TEXT)");
|
||||
database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` "
|
||||
+ "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, "
|
||||
+ "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), "
|
||||
+ "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
|
||||
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
|
||||
database.execSQL("CREATE UNIQUE INDEX "
|
||||
+ "`index_playlist_stream_join_playlist_id_join_index` "
|
||||
+ "ON `playlist_stream_join` (`playlist_id`, `join_index`)");
|
||||
database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` "
|
||||
+ "ON `playlist_stream_join` (`stream_id`)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
|
||||
+ "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)");
|
||||
database.execSQL("CREATE INDEX `index_remote_playlists_name` "
|
||||
+ "ON `remote_playlists` (`name`)");
|
||||
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
|
||||
+ "ON `remote_playlists` (`service_id`, `url`)");
|
||||
|
||||
// Populate streams table with existing entries in watch history
|
||||
// Latest data first, thus ignoring older entries with the same indices
|
||||
database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, "
|
||||
+ "stream_type, duration, uploader, thumbnail_url) "
|
||||
|
||||
+ "SELECT service_id, url, title, 'VIDEO_STREAM', duration, "
|
||||
+ "uploader, thumbnail_url "
|
||||
|
||||
+ "FROM watch_history "
|
||||
+ "ORDER BY creation_date DESC");
|
||||
|
||||
// Once the streams have PKs, join them with the normalized history table
|
||||
// and populate it with the remaining data from watch history
|
||||
database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)"
|
||||
+ "SELECT uid, creation_date, 1 "
|
||||
+ "FROM watch_history INNER JOIN streams "
|
||||
+ "ON watch_history.service_id == streams.service_id "
|
||||
+ "AND watch_history.url == streams.url "
|
||||
+ "ORDER BY creation_date DESC");
|
||||
|
||||
database.execSQL("DROP TABLE IF EXISTS watch_history");
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Stop migrating database");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
// Add NOT NULLs and new fields
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS streams_new "
|
||||
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, "
|
||||
+ "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, "
|
||||
+ "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, "
|
||||
+ "textual_upload_date TEXT, upload_date INTEGER, "
|
||||
+ "is_upload_date_approximation INTEGER)");
|
||||
|
||||
database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, "
|
||||
+ "duration, uploader, thumbnail_url, view_count, textual_upload_date, "
|
||||
+ "upload_date, is_upload_date_approximation) "
|
||||
|
||||
+ "SELECT uid, service_id, url, ifnull(title, ''), "
|
||||
+ "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), "
|
||||
+ "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL "
|
||||
|
||||
+ "FROM streams WHERE url IS NOT NULL");
|
||||
|
||||
database.execSQL("DROP TABLE streams");
|
||||
database.execSQL("ALTER TABLE streams_new RENAME TO streams");
|
||||
database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url "
|
||||
+ "ON streams (service_id, url)");
|
||||
|
||||
// Tables for feed feature
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed "
|
||||
+ "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
|
||||
+ "PRIMARY KEY(stream_id, subscription_id), "
|
||||
+ "FOREIGN KEY(stream_id) REFERENCES streams(uid) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
|
||||
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
|
||||
database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group "
|
||||
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, "
|
||||
+ "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)");
|
||||
database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join "
|
||||
+ "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
|
||||
+ "PRIMARY KEY(group_id, subscription_id), "
|
||||
+ "FOREIGN KEY(group_id) REFERENCES feed_group(uid) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
|
||||
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
|
||||
database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id "
|
||||
+ "ON feed_group_subscription_join (subscription_id)");
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated "
|
||||
+ "(subscription_id INTEGER NOT NULL, last_updated INTEGER, "
|
||||
+ "PRIMARY KEY(subscription_id), "
|
||||
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
|
||||
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_3_4 = new Migration(DB_VER_3, DB_VER_4) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE streams ADD COLUMN uploader_url TEXT"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
// Create a new column thumbnail_stream_id
|
||||
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` "
|
||||
+ "INTEGER NOT NULL DEFAULT -1");
|
||||
|
||||
// Migrate the thumbnail_url to the thumbnail_stream_id
|
||||
database.execSQL("UPDATE playlists SET thumbnail_stream_id = ("
|
||||
+ " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END"
|
||||
+ " FROM ("
|
||||
+ " SELECT p.uid AS playlist_uid, s.uid AS stream_uid"
|
||||
+ " FROM playlists p"
|
||||
+ " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id"
|
||||
+ " LEFT JOIN streams s ON s.uid = ps.stream_id"
|
||||
+ " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table"
|
||||
+ " WHERE playlist_uid = playlists.uid)");
|
||||
|
||||
// Remove the thumbnail_url field in the playlist table
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`"
|
||||
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "name TEXT, "
|
||||
+ "is_thumbnail_permanent INTEGER NOT NULL, "
|
||||
+ "thumbnail_stream_id INTEGER NOT NULL)");
|
||||
|
||||
database.execSQL("INSERT INTO playlists_new"
|
||||
+ " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id "
|
||||
+ " FROM playlists");
|
||||
|
||||
|
||||
database.execSQL("DROP TABLE playlists");
|
||||
database.execSQL("ALTER TABLE playlists_new RENAME TO playlists");
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS "
|
||||
+ "`index_playlists_name` ON `playlists` (`name`)");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
|
||||
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
|
||||
database.execSQL("UPDATE search_history SET search = trim(search)");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
try {
|
||||
database.beginTransaction();
|
||||
|
||||
// Update playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
database.execSQL("CREATE TABLE `playlists_tmp` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
|
||||
+ "`thumbnail_stream_id` INTEGER NOT NULL, "
|
||||
+ "`display_index` INTEGER NOT NULL)");
|
||||
database.execSQL("INSERT INTO `playlists_tmp` "
|
||||
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
|
||||
+ "`display_index`) "
|
||||
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
|
||||
+ "-1 "
|
||||
+ "FROM `playlists`");
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
database.execSQL("DROP TABLE `playlists`");
|
||||
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
|
||||
|
||||
|
||||
// Update remote_playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
database.execSQL("CREATE TABLE `remote_playlists_tmp` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
|
||||
+ "`thumbnail_url` TEXT, `uploader` TEXT, "
|
||||
+ "`display_index` INTEGER NOT NULL,"
|
||||
+ "`stream_count` INTEGER)");
|
||||
database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
|
||||
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
|
||||
+ "`stream_count`)"
|
||||
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
|
||||
+ "-1, `stream_count` FROM `remote_playlists`");
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
database.execSQL("DROP TABLE `remote_playlists`");
|
||||
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
|
||||
|
||||
// Create index on the new table.
|
||||
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
|
||||
+ "ON `remote_playlists` (`service_id`, `url`)");
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
368
app/src/main/java/org/schabi/newpipe/database/Migrations.kt
Normal file
368
app/src/main/java/org/schabi/newpipe/database/Migrations.kt
Normal file
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.util.Log
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.schabi.newpipe.MainActivity
|
||||
|
||||
object Migrations {
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////// //
|
||||
// Test new migrations manually by importing a database from daily usage //
|
||||
// and checking if the migration works (Use the Database Inspector //
|
||||
// https://developer.android.com/studio/inspect/database). //
|
||||
// If you add a migration point it out in the pull request, so that //
|
||||
// others remember to test it themselves. //
|
||||
// /////////////////////////////////////////////////////////////////////// //
|
||||
|
||||
const val DB_VER_1 = 1
|
||||
const val DB_VER_2 = 2
|
||||
const val DB_VER_3 = 3
|
||||
const val DB_VER_4 = 4
|
||||
const val DB_VER_5 = 5
|
||||
const val DB_VER_6 = 6
|
||||
const val DB_VER_7 = 7
|
||||
const val DB_VER_8 = 8
|
||||
const val DB_VER_9 = 9
|
||||
|
||||
private val TAG = Migrations::class.java.getName()
|
||||
private val isDebug = MainActivity.DEBUG
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(DB_VER_1, DB_VER_2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
if (isDebug) {
|
||||
Log.d(TAG, "Start migrating database")
|
||||
}
|
||||
|
||||
/*
|
||||
* Unfortunately these queries must be hardcoded due to the possibility of
|
||||
* schema and names changing at a later date, thus invalidating the older migration
|
||||
* scripts if they are not hardcoded.
|
||||
* */
|
||||
|
||||
// Not much we can do about this, since room doesn't create tables before migration.
|
||||
// It's either this or blasting the entire database anew.
|
||||
db.execSQL(
|
||||
"CREATE INDEX `index_search_history_search` " +
|
||||
"ON `search_history` (`search`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `streams` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
|
||||
"`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
|
||||
"`thumbnail_url` TEXT)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_streams_service_id_url` " +
|
||||
"ON `streams` (`service_id`, `url`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `stream_history` " +
|
||||
"(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
|
||||
"`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
|
||||
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX `index_stream_history_stream_id` " +
|
||||
"ON `stream_history` (`stream_id`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `stream_state` " +
|
||||
"(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
|
||||
"REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlists` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`name` TEXT, `thumbnail_url` TEXT)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
|
||||
"(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
|
||||
"`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
|
||||
"FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
|
||||
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX " +
|
||||
"`index_playlist_stream_join_playlist_id_join_index` " +
|
||||
"ON `playlist_stream_join` (`playlist_id`, `join_index`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX `index_playlist_stream_join_stream_id` " +
|
||||
"ON `playlist_stream_join` (`stream_id`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `remote_playlists` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
|
||||
"`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX `index_remote_playlists_name` " +
|
||||
"ON `remote_playlists` (`name`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
|
||||
"ON `remote_playlists` (`service_id`, `url`)"
|
||||
)
|
||||
|
||||
// Populate streams table with existing entries in watch history
|
||||
// Latest data first, thus ignoring older entries with the same indices
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO streams (service_id, url, title, " +
|
||||
"stream_type, duration, uploader, thumbnail_url) " +
|
||||
|
||||
"SELECT service_id, url, title, 'VIDEO_STREAM', duration, " +
|
||||
"uploader, thumbnail_url " +
|
||||
|
||||
"FROM watch_history " +
|
||||
"ORDER BY creation_date DESC"
|
||||
)
|
||||
|
||||
// Once the streams have PKs, join them with the normalized history table
|
||||
// and populate it with the remaining data from watch history
|
||||
db.execSQL(
|
||||
"INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
|
||||
"SELECT uid, creation_date, 1 " +
|
||||
"FROM watch_history INNER JOIN streams " +
|
||||
"ON watch_history.service_id == streams.service_id " +
|
||||
"AND watch_history.url == streams.url " +
|
||||
"ORDER BY creation_date DESC"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS watch_history")
|
||||
|
||||
if (isDebug) {
|
||||
Log.d(TAG, "Stop migrating database")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(DB_VER_2, DB_VER_3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Add NOT NULLs and new fields
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS streams_new " +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
|
||||
"stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " +
|
||||
"uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " +
|
||||
"textual_upload_date TEXT, upload_date INTEGER, " +
|
||||
"is_upload_date_approximation INTEGER)"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
|
||||
"duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
|
||||
"upload_date, is_upload_date_approximation) " +
|
||||
|
||||
"SELECT uid, service_id, url, ifnull(title, ''), " +
|
||||
"ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " +
|
||||
"ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " +
|
||||
|
||||
"FROM streams WHERE url IS NOT NULL"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE streams")
|
||||
db.execSQL("ALTER TABLE streams_new RENAME TO streams")
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX index_streams_service_id_url " +
|
||||
"ON streams (service_id, url)"
|
||||
)
|
||||
|
||||
// Tables for feed feature
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed " +
|
||||
"(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(stream_id, subscription_id), " +
|
||||
"FOREIGN KEY(stream_id) REFERENCES streams(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
|
||||
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_group " +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
|
||||
"icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
|
||||
"(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(group_id, subscription_id), " +
|
||||
"FOREIGN KEY(group_id) REFERENCES feed_group(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
|
||||
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX index_feed_group_subscription_join_subscription_id " +
|
||||
"ON feed_group_subscription_join (subscription_id)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_last_updated " +
|
||||
"(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
|
||||
"PRIMARY KEY(subscription_id), " +
|
||||
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(DB_VER_3, DB_VER_4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = object : Migration(DB_VER_4, DB_VER_5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
|
||||
"INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(DB_VER_5, DB_VER_6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
|
||||
"INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_6_7 = object : Migration(DB_VER_6, DB_VER_7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Create a new column thumbnail_stream_id
|
||||
db.execSQL(
|
||||
"ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
|
||||
"INTEGER NOT NULL DEFAULT -1"
|
||||
)
|
||||
|
||||
// Migrate the thumbnail_url to the thumbnail_stream_id
|
||||
db.execSQL(
|
||||
"UPDATE playlists SET thumbnail_stream_id = (" +
|
||||
" SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
|
||||
" FROM (" +
|
||||
" SELECT p.uid AS playlist_uid, s.uid AS stream_uid" +
|
||||
" FROM playlists p" +
|
||||
" LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" +
|
||||
" LEFT JOIN streams s ON s.uid = ps.stream_id" +
|
||||
" WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" +
|
||||
" WHERE playlist_uid = playlists.uid)"
|
||||
)
|
||||
|
||||
// Remove the thumbnail_url field in the playlist table
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlists_new`" +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"name TEXT, " +
|
||||
"is_thumbnail_permanent INTEGER NOT NULL, " +
|
||||
"thumbnail_stream_id INTEGER NOT NULL)"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"INSERT INTO playlists_new" +
|
||||
" SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
|
||||
" FROM playlists"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE playlists")
|
||||
db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS " +
|
||||
"`index_playlists_name` ON `playlists` (`name`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_7_8 = object : Migration(DB_VER_7, DB_VER_8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
|
||||
"MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
|
||||
)
|
||||
db.execSQL("UPDATE search_history SET search = trim(search)")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_8_9 = object : Migration(DB_VER_8, DB_VER_9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
|
||||
// Update playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
db.execSQL(
|
||||
"CREATE TABLE `playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
|
||||
"`thumbnail_stream_id` INTEGER NOT NULL, " +
|
||||
"`display_index` INTEGER NOT NULL)"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO `playlists_tmp` " +
|
||||
"(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"`display_index`) " +
|
||||
"SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"-1 " +
|
||||
"FROM `playlists`"
|
||||
)
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
db.execSQL("DROP TABLE `playlists`")
|
||||
db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
|
||||
|
||||
// Update remote_playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
db.execSQL(
|
||||
"CREATE TABLE `remote_playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
|
||||
"`thumbnail_url` TEXT, `uploader` TEXT, " +
|
||||
"`display_index` INTEGER NOT NULL," +
|
||||
"`stream_count` INTEGER)"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
|
||||
"`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
|
||||
"`stream_count`)" +
|
||||
"SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
|
||||
"-1, `stream_count` FROM `remote_playlists`"
|
||||
)
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
db.execSQL("DROP TABLE `remote_playlists`")
|
||||
db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
|
||||
|
||||
// Create index on the new table.
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
|
||||
"ON `remote_playlists` (`service_id`, `url`)"
|
||||
)
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,10 +168,10 @@ abstract class FeedDAO {
|
||||
ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId
|
||||
"""
|
||||
)
|
||||
abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime>>
|
||||
abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime?>>
|
||||
|
||||
@Query("SELECT MIN(last_updated) FROM feed_last_updated")
|
||||
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<OffsetDateTime>>
|
||||
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<OffsetDateTime?>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
|
||||
abstract fun notLoadedCount(): Flowable<Long>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.database.history.dao;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
|
||||
public interface HistoryDAO<T> extends BasicDAO<T> {
|
||||
T getLatestEntry();
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.schabi.newpipe.database.history.dao;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.CREATION_DATE;
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.ID;
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH;
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE_NAME;
|
||||
|
||||
@Dao
|
||||
public interface SearchHistoryDAO extends HistoryDAO<SearchHistoryEntry> {
|
||||
String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC";
|
||||
String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC";
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME
|
||||
+ " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")")
|
||||
@Nullable
|
||||
SearchHistoryEntry getLatestEntry();
|
||||
|
||||
@Query("DELETE FROM " + TABLE_NAME)
|
||||
@Override
|
||||
int deleteAll();
|
||||
|
||||
@Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query")
|
||||
int deleteAllWhereQuery(String query);
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE)
|
||||
@Override
|
||||
Flowable<List<SearchHistoryEntry>> getAll();
|
||||
|
||||
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH
|
||||
+ ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
|
||||
Flowable<List<String>> getUniqueEntries(int limit);
|
||||
|
||||
@Query("SELECT * FROM " + TABLE_NAME
|
||||
+ " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)
|
||||
@Override
|
||||
Flowable<List<SearchHistoryEntry>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
|
||||
+ " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
|
||||
Flowable<List<String>> getSimilarEntries(String query, int limit);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2021 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.history.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
|
||||
@Dao
|
||||
interface SearchHistoryDAO : BasicDAO<SearchHistoryEntry> {
|
||||
|
||||
@get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)")
|
||||
val latestEntry: SearchHistoryEntry?
|
||||
|
||||
@Query("DELETE FROM search_history")
|
||||
override fun deleteAll(): Int
|
||||
|
||||
@Query("DELETE FROM search_history WHERE search = :query")
|
||||
fun deleteAllWhereQuery(query: String): Int
|
||||
|
||||
@Query("SELECT * FROM search_history ORDER BY creation_date DESC")
|
||||
override fun getAll(): Flowable<List<SearchHistoryEntry>>
|
||||
|
||||
@Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit")
|
||||
fun getUniqueEntries(limit: Int): Flowable<MutableList<String>>
|
||||
|
||||
@Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC")
|
||||
override fun listByService(serviceId: Int): Flowable<List<SearchHistoryEntry>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT search FROM search_history WHERE search LIKE :query ||
|
||||
'%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit
|
||||
"""
|
||||
)
|
||||
fun getSimilarEntries(query: String, limit: Int): Flowable<MutableList<String>>
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.schabi.newpipe.database.history.dao;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT;
|
||||
import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE;
|
||||
import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Dao
|
||||
public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity> {
|
||||
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE
|
||||
+ " WHERE " + STREAM_ACCESS_DATE + " = "
|
||||
+ "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")")
|
||||
@Override
|
||||
@Nullable
|
||||
public abstract StreamHistoryEntity getLatestEntry();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE)
|
||||
public abstract Flowable<List<StreamHistoryEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE)
|
||||
public abstract int deleteAll();
|
||||
|
||||
@Override
|
||||
public Flowable<List<StreamHistoryEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
+ " INNER JOIN " + STREAM_HISTORY_TABLE
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " ORDER BY " + STREAM_ACCESS_DATE + " DESC")
|
||||
public abstract Flowable<List<StreamHistoryEntry>> getHistory();
|
||||
|
||||
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
+ " INNER JOIN " + STREAM_HISTORY_TABLE
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " ORDER BY " + STREAM_ID + " ASC")
|
||||
public abstract Flowable<List<StreamHistoryEntry>> getHistorySortedById();
|
||||
|
||||
@Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID
|
||||
+ " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1")
|
||||
@Nullable
|
||||
public abstract StreamHistoryEntity getLatestEntry(long streamId);
|
||||
|
||||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
public abstract int deleteStreamHistory(long streamId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
+ " INNER JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + ", "
|
||||
+ " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", "
|
||||
+ " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT
|
||||
+ " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")"
|
||||
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
|
||||
public abstract Flowable<List<StreamStatisticsEntry>> getStatistics();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.history.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
|
||||
|
||||
@Dao
|
||||
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
|
||||
|
||||
@Query("SELECT * FROM stream_history")
|
||||
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>>
|
||||
|
||||
@Query("DELETE FROM stream_history")
|
||||
abstract override fun deleteAll(): Int
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<StreamHistoryEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
|
||||
abstract val history: Flowable<MutableList<StreamHistoryEntry>>
|
||||
|
||||
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
|
||||
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
|
||||
|
||||
@Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
|
||||
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity
|
||||
|
||||
@Query("DELETE FROM stream_history WHERE stream_id = :streamId")
|
||||
abstract fun deleteStreamHistory(streamId: Long): Int
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM streams
|
||||
|
||||
INNER JOIN (
|
||||
SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
|
||||
FROM stream_history
|
||||
GROUP BY stream_id
|
||||
)
|
||||
ON uid = stream_id
|
||||
|
||||
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
|
||||
ON uid = stream_id_alias
|
||||
"""
|
||||
)
|
||||
abstract fun getStatistics(): Flowable<MutableList<StreamStatisticsEntry>>
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
@@ -11,23 +17,24 @@ import java.time.OffsetDateTime
|
||||
tableName = SearchHistoryEntry.TABLE_NAME,
|
||||
indices = [Index(value = [SearchHistoryEntry.SEARCH])]
|
||||
)
|
||||
data class SearchHistoryEntry(
|
||||
@field:ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?,
|
||||
@field:ColumnInfo(
|
||||
name = SERVICE_ID
|
||||
) var serviceId: Int,
|
||||
@field:ColumnInfo(name = SEARCH) var search: String?
|
||||
) {
|
||||
data class SearchHistoryEntry @JvmOverloads constructor(
|
||||
@ColumnInfo(name = CREATION_DATE)
|
||||
var creationDate: OffsetDateTime?,
|
||||
|
||||
@ColumnInfo(name = SERVICE_ID)
|
||||
val serviceId: Int,
|
||||
|
||||
@ColumnInfo(name = SEARCH)
|
||||
val search: String?,
|
||||
|
||||
@ColumnInfo(name = ID)
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long = 0
|
||||
val id: Long = 0,
|
||||
) {
|
||||
|
||||
@Ignore
|
||||
fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean {
|
||||
return (
|
||||
serviceId == otherEntry.serviceId &&
|
||||
search == otherEntry.search
|
||||
)
|
||||
return serviceId == otherEntry.serviceId && search == otherEntry.search
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package org.schabi.newpipe.database.history.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
import androidx.room.Index;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
import static androidx.room.ForeignKey.CASCADE;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
|
||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
|
||||
|
||||
@Entity(tableName = STREAM_HISTORY_TABLE,
|
||||
primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE},
|
||||
// No need to index for timestamp as they will almost always be unique
|
||||
indices = {@Index(value = {JOIN_STREAM_ID})},
|
||||
foreignKeys = {
|
||||
@ForeignKey(entity = StreamEntity.class,
|
||||
parentColumns = StreamEntity.STREAM_ID,
|
||||
childColumns = JOIN_STREAM_ID,
|
||||
onDelete = CASCADE, onUpdate = CASCADE)
|
||||
})
|
||||
public class StreamHistoryEntity {
|
||||
public static final String STREAM_HISTORY_TABLE = "stream_history";
|
||||
public static final String JOIN_STREAM_ID = "stream_id";
|
||||
public static final String STREAM_ACCESS_DATE = "access_date";
|
||||
public static final String STREAM_REPEAT_COUNT = "repeat_count";
|
||||
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
private long streamUid;
|
||||
|
||||
@NonNull
|
||||
@ColumnInfo(name = STREAM_ACCESS_DATE)
|
||||
private OffsetDateTime accessDate;
|
||||
|
||||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||
private long repeatCount;
|
||||
|
||||
/**
|
||||
* @param streamUid the stream id this history item will refer to
|
||||
* @param accessDate the last time the stream was accessed
|
||||
* @param repeatCount the total number of views this stream received
|
||||
*/
|
||||
public StreamHistoryEntity(final long streamUid,
|
||||
@NonNull final OffsetDateTime accessDate,
|
||||
final long repeatCount) {
|
||||
this.streamUid = streamUid;
|
||||
this.accessDate = accessDate;
|
||||
this.repeatCount = repeatCount;
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
return streamUid;
|
||||
}
|
||||
|
||||
public void setStreamUid(final long streamUid) {
|
||||
this.streamUid = streamUid;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public OffsetDateTime getAccessDate() {
|
||||
return accessDate;
|
||||
}
|
||||
|
||||
public void setAccessDate(@NonNull final OffsetDateTime accessDate) {
|
||||
this.accessDate = accessDate;
|
||||
}
|
||||
|
||||
public long getRepeatCount() {
|
||||
return repeatCount;
|
||||
}
|
||||
|
||||
public void setRepeatCount(final long repeatCount) {
|
||||
this.repeatCount = repeatCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* @param streamUid the stream id this history item will refer to
|
||||
* @param accessDate the last time the stream was accessed
|
||||
* @param repeatCount the total number of views this stream received
|
||||
*/
|
||||
@Entity(
|
||||
tableName = STREAM_HISTORY_TABLE,
|
||||
primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
|
||||
indices = [Index(value = [JOIN_STREAM_ID])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(STREAM_ID),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class StreamHistoryEntity(
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
val streamUid: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_ACCESS_DATE)
|
||||
var accessDate: OffsetDateTime,
|
||||
|
||||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||
var repeatCount: Long
|
||||
) {
|
||||
companion object {
|
||||
const val STREAM_HISTORY_TABLE: String = "stream_history"
|
||||
const val STREAM_ACCESS_DATE: String = "access_date"
|
||||
const val JOIN_STREAM_ID: String = "stream_id"
|
||||
const val STREAM_REPEAT_COUNT: String = "repeat_count"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
/**
|
||||
* This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
|
||||
* how many times a specific stream is already contained inside a local playlist. Used to be able
|
||||
* to grey out playlists which already contain the current stream in the playlist append dialog.
|
||||
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
|
||||
*/
|
||||
public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
|
||||
public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
|
||||
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
|
||||
public final long timesStreamIsContained;
|
||||
|
||||
@SuppressWarnings("checkstyle:ParameterNumber")
|
||||
public PlaylistDuplicatesEntry(final long uid,
|
||||
final String name,
|
||||
final String thumbnailUrl,
|
||||
final boolean isThumbnailPermanent,
|
||||
final long thumbnailStreamId,
|
||||
final long displayIndex,
|
||||
final long streamCount,
|
||||
final long timesStreamIsContained) {
|
||||
super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
|
||||
streamCount);
|
||||
this.timesStreamIsContained = timesStreamIsContained;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
/**
|
||||
* This class adds a field to [PlaylistMetadataEntry] that contains an integer representing
|
||||
* how many times a specific stream is already contained inside a local playlist. Used to be able
|
||||
* to grey out playlists which already contain the current stream in the playlist append dialog.
|
||||
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates
|
||||
*/
|
||||
data class PlaylistDuplicatesEntry(
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
|
||||
override val uid: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
|
||||
override val thumbnailUrl: String?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
override val isThumbnailPermanent: Boolean?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
override val thumbnailStreamId: Long?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
|
||||
override var displayIndex: Long?,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
|
||||
override val streamCount: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
|
||||
override val orderingName: String?,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
|
||||
val timesStreamIsContained: Long
|
||||
) : PlaylistMetadataEntry(
|
||||
uid = uid,
|
||||
orderingName = orderingName,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
isThumbnailPermanent = isThumbnailPermanent,
|
||||
thumbnailStreamId = thumbnailStreamId,
|
||||
displayIndex = displayIndex,
|
||||
streamCount = streamCount
|
||||
) {
|
||||
companion object {
|
||||
const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
|
||||
public interface PlaylistLocalItem extends LocalItem {
|
||||
String getOrderingName();
|
||||
|
||||
long getDisplayIndex();
|
||||
|
||||
long getUid();
|
||||
|
||||
void setDisplayIndex(long displayIndex);
|
||||
|
||||
@Nullable
|
||||
String getThumbnailUrl();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
|
||||
interface PlaylistLocalItem : LocalItem {
|
||||
val orderingName: String?
|
||||
val displayIndex: Long?
|
||||
val uid: Long
|
||||
val thumbnailUrl: String?
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
private final long uid;
|
||||
@ColumnInfo(name = PLAYLIST_NAME)
|
||||
public final String name;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
private final boolean isThumbnailPermanent;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
private final long thumbnailStreamId;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
||||
public final String thumbnailUrl;
|
||||
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex;
|
||||
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
|
||||
public final long streamCount;
|
||||
|
||||
public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
|
||||
final boolean isThumbnailPermanent, final long thumbnailStreamId,
|
||||
final long displayIndex, final long streamCount) {
|
||||
this.uid = uid;
|
||||
this.name = name;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
this.displayIndex = displayIndex;
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalItemType getLocalItemType() {
|
||||
return LocalItemType.PLAYLIST_LOCAL_ITEM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOrderingName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public boolean isThumbnailPermanent() {
|
||||
return isThumbnailPermanent;
|
||||
}
|
||||
|
||||
public long getThumbnailStreamId() {
|
||||
return thumbnailStreamId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import org.schabi.newpipe.database.LocalItem.LocalItemType
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
open class PlaylistMetadataEntry(
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
|
||||
override val uid: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
|
||||
override val orderingName: String?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
|
||||
override val thumbnailUrl: String?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
|
||||
override var displayIndex: Long?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
open val isThumbnailPermanent: Boolean?,
|
||||
|
||||
@ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
open val thumbnailStreamId: Long?,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
|
||||
open val streamCount: Long
|
||||
) : PlaylistLocalItem {
|
||||
|
||||
override val localItemType: LocalItemType
|
||||
get() = LocalItemType.PLAYLIST_LOCAL_ITEM
|
||||
|
||||
companion object {
|
||||
const val PLAYLIST_STREAM_COUNT: String = "streamCount"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020-2023 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
@@ -23,18 +29,21 @@ data class PlaylistStreamEntry(
|
||||
val joinIndex: Int
|
||||
) : LocalItem {
|
||||
|
||||
override val localItemType: LocalItem.LocalItemType
|
||||
get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
|
||||
|
||||
@Throws(IllegalArgumentException::class)
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.uploaderUrl = streamEntity.uploaderUrl
|
||||
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||
return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
|
||||
return StreamInfoItem(
|
||||
streamEntity.serviceId,
|
||||
streamEntity.url,
|
||||
streamEntity.title,
|
||||
streamEntity.streamType
|
||||
).apply {
|
||||
duration = streamEntity.duration
|
||||
uploaderName = streamEntity.uploader
|
||||
uploaderUrl = streamEntity.uploaderUrl
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
|
||||
|
||||
@Dao
|
||||
public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + PLAYLIST_TABLE)
|
||||
Flowable<List<PlaylistEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + PLAYLIST_TABLE)
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
default Flowable<List<PlaylistEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<List<PlaylistEntity>> getPlaylist(long playlistId);
|
||||
|
||||
@Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
|
||||
int deletePlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
|
||||
Flowable<Long> getCount();
|
||||
|
||||
@Transaction
|
||||
default long upsertPlaylist(final PlaylistEntity playlist) {
|
||||
final long playlistId = playlist.getUid();
|
||||
|
||||
if (playlistId == -1) {
|
||||
// This situation is probably impossible.
|
||||
return insert(playlist);
|
||||
} else {
|
||||
update(playlist);
|
||||
return playlistId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
@Dao
|
||||
interface PlaylistDAO : BasicDAO<PlaylistEntity> {
|
||||
|
||||
@Query("SELECT * FROM playlists")
|
||||
override fun getAll(): Flowable<List<PlaylistEntity>>
|
||||
|
||||
@Query("DELETE FROM playlists")
|
||||
override fun deleteAll(): Int
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<PlaylistEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE uid = :playlistId")
|
||||
fun getPlaylist(playlistId: Long): Flowable<MutableList<PlaylistEntity>>
|
||||
|
||||
@Query("DELETE FROM playlists WHERE uid = :playlistId")
|
||||
fun deletePlaylist(playlistId: Long): Int
|
||||
|
||||
@get:Query("SELECT COUNT(*) FROM playlists")
|
||||
val count: Flowable<Long>
|
||||
|
||||
@Transaction
|
||||
fun upsertPlaylist(playlist: PlaylistEntity): Long {
|
||||
if (playlist.uid == -1L) {
|
||||
// This situation is probably impossible.
|
||||
return insert(playlist)
|
||||
} else {
|
||||
update(playlist)
|
||||
return playlist.uid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
|
||||
|
||||
@Dao
|
||||
public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
|
||||
Flowable<List<PlaylistRemoteEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylists();
|
||||
|
||||
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
|
||||
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
Long getPlaylistIdInternal(long serviceId, String url);
|
||||
|
||||
@Transaction
|
||||
default long upsert(final PlaylistRemoteEntity playlist) {
|
||||
final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl());
|
||||
|
||||
if (playlistId == null) {
|
||||
return insert(playlist);
|
||||
} else {
|
||||
playlist.setUid(playlistId);
|
||||
update(playlist);
|
||||
return playlistId;
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
int deletePlaylist(long playlistId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
|
||||
@Dao
|
||||
interface PlaylistRemoteDAO : BasicDAO<PlaylistRemoteEntity> {
|
||||
|
||||
@Query("SELECT * FROM remote_playlists")
|
||||
override fun getAll(): Flowable<List<PlaylistRemoteEntity>>
|
||||
|
||||
@Query("DELETE FROM remote_playlists")
|
||||
override fun deleteAll(): Int
|
||||
|
||||
@Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId")
|
||||
override fun listByService(serviceId: Int): Flowable<List<PlaylistRemoteEntity>>
|
||||
|
||||
@Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
|
||||
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity>
|
||||
|
||||
@Query("SELECT * FROM remote_playlists WHERE url = :url AND uid = :serviceId")
|
||||
fun getPlaylist(serviceId: Long, url: String?): Flowable<MutableList<PlaylistRemoteEntity>>
|
||||
|
||||
@get:Query("SELECT * FROM remote_playlists ORDER BY display_index")
|
||||
val playlists: Flowable<MutableList<PlaylistRemoteEntity>>
|
||||
|
||||
@Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
|
||||
fun getPlaylistIdInternal(serviceId: Long, url: String?): Long?
|
||||
|
||||
@Transaction
|
||||
fun upsert(playlist: PlaylistRemoteEntity): Long {
|
||||
val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url)
|
||||
|
||||
if (playlistId == null) {
|
||||
return insert(playlist)
|
||||
} else {
|
||||
playlist.uid = playlistId
|
||||
update(playlist)
|
||||
return playlistId
|
||||
}
|
||||
}
|
||||
|
||||
@Query("DELETE FROM remote_playlists WHERE uid = :playlistId")
|
||||
fun deletePlaylist(playlistId: Long): Int
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
|
||||
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Dao
|
||||
public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
|
||||
Flowable<List<PlaylistStreamEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
default Flowable<List<PlaylistStreamEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
void deleteBatch(long playlistId);
|
||||
|
||||
@Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)"
|
||||
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
|
||||
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID
|
||||
+ " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END"
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
|
||||
+ " LIMIT 1"
|
||||
)
|
||||
Flowable<Long> getAutomaticThumbnailStreamId(long playlistId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||
// get ids of streams of the given playlist
|
||||
+ "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
|
||||
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
|
||||
|
||||
// then merge with the stream metadata
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
|
||||
|
||||
+ " ORDER BY " + JOIN_INDEX + " ASC")
|
||||
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
|
||||
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
|
||||
+ PLAYLIST_DISPLAY_INDEX + ", "
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
|
||||
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
|
||||
|
||||
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT
|
||||
+ " FROM " + PLAYLIST_TABLE
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||
+ " GROUP BY " + PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
|
||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT *, MIN(" + JOIN_INDEX + ")"
|
||||
+ " FROM " + STREAM_TABLE + " INNER JOIN"
|
||||
+ " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
|
||||
+ " FROM " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " LEFT JOIN "
|
||||
+ "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
|
||||
+ STREAM_PROGRESS_MILLIS
|
||||
+ " FROM " + STREAM_STATE_TABLE + " )"
|
||||
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
|
||||
+ " GROUP BY " + STREAM_ID
|
||||
+ " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
|
||||
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
|
||||
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
|
||||
+ PLAYLIST_DISPLAY_INDEX + ", "
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
+ " ELSE (SELECT " + STREAM_THUMBNAIL_URL
|
||||
+ " FROM " + STREAM_TABLE
|
||||
+ " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
|
||||
+ " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
|
||||
|
||||
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
|
||||
+ "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
|
||||
+ PLAYLIST_TIMES_STREAM_IS_CONTAINED
|
||||
|
||||
+ " FROM " + PLAYLIST_TABLE
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||
|
||||
+ " LEFT JOIN " + STREAM_TABLE
|
||||
+ " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||
+ " AND :streamUrl = :streamUrl"
|
||||
|
||||
+ " GROUP BY " + JOIN_PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX + ", " + PLAYLIST_NAME)
|
||||
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
|
||||
@Dao
|
||||
interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
|
||||
|
||||
@Query("SELECT * FROM playlist_stream_join")
|
||||
override fun getAll(): Flowable<List<PlaylistStreamEntity>>
|
||||
|
||||
@Query("DELETE FROM playlist_stream_join")
|
||||
override fun deleteAll(): Int
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<PlaylistStreamEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
@Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId")
|
||||
fun deleteBatch(playlistId: Long)
|
||||
|
||||
@Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId")
|
||||
fun getMaximumIndexOf(playlistId: Long): Flowable<Int>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END
|
||||
FROM streams
|
||||
|
||||
LEFT JOIN playlist_stream_join
|
||||
ON uid = stream_id
|
||||
|
||||
WHERE playlist_id = :playlistId LIMIT 1
|
||||
"""
|
||||
)
|
||||
fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable<Long>
|
||||
|
||||
// get ids of streams of the given playlist then merge with the stream metadata
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM streams
|
||||
|
||||
INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
|
||||
ON uid = stream_id
|
||||
|
||||
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
|
||||
ON uid = stream_id_alias
|
||||
|
||||
ORDER BY join_index ASC
|
||||
"""
|
||||
)
|
||||
fun getOrderedStreamsOf(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
|
||||
(SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
|
||||
|
||||
COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists
|
||||
|
||||
LEFT JOIN playlist_stream_join
|
||||
ON playlists.uid = playlist_id
|
||||
|
||||
GROUP BY uid
|
||||
ORDER BY display_index
|
||||
"""
|
||||
)
|
||||
fun getPlaylistMetadata(): Flowable<MutableList<PlaylistMetadataEntry>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT *, MIN(join_index) FROM streams
|
||||
|
||||
INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
|
||||
ON uid = stream_id
|
||||
|
||||
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
|
||||
ON uid = stream_id_alias
|
||||
|
||||
GROUP BY uid
|
||||
ORDER BY MIN(join_index) ASC
|
||||
"""
|
||||
)
|
||||
fun getStreamsWithoutDuplicates(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
|
||||
(SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
|
||||
|
||||
COALESCE(COUNT(playlist_id), 0) AS streamCount,
|
||||
COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists
|
||||
|
||||
LEFT JOIN playlist_stream_join
|
||||
ON playlists.uid = playlist_id
|
||||
|
||||
LEFT JOIN streams
|
||||
ON streams.uid = stream_id AND :streamUrl = :streamUrl
|
||||
|
||||
GROUP BY playlist_id
|
||||
ORDER BY display_index, name
|
||||
"""
|
||||
)
|
||||
fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable<MutableList<PlaylistDuplicatesEntry>>
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
|
||||
@Entity(tableName = PLAYLIST_TABLE)
|
||||
public class PlaylistEntity {
|
||||
|
||||
public static final String DEFAULT_THUMBNAIL = "drawable://"
|
||||
+ R.drawable.placeholder_thumbnail_playlist;
|
||||
public static final long DEFAULT_THUMBNAIL_ID = -1;
|
||||
|
||||
public static final String PLAYLIST_TABLE = "playlists";
|
||||
public static final String PLAYLIST_ID = "uid";
|
||||
public static final String PLAYLIST_NAME = "name";
|
||||
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||
public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
|
||||
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
|
||||
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
private long uid = 0;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_NAME)
|
||||
private String name;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
private boolean isThumbnailPermanent;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
private long thumbnailStreamId;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex;
|
||||
|
||||
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
|
||||
final long thumbnailStreamId, final long displayIndex) {
|
||||
this.name = name;
|
||||
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistEntity(final PlaylistMetadataEntry item) {
|
||||
this.uid = item.getUid();
|
||||
this.name = item.name;
|
||||
this.isThumbnailPermanent = item.isThumbnailPermanent();
|
||||
this.thumbnailStreamId = item.getThumbnailStreamId();
|
||||
this.displayIndex = item.getDisplayIndex();
|
||||
}
|
||||
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
public void setUid(final long uid) {
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(final String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public long getThumbnailStreamId() {
|
||||
return thumbnailStreamId;
|
||||
}
|
||||
|
||||
public void setThumbnailStreamId(final long thumbnailStreamId) {
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
}
|
||||
|
||||
public boolean getIsThumbnailPermanent() {
|
||||
return isThumbnailPermanent;
|
||||
}
|
||||
|
||||
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
|
||||
this.isThumbnailPermanent = isThumbnailSet;
|
||||
}
|
||||
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
|
||||
|
||||
@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE)
|
||||
data class PlaylistEntity @JvmOverloads constructor(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_NAME)
|
||||
var name: String?,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
var isThumbnailPermanent: Boolean,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
var thumbnailStreamId: Long,
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
|
||||
var displayIndex: Long
|
||||
) {
|
||||
|
||||
@Ignore
|
||||
constructor(item: PlaylistMetadataEntry) : this(
|
||||
uid = item.uid,
|
||||
name = item.orderingName,
|
||||
isThumbnailPermanent = item.isThumbnailPermanent!!,
|
||||
thumbnailStreamId = item.thumbnailStreamId!!,
|
||||
displayIndex = item.displayIndex!!,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_THUMBNAIL_ID = -1L
|
||||
|
||||
const val PLAYLIST_TABLE = "playlists"
|
||||
const val PLAYLIST_ID = "uid"
|
||||
const val PLAYLIST_NAME = "name"
|
||||
const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
|
||||
const val PLAYLIST_DISPLAY_INDEX = "display_index"
|
||||
const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"
|
||||
const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.model;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
|
||||
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
|
||||
|
||||
@Entity(tableName = REMOTE_PLAYLIST_TABLE,
|
||||
indices = {
|
||||
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
|
||||
})
|
||||
public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists";
|
||||
public static final String REMOTE_PLAYLIST_ID = "uid";
|
||||
public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id";
|
||||
public static final String REMOTE_PLAYLIST_NAME = "name";
|
||||
public static final String REMOTE_PLAYLIST_URL = "url";
|
||||
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
|
||||
public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
|
||||
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_ID)
|
||||
private long uid = 0;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
|
||||
private int serviceId = Constants.NO_SERVICE_ID;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_NAME)
|
||||
private String name;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_URL)
|
||||
private String url;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
|
||||
private String thumbnailUrl;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
|
||||
private String uploader;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex = -1; // Make sure the new item is on the top
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
|
||||
private Long streamCount;
|
||||
|
||||
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
|
||||
final String thumbnailUrl, final String uploader,
|
||||
final Long streamCount) {
|
||||
this.serviceId = serviceId;
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.uploader = uploader;
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
|
||||
final String thumbnailUrl, final String uploader,
|
||||
final long displayIndex, final Long streamCount) {
|
||||
this.serviceId = serviceId;
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.uploader = uploader;
|
||||
this.displayIndex = displayIndex;
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistRemoteEntity(final PlaylistInfo info) {
|
||||
this(info.getServiceId(), info.getName(), info.getUrl(),
|
||||
// use uploader avatar when no thumbnail is available
|
||||
ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
|
||||
? info.getUploaderAvatars() : info.getThumbnails()),
|
||||
info.getUploaderName(), info.getStreamCount());
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public boolean isIdenticalTo(final PlaylistInfo info) {
|
||||
/*
|
||||
* Returns boolean comparing the online playlist and the local copy.
|
||||
* (False if info changed such as playlist name or track count)
|
||||
*/
|
||||
return getServiceId() == info.getServiceId()
|
||||
&& getStreamCount() == info.getStreamCount()
|
||||
&& TextUtils.equals(getName(), info.getName())
|
||||
&& TextUtils.equals(getUrl(), info.getUrl())
|
||||
// we want to update the local playlist data even when either the remote thumbnail
|
||||
// URL changes, or the preferred image quality setting is changed by the user
|
||||
&& TextUtils.equals(getThumbnailUrl(),
|
||||
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
|
||||
&& TextUtils.equals(getUploader(), info.getUploaderName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
public void setUid(final long uid) {
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(final int serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(final String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
public void setThumbnailUrl(final String thumbnailUrl) {
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(final String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getUploader() {
|
||||
return uploader;
|
||||
}
|
||||
|
||||
public void setUploader(final String uploader) {
|
||||
this.uploader = uploader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
public Long getStreamCount() {
|
||||
return streamCount;
|
||||
}
|
||||
|
||||
public void setStreamCount(final Long streamCount) {
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalItemType getLocalItemType() {
|
||||
return PLAYLIST_REMOTE_ITEM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOrderingName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import org.schabi.newpipe.database.LocalItem.LocalItemType
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
|
||||
import org.schabi.newpipe.util.NO_SERVICE_ID
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@Entity(
|
||||
tableName = REMOTE_PLAYLIST_TABLE,
|
||||
indices = [
|
||||
Index(
|
||||
value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL],
|
||||
unique = true
|
||||
)
|
||||
]
|
||||
)
|
||||
data class PlaylistRemoteEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_ID)
|
||||
override var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
|
||||
val serviceId: Int = NO_SERVICE_ID,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_NAME)
|
||||
override val orderingName: String?,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_URL)
|
||||
val url: String?,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
|
||||
override val thumbnailUrl: String?,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
|
||||
val uploader: String?,
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
|
||||
override var displayIndex: Long = -1, // Make sure the new item is on the top
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
|
||||
val streamCount: Long?
|
||||
) : PlaylistLocalItem {
|
||||
|
||||
constructor(playlistInfo: PlaylistInfo) : this(
|
||||
serviceId = playlistInfo.serviceId,
|
||||
orderingName = playlistInfo.name,
|
||||
url = playlistInfo.url,
|
||||
thumbnailUrl = ImageStrategy.imageListToDbUrl(
|
||||
if (playlistInfo.thumbnails.isEmpty()) {
|
||||
playlistInfo.uploaderAvatars
|
||||
} else {
|
||||
playlistInfo.thumbnails
|
||||
}
|
||||
),
|
||||
uploader = playlistInfo.uploaderName,
|
||||
streamCount = playlistInfo.streamCount
|
||||
)
|
||||
|
||||
override val localItemType: LocalItemType
|
||||
get() = LocalItemType.PLAYLIST_REMOTE_ITEM
|
||||
|
||||
/**
|
||||
* Returns boolean comparing the online playlist and the local copy.
|
||||
* (False if info changed such as playlist name or track count)
|
||||
*/
|
||||
@Ignore
|
||||
fun isIdenticalTo(info: PlaylistInfo): Boolean {
|
||||
return this.serviceId == info.serviceId && this.streamCount == info.streamCount &&
|
||||
TextUtils.equals(this.orderingName, info.name) &&
|
||||
TextUtils.equals(this.url, info.url) &&
|
||||
// we want to update the local playlist data even when either the remote thumbnail
|
||||
// URL changes, or the preferred image quality setting is changed by the user
|
||||
TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) &&
|
||||
TextUtils.equals(this.uploader, info.uploaderName)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REMOTE_PLAYLIST_TABLE = "remote_playlists"
|
||||
const val REMOTE_PLAYLIST_ID = "uid"
|
||||
const val REMOTE_PLAYLIST_SERVICE_ID = "service_id"
|
||||
const val REMOTE_PLAYLIST_NAME = "name"
|
||||
const val REMOTE_PLAYLIST_URL = "url"
|
||||
const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
|
||||
const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"
|
||||
const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index"
|
||||
const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.schabi.newpipe.database.playlist.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
import androidx.room.Index;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
|
||||
import static androidx.room.ForeignKey.CASCADE;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
||||
|
||||
@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE,
|
||||
primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX},
|
||||
indices = {
|
||||
@Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true),
|
||||
@Index(value = {JOIN_STREAM_ID})
|
||||
},
|
||||
foreignKeys = {
|
||||
@ForeignKey(entity = PlaylistEntity.class,
|
||||
parentColumns = PlaylistEntity.PLAYLIST_ID,
|
||||
childColumns = JOIN_PLAYLIST_ID,
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true),
|
||||
@ForeignKey(entity = StreamEntity.class,
|
||||
parentColumns = StreamEntity.STREAM_ID,
|
||||
childColumns = JOIN_STREAM_ID,
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true)
|
||||
})
|
||||
public class PlaylistStreamEntity {
|
||||
public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join";
|
||||
public static final String JOIN_PLAYLIST_ID = "playlist_id";
|
||||
public static final String JOIN_STREAM_ID = "stream_id";
|
||||
public static final String JOIN_INDEX = "join_index";
|
||||
|
||||
@ColumnInfo(name = JOIN_PLAYLIST_ID)
|
||||
private long playlistUid;
|
||||
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
private long streamUid;
|
||||
|
||||
@ColumnInfo(name = JOIN_INDEX)
|
||||
private int index;
|
||||
|
||||
public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) {
|
||||
this.playlistUid = playlistUid;
|
||||
this.streamUid = streamUid;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public long getPlaylistUid() {
|
||||
return playlistUid;
|
||||
}
|
||||
|
||||
public void setPlaylistUid(final long playlistUid) {
|
||||
this.playlistUid = playlistUid;
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
return streamUid;
|
||||
}
|
||||
|
||||
public void setStreamUid(final long streamUid) {
|
||||
this.streamUid = streamUid;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public void setIndex(final int index) {
|
||||
this.index = index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2020 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
|
||||
@Entity(
|
||||
tableName = PLAYLIST_STREAM_JOIN_TABLE,
|
||||
primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX],
|
||||
indices = [
|
||||
Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true),
|
||||
Index(value = [JOIN_STREAM_ID])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = PlaylistEntity::class,
|
||||
parentColumns = arrayOf(PLAYLIST_ID),
|
||||
childColumns = arrayOf(JOIN_PLAYLIST_ID),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE,
|
||||
deferred = true
|
||||
),
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(StreamEntity.STREAM_ID),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE,
|
||||
deferred = true
|
||||
)
|
||||
]
|
||||
)
|
||||
data class PlaylistStreamEntity(
|
||||
@ColumnInfo(name = JOIN_PLAYLIST_ID)
|
||||
val playlistUid: Long,
|
||||
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
val streamUid: Long,
|
||||
|
||||
@ColumnInfo(name = JOIN_INDEX)
|
||||
val index: Int
|
||||
) : LocalItem {
|
||||
|
||||
override val localItemType: LocalItem.LocalItemType
|
||||
get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
|
||||
|
||||
companion object {
|
||||
const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"
|
||||
const val JOIN_PLAYLIST_ID = "playlist_id"
|
||||
const val JOIN_STREAM_ID = "stream_id"
|
||||
const val JOIN_INDEX = "join_index"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020-2023 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.stream
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Ignore
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class StreamStatisticsEntry(
|
||||
data class StreamStatisticsEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@@ -26,18 +33,23 @@ class StreamStatisticsEntry(
|
||||
@ColumnInfo(name = STREAM_WATCH_COUNT)
|
||||
val watchCount: Long
|
||||
) : LocalItem {
|
||||
|
||||
override val localItemType: LocalItem.LocalItemType
|
||||
get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
|
||||
|
||||
@Ignore
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.uploaderUrl = streamEntity.uploaderUrl
|
||||
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||
return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
|
||||
return StreamInfoItem(
|
||||
streamEntity.serviceId,
|
||||
streamEntity.url,
|
||||
streamEntity.title,
|
||||
streamEntity.streamType
|
||||
).apply {
|
||||
duration = streamEntity.duration
|
||||
uploaderName = streamEntity.uploader
|
||||
uploaderUrl = streamEntity.uploaderUrl
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package org.schabi.newpipe.database.stream.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Dao
|
||||
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
|
||||
@Override
|
||||
@Query("SELECT * FROM " + STREAM_STATE_TABLE)
|
||||
Flowable<List<StreamStateEntity>> getAll();
|
||||
|
||||
@Override
|
||||
@Query("DELETE FROM " + STREAM_STATE_TABLE)
|
||||
int deleteAll();
|
||||
|
||||
@Override
|
||||
default Flowable<List<StreamStateEntity>> listByService(final int serviceId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
Flowable<List<StreamStateEntity>> getState(long streamId);
|
||||
|
||||
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
int deleteState(long streamId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
void silentInsertInternal(StreamStateEntity streamState);
|
||||
|
||||
@Transaction
|
||||
default long upsert(final StreamStateEntity stream) {
|
||||
silentInsertInternal(stream);
|
||||
return update(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2021 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.stream.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
|
||||
@Dao
|
||||
interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
|
||||
override fun getAll(): Flowable<List<StreamStateEntity>>
|
||||
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
|
||||
override fun deleteAll(): Int
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
|
||||
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun deleteState(streamId: Long): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
|
||||
fun silentInsertInternal(streamState: StreamStateEntity)
|
||||
|
||||
@Transaction
|
||||
fun upsert(stream: StreamStateEntity): Long {
|
||||
silentInsertInternal(stream)
|
||||
return update(stream).toLong()
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package org.schabi.newpipe.database.stream.model;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import static androidx.room.ForeignKey.CASCADE;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||
|
||||
@Entity(tableName = STREAM_STATE_TABLE,
|
||||
primaryKeys = {JOIN_STREAM_ID},
|
||||
foreignKeys = {
|
||||
@ForeignKey(entity = StreamEntity.class,
|
||||
parentColumns = StreamEntity.STREAM_ID,
|
||||
childColumns = JOIN_STREAM_ID,
|
||||
onDelete = CASCADE, onUpdate = CASCADE)
|
||||
})
|
||||
public class StreamStateEntity {
|
||||
public static final String STREAM_STATE_TABLE = "stream_state";
|
||||
public static final String JOIN_STREAM_ID = "stream_id";
|
||||
// This additional field is required for the SQL query because 'stream_id' is used
|
||||
// for some other joins already
|
||||
public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias";
|
||||
public static final String STREAM_PROGRESS_MILLIS = "progress_time";
|
||||
|
||||
/**
|
||||
* Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
|
||||
*/
|
||||
public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
|
||||
|
||||
/**
|
||||
* Stream will be considered finished if the playback time left exceeds this threshold
|
||||
* (60000ms = 60s).
|
||||
* @see #isFinished(long)
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
|
||||
*/
|
||||
public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000;
|
||||
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
private long streamUid;
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
|
||||
private long progressMillis;
|
||||
|
||||
public StreamStateEntity(final long streamUid, final long progressMillis) {
|
||||
this.streamUid = streamUid;
|
||||
this.progressMillis = progressMillis;
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
return streamUid;
|
||||
}
|
||||
|
||||
public void setStreamUid(final long streamUid) {
|
||||
this.streamUid = streamUid;
|
||||
}
|
||||
|
||||
public long getProgressMillis() {
|
||||
return progressMillis;
|
||||
}
|
||||
|
||||
public void setProgressMillis(final long progressMillis) {
|
||||
this.progressMillis = progressMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state will be considered valid, and thus be saved, if the progress is more than {@link
|
||||
* #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether this stream state entity should be saved or not
|
||||
*/
|
||||
public boolean isValid(final long durationInSeconds) {
|
||||
return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|
||||
|| progressMillis > durationInSeconds * 1000 / 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* The video will be considered as finished, if the time left is less than {@link
|
||||
* #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length.
|
||||
* The state will be saved anyway, so that it can be shown under stream info items, but the
|
||||
* player will not resume if a state is considered as finished. Finished streams are also the
|
||||
* ones that can be filtered out in the feed fragment.
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
|
||||
* @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether the stream is finished or not
|
||||
*/
|
||||
public boolean isFinished(final long durationInSeconds) {
|
||||
return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
&& progressMillis >= durationInSeconds * 1000 * 3 / 4;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable final Object obj) {
|
||||
if (obj instanceof StreamStateEntity) {
|
||||
return ((StreamStateEntity) obj).streamUid == streamUid
|
||||
&& ((StreamStateEntity) obj).progressMillis == progressMillis;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(streamUid, progressMillis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2023 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.stream.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
|
||||
|
||||
@Entity(
|
||||
tableName = STREAM_STATE_TABLE,
|
||||
primaryKeys = [JOIN_STREAM_ID],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(STREAM_ID),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class StreamStateEntity(
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
val streamUid: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
|
||||
val progressMillis: Long
|
||||
) {
|
||||
/**
|
||||
* The state will be considered valid, and thus be saved, if the progress is more than
|
||||
* [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length.
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether this stream state entity should be saved or not
|
||||
*/
|
||||
fun isValid(durationInSeconds: Long): Boolean {
|
||||
return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS ||
|
||||
progressMillis > durationInSeconds * 1000 / 4
|
||||
}
|
||||
|
||||
/**
|
||||
* The video will be considered as finished, if the time left is less than
|
||||
* [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length.
|
||||
* The state will be saved anyway, so that it can be shown under stream info items, but the
|
||||
* player will not resume if a state is considered as finished. Finished streams are also the
|
||||
* ones that can be filtered out in the feed fragment.
|
||||
* @param durationInSeconds the duration of the stream connected with this state, in seconds
|
||||
* @return whether the stream is finished or not
|
||||
*/
|
||||
fun isFinished(durationInSeconds: Long): Boolean {
|
||||
return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS &&
|
||||
progressMillis >= durationInSeconds * 1000 * 3 / 4
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_STATE_TABLE = "stream_state"
|
||||
const val JOIN_STREAM_ID = "stream_id"
|
||||
|
||||
// This additional field is required for the SQL query because 'stream_id' is used
|
||||
// for some other joins already
|
||||
const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
|
||||
const val STREAM_PROGRESS_MILLIS = "progress_time"
|
||||
|
||||
/**
|
||||
* Playback state will not be saved, if playback time is less than this threshold
|
||||
* (5000ms = 5s).
|
||||
*/
|
||||
const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L
|
||||
|
||||
/**
|
||||
* Stream will be considered finished if the playback time left exceeds this threshold
|
||||
* (60000ms = 60s).
|
||||
* @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished
|
||||
*/
|
||||
const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface NotificationMode {
|
||||
|
||||
int DISABLED = 0;
|
||||
int ENABLED = 1;
|
||||
//other values reserved for the future
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2021 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.subscription
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
|
||||
@IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class NotificationMode {
|
||||
companion object {
|
||||
const val DISABLED = 0
|
||||
const val ENABLED = 1 // other values reserved for the future
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
if (uidFromInsert != -1L) {
|
||||
entity.uid = uidFromInsert
|
||||
} else {
|
||||
val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url)
|
||||
val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!)
|
||||
?: throw IllegalStateException("Subscription cannot be null just after insertion.")
|
||||
entity.uid = subscriptionIdFromDb
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
|
||||
|
||||
@Entity(tableName = SUBSCRIPTION_TABLE,
|
||||
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
||||
public class SubscriptionEntity {
|
||||
public static final String SUBSCRIPTION_UID = "uid";
|
||||
public static final String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
public static final String SUBSCRIPTION_URL = "url";
|
||||
public static final String SUBSCRIPTION_NAME = "name";
|
||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
|
||||
private int serviceId = Constants.NO_SERVICE_ID;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_URL)
|
||||
private String url;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NAME)
|
||||
private String name;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
|
||||
private String avatarUrl;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
|
||||
private Long subscriberCount;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
private String description;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
private int notificationMode;
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
result.setServiceId(info.getServiceId());
|
||||
result.setUrl(info.getUrl());
|
||||
result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
|
||||
info.getDescription(), info.getSubscriberCount());
|
||||
return result;
|
||||
}
|
||||
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
public void setUid(final long uid) {
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public void setServiceId(final int serviceId) {
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(final String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(final String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
public void setAvatarUrl(@Nullable final String avatarUrl) {
|
||||
this.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
public Long getSubscriberCount() {
|
||||
return subscriberCount;
|
||||
}
|
||||
|
||||
public void setSubscriberCount(final Long subscriberCount) {
|
||||
this.subscriberCount = subscriberCount;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(final String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@NotificationMode
|
||||
public int getNotificationMode() {
|
||||
return notificationMode;
|
||||
}
|
||||
|
||||
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||
this.notificationMode = notificationMode;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||
this.setName(n);
|
||||
this.setAvatarUrl(au);
|
||||
this.setDescription(d);
|
||||
this.setSubscriberCount(sc);
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public ChannelInfoItem toChannelInfoItem() {
|
||||
final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
|
||||
item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()));
|
||||
item.setSubscriberCount(getSubscriberCount());
|
||||
item.setDescription(getDescription());
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
// TODO: Remove these generated methods by migrating this class to a data class from Kotlin.
|
||||
@Override
|
||||
@SuppressWarnings("EqualsReplaceableByObjectsCall")
|
||||
public boolean equals(final Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final SubscriptionEntity that = (SubscriptionEntity) o;
|
||||
|
||||
if (uid != that.uid) {
|
||||
return false;
|
||||
}
|
||||
if (serviceId != that.serviceId) {
|
||||
return false;
|
||||
}
|
||||
if (!url.equals(that.url)) {
|
||||
return false;
|
||||
}
|
||||
if (name != null ? !name.equals(that.name) : that.name != null) {
|
||||
return false;
|
||||
}
|
||||
if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) {
|
||||
return false;
|
||||
}
|
||||
if (subscriberCount != null
|
||||
? !subscriberCount.equals(that.subscriberCount)
|
||||
: that.subscriberCount != null) {
|
||||
return false;
|
||||
}
|
||||
return description != null
|
||||
? description.equals(that.description)
|
||||
: that.description == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = (int) (uid ^ (uid >>> 32));
|
||||
result = 31 * result + serviceId;
|
||||
result = 31 * result + url.hashCode();
|
||||
result = 31 * result + (name != null ? name.hashCode() : 0);
|
||||
result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0);
|
||||
result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0);
|
||||
result = 31 * result + (description != null ? description.hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.subscription
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.util.NO_SERVICE_ID
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@Entity(
|
||||
tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE,
|
||||
indices = [
|
||||
Index(
|
||||
value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL],
|
||||
unique = true
|
||||
)
|
||||
]
|
||||
)
|
||||
data class SubscriptionEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
|
||||
var serviceId: Int = NO_SERVICE_ID,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_URL)
|
||||
var url: String? = null,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NAME)
|
||||
var name: String? = null,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
|
||||
var avatarUrl: String? = null,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
|
||||
var subscriberCount: Long? = null,
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
var description: String? = null,
|
||||
|
||||
@get:NotificationMode
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
var notificationMode: Int = 0
|
||||
) {
|
||||
@Ignore
|
||||
fun toChannelInfoItem(): ChannelInfoItem {
|
||||
return ChannelInfoItem(this.serviceId, this.url, this.name).apply {
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl)
|
||||
subscriberCount = this.subscriberCount
|
||||
description = this.description
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SUBSCRIPTION_UID: String = "uid"
|
||||
const val SUBSCRIPTION_TABLE: String = "subscriptions"
|
||||
const val SUBSCRIPTION_SERVICE_ID: String = "service_id"
|
||||
const val SUBSCRIPTION_URL: String = "url"
|
||||
const val SUBSCRIPTION_NAME: String = "name"
|
||||
const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url"
|
||||
const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count"
|
||||
const val SUBSCRIPTION_DESCRIPTION: String = "description"
|
||||
const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode"
|
||||
|
||||
@JvmStatic
|
||||
@Ignore
|
||||
fun from(info: ChannelInfo): SubscriptionEntity {
|
||||
return SubscriptionEntity(
|
||||
serviceId = info.serviceId,
|
||||
url = info.url,
|
||||
name = info.name,
|
||||
avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars),
|
||||
description = info.description,
|
||||
subscriberCount = info.subscriberCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1416,10 +1416,8 @@ public final class VideoDetailFragment
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
// Rebound to the service if it was closed via notification or mini player
|
||||
if (!playerHolder.isBound()) {
|
||||
playerHolder.startService(
|
||||
false, VideoDetailFragment.this);
|
||||
}
|
||||
playerHolder.setListener(VideoDetailFragment.this);
|
||||
playerHolder.tryBindIfNeeded(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,10 +361,10 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
final SubscriptionEntity channel = new SubscriptionEntity();
|
||||
channel.setServiceId(info.getServiceId());
|
||||
channel.setUrl(info.getUrl());
|
||||
channel.setData(info.getName(),
|
||||
ImageStrategy.imageListToDbUrl(info.getAvatars()),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
channel.setName(info.getName());
|
||||
channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars()));
|
||||
channel.setDescription(info.getDescription());
|
||||
channel.setSubscriberCount(info.getSubscriberCount());
|
||||
channelSubscription = null;
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel));
|
||||
|
||||
@@ -76,7 +76,8 @@ public class SuggestionListAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private static class SuggestionItemCallback extends DiffUtil.ItemCallback<SuggestionItem> {
|
||||
private static final class SuggestionItemCallback
|
||||
extends DiffUtil.ItemCallback<SuggestionItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
|
||||
@NonNull final SuggestionItem newItem) {
|
||||
|
||||
@@ -140,7 +140,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
if (selectedItem instanceof PlaylistMetadataEntry) {
|
||||
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
|
||||
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
|
||||
entry.name);
|
||||
entry.getOrderingName());
|
||||
|
||||
} else if (selectedItem instanceof PlaylistRemoteEntity) {
|
||||
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
|
||||
@@ -148,7 +148,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
fragmentManager,
|
||||
entry.getServiceId(),
|
||||
entry.getUrl(),
|
||||
entry.getName());
|
||||
entry.getOrderingName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,11 +378,11 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
|
||||
if (item instanceof PlaylistMetadataEntry
|
||||
&& ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
|
||||
((PlaylistMetadataEntry) item).setDisplayIndex(i);
|
||||
((PlaylistMetadataEntry) item).setDisplayIndex((long) i);
|
||||
localItemsUpdate.add((PlaylistMetadataEntry) item);
|
||||
} else if (item instanceof PlaylistRemoteEntity
|
||||
&& ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
|
||||
((PlaylistRemoteEntity) item).setDisplayIndex(i);
|
||||
((PlaylistRemoteEntity) item).setDisplayIndex((long) i);
|
||||
remoteItemsUpdate.add((PlaylistRemoteEntity) item);
|
||||
}
|
||||
}
|
||||
@@ -487,7 +487,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
|
||||
showDeleteDialog(item.getName(), item);
|
||||
showDeleteDialog(item.getOrderingName(), item);
|
||||
}
|
||||
|
||||
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
|
||||
@@ -508,7 +508,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
if (items.get(index).equals(rename)) {
|
||||
showRenameDialog(selectedItem);
|
||||
} else if (items.get(index).equals(delete)) {
|
||||
showDeleteDialog(selectedItem.name, selectedItem);
|
||||
showDeleteDialog(selectedItem.getOrderingName(), selectedItem);
|
||||
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
|
||||
final long thumbnailStreamId = localPlaylistManager
|
||||
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
|
||||
@@ -529,7 +529,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
DialogEditTextBinding.inflate(getLayoutInflater());
|
||||
dialogBinding.dialogEditText.setHint(R.string.name);
|
||||
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
dialogBinding.dialogEditText.setText(selectedItem.name);
|
||||
dialogBinding.dialogEditText.setText(selectedItem.getOrderingName());
|
||||
|
||||
new AlertDialog.Builder(activity)
|
||||
.setView(dialogBinding.getRoot())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.local.dialog;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL_ID;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -14,7 +16,6 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.local.LocalItemListAdapter;
|
||||
@@ -138,7 +139,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
|
||||
private boolean anyPlaylistContainsDuplicates(final List<PlaylistDuplicatesEntry> playlists) {
|
||||
return playlists.stream()
|
||||
.anyMatch(playlist -> playlist.timesStreamIsContained > 0);
|
||||
.anyMatch(playlist -> playlist.getTimesStreamIsContained() > 0);
|
||||
}
|
||||
|
||||
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
|
||||
@@ -146,9 +147,9 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
@NonNull final List<StreamEntity> streams) {
|
||||
|
||||
final String toastText;
|
||||
if (playlist.timesStreamIsContained > 0) {
|
||||
if (playlist.getTimesStreamIsContained() > 0) {
|
||||
toastText = getString(R.string.playlist_add_stream_success_duplicate,
|
||||
playlist.timesStreamIsContained);
|
||||
playlist.getTimesStreamIsContained());
|
||||
} else {
|
||||
toastText = getString(R.string.playlist_add_stream_success);
|
||||
}
|
||||
@@ -160,8 +161,9 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
.subscribe(ignored -> {
|
||||
successToast.show();
|
||||
|
||||
if (playlist.thumbnailUrl != null
|
||||
&& playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
|
||||
if (playlist.getThumbnailStreamId() != null
|
||||
&& playlist.getThumbnailStreamId() == DEFAULT_THUMBNAIL_ID
|
||||
) {
|
||||
playlistDisposables.add(manager
|
||||
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
|
||||
false)
|
||||
|
||||
@@ -177,7 +177,7 @@ class FeedDatabaseManager(context: Context) {
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime>> {
|
||||
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<OffsetDateTime?>> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
|
||||
else -> feedTable.oldestSubscriptionUpdate(groupId)
|
||||
|
||||
@@ -507,7 +507,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
.setTitle(R.string.feed_load_error)
|
||||
.setPositiveButton(R.string.unsubscribe) { _, _ ->
|
||||
SubscriptionManager(requireContext())
|
||||
.deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url!!)
|
||||
.subscribe()
|
||||
handleItemsErrors(nextItemsErrors)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class FeedViewModel(
|
||||
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
|
||||
|
||||
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
|
||||
t5: Long, t6: List<OffsetDateTime> ->
|
||||
t5: Long, t6: List<OffsetDateTime?> ->
|
||||
return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,15 +35,15 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
|
||||
}
|
||||
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
|
||||
|
||||
itemTitleView.setText(item.name);
|
||||
itemTitleView.setText(item.getOrderingName());
|
||||
itemStreamCountView.setText(Localization.localizeStreamCountMini(
|
||||
itemStreamCountView.getContext(), item.streamCount));
|
||||
itemStreamCountView.getContext(), item.getStreamCount()));
|
||||
itemUploaderView.setVisibility(View.INVISIBLE);
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView);
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
|
||||
if (item instanceof PlaylistDuplicatesEntry
|
||||
&& ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) {
|
||||
&& ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) {
|
||||
itemView.setAlpha(GRAYED_OUT_ALPHA);
|
||||
} else {
|
||||
itemView.setAlpha(1.0f);
|
||||
|
||||
@@ -34,7 +34,7 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
|
||||
}
|
||||
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
|
||||
|
||||
itemTitleView.setText(item.getName());
|
||||
itemTitleView.setText(item.getOrderingName());
|
||||
itemStreamCountView.setText(Localization.localizeStreamCountMini(
|
||||
itemStreamCountView.getContext(), item.getStreamCount()));
|
||||
// Here is where the uploader name is set in the bookmarked playlists library
|
||||
|
||||
@@ -148,7 +148,7 @@ public class LocalPlaylistManager {
|
||||
|
||||
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
|
||||
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
|
||||
.getIsThumbnailPermanent();
|
||||
.isThumbnailPermanent();
|
||||
}
|
||||
|
||||
public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) {
|
||||
@@ -174,7 +174,7 @@ public class LocalPlaylistManager {
|
||||
}
|
||||
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
|
||||
playlist.setThumbnailStreamId(thumbnailStreamId);
|
||||
playlist.setIsThumbnailPermanent(isPermanent);
|
||||
playlist.setThumbnailPermanent(isPermanent);
|
||||
}
|
||||
return playlistTable.update(playlist);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
|
||||
@@ -26,7 +26,7 @@ class SubscriptionManager(context: Context) {
|
||||
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||
|
||||
fun subscriptionTable(): SubscriptionDAO = subscriptionTable
|
||||
fun subscriptions() = subscriptionTable.all
|
||||
fun subscriptions() = subscriptionTable.getAll()
|
||||
|
||||
fun getSubscriptions(
|
||||
currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
@@ -44,7 +44,7 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId)
|
||||
else -> subscriptionTable.all
|
||||
else -> subscriptionTable.getAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,12 @@ class SubscriptionManager(context: Context) {
|
||||
subscriptionTable.getSubscription(info.serviceId, info.url)
|
||||
.flatMapCompletable {
|
||||
Completable.fromRunnable {
|
||||
it.setData(
|
||||
info.name,
|
||||
ImageStrategy.imageListToDbUrl(info.avatars),
|
||||
info.description,
|
||||
info.subscriberCount
|
||||
)
|
||||
it.apply {
|
||||
name = info.name
|
||||
avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars)
|
||||
description = info.description
|
||||
subscriberCount = info.subscriberCount
|
||||
}
|
||||
subscriptionTable.update(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import android.view.LayoutInflater;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
@@ -122,12 +123,12 @@ import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
@@ -352,30 +353,17 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
@SuppressWarnings("MethodLength")
|
||||
public void handleIntent(@NonNull final Intent intent) {
|
||||
|
||||
final PlayerIntentType playerIntentType = intent.getParcelableExtra(PLAYER_INTENT_TYPE);
|
||||
final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
|
||||
PlayerIntentType.class);
|
||||
if (playerIntentType == null) {
|
||||
return;
|
||||
}
|
||||
final PlayerType newPlayerType;
|
||||
// TODO: this should be in the second switch below, but I’m not sure whether I
|
||||
// can move the initUIs stuff without breaking the setup for edge cases somehow.
|
||||
switch (playerIntentType) {
|
||||
case TimestampChange -> {
|
||||
// TODO: this breaks out of the pattern of asking for the permission before
|
||||
// sending the PlayerIntent, but I’m not sure yet how to combine the permissions
|
||||
// with the new enum approach. Maybe it’s better that the player asks anyway?
|
||||
if (!PermissionHelper.isPopupEnabledElseAsk(context)) {
|
||||
return;
|
||||
}
|
||||
newPlayerType = PlayerType.POPUP;
|
||||
}
|
||||
default -> {
|
||||
newPlayerType = PlayerType.retrieveFromIntent(intent);
|
||||
}
|
||||
// when playing from a timestamp, keep the current player as-is.
|
||||
if (playerIntentType != PlayerIntentType.TimestampChange) {
|
||||
playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
|
||||
}
|
||||
|
||||
playerType = newPlayerType;
|
||||
initUIsForCurrentPlayerType();
|
||||
isAudioOnly = audioPlayerSelected();
|
||||
|
||||
@@ -416,15 +404,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
break;
|
||||
}
|
||||
case TimestampChange -> {
|
||||
final TimestampChangeData dat = intent.getParcelableExtra(PLAYER_INTENT_DATA);
|
||||
assert dat != null;
|
||||
final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
|
||||
PLAYER_INTENT_DATA, TimestampChangeData.class));
|
||||
final Single<StreamInfo> single =
|
||||
ExtractorHelper.getStreamInfo(dat.getServiceId(), dat.getUrl(), false);
|
||||
ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false);
|
||||
streamItemDisposable.add(single.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(info -> {
|
||||
final @Nullable PlayQueue oldPlayQueue = playQueue;
|
||||
info.setStartPosition(dat.getSeconds());
|
||||
info.setStartPosition(data.getSeconds());
|
||||
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
|
||||
|
||||
// If the stream is already playing,
|
||||
@@ -438,7 +426,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
|
||||
dat.getSeconds() * 1000L);
|
||||
data.getSeconds() * 1000L);
|
||||
simpleExoPlayer.setPlayWhenReady(playWhenReady);
|
||||
|
||||
} else {
|
||||
@@ -462,9 +450,9 @@ public final class Player implements PlaybackListener, Listener {
|
||||
// This will only show a snackbar if the passed context has a root view:
|
||||
// otherwise it will resort to showing a notification, so we are safe
|
||||
// here.
|
||||
ErrorUtil.createNotification(context,
|
||||
new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, dat.getUrl(),
|
||||
null, dat.getUrl()));
|
||||
final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
|
||||
data.getUrl(), null, data.getUrl());
|
||||
ErrorUtil.createNotification(context, info);
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import kotlinx.parcelize.Parcelize
|
||||
// We model this as an enum class plus one struct for each enum value
|
||||
// so we can consume it from Java properly. After converting to Kotlin,
|
||||
// we could switch to a sealed enum class & a proper Kotlin `when` match.
|
||||
|
||||
@Parcelize
|
||||
enum class PlayerIntentType : Parcelable {
|
||||
enum class PlayerIntentType {
|
||||
Enqueue,
|
||||
EnqueueNext,
|
||||
TimestampChange,
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
@@ -156,25 +157,24 @@ public final class PlayerService extends MediaBrowserServiceCompat {
|
||||
}
|
||||
}
|
||||
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||
&& (player == null || player.getPlayQueue() == null)) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
if (player == null) {
|
||||
// No need to process media button's actions or other system intents if the player is
|
||||
// not running. However, since the current intent might have been issued by the system
|
||||
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
||||
// a (dummy) foreground notification, otherwise we'd incur in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
||||
// we stop the service again.
|
||||
Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
|
||||
NotificationUtil.startForegroundWithDummyNotification(this);
|
||||
destroyPlayerAndStopService();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (player != null) {
|
||||
final PlayerType oldPlayerType = player.getPlayerType();
|
||||
player.handleIntent(intent);
|
||||
player.handleIntentPost(oldPlayerType);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
}
|
||||
final PlayerType oldPlayerType = player.getPlayerType();
|
||||
player.handleIntent(intent);
|
||||
player.handleIntentPost(oldPlayerType);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
public enum PlayerType {
|
||||
MAIN,
|
||||
AUDIO,
|
||||
POPUP;
|
||||
|
||||
/**
|
||||
* @return an integer representing this {@link PlayerType}, to be used to save it in intents
|
||||
* @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
|
||||
* integers from an intent
|
||||
*/
|
||||
public int valueForIntent() {
|
||||
return ordinal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param intent the intent to retrieve a player type from
|
||||
* @return the player type integer retrieved from the intent, converted back into a {@link
|
||||
* PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
|
||||
* intent
|
||||
* @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
|
||||
* @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
|
||||
*/
|
||||
public static PlayerType retrieveFromIntent(final Intent intent) {
|
||||
return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +154,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||
notifyAudioSessionUpdate(true, audioSessionId);
|
||||
}
|
||||
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
||||
if (!PlayerHelper.isUsingDSP()) {
|
||||
return;
|
||||
}
|
||||
final Intent intent = new Intent(active
|
||||
? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
|
||||
: AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
|
||||
|
||||
@@ -296,10 +296,6 @@ public final class PlayerHelper {
|
||||
AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION);
|
||||
}
|
||||
|
||||
public static boolean isUsingDSP() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
|
||||
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
|
||||
|
||||
@@ -192,9 +192,11 @@ public final class PlayerHolder {
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
// notify the main activity that binding the service has completed, so that it can
|
||||
// open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
if (playerService != null && playerService.getPlayer() != null) {
|
||||
// notify the main activity that binding the service has completed and that there is
|
||||
// a player, so that it can open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ class MediaBrowserImpl(
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||
val history = database.streamHistoryDAO().history.firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ class MediaBrowserPlaybackPreparer(
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
return database.streamHistoryDAO().history
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
|
||||
@@ -5,7 +5,9 @@ import static androidx.media.app.NotificationCompat.MediaStyle;
|
||||
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.graphics.Bitmap;
|
||||
@@ -24,6 +26,7 @@ import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerIntentType;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
@@ -90,12 +93,9 @@ public final class NotificationUtil {
|
||||
Log.d(TAG, "createNotification()");
|
||||
}
|
||||
notificationManager = NotificationManagerCompat.from(player.getContext());
|
||||
final NotificationCompat.Builder builder =
|
||||
new NotificationCompat.Builder(player.getContext(),
|
||||
player.getContext().getString(R.string.notification_channel_id));
|
||||
final MediaStyle mediaStyle = new MediaStyle();
|
||||
|
||||
// setup media style (compact notification slots and media session)
|
||||
final MediaStyle mediaStyle = new MediaStyle();
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// notification actions are ignored on Android 13+, and are replaced by code in
|
||||
// MediaSessionPlayerUi
|
||||
@@ -108,18 +108,9 @@ public final class NotificationUtil {
|
||||
.ifPresent(mediaStyle::setMediaSession);
|
||||
|
||||
// setup notification builder
|
||||
builder.setStyle(mediaStyle)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setShowWhen(false)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(player.getContext(),
|
||||
R.color.dark_background_color))
|
||||
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
|
||||
.setColorized(player.getPrefs().getBoolean(
|
||||
player.getContext().getString(R.string.notification_colorize_key), true))
|
||||
.setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
|
||||
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
|
||||
player.getContext().getString(R.string.notification_colorize_key), true));
|
||||
|
||||
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
|
||||
setLargeIcon(builder);
|
||||
@@ -168,17 +159,17 @@ public final class NotificationUtil {
|
||||
&& notificationBuilder.mActions.get(2).actionIntent != null);
|
||||
}
|
||||
|
||||
public static void startForegroundWithDummyNotification(final PlayerService service) {
|
||||
final var builder = setupNotificationBuilder(service, new MediaStyle());
|
||||
startForeground(service, builder.build());
|
||||
}
|
||||
|
||||
public void createNotificationAndStartForeground() {
|
||||
if (notificationBuilder == null) {
|
||||
notificationBuilder = createNotification();
|
||||
}
|
||||
updateNotification();
|
||||
|
||||
// ServiceInfo constants are not used below Android Q, so 0 is set here
|
||||
final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
|
||||
ServiceCompat.startForeground(player.getService(), NOTIFICATION_ID,
|
||||
notificationBuilder.build(), serviceType);
|
||||
startForeground(player.getService(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
public void cancelNotificationAndStopForeground() {
|
||||
@@ -192,6 +183,34 @@ public final class NotificationUtil {
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION
|
||||
/////////////////////////////////////////////////////
|
||||
|
||||
private static NotificationCompat.Builder setupNotificationBuilder(final Context context,
|
||||
final MediaStyle style) {
|
||||
return new NotificationCompat.Builder(context,
|
||||
context.getString(R.string.notification_channel_id))
|
||||
.setStyle(style)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setShowWhen(false)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(context, R.color.dark_background_color))
|
||||
.setDeleteIntent(PendingIntentCompat.getBroadcast(context,
|
||||
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
|
||||
}
|
||||
|
||||
private static void startForeground(final PlayerService service,
|
||||
final Notification notification) {
|
||||
// ServiceInfo constants are not used below Android Q, so 0 is set here
|
||||
final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
|
||||
ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType);
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// ACTIONS
|
||||
/////////////////////////////////////////////////////
|
||||
|
||||
@@ -289,8 +289,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
||||
binding.topControls.setClickable(true);
|
||||
binding.topControls.setFocusable(true);
|
||||
|
||||
binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
|
||||
// Reset workaround changes from popup player
|
||||
binding.audioTrackTextView.setMaxWidth(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -934,8 +936,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
||||
}
|
||||
fragmentListener.onFullscreenStateChanged(isFullscreen);
|
||||
|
||||
binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||
binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
|
||||
setupScreenRotationButton();
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.gesture.BasePlayerGestureListener;
|
||||
import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
public final class PopupPlayerUi extends VideoPlayerUi {
|
||||
private static final String TAG = PopupPlayerUi.class.getSimpleName();
|
||||
@@ -174,6 +175,8 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
||||
binding.topControls.setClickable(false);
|
||||
binding.topControls.setFocusable(false);
|
||||
binding.bottomControls.bringToFront();
|
||||
// Workaround that UI elements are pushed off screen
|
||||
binding.audioTrackTextView.setMaxWidth(DeviceUtils.dpToPx(48, context));
|
||||
super.setupElementsVisibility();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
@@ -155,9 +157,9 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
|
||||
private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
|
||||
try {
|
||||
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
|
||||
//checkpoint before export
|
||||
NewPipeDatabase.checkpoint();
|
||||
executor.submit(NewPipeDatabase::checkpoint).get();
|
||||
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
|
||||
@@ -103,12 +103,12 @@ public final class NewPipeSettings {
|
||||
}
|
||||
|
||||
public static boolean useStorageAccessFramework(final Context context) {
|
||||
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a
|
||||
// remote (see #6455).
|
||||
if (DeviceUtils.isFireTv()) {
|
||||
return false;
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return true;
|
||||
} else if (DeviceUtils.isFireTv()) {
|
||||
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with
|
||||
// a remote (see #6455).
|
||||
return false;
|
||||
}
|
||||
|
||||
final String key = context.getString(R.string.storage_use_saf);
|
||||
|
||||
@@ -396,7 +396,8 @@ public class PeertubeInstanceListFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
private static class PeertubeInstanceCallback extends DiffUtil.ItemCallback<PeertubeInstance> {
|
||||
private static final class PeertubeInstanceCallback
|
||||
extends DiffUtil.ItemCallback<PeertubeInstance> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull final PeertubeInstance oldItem,
|
||||
@NonNull final PeertubeInstance newItem) {
|
||||
|
||||
@@ -174,7 +174,7 @@ public class SelectChannelFragment extends DialogFragment {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectChannelAdapter
|
||||
private final class SelectChannelAdapter
|
||||
extends RecyclerView.Adapter<SelectChannelAdapter.SelectChannelItemHolder> {
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -175,7 +175,7 @@ public class SelectFeedGroupFragment extends DialogFragment {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectFeedGroupAdapter
|
||||
private final class SelectFeedGroupAdapter
|
||||
extends RecyclerView.Adapter<SelectFeedGroupAdapter.SelectFeedGroupItemHolder> {
|
||||
@NonNull
|
||||
@Override
|
||||
|
||||
@@ -118,12 +118,12 @@ public class SelectPlaylistFragment extends DialogFragment {
|
||||
|
||||
if (selectedItem instanceof PlaylistMetadataEntry) {
|
||||
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
|
||||
onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name);
|
||||
onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.getOrderingName());
|
||||
|
||||
} else if (selectedItem instanceof PlaylistRemoteEntity) {
|
||||
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
|
||||
onSelectedListener.onRemotePlaylistSelected(
|
||||
entry.getServiceId(), entry.getUrl(), entry.getName());
|
||||
entry.getServiceId(), entry.getUrl(), entry.getOrderingName());
|
||||
}
|
||||
}
|
||||
dismiss();
|
||||
@@ -138,7 +138,7 @@ public class SelectPlaylistFragment extends DialogFragment {
|
||||
void onRemotePlaylistSelected(int serviceId, String url, String name);
|
||||
}
|
||||
|
||||
private class SelectPlaylistAdapter
|
||||
private final class SelectPlaylistAdapter
|
||||
extends RecyclerView.Adapter<SelectPlaylistAdapter.SelectPlaylistItemHolder> {
|
||||
@NonNull
|
||||
@Override
|
||||
@@ -157,14 +157,15 @@ public class SelectPlaylistFragment extends DialogFragment {
|
||||
if (selectedItem instanceof PlaylistMetadataEntry) {
|
||||
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
|
||||
|
||||
holder.titleView.setText(entry.name);
|
||||
holder.titleView.setText(entry.getOrderingName());
|
||||
holder.view.setOnClickListener(view -> clickedItem(position));
|
||||
PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView);
|
||||
PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl())
|
||||
.into(holder.thumbnailView);
|
||||
|
||||
} else if (selectedItem instanceof PlaylistRemoteEntity) {
|
||||
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
|
||||
|
||||
holder.titleView.setText(entry.getName());
|
||||
holder.titleView.setText(entry.getOrderingName());
|
||||
holder.view.setOnClickListener(view -> clickedItem(position));
|
||||
PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl())
|
||||
.into(holder.thumbnailView);
|
||||
|
||||
@@ -31,7 +31,7 @@ class NotificationModeConfigAdapter(
|
||||
|
||||
fun update(newData: List<SubscriptionEntity>) {
|
||||
val items = newData.map {
|
||||
SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url)
|
||||
SubscriptionItem(it.uid, it.name!!, it.notificationMode, it.serviceId, it.url!!)
|
||||
}
|
||||
submitList(items)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,8 @@ class PreferenceSearchAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private static class PreferenceCallback extends DiffUtil.ItemCallback<PreferenceSearchItem> {
|
||||
private static final class PreferenceCallback
|
||||
extends DiffUtil.ItemCallback<PreferenceSearchItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull final PreferenceSearchItem oldItem,
|
||||
@NonNull final PreferenceSearchItem newItem) {
|
||||
|
||||
@@ -15,7 +15,11 @@ import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* @author kapodamy
|
||||
* Converts TTML subtitles to SRT format.
|
||||
*
|
||||
* References:
|
||||
* - TTML 2.0 (W3C): https://www.w3.org/TR/ttml2/
|
||||
* - SRT format: https://en.wikipedia.org/wiki/SubRip
|
||||
*/
|
||||
public class SrtFromTtmlWriter {
|
||||
private static final String NEW_LINE = "\r\n";
|
||||
@@ -24,7 +28,11 @@ public class SrtFromTtmlWriter {
|
||||
private final boolean ignoreEmptyFrames;
|
||||
private final Charset charset = StandardCharsets.UTF_8;
|
||||
|
||||
private int frameIndex = 0;
|
||||
// According to the SubRip (.srt) specification, subtitle
|
||||
// numbering must start from 1.
|
||||
// Some players accept 0 or even negative indices,
|
||||
// but to ensure compliance we start at 1.
|
||||
private int frameIndex = 1;
|
||||
|
||||
public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) {
|
||||
this.out = out;
|
||||
@@ -39,7 +47,8 @@ public class SrtFromTtmlWriter {
|
||||
|
||||
private void writeFrame(final String begin, final String end, final StringBuilder text)
|
||||
throws IOException {
|
||||
writeString(String.valueOf(frameIndex++));
|
||||
writeString(String.valueOf(frameIndex));
|
||||
frameIndex += 1;
|
||||
writeString(NEW_LINE);
|
||||
writeString(begin);
|
||||
writeString(" --> ");
|
||||
@@ -54,6 +63,226 @@ public class SrtFromTtmlWriter {
|
||||
out.write(text.getBytes(charset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode XML or HTML entities into their actual (literal) characters.
|
||||
*
|
||||
* TTML is XML-based, so text nodes may contain escaped entities
|
||||
* instead of direct characters. For example:
|
||||
*
|
||||
* "&" → "&"
|
||||
* "<" → "<"
|
||||
* ">" → ">"
|
||||
* "	" → "\t" (TAB)
|
||||
* "
" ( ) → "\n" (LINE FEED)
|
||||
*
|
||||
* XML files cannot contain characters like "<", ">", "&" directly,
|
||||
* so they must be represented using their entity-encoded forms.
|
||||
*
|
||||
* Jsoup sometimes leaves nested or encoded entities unresolved
|
||||
* (e.g. inside <p> text nodes in TTML files), so this function
|
||||
* acts as a final “safety net” to ensure all entities are decoded
|
||||
* before further normalization.
|
||||
*
|
||||
* Character representation layers for reference:
|
||||
* - Literal characters: <, >, &
|
||||
* → appear in runtime/output text (e.g. final SRT output)
|
||||
* - Escaped entities: <, >, &
|
||||
* → appear in XML/HTML/TTML source files
|
||||
* - Numeric entities:  , 	, 
|
||||
* → appear mainly in XML/TTML files (also valid in HTML)
|
||||
* for non-printable or special characters
|
||||
* - Unicode escapes: \u00A0 (Java/Unicode internal form)
|
||||
* → appear only in Java source code (NOT valid in XML)
|
||||
*
|
||||
* XML entities include both named (&, <) and numeric
|
||||
* ( ,  ) forms.
|
||||
*
|
||||
* @param encodedEntities The raw text fragment possibly containing
|
||||
* encoded XML entities.
|
||||
* @return A decoded string where all entities are replaced by their
|
||||
* actual (literal) characters.
|
||||
*/
|
||||
private String decodeXmlEntities(final String encodedEntities) {
|
||||
return Parser.unescapeEntities(encodedEntities, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rare XML entity characters like LF: 
(`\n`),
|
||||
* CR: 
(`\r`) and CRLF: (`\r\n`).
|
||||
*
|
||||
* These are technically valid in TTML (XML allows them)
|
||||
* but unusual in practice, since most TTML line breaks
|
||||
* are represented as <br/> tags instead.
|
||||
* As a defensive approach, we normalize them:
|
||||
*
|
||||
* - Windows (\r\n), macOS (\r), and Unix (\n) → unified SRT NEW_LINE (\r\n)
|
||||
*
|
||||
* Although well-formed TTML normally encodes line breaks
|
||||
* as <br/> tags, some auto-generated or malformed TTML files
|
||||
* may embed literal newline entities (
, 
). This
|
||||
* normalization ensures these cases render properly in SRT
|
||||
* players instead of breaking the subtitle structure.
|
||||
*
|
||||
* @param text To be normalized text with actual characters.
|
||||
* @return Unified SRT NEW_LINE converted from all kinds of line breaks.
|
||||
*/
|
||||
private String normalizeLineBreakForSrt(final String text) {
|
||||
String cleaned = text;
|
||||
|
||||
// NOTE:
|
||||
// The order of newline replacements must NOT change,
|
||||
// or duplicated line breaks (e.g. \r\n → \n\n) will occur.
|
||||
cleaned = cleaned.replace("\r\n", "\n")
|
||||
.replace("\r", "\n");
|
||||
|
||||
cleaned = cleaned.replace("\n", NEW_LINE);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String normalizeForSrt(final String actualText) {
|
||||
String cleaned = actualText;
|
||||
|
||||
// Replace NBSP "non-breaking space" (\u00A0) with regular space ' '(\u0020).
|
||||
//
|
||||
// Why:
|
||||
// - Some viewers render NBSP(\u00A0) incorrectly:
|
||||
// * MPlayer 1.5: shown as “??”
|
||||
// * Linux command `cat -A`: displayed as control-like markers
|
||||
// (M-BM-)
|
||||
// * Acode (Android editor): displayed as visible replacement
|
||||
// glyphs (red dots)
|
||||
// - Other viewers show it as a normal space (e.g., VS Code 1.104.0,
|
||||
// vlc 3.0.20, mpv 0.37.0, Totem 43.0)
|
||||
// → Mixed rendering creates inconsistency and may confuse users.
|
||||
//
|
||||
// Details:
|
||||
// - YouTube TTML subtitles use both regular spaces (\u0020)
|
||||
// and non-breaking spaces (\u00A0).
|
||||
// - SRT subtitles only support regular spaces (\u0020),
|
||||
// so \u00A0 may cause display issues.
|
||||
// - \u00A0 and \u0020 are visually identical (i.e., they both
|
||||
// appear as spaces ' '), but they differ in Unicode encoding,
|
||||
// and NBSP (\u00A0) renders differently in different viewers.
|
||||
// - SRT is a plain-text format and does not interpret
|
||||
// "non-breaking" behavior.
|
||||
//
|
||||
// Conclusion:
|
||||
// - Ensure uniform behavior, so replace it to a regular space
|
||||
// without "non-breaking" behavior.
|
||||
//
|
||||
// References:
|
||||
// - Unicode U+00A0 NBSP (Latin-1 Supplement):
|
||||
// https://unicode.org/charts/PDF/U0080.pdf
|
||||
cleaned = cleaned.replace('\u00A0', ' ') // Non-breaking space
|
||||
.replace('\u202F', ' ') // Narrow no-break space
|
||||
.replace('\u205F', ' ') // Medium mathematical space
|
||||
.replace('\u3000', ' ') // Ideographic space
|
||||
// \u2000 ~ \u200A are whitespace characters (e.g.,
|
||||
// en space, em space), replaced with regular space (\u0020).
|
||||
.replaceAll("[\\u2000-\\u200A]", " "); // Whitespace characters
|
||||
|
||||
// \u200B ~ \u200F are a range of non-spacing characters
|
||||
// (e.g., zero-width space, zero-width non-joiner, etc.),
|
||||
// which have no effect in *.SRT files and may cause
|
||||
// display issues.
|
||||
// These characters are invisible to the human eye, and
|
||||
// they still exist in the encoding, so they need to be
|
||||
// removed.
|
||||
// After removal, the actual content becomes completely
|
||||
// empty "", meaning there are no characters left, just
|
||||
// an empty space, which helps avoid formatting issues
|
||||
// in subtitles.
|
||||
cleaned = cleaned.replaceAll("[\\u200B-\\u200F]", ""); // Non-spacing characters
|
||||
|
||||
// Remove control characters (\u0000 ~ \u001F, except
|
||||
// \n, \r, \t).
|
||||
// - These are ASCII C0 control codes (e.g. \u0001 SOH,
|
||||
// \u0008 BS, \u001F US), invisible and irrelevant in
|
||||
// subtitles, may cause square boxes (?) in players.
|
||||
// - Reference:
|
||||
// Unicode Basic Latin (https://unicode.org/charts/PDF/U0000.pdf)
|
||||
// ASCII Control (https://en.wikipedia.org/wiki/ASCII#Control_characters)
|
||||
cleaned = cleaned.replaceAll("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", "");
|
||||
|
||||
// Reasoning:
|
||||
// - subtitle files generally don't require tabs for alignment.
|
||||
// - Tabs can be displayed with varying widths across different
|
||||
// editors or platforms, which may cause display issues.
|
||||
// - Replace it with a single space for consistent display
|
||||
// across different editors or platforms.
|
||||
cleaned = cleaned.replace('\t', ' ');
|
||||
|
||||
cleaned = normalizeLineBreakForSrt(cleaned);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String sanitizeFragment(final String raw) {
|
||||
if (null == raw) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final String actualCharacters = decodeXmlEntities(raw);
|
||||
|
||||
final String srtSafeText = normalizeForSrt(actualCharacters);
|
||||
|
||||
return srtSafeText;
|
||||
}
|
||||
|
||||
// Recursively process all child nodes to ensure text inside
|
||||
// nested tags (e.g., <span>) is also extracted.
|
||||
private void traverseChildNodesForNestedTags(final Node parent,
|
||||
final StringBuilder text) {
|
||||
for (final Node child : parent.childNodes()) {
|
||||
extractText(child, text);
|
||||
}
|
||||
}
|
||||
|
||||
// CHECKSTYLE:OFF checkstyle:JavadocStyle
|
||||
// checkstyle does not understand that span tags are inside a code block
|
||||
/**
|
||||
* <p>Recursive method to extract text from all nodes.</p>
|
||||
* <p>
|
||||
* This method processes {@link TextNode}s and {@code <br>} tags,
|
||||
* recursively extracting text from nested tags
|
||||
* (e.g. extracting text from nested {@code <span>} tags).
|
||||
* Newlines are added for {@code <br>} tags.
|
||||
* </p>
|
||||
* @param node the current node to process
|
||||
* @param text the {@link StringBuilder} to append the extracted text to
|
||||
*/
|
||||
// --------------------------------------------------------------------
|
||||
// [INTERNAL NOTE] TTML text layer explanation
|
||||
//
|
||||
// TTML parsing involves multiple text "layers":
|
||||
// 1. Raw XML entities (e.g., <,  ) are decoded by Jsoup.
|
||||
// 2. extractText() works on DOM TextNodes (already parsed strings).
|
||||
// 3. sanitizeFragment() decodes remaining entities and fixes
|
||||
// Unicode quirks.
|
||||
// 4. normalizeForSrt() ensures literal text is safe for SRT output.
|
||||
//
|
||||
// In short:
|
||||
// Jsoup handles XML-level syntax,
|
||||
// our code handles text-level normalization for subtitles.
|
||||
// --------------------------------------------------------------------
|
||||
private void extractText(final Node node, final StringBuilder text) {
|
||||
if (node instanceof TextNode textNode) {
|
||||
String rawTtmlFragment = textNode.getWholeText();
|
||||
String srtContent = sanitizeFragment(rawTtmlFragment);
|
||||
text.append(srtContent);
|
||||
} else if (node instanceof Element element) {
|
||||
// <br> is a self-closing HTML tag used to insert a line break.
|
||||
if (element.tagName().equalsIgnoreCase("br")) {
|
||||
// Add a newline for <br> tags
|
||||
text.append(NEW_LINE);
|
||||
}
|
||||
}
|
||||
|
||||
traverseChildNodesForNestedTags(node, text);
|
||||
}
|
||||
// CHECKSTYLE:ON
|
||||
|
||||
public void build(final SharpStream ttml) throws IOException {
|
||||
/*
|
||||
* TTML parser with BASIC support
|
||||
@@ -74,21 +303,15 @@ public class SrtFromTtmlWriter {
|
||||
final Elements paragraphList = doc.select("body > div > p");
|
||||
|
||||
// check if has frames
|
||||
if (paragraphList.size() < 1) {
|
||||
if (paragraphList.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final Element paragraph : paragraphList) {
|
||||
text.setLength(0);
|
||||
|
||||
for (final Node children : paragraph.childNodes()) {
|
||||
if (children instanceof TextNode) {
|
||||
text.append(((TextNode) children).text());
|
||||
} else if (children instanceof Element
|
||||
&& ((Element) children).tagName().equalsIgnoreCase("br")) {
|
||||
text.append(NEW_LINE);
|
||||
}
|
||||
}
|
||||
// Recursively extract text from all child nodes
|
||||
extractText(paragraph, text);
|
||||
|
||||
if (ignoreEmptyFrames && text.length() < 1) {
|
||||
continue;
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Parcelable;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -70,6 +69,7 @@ import org.schabi.newpipe.settings.SettingsActivity;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class NavigationHelper {
|
||||
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
|
||||
@@ -89,31 +89,22 @@ public final class NavigationHelper {
|
||||
@NonNull final Class<T> targetClazz,
|
||||
@Nullable final PlayQueue playQueue,
|
||||
@NonNull final PlayerIntentType playerIntentType) {
|
||||
final Intent intent = new Intent(context, targetClazz);
|
||||
|
||||
if (playQueue != null) {
|
||||
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class);
|
||||
if (cacheKey != null) {
|
||||
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
|
||||
}
|
||||
}
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
|
||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||
intent.putExtra(Player.PLAYER_INTENT_TYPE, (Parcelable) playerIntentType);
|
||||
|
||||
return intent;
|
||||
final String cacheKey = Optional.ofNullable(playQueue)
|
||||
.map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class))
|
||||
.orElse(null);
|
||||
return new Intent(context, targetClazz)
|
||||
.putExtra(Player.PLAY_QUEUE_KEY, cacheKey)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN)
|
||||
.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
|
||||
.putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static Intent getPlayerTimestampIntent(@NonNull final Context context,
|
||||
@NonNull final TimestampChangeData
|
||||
timestampChangeData) {
|
||||
final Intent intent = new Intent(context, PlayerService.class);
|
||||
|
||||
intent.putExtra(Player.PLAYER_INTENT_TYPE, (Parcelable) PlayerIntentType.TimestampChange);
|
||||
intent.putExtra(Player.PLAYER_INTENT_DATA, timestampChangeData);
|
||||
|
||||
return intent;
|
||||
@NonNull final TimestampChangeData data) {
|
||||
return new Intent(context, PlayerService.class)
|
||||
.putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange)
|
||||
.putExtra(Player.PLAYER_INTENT_DATA, data);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -156,9 +147,9 @@ public final class NavigationHelper {
|
||||
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers);
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent())
|
||||
final var intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
|
||||
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
@@ -170,9 +161,9 @@ public final class NavigationHelper {
|
||||
.show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers);
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
|
||||
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
|
||||
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
@@ -195,9 +186,8 @@ public final class NavigationHelper {
|
||||
// by long pressing the video detail fragment, playlist or channel controls
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.Enqueue)
|
||||
.putExtra(Player.RESUME_PLAYBACK, false);
|
||||
|
||||
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
|
||||
.putExtra(Player.RESUME_PLAYBACK, false)
|
||||
.putExtra(Player.PLAYER_TYPE, playerType);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
@@ -219,9 +209,8 @@ public final class NavigationHelper {
|
||||
playerType = PlayerType.AUDIO;
|
||||
}
|
||||
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
|
||||
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
|
||||
|
||||
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
|
||||
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue)
|
||||
.putExtra(Player.PLAYER_TYPE, playerType);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
|
||||
@@ -89,9 +90,12 @@ public final class PermissionHelper {
|
||||
&& ContextCompat.checkSelfPermission(activity,
|
||||
Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(activity,
|
||||
new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode);
|
||||
return false;
|
||||
if (!App.getApp().getNotificationsRequested()) {
|
||||
ActivityCompat.requestPermissions(activity,
|
||||
new String[]{Manifest.permission.POST_NOTIFICATIONS}, requestCode);
|
||||
App.getApp().setNotificationsRequested();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ public final class PlayButtonHelper {
|
||||
});
|
||||
|
||||
// long click listener
|
||||
playlistControlBinding.playlistCtrlPlayAllButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.MAIN);
|
||||
return true;
|
||||
});
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP);
|
||||
return true;
|
||||
|
||||
@@ -5,10 +5,9 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
@@ -23,6 +22,7 @@ import androidx.core.content.FileProvider;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.RouterActivity;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
@@ -62,8 +62,9 @@ public final class ShareUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the url with the system default browser. If no browser is set as default, falls back to
|
||||
* {@link #openAppChooser(Context, Intent, boolean)}.
|
||||
* Open the url with the system default browser. If no browser is installed, falls back to
|
||||
* {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available
|
||||
* to handle the action, or possible OEM-related edge cases).
|
||||
* <p>
|
||||
* This function selects the package to open based on which apps respond to the {@code http://}
|
||||
* schema alone, which should exclude special non-browser apps that are can handle the url (e.g.
|
||||
@@ -77,44 +78,26 @@ public final class ShareUtils {
|
||||
* @param url the url to browse
|
||||
**/
|
||||
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
|
||||
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
|
||||
// Target a generic http://, so we are sure to get a browser and not e.g. the yt app.
|
||||
// Note that this requires the `http` schema to be added to `<queries>` in the manifest.
|
||||
final ResolveInfo defaultBrowserInfo;
|
||||
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
|
||||
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
|
||||
} else {
|
||||
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
|
||||
PackageManager.MATCH_DEFAULT_ONLY);
|
||||
}
|
||||
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (defaultBrowserInfo == null) {
|
||||
// No app installed to open a web URL, but it may be handled by other apps so try
|
||||
// opening a system chooser for the link in this case (it could be bypassed by the
|
||||
// system if there is only one app which can open the link or a default app associated
|
||||
// with the link domain on Android 12 and higher)
|
||||
// See https://stackoverflow.com/a/58801285 and `setSelector` documentation
|
||||
intent.setSelector(browserIntent);
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
// No browser is available. This should, in the end, yield a nice AOSP error message
|
||||
// indicating that no app is available to handle this action.
|
||||
//
|
||||
// Note: there are some situations where modified OEM ROMs have apps that appear
|
||||
// to be browsers but are actually app choosers. If starting the Activity fails
|
||||
// related to this, opening the system app chooser is still the correct behavior.
|
||||
intent.setSelector(null);
|
||||
openAppChooser(context, intent, true);
|
||||
return;
|
||||
}
|
||||
|
||||
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
|
||||
|
||||
if (defaultBrowserPackage.equals("android")) {
|
||||
// No browser set as default (doesn't work on some devices)
|
||||
openAppChooser(context, intent, true);
|
||||
} else {
|
||||
try {
|
||||
intent.setPackage(defaultBrowserPackage);
|
||||
context.startActivity(intent);
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
// Not a browser but an app chooser because of OEMs changes
|
||||
intent.setPackage(null);
|
||||
openAppChooser(context, intent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +173,18 @@ public final class ShareUtils {
|
||||
chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with));
|
||||
}
|
||||
|
||||
// Avoid opening in NewPipe
|
||||
// (Implementation note: if the URL is one for which NewPipe itself
|
||||
// is set as handler on Android >= 12, we actually remove the only eligible app
|
||||
// for this link, and browsers will not be offered to the user. For that, use
|
||||
// `openUrlInBrowser`.)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
chooserIntent.putExtra(
|
||||
Intent.EXTRA_EXCLUDE_COMPONENTS,
|
||||
new ComponentName[]{new ComponentName(context, RouterActivity.class)}
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate any clip data and flags from the original intent.
|
||||
final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
@@ -661,7 +661,8 @@ public class DownloadMission extends Mission {
|
||||
* @return {@code true}, if storage is invalid and cannot be used
|
||||
*/
|
||||
public boolean hasInvalidStorage() {
|
||||
return errCode == ERROR_PROGRESS_LOST || storage == null || !storage.existsAsFile();
|
||||
// Don't consider ERROR_PROGRESS_LOST as invalid storage - it can be recovered
|
||||
return storage == null || !storage.existsAsFile();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,8 @@ import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import us.shandian.giga.util.Utility;
|
||||
|
||||
import static org.schabi.newpipe.BuildConfig.DEBUG;
|
||||
import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING;
|
||||
import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST;
|
||||
|
||||
public class DownloadManager {
|
||||
private static final String TAG = DownloadManager.class.getSimpleName();
|
||||
@@ -149,12 +151,31 @@ public class DownloadManager {
|
||||
if (sub.getName().equals(".tmp")) continue;
|
||||
|
||||
DownloadMission mis = Utility.readFromFile(sub);
|
||||
if (mis == null || mis.isFinished() || mis.hasInvalidStorage()) {
|
||||
if (mis == null) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
sub.delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
// DON'T delete missions that are truly finished - let them be moved to finished list
|
||||
if (mis.isFinished()) {
|
||||
// Move to finished missions instead of deleting
|
||||
setFinished(mis);
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
sub.delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
// DON'T delete missions with storage issues - try to recover them
|
||||
if (mis.hasInvalidStorage() && mis.errCode != ERROR_PROGRESS_LOST) {
|
||||
// Only delete if it's truly unrecoverable (not just progress lost)
|
||||
if (mis.storage == null) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
sub.delete();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
mis.threads = new Thread[0];
|
||||
|
||||
boolean exists;
|
||||
@@ -163,16 +184,13 @@ public class DownloadManager {
|
||||
exists = !mis.storage.isInvalid() && mis.storage.existsAsFile();
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex);
|
||||
mis.storage.invalidate();
|
||||
// Don't invalidate storage immediately - try to recover first
|
||||
exists = false;
|
||||
}
|
||||
|
||||
if (mis.isPsRunning()) {
|
||||
if (mis.psAlgorithm.worksOnSameFile) {
|
||||
// Incomplete post-processing results in a corrupted download file
|
||||
// because the selected algorithm works on the same file to save space.
|
||||
// the file will be deleted if the storage API
|
||||
// is Java IO (avoid showing the "Save as..." dialog)
|
||||
if (exists && mis.storage.isDirect() && !mis.storage.delete())
|
||||
Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath());
|
||||
}
|
||||
@@ -181,10 +199,11 @@ public class DownloadManager {
|
||||
mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED;
|
||||
} else if (!exists) {
|
||||
tryRecover(mis);
|
||||
|
||||
// the progress is lost, reset mission state
|
||||
if (mis.isInitialized())
|
||||
mis.resetState(true, true, DownloadMission.ERROR_PROGRESS_LOST);
|
||||
// Keep the mission even if recovery fails - don't reset to ERROR_PROGRESS_LOST
|
||||
// This allows user to see the failed download and potentially retry
|
||||
if (mis.isInitialized() && mis.errCode == ERROR_NOTHING) {
|
||||
mis.resetState(true, true, ERROR_PROGRESS_LOST);
|
||||
}
|
||||
}
|
||||
|
||||
if (mis.psAlgorithm != null) {
|
||||
@@ -448,7 +467,7 @@ public class DownloadManager {
|
||||
continue;
|
||||
|
||||
resumeMission(mission);
|
||||
if (mission.errCode != DownloadMission.ERROR_NOTHING) continue;
|
||||
if (mission.errCode != ERROR_NOTHING) continue;
|
||||
|
||||
if (mPrefQueueLimit) return true;
|
||||
flag = true;
|
||||
@@ -512,6 +531,15 @@ public class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canRecoverMission(DownloadMission mission) {
|
||||
if (mission == null) return false;
|
||||
|
||||
// Can recover missions with progress lost or storage issues
|
||||
return mission.errCode == ERROR_PROGRESS_LOST ||
|
||||
mission.storage == null ||
|
||||
!mission.storage.existsAsFile();
|
||||
}
|
||||
|
||||
public MissionState checkForExistingMission(StoredFileHelper storage) {
|
||||
synchronized (this) {
|
||||
DownloadMission pending = getPendingMission(storage);
|
||||
@@ -584,8 +612,13 @@ public class DownloadManager {
|
||||
ArrayList<Mission> finished = new ArrayList<>(mMissionsFinished);
|
||||
List<Mission> remove = new ArrayList<>(hidden);
|
||||
|
||||
// hide missions (if required)
|
||||
remove.removeIf(mission -> pending.remove(mission) || finished.remove(mission));
|
||||
// Don't hide recoverable missions
|
||||
remove.removeIf(mission -> {
|
||||
if (mission instanceof DownloadMission dm && canRecoverMission(dm)) {
|
||||
return false; // Don't remove recoverable missions
|
||||
}
|
||||
return pending.remove(mission) || finished.remove(mission);
|
||||
});
|
||||
|
||||
int fakeTotal = pending.size();
|
||||
if (fakeTotal > 0) fakeTotal++;
|
||||
|
||||
@@ -109,71 +109,89 @@
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/close"
|
||||
android:focusable="true"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/ic_close"
|
||||
android:visibility="gone"
|
||||
app:tint="@color/white"
|
||||
android:contentDescription="@string/close"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/metadataView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical"
|
||||
tools:ignore="RtlHardcoded">
|
||||
android:orientation="horizontal">
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/titleTextView"
|
||||
android:layout_width="match_parent"
|
||||
<LinearLayout
|
||||
android:id="@+id/metadataView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:fadingEdge="horizontal"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="RtlHardcoded"
|
||||
tools:text="The Video Title LONG very LONG" />
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical"
|
||||
tools:ignore="NestedWeights,RtlHardcoded">
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/channelTextView"
|
||||
android:layout_width="match_parent"
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/titleTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:fadingEdge="horizontal"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="RtlHardcoded"
|
||||
tools:text="The Video Title LONG very LONG" />
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/channelTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:fadingEdge="horizontal"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
tools:text="The Video Artist LONG very LONG very Long" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="marquee"
|
||||
android:fadingEdge="horizontal"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
tools:text="The Video Artist LONG very LONG very Long" />
|
||||
android:layout_weight="1">
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/audioTrackTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_gravity="right"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center"
|
||||
android:minWidth="0dp"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:singleLine="true"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:ignore="HardcodedText,RtlHardcoded"
|
||||
tools:text="English (United States) original"
|
||||
tools:visibility="visible" />
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/audioTrackTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="35dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center"
|
||||
android:minWidth="0dp"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:ignore="HardcodedText,RtlHardcoded"
|
||||
tools:visibility="visible"
|
||||
tools:text="English (Original)" />
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/qualityTextView"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -209,6 +227,7 @@
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/open_play_queue"
|
||||
android:focusable="true"
|
||||
android:paddingStart="3dp"
|
||||
android:paddingTop="5dp"
|
||||
@@ -218,7 +237,6 @@
|
||||
android:src="@drawable/ic_list"
|
||||
android:visibility="gone"
|
||||
app:tint="@color/white"
|
||||
android:contentDescription="@string/open_play_queue"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
@@ -228,16 +246,16 @@
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/chapters"
|
||||
android:focusable="true"
|
||||
android:paddingStart="6dp"
|
||||
android:paddingTop="3dp"
|
||||
android:paddingEnd="6dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:paddingTop="3dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_menu_book"
|
||||
android:visibility="gone"
|
||||
app:tint="@color/white"
|
||||
android:contentDescription="@string/chapters"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
@@ -246,12 +264,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/more_options"
|
||||
android:focusable="true"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/ic_expand_more"
|
||||
app:tint="@color/white"
|
||||
android:contentDescription="@string/more_options"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -368,11 +386,11 @@
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/toggle_fullscreen"
|
||||
android:focusable="true"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_fullscreen"
|
||||
android:contentDescription="@string/toggle_fullscreen"
|
||||
android:visibility="gone"
|
||||
app:tint="@color/white"
|
||||
tools:ignore="RtlHardcoded"
|
||||
@@ -492,13 +510,13 @@
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/toggle_screen_orientation"
|
||||
android:focusable="true"
|
||||
android:nextFocusUp="@id/playbackSeekBar"
|
||||
android:padding="@dimen/player_main_buttons_padding"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_fullscreen"
|
||||
android:visibility="gone"
|
||||
android:contentDescription="@string/toggle_screen_orientation"
|
||||
app:tint="@color/white"
|
||||
tools:ignore="RtlHardcoded"
|
||||
tools:visibility="visible" />
|
||||
@@ -520,10 +538,10 @@
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/previous_stream"
|
||||
android:focusable="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_previous"
|
||||
android:contentDescription="@string/previous_stream"
|
||||
app:tint="@color/white" />
|
||||
|
||||
|
||||
@@ -533,9 +551,9 @@
|
||||
android:layout_height="60dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pause"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_pause"
|
||||
android:contentDescription="@string/pause"
|
||||
app:tint="@color/white" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
@@ -546,10 +564,10 @@
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/next_stream"
|
||||
android:focusable="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_next"
|
||||
android:contentDescription="@string/next_stream"
|
||||
app:tint="@color/white" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -596,12 +614,12 @@
|
||||
android:layout_marginLeft="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/notification_action_repeat"
|
||||
android:focusable="true"
|
||||
android:padding="10dp"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/exo_controls_repeat_off"
|
||||
android:tint="?attr/colorAccent"
|
||||
android:contentDescription="@string/notification_action_repeat"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
@@ -612,12 +630,12 @@
|
||||
android:layout_toRightOf="@id/repeatButton"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/notification_action_shuffle"
|
||||
android:focusable="true"
|
||||
android:padding="10dp"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/ic_shuffle"
|
||||
android:tint="?attr/colorAccent"
|
||||
android:contentDescription="@string/notification_action_shuffle"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
@@ -639,12 +657,12 @@
|
||||
android:layout_toLeftOf="@+id/itemsListClose"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/add_to_playlist"
|
||||
android:focusable="true"
|
||||
android:padding="10dp"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/ic_playlist_add"
|
||||
android:tint="?attr/colorAccent"
|
||||
android:contentDescription="@string/add_to_playlist"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
|
||||
@@ -886,4 +886,24 @@
|
||||
<string name="search_with_service_name_and_filter">البحث %1$s (%2$s)</string>
|
||||
<string name="migration_info_6_7_title">تمت إزالة صفحة أفضل 50 من SoundCloud</string>
|
||||
<string name="migration_info_6_7_message">أوقفت SoundCloud صفحة أفضل 50 الأصلية. تمت إزالة علامة التبويب المقابلة من صفحتك الرئيسية.</string>
|
||||
<string name="migration_info_7_8_title">تمت إزالة تريندات YouTube المجمعة</string>
|
||||
<string name="migration_info_7_8_message">أوقف YouTube صفحة الترند المدمجة اعتبارًا من 21 يوليو 2025. استبدلت NewPipe صفحة الموضوعات المتداولة الافتراضية بصفحة الموضوعات المتداولة الشائعة مع البث المباشر المتداول.\n\nيمكنك أيضًا تحديد صفحات رائجة مختلفة في \"الإعدادات > المحتوى > محتوى الصفحة الرئيسية\".</string>
|
||||
<string name="trending_gaming">توجهات الألعاب</string>
|
||||
<string name="trending_podcasts">توجهات البث الصوتي</string>
|
||||
<string name="trending_movies">الأفلام والعروض الأكثر رواجاً</string>
|
||||
<string name="trending_music">الموسيقى الرائجة</string>
|
||||
<string name="short_thousand">%s الف</string>
|
||||
<string name="short_million">%s مليون</string>
|
||||
<string name="short_billion">%sمليار</string>
|
||||
<string name="permission_display_over_apps_message">لاستخدام المشغل المنبثق، يرجى تحديد %1$s في قائمة إعدادات اندرويد التالية وتمكين %2$s.</string>
|
||||
<string name="permission_display_over_apps_permission_name">“السماح بالعرض فوق التطبيقات الاخرى”</string>
|
||||
<string name="delete_file">حذف ملف</string>
|
||||
<string name="delete_entry">حذف المدخلات</string>
|
||||
<string name="account_terminated_service_provides_reason">تم إنهاء الحساب\n\n%1$s يقدم هذا السبب: %2$s</string>
|
||||
<string name="entry_deleted">تم حذف المدخلات</string>
|
||||
<string name="player_http_403">تم تلقي خطأ HTTP 403 من الخادم أثناء التشغيل، ويرجح أن يكون السبب هو انتهاء صلاحية عنوان URL للبث أو حظر عنوان IP</string>
|
||||
<string name="player_http_invalid_status">حدث خطأ HTTP %1$s من الخادم أثناء التشغيل</string>
|
||||
<string name="youtube_player_http_403">تم تلقي خطأ HTTP 403 من الخادم أثناء التشغيل، ويرجح أن يكون السبب هو حظر عنوان IP أو مشكلات في إزالة التعتيم عن عنوان URL للبث</string>
|
||||
<string name="sign_in_confirm_not_bot_error">رفض %1$s تقديم البيانات، وطلب تسجيل الدخول للتأكد من أن الطالب ليس روبوتًا.\n\nربما تم حظر عنوان IP الخاص بك مؤقتًا من قبل %1$s، يمكنك الانتظار بعض الوقت أو التبديل إلى عنوان IP مختلف (على سبيل المثال عن طريق تشغيل/إيقاف تشغيل VPN، أو التبديل من WiFi إلى بيانات الهاتف المحمول).</string>
|
||||
<string name="unsupported_content_in_country">هذا المحتوى غير متاح للبلد المحدد حاليًا.\n\nقم بتغيير اختيارك من ”الإعدادات > المحتوى > البلد الافتراضي للمحتوى“.</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user