mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-12-05 01:10:43 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d1d7e0870 | ||
|
|
4a00dbb15d | ||
|
|
5c9ac912ac | ||
|
|
ffb82dc88c | ||
|
|
e91d647b27 | ||
|
|
6055cf2938 | ||
|
|
22dfe9519f | ||
|
|
e045251b58 | ||
|
|
ebe07596ba | ||
|
|
18f1cf2075 | ||
|
|
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 | ||
|
|
eb277fe14b | ||
|
|
d77771a60c | ||
|
|
01f9a3de33 | ||
|
|
150649aea9 | ||
|
|
3803d49489 | ||
|
|
25a4a9a253 | ||
|
|
d534946550 | ||
|
|
8fb3e90fe1 | ||
|
|
5750ef6aa8 | ||
|
|
ab7d1377e5 | ||
|
|
fd24c08529 | ||
|
|
e14ec3a4f9 | ||
|
|
b592403a66 | ||
|
|
90e1ac56ef | ||
|
|
32eb3afe16 | ||
|
|
83a0abddcc | ||
|
|
35c7f2f5d1 | ||
|
|
8afb00d2f0 | ||
|
|
f27ec53c08 | ||
|
|
a3ddd616f9 | ||
|
|
79980e2078 | ||
|
|
b204fad9d5 | ||
|
|
08f51abefb | ||
|
|
204df4c45a | ||
|
|
989c0cfd28 | ||
|
|
a369deeef4 | ||
|
|
1bde2dcd9f | ||
|
|
29a3ca83b5 | ||
|
|
38064be702 | ||
|
|
d17eae9bad | ||
|
|
74562db965 | ||
|
|
386d5197d8 | ||
|
|
ccd76dea1f | ||
|
|
e1888ede87 | ||
|
|
2c35db7a07 | ||
|
|
9282cce6a8 | ||
|
|
7644066c5a | ||
|
|
9bc8139b8c | ||
|
|
ff3526b28d | ||
|
|
d6c0dc32d1 | ||
|
|
124ab56c5f | ||
|
|
56f79fac13 | ||
|
|
ef29c318b0 | ||
|
|
9f11db8e06 | ||
|
|
fece0741e5 | ||
|
|
b9b47fc520 | ||
|
|
59db955493 | ||
|
|
22a709d53b | ||
|
|
329d76c857 | ||
|
|
9f526e8e8f | ||
|
|
50caba6606 | ||
|
|
26443f9f14 | ||
|
|
366129eee2 | ||
|
|
4c8d44b6ba | ||
|
|
14cd562ebd | ||
|
|
04ef608f7a |
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)
|
||||
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -72,8 +72,8 @@ jobs:
|
||||
- api-level: 21
|
||||
target: default
|
||||
arch: x86
|
||||
- api-level: 33
|
||||
target: google_apis # emulator API 33 only exists with Google APIs
|
||||
- api-level: 35
|
||||
target: default
|
||||
arch: x86_64
|
||||
|
||||
permissions:
|
||||
|
||||
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 34
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
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:v0.24.8'
|
||||
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 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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
app/build.gradle.kts
Normal file
306
app/build.gradle.kts
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
// 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)
|
||||
compileOnly(libs.google.autoservice.annotations)
|
||||
ksp(libs.zacsweers.autoservice.compiler)
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -23,8 +24,23 @@ import static org.junit.Assert.assertTrue;
|
||||
@LargeTest
|
||||
public class ErrorInfoTest {
|
||||
|
||||
/**
|
||||
* @param errorInfo the error info to access
|
||||
* @return the private field errorInfo.message.stringRes using reflection
|
||||
*/
|
||||
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
|
||||
throws NoSuchFieldException, IllegalAccessException {
|
||||
final var message = ErrorInfo.class.getDeclaredField("message");
|
||||
message.setAccessible(true);
|
||||
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
|
||||
|
||||
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
|
||||
stringRes.setAccessible(true);
|
||||
return (int) Objects.requireNonNull(stringRes.get(messageValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorInfoTestParcelable() {
|
||||
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
|
||||
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
|
||||
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
|
||||
// Obtain a Parcel object and write the parcelable object to it:
|
||||
@@ -39,7 +55,7 @@ public class ErrorInfoTest {
|
||||
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
|
||||
infoFromParcel.getServiceName());
|
||||
assertEquals("request", infoFromParcel.getRequest());
|
||||
assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
|
||||
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
|
||||
|
||||
parcel.recycle();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- We need to be able to open links in the browser on API 30+ -->
|
||||
@@ -94,9 +96,22 @@
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
||||
<service android:name=".local.subscription.services.SubscriptionsExportService" />
|
||||
<service android:name=".local.feed.service.FeedLoadService" />
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsExportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".local.feed.service.FeedLoadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
@@ -128,7 +143,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<service android:name="us.shandian.giga.service.DownloadManagerService" />
|
||||
<service android:name="us.shandian.giga.service.DownloadManagerService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:name=".util.FilePickerActivityHelper"
|
||||
@@ -324,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>
|
||||
@@ -421,6 +438,7 @@
|
||||
</activity>
|
||||
<service
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.GravityCompat;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.fragment.app.Fragment;
|
||||
@@ -896,7 +897,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
};
|
||||
final IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
|
||||
registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
|
||||
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
||||
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,20 +58,10 @@ import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.StreamingService.LinkType;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
@@ -260,7 +250,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||
showUnsupportedUrlDialog(url);
|
||||
}
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable,
|
||||
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
|
||||
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
|
||||
null, url))));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,40 +260,19 @@ public class RouterActivity extends AppCompatActivity {
|
||||
* @param errorInfo the error information
|
||||
*/
|
||||
private static void handleError(final Context context, final ErrorInfo errorInfo) {
|
||||
if (errorInfo.getThrowable() != null) {
|
||||
errorInfo.getThrowable().printStackTrace();
|
||||
}
|
||||
|
||||
if (errorInfo.getThrowable() instanceof ReCaptchaException) {
|
||||
if (errorInfo.getRecaptchaUrl() != null) {
|
||||
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
|
||||
// Starting ReCaptcha Challenge Activity
|
||||
final Intent intent = new Intent(context, ReCaptchaActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
|
||||
context.startActivity(intent);
|
||||
} else if (errorInfo.getThrowable() != null
|
||||
&& ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
|
||||
Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
|
||||
Toast.makeText(context, R.string.restricted_video_no_stream,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
|
||||
Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof PaidContentException) {
|
||||
Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof PrivateContentException) {
|
||||
Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
|
||||
Toast.makeText(context, R.string.soundcloud_go_plus_content,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
|
||||
Toast.makeText(context, R.string.youtube_music_premium_content,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
|
||||
Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
|
||||
Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
} else if (errorInfo.isReportable()) {
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
} else {
|
||||
// this exception does not usually indicate a problem that should be reported,
|
||||
// so just show a toast instead of the notification
|
||||
Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
if (context instanceof RouterActivity) {
|
||||
@@ -665,7 +635,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable,
|
||||
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
|
||||
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
|
||||
null, currentUrl)))
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -852,10 +823,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||
})
|
||||
)),
|
||||
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.REQUESTED_STREAM,
|
||||
throwable, UserAction.REQUESTED_STREAM,
|
||||
"Tried to add " + currentUrl + " to a playlist",
|
||||
((RouterActivity) ctx).currentService.getServiceId())
|
||||
((RouterActivity) ctx).currentService.getServiceId(),
|
||||
currentUrl)
|
||||
))
|
||||
)
|
||||
);
|
||||
@@ -995,7 +966,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
|
||||
choice.url + " opened with " + choice.playerChoice,
|
||||
choice.serviceId)));
|
||||
choice.serviceId, choice.url)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,8 +389,7 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading video stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading video stream size", currentInfo))));
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
@@ -399,8 +398,7 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading audio stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading audio stream size", currentInfo))));
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
@@ -409,8 +407,7 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading subtitle stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading subtitle stream size", currentInfo))));
|
||||
}
|
||||
|
||||
private void setupAudioTrackSpinner() {
|
||||
|
||||
@@ -36,8 +36,8 @@ public class AcraReportSender implements ReportSender {
|
||||
ErrorUtil.openActivity(context, new ErrorInfo(
|
||||
new String[]{report.getString(ReportField.STACK_TRACE)},
|
||||
UserAction.UI_ERROR,
|
||||
ErrorInfo.SERVICE_NONE,
|
||||
"ACRA report",
|
||||
null,
|
||||
R.string.app_ui_crash));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
|
||||
// normal bugreport
|
||||
buildInfo(errorInfo);
|
||||
activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
|
||||
activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
|
||||
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
|
||||
|
||||
// print stack trace once again for debugging:
|
||||
|
||||
@@ -1,115 +1,304 @@
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.Loader
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.player.mediasource.FailedMediaSource
|
||||
import org.schabi.newpipe.player.resolver.PlaybackResolver
|
||||
import java.net.UnknownHostException
|
||||
|
||||
/**
|
||||
* An error has occurred in the app. This class contains plain old parcelable data that can be used
|
||||
* to report the error and to show it to the user along with correct action buttons.
|
||||
*/
|
||||
@Parcelize
|
||||
class ErrorInfo(
|
||||
class ErrorInfo private constructor(
|
||||
val stackTraces: Array<String>,
|
||||
val userAction: UserAction,
|
||||
val serviceName: String,
|
||||
val request: String,
|
||||
val messageStringId: Int
|
||||
val serviceId: Int?,
|
||||
private val message: ErrorMessage,
|
||||
/**
|
||||
* If `true`, a report button will be shown for this error. Otherwise the error is not something
|
||||
* that can really be reported (e.g. a network issue, or content not being available at all).
|
||||
*/
|
||||
val isReportable: Boolean,
|
||||
/**
|
||||
* If `true`, the process causing this error can be retried, otherwise not.
|
||||
*/
|
||||
val isRetryable: Boolean,
|
||||
/**
|
||||
* If present, indicates that the exception was a ReCaptchaException, and this is the URL
|
||||
* provided by the service that can be used to solve the ReCaptcha challenge.
|
||||
*/
|
||||
val recaptchaUrl: String?,
|
||||
/**
|
||||
* If present, this resource can alternatively be opened in browser (useful if NewPipe is
|
||||
* badly broken).
|
||||
*/
|
||||
val openInBrowserUrl: String?,
|
||||
) : Parcelable {
|
||||
|
||||
// no need to store throwable, all data for report is in other variables
|
||||
// also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
|
||||
@IgnoredOnParcel
|
||||
var throwable: Throwable? = null
|
||||
|
||||
private constructor(
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
throwable: Throwable,
|
||||
userAction: UserAction,
|
||||
serviceName: String,
|
||||
request: String
|
||||
request: String,
|
||||
serviceId: Int? = null,
|
||||
openInBrowserUrl: String? = null,
|
||||
) : this(
|
||||
throwableToStringList(throwable),
|
||||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable, userAction)
|
||||
) {
|
||||
this.throwable = throwable
|
||||
}
|
||||
serviceId,
|
||||
getMessage(throwable, userAction, serviceId),
|
||||
isReportable(throwable),
|
||||
isRetryable(throwable),
|
||||
(throwable as? ReCaptchaException)?.url,
|
||||
openInBrowserUrl,
|
||||
)
|
||||
|
||||
private constructor(
|
||||
throwable: List<Throwable>,
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
throwables: List<Throwable>,
|
||||
userAction: UserAction,
|
||||
serviceName: String,
|
||||
request: String
|
||||
request: String,
|
||||
serviceId: Int? = null,
|
||||
openInBrowserUrl: String? = null,
|
||||
) : this(
|
||||
throwableListToStringList(throwable),
|
||||
throwableListToStringList(throwables),
|
||||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable.firstOrNull(), userAction)
|
||||
) {
|
||||
this.throwable = throwable.firstOrNull()
|
||||
serviceId,
|
||||
getMessage(throwables.firstOrNull(), userAction, serviceId),
|
||||
throwables.any(::isReportable),
|
||||
throwables.isEmpty() || throwables.any(::isRetryable),
|
||||
throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
|
||||
openInBrowserUrl,
|
||||
)
|
||||
|
||||
// constructor to manually build ErrorInfo when no throwable is available
|
||||
constructor(
|
||||
stackTraces: Array<String>,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
serviceId: Int?,
|
||||
@StringRes message: Int
|
||||
) :
|
||||
this(
|
||||
stackTraces, userAction, request, serviceId, ErrorMessage(message),
|
||||
true, false, null, null
|
||||
)
|
||||
|
||||
// constructor with only one throwable to extract service id and openInBrowserUrl from an Info
|
||||
constructor(
|
||||
throwable: Throwable,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
info: Info?,
|
||||
) :
|
||||
this(throwable, userAction, request, info?.serviceId, info?.url)
|
||||
|
||||
// constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
|
||||
constructor(
|
||||
throwables: List<Throwable>,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
info: Info?,
|
||||
) :
|
||||
this(throwables, userAction, request, info?.serviceId, info?.url)
|
||||
|
||||
fun getServiceName(): String {
|
||||
return getServiceName(serviceId)
|
||||
}
|
||||
|
||||
// constructors with single throwable
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
// constructors with list of throwables
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
fun getMessage(context: Context): String {
|
||||
return message.getString(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SERVICE_NONE = "none"
|
||||
@Parcelize
|
||||
class ErrorMessage(
|
||||
@StringRes
|
||||
private val stringRes: Int,
|
||||
private vararg val formatArgs: String,
|
||||
) : Parcelable {
|
||||
fun getString(context: Context): String {
|
||||
return if (formatArgs.isEmpty()) {
|
||||
// use ContextCompat.getString() just in case context is not AppCompatActivity
|
||||
ContextCompat.getString(context, stringRes)
|
||||
} else {
|
||||
// ContextCompat.getString() with formatArgs does not exist, so we just
|
||||
// replicate its source code but with formatArgs
|
||||
ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val SERVICE_NONE = "<unknown_service>"
|
||||
|
||||
private fun getServiceName(serviceId: Int?) =
|
||||
// not using getNameOfServiceById since we want to accept a nullable serviceId and we
|
||||
// want to default to SERVICE_NONE
|
||||
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
||||
?: SERVICE_NONE
|
||||
|
||||
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
||||
|
||||
fun throwableListToStringList(throwableList: List<Throwable>) =
|
||||
throwableList.map { it.stackTraceToString() }.toTypedArray()
|
||||
|
||||
private fun getInfoServiceName(info: Info?) =
|
||||
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
|
||||
|
||||
@StringRes
|
||||
private fun getMessageStringId(
|
||||
fun getMessage(
|
||||
throwable: Throwable?,
|
||||
action: UserAction
|
||||
): Int {
|
||||
action: UserAction?,
|
||||
serviceId: Int?,
|
||||
): ErrorMessage {
|
||||
return when {
|
||||
throwable is AccountTerminatedException -> R.string.account_terminated
|
||||
throwable is ContentNotAvailableException -> R.string.content_not_available
|
||||
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
||||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||
throwable is ExtractionException -> R.string.parsing_error
|
||||
// player exceptions
|
||||
// some may be IOException, so do these checks before isNetworkRelated!
|
||||
throwable is ExoPlaybackException -> {
|
||||
when (throwable.type) {
|
||||
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
|
||||
ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
|
||||
else -> R.string.player_unrecoverable_failure
|
||||
val cause = throwable.cause
|
||||
when {
|
||||
cause is HttpDataSource.InvalidResponseCodeException -> {
|
||||
if (cause.responseCode == 403) {
|
||||
if (serviceId == YouTube.serviceId) {
|
||||
ErrorMessage(R.string.youtube_player_http_403)
|
||||
} else {
|
||||
ErrorMessage(R.string.player_http_403)
|
||||
}
|
||||
} else {
|
||||
ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
|
||||
}
|
||||
}
|
||||
cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
|
||||
getMessage(throwable, action, serviceId)
|
||||
throwable.type == ExoPlaybackException.TYPE_SOURCE ->
|
||||
ErrorMessage(R.string.player_stream_failure)
|
||||
throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
|
||||
ErrorMessage(R.string.player_recoverable_failure)
|
||||
else ->
|
||||
ErrorMessage(R.string.player_unrecoverable_failure)
|
||||
}
|
||||
}
|
||||
action == UserAction.UI_ERROR -> R.string.app_ui_crash
|
||||
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
|
||||
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
|
||||
action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
|
||||
action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
|
||||
action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
|
||||
else -> R.string.general_error
|
||||
throwable is FailedMediaSource.FailedMediaSourceException ->
|
||||
getMessage(throwable.cause, action, serviceId)
|
||||
throwable is PlaybackResolver.ResolverException ->
|
||||
ErrorMessage(R.string.player_stream_failure)
|
||||
|
||||
// content not available exceptions
|
||||
throwable is AccountTerminatedException ->
|
||||
throwable.message
|
||||
?.takeIf { reason -> !reason.isEmpty() }
|
||||
?.let { reason ->
|
||||
ErrorMessage(
|
||||
R.string.account_terminated_service_provides_reason,
|
||||
getServiceName(serviceId),
|
||||
reason
|
||||
)
|
||||
}
|
||||
?: ErrorMessage(R.string.account_terminated)
|
||||
throwable is AgeRestrictedContentException ->
|
||||
ErrorMessage(R.string.restricted_video_no_stream)
|
||||
throwable is GeographicRestrictionException ->
|
||||
ErrorMessage(R.string.georestricted_content)
|
||||
throwable is PaidContentException ->
|
||||
ErrorMessage(R.string.paid_content)
|
||||
throwable is PrivateContentException ->
|
||||
ErrorMessage(R.string.private_content)
|
||||
throwable is SoundCloudGoPlusContentException ->
|
||||
ErrorMessage(R.string.soundcloud_go_plus_content)
|
||||
throwable is UnsupportedContentInCountryException ->
|
||||
ErrorMessage(R.string.unsupported_content_in_country)
|
||||
throwable is YoutubeMusicPremiumContentException ->
|
||||
ErrorMessage(R.string.youtube_music_premium_content)
|
||||
throwable is SignInConfirmNotBotException ->
|
||||
ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
|
||||
throwable is ContentNotAvailableException ->
|
||||
ErrorMessage(R.string.content_not_available)
|
||||
|
||||
// other extractor exceptions
|
||||
throwable is ContentNotSupportedException ->
|
||||
ErrorMessage(R.string.content_not_supported)
|
||||
// ReCaptchas will be handled in a special way anyway
|
||||
throwable is ReCaptchaException ->
|
||||
ErrorMessage(R.string.recaptcha_request_toast)
|
||||
// test this at the end as many exceptions could be a subclass of IOException
|
||||
throwable != null && throwable.isNetworkRelated ->
|
||||
ErrorMessage(R.string.network_error)
|
||||
// an extraction exception unrelated to the network
|
||||
// is likely an issue with parsing the website
|
||||
throwable is ExtractionException ->
|
||||
ErrorMessage(R.string.parsing_error)
|
||||
|
||||
// user actions (in case the exception is null or unrecognizable)
|
||||
action == UserAction.UI_ERROR ->
|
||||
ErrorMessage(R.string.app_ui_crash)
|
||||
action == UserAction.REQUESTED_COMMENTS ->
|
||||
ErrorMessage(R.string.error_unable_to_load_comments)
|
||||
action == UserAction.SUBSCRIPTION_CHANGE ->
|
||||
ErrorMessage(R.string.subscription_change_failed)
|
||||
action == UserAction.SUBSCRIPTION_UPDATE ->
|
||||
ErrorMessage(R.string.subscription_update_failed)
|
||||
action == UserAction.LOAD_IMAGE ->
|
||||
ErrorMessage(R.string.could_not_load_thumbnails)
|
||||
action == UserAction.DOWNLOAD_OPEN_DIALOG ->
|
||||
ErrorMessage(R.string.could_not_setup_download_menu)
|
||||
else ->
|
||||
ErrorMessage(R.string.error_snackbar_message)
|
||||
}
|
||||
}
|
||||
|
||||
fun isReportable(throwable: Throwable?): Boolean {
|
||||
return when (throwable) {
|
||||
// we don't have an exception, so this is a manually built error, which likely
|
||||
// indicates that it's important and is thus reportable
|
||||
null -> true
|
||||
// the service explicitly said that content is not available (e.g. age restrictions,
|
||||
// video deleted, etc.), there is no use in letting users report it
|
||||
is ContentNotAvailableException -> false
|
||||
// we know the content is not supported, no need to let the user report it
|
||||
is ContentNotSupportedException -> false
|
||||
// happens often when there is no internet connection; we don't use
|
||||
// `throwable.isNetworkRelated` since any `IOException` would make that function
|
||||
// return true, but not all `IOException`s are network related
|
||||
is UnknownHostException -> false
|
||||
// by default, this is an unexpected exception, which the user could report
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun isRetryable(throwable: Throwable?): Boolean {
|
||||
return when (throwable) {
|
||||
// we know the content is not available, retrying won't help
|
||||
is ContentNotAvailableException -> false
|
||||
// we know the content is not supported, retrying won't help
|
||||
is ContentNotSupportedException -> false
|
||||
// by default (including if throwable is null), enable retrying (though the retry
|
||||
// button will be shown only if a way to perform the retry is implemented)
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
@@ -14,21 +13,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.isInterruptedCaused
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -78,64 +63,32 @@ class ErrorPanelHelper(
|
||||
}
|
||||
|
||||
fun showError(errorInfo: ErrorInfo) {
|
||||
|
||||
if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ensureDefaultVisibility()
|
||||
errorTextView.text = errorInfo.getMessage(context)
|
||||
|
||||
if (errorInfo.throwable is ReCaptchaException) {
|
||||
errorTextView.setText(R.string.recaptcha_request_toast)
|
||||
|
||||
showAndSetErrorButtonAction(
|
||||
R.string.recaptcha_solve
|
||||
) {
|
||||
if (errorInfo.recaptchaUrl != null) {
|
||||
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
|
||||
// Starting ReCaptcha Challenge Activity
|
||||
val intent = Intent(context, ReCaptchaActivity::class.java)
|
||||
intent.putExtra(
|
||||
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
|
||||
(errorInfo.throwable as ReCaptchaException).url
|
||||
)
|
||||
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
|
||||
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
|
||||
errorActionButton.setOnClickListener(null)
|
||||
}
|
||||
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
} else if (errorInfo.throwable is AccountTerminatedException) {
|
||||
errorTextView.setText(R.string.account_terminated)
|
||||
|
||||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||
errorServiceInfoTextView.text = context.resources.getString(
|
||||
R.string.service_provides_reason,
|
||||
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
|
||||
)
|
||||
errorServiceInfoTextView.isVisible = true
|
||||
|
||||
errorServiceExplanationTextView.text =
|
||||
(errorInfo.throwable as AccountTerminatedException).message
|
||||
errorServiceExplanationTextView.isVisible = true
|
||||
}
|
||||
} else {
|
||||
showAndSetErrorButtonAction(
|
||||
R.string.error_snackbar_action
|
||||
) {
|
||||
} else if (errorInfo.isReportable) {
|
||||
showAndSetErrorButtonAction(R.string.error_snackbar_action) {
|
||||
ErrorUtil.openActivity(context, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
|
||||
if (errorInfo.isRetryable) {
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
}
|
||||
|
||||
if (errorInfo.throwable !is ContentNotAvailableException &&
|
||||
errorInfo.throwable !is ContentNotSupportedException
|
||||
) {
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
if (errorInfo.openInBrowserUrl != null) {
|
||||
errorOpenInBrowserButton.isVisible = true
|
||||
errorOpenInBrowserButton.setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
|
||||
}
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
}
|
||||
|
||||
setRootVisible()
|
||||
@@ -153,15 +106,6 @@ class ErrorPanelHelper(
|
||||
errorActionButton.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun showAndSetOpenInBrowserButtonAction(
|
||||
errorInfo: ErrorInfo
|
||||
) {
|
||||
errorOpenInBrowserButton.isVisible = true
|
||||
errorOpenInBrowserButton.setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(context, errorInfo.request)
|
||||
}
|
||||
}
|
||||
|
||||
fun showTextError(errorString: String) {
|
||||
ensureDefaultVisibility()
|
||||
|
||||
@@ -192,27 +136,5 @@ class ErrorPanelHelper(
|
||||
companion object {
|
||||
val TAG: String = ErrorPanelHelper::class.simpleName!!
|
||||
val DEBUG: Boolean = MainActivity.DEBUG
|
||||
|
||||
@StringRes
|
||||
fun getExceptionDescription(throwable: Throwable?): Int {
|
||||
return when (throwable) {
|
||||
is AgeRestrictedContentException -> R.string.restricted_video_no_stream
|
||||
is GeographicRestrictionException -> R.string.georestricted_content
|
||||
is PaidContentException -> R.string.paid_content
|
||||
is PrivateContentException -> R.string.private_content
|
||||
is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
|
||||
is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
|
||||
is ContentNotAvailableException -> R.string.content_not_available
|
||||
is ContentNotSupportedException -> R.string.content_not_supported
|
||||
else -> {
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
if (throwable != null && throwable.isNetworkRelated) {
|
||||
R.string.network_error
|
||||
} else {
|
||||
R.string.error_snackbar_message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
@@ -121,7 +122,7 @@ class ErrorUtil {
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_bug_report)
|
||||
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setContentText(errorInfo.getMessage(context))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
@@ -136,9 +137,11 @@ class ErrorUtil {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
@@ -153,10 +156,10 @@ class ErrorUtil {
|
||||
// fallback to showing a notification if no root view is available
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
|
||||
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
||||
openActivity(context, errorInfo)
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ public enum UserAction {
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
|
||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab");
|
||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
|
||||
PLAY_ON_POPUP("play on popup"),
|
||||
SUBSCRIPTIONS("loading subscriptions");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerIntentType;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
@@ -876,7 +877,7 @@ public final class VideoDetailFragment
|
||||
}
|
||||
}
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
|
||||
url == null ? "no url" : url, serviceId)));
|
||||
url == null ? "no url" : url, serviceId, url)));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -1166,8 +1167,12 @@ public final class VideoDetailFragment
|
||||
final PlayQueue queue = setupPlayQueueForIntent(false);
|
||||
tryAddVideoPlayerView();
|
||||
|
||||
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
|
||||
PlayerService.class, queue, true, autoPlayEnabled);
|
||||
final Context context = requireContext();
|
||||
final Intent playerIntent =
|
||||
NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
|
||||
.putExtra(Player.RESUME_PLAYBACK, true);
|
||||
ContextCompat.startForegroundService(activity, playerIntent);
|
||||
}
|
||||
|
||||
@@ -1225,7 +1230,13 @@ public final class VideoDetailFragment
|
||||
disposables.add(recordManager.onViewed(info).onErrorComplete()
|
||||
.subscribe(
|
||||
ignored -> { /* successful */ },
|
||||
error -> Log.e(TAG, "Register view failure: ", error)
|
||||
error -> showSnackBarError(
|
||||
new ErrorInfo(
|
||||
error,
|
||||
UserAction.PLAY_STREAM,
|
||||
"Got an error when modifying history on viewed"
|
||||
)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1411,10 +1422,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;
|
||||
}
|
||||
}
|
||||
@@ -1423,7 +1432,8 @@ public final class VideoDetailFragment
|
||||
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
|
||||
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
|
||||
intentFilter.addAction(ACTION_PLAYER_STARTED);
|
||||
activity.registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
}
|
||||
|
||||
|
||||
@@ -1592,8 +1602,8 @@ public final class VideoDetailFragment
|
||||
}
|
||||
|
||||
if (!info.getErrors().isEmpty()) {
|
||||
showSnackBarError(new ErrorInfo(info.getErrors(),
|
||||
UserAction.REQUESTED_STREAM, info.getUrl(), info));
|
||||
showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
|
||||
"Some info not extracted: " + info.getUrl(), info));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1896,12 +1906,13 @@ public final class VideoDetailFragment
|
||||
|
||||
@Override
|
||||
public void onScreenRotationButtonClicked() {
|
||||
// On Android TV screen rotation is not supported
|
||||
// In tablet user experience will be better if screen will not be rotated
|
||||
// from landscape to portrait every time.
|
||||
// Just turn on fullscreen mode in landscape orientation
|
||||
// or portrait & unlocked global orientation
|
||||
final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
|
||||
if (DeviceUtils.isTablet(activity)
|
||||
if (DeviceUtils.isTv(activity) || DeviceUtils.isTablet(activity)
|
||||
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
|
||||
return;
|
||||
|
||||
@@ -153,7 +153,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
handleResult(result);
|
||||
}, throwable ->
|
||||
showError(new ErrorInfo(throwable, errorUserAction,
|
||||
"Start loading: " + url, serviceId)));
|
||||
"Start loading: " + url, serviceId, url)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +184,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
handleNextItems(infoItemsPage);
|
||||
}, (@NonNull Throwable throwable) ->
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
|
||||
errorUserAction, "Loading more items: " + url, serviceId)));
|
||||
errorUserAction, "Loading more items: " + url, serviceId, url)));
|
||||
}
|
||||
|
||||
private void forbidDownwardFocusScroll() {
|
||||
@@ -210,7 +210,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
|
||||
if (!result.getErrors().isEmpty()) {
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction,
|
||||
"Get next items of: " + url, serviceId));
|
||||
"Get next items of: " + url, serviceId, url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
|
||||
errorUserAction, "Start loading: " + url, serviceId));
|
||||
errorUserAction, "Start loading: " + url, serviceId, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -577,7 +577,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
isLoading.set(false);
|
||||
handleResult(result);
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
|
||||
url == null ? "No URL" : url, serviceId)));
|
||||
url == null ? "No URL" : url, serviceId, url)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -54,6 +54,7 @@ import org.schabi.newpipe.extractor.MetaInfo;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.Page;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.search.SearchExtractor;
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
|
||||
@@ -934,7 +935,21 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
infoListAdapter.clearStreamItemList();
|
||||
showEmptyState();
|
||||
} else {
|
||||
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId));
|
||||
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId,
|
||||
getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getOpenInBrowserUrlForErrors() {
|
||||
if (TextUtils.isEmpty(searchString)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return service.getSearchQHFactory().getUrl(searchString,
|
||||
Arrays.asList(contentFilter), sortFilter);
|
||||
} catch (final NullPointerException | ParsingException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,7 +1037,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
&& !(exceptions.size() == 1
|
||||
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
searchString, serviceId));
|
||||
searchString, serviceId, getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
|
||||
searchSuggestion = result.getSearchSuggestion();
|
||||
@@ -1095,13 +1110,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
// whose results are handled here, but let's check it anyway
|
||||
if (nextPage == null) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → nextPage == null", serviceId));
|
||||
"\"" + searchString + "\" → nextPage == null", serviceId,
|
||||
getOpenInBrowserUrlForErrors()));
|
||||
} else {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
|
||||
+ "pageIds: " + nextPage.getIds() + ", "
|
||||
+ "pageCookies: " + nextPage.getCookies(),
|
||||
serviceId));
|
||||
serviceId, getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -13,6 +13,9 @@ import androidx.annotation.StringRes;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
@@ -132,6 +135,16 @@ public enum StreamDialogDefaultEntry {
|
||||
MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
|
||||
new HistoryRecordManager(fragment.getContext())
|
||||
.markAsWatched(item)
|
||||
.doOnError(error -> {
|
||||
ErrorUtil.showSnackbar(
|
||||
fragment.requireContext(),
|
||||
new ErrorInfo(
|
||||
error,
|
||||
UserAction.OPEN_INFO_ITEM_DIALOG,
|
||||
"Got an error when trying to mark as watched"
|
||||
)
|
||||
);
|
||||
})
|
||||
.onErrorComplete()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Constraints
|
||||
@@ -83,7 +85,9 @@ class NotificationWorker(
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||
.build()
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||
// ServiceInfo constants are not used below Android Q, so 0 is set here
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
@@ -200,7 +201,7 @@ class FeedLoadService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
|
||||
ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
||||
ServiceHelper.getNameOfServiceById(currentServiceId),
|
||||
"Service does not support importing subscriptions",
|
||||
currentServiceId,
|
||||
R.string.general_error));
|
||||
activity.finish();
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ
|
||||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
|
||||
import static com.google.android.exoplayer2.Player.DiscontinuityReason;
|
||||
import static com.google.android.exoplayer2.Player.Listener;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
|
||||
import static com.google.android.exoplayer2.Player.RepeatMode;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
|
||||
@@ -60,6 +60,8 @@ 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;
|
||||
|
||||
@@ -108,6 +110,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager;
|
||||
import org.schabi.newpipe.player.playback.PlaybackListener;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
|
||||
@@ -117,6 +120,7 @@ import org.schabi.newpipe.player.ui.PlayerUiList;
|
||||
import org.schabi.newpipe.player.ui.PopupPlayerUi;
|
||||
import org.schabi.newpipe.player.ui.VideoPlayerUi;
|
||||
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.SerializedCache;
|
||||
@@ -124,14 +128,17 @@ 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;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public final class Player implements PlaybackListener, Listener {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@@ -153,15 +160,13 @@ public final class Player implements PlaybackListener, Listener {
|
||||
// Intent
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static final String REPEAT_MODE = "repeat_mode";
|
||||
public static final String PLAYBACK_QUALITY = "playback_quality";
|
||||
public static final String PLAY_QUEUE_KEY = "play_queue_key";
|
||||
public static final String ENQUEUE = "enqueue";
|
||||
public static final String ENQUEUE_NEXT = "enqueue_next";
|
||||
public static final String RESUME_PLAYBACK = "resume_playback";
|
||||
public static final String PLAY_WHEN_READY = "play_when_ready";
|
||||
public static final String PLAYER_TYPE = "player_type";
|
||||
public static final String IS_MUTED = "is_muted";
|
||||
public static final String PLAYER_INTENT_TYPE = "player_intent_type";
|
||||
public static final String PLAYER_INTENT_DATA = "player_intent_data";
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Time constants
|
||||
@@ -246,6 +251,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
|
||||
@NonNull
|
||||
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
|
||||
@NonNull
|
||||
private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
|
||||
|
||||
// This is the only listener we need for thumbnail loading, since there is always at most only
|
||||
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
|
||||
@@ -346,49 +353,121 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
@SuppressWarnings("MethodLength")
|
||||
public void handleIntent(@NonNull final Intent intent) {
|
||||
// fail fast if no play queue was provided
|
||||
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
|
||||
if (queueCache == null) {
|
||||
final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
|
||||
PlayerIntentType.class);
|
||||
if (playerIntentType == null) {
|
||||
return;
|
||||
}
|
||||
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
// 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.
|
||||
// when playing from a timestamp, keep the current player as-is.
|
||||
if (playerIntentType != PlayerIntentType.TimestampChange) {
|
||||
playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
|
||||
}
|
||||
|
||||
final PlayerType oldPlayerType = playerType;
|
||||
playerType = PlayerType.retrieveFromIntent(intent);
|
||||
initUIsForCurrentPlayerType();
|
||||
// We need to setup audioOnly before super(), see "sourceOf"
|
||||
isAudioOnly = audioPlayerSelected();
|
||||
|
||||
if (intent.hasExtra(PLAYBACK_QUALITY)) {
|
||||
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
|
||||
}
|
||||
|
||||
// Resolve enqueue intents
|
||||
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
|
||||
playQueue.append(newQueue.getStreams());
|
||||
return;
|
||||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||||
|
||||
// Resolve enqueue next intents
|
||||
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
|
||||
final int currentIndex = playQueue.getIndex();
|
||||
playQueue.append(newQueue.getStreams());
|
||||
playQueue.move(playQueue.size() - 1, currentIndex + 1);
|
||||
switch (playerIntentType) {
|
||||
case Enqueue -> {
|
||||
if (playQueue != null) {
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
playQueue.append(newQueue.getStreams());
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This falls through to the old logic, there was no playQueue
|
||||
// yet so we should start the player and add the new video
|
||||
break;
|
||||
}
|
||||
case EnqueueNext -> {
|
||||
if (playQueue != null) {
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
final PlayQueueItem newItem = newQueue.getStreams().get(0);
|
||||
newQueue.enqueueNext(newItem, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This falls through to the old logic, there was no playQueue
|
||||
// yet so we should start the player and add the new video
|
||||
break;
|
||||
}
|
||||
case TimestampChange -> {
|
||||
final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
|
||||
PLAYER_INTENT_DATA, TimestampChangeData.class));
|
||||
final Single<StreamInfo> single =
|
||||
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(data.getSeconds());
|
||||
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
|
||||
|
||||
// If the stream is already playing,
|
||||
// we can just seek to the appropriate timestamp
|
||||
if (oldPlayQueue != null
|
||||
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
if (simpleExoPlayer.getPlaybackState()
|
||||
== com.google.android.exoplayer2.Player.STATE_IDLE) {
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
|
||||
data.getSeconds() * 1000L);
|
||||
simpleExoPlayer.setPlayWhenReady(playWhenReady);
|
||||
|
||||
} else {
|
||||
final PlayQueue newPlayQueue;
|
||||
|
||||
// If there is no queue yet, just add our item
|
||||
if (oldPlayQueue == null) {
|
||||
newPlayQueue = new SinglePlayQueue(playQueueItem);
|
||||
|
||||
// else we add the timestamped stream behind the current video
|
||||
// and start playing it.
|
||||
} else {
|
||||
oldPlayQueue.enqueueNext(playQueueItem, true);
|
||||
oldPlayQueue.offsetIndex(1);
|
||||
newPlayQueue = oldPlayQueue;
|
||||
}
|
||||
initPlayback(newPlayQueue, playWhenReady);
|
||||
}
|
||||
|
||||
}, throwable -> {
|
||||
// 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.
|
||||
final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
|
||||
data.getUrl(), null, data.getUrl());
|
||||
ErrorUtil.createNotification(context, info);
|
||||
}));
|
||||
return;
|
||||
}
|
||||
case AllOthers -> {
|
||||
// fallthrough; TODO: put other intent data in separate cases
|
||||
}
|
||||
}
|
||||
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
|
||||
final float playbackSpeed = savedParameters.speed;
|
||||
final float playbackPitch = savedParameters.pitch;
|
||||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||||
|
||||
// branching parameters for below
|
||||
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
|
||||
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
|
||||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||||
|
||||
/*
|
||||
* TODO As seen in #7427 this does not work:
|
||||
@@ -403,7 +482,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
if (!exoPlayerIsNull()
|
||||
&& newQueue.size() == 1 && newQueue.getItem() != null
|
||||
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
||||
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
|
||||
&& newQueue.getItem().isSameItem(playQueue.getItem())
|
||||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
@@ -429,7 +508,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
|
||||
&& DependentPreferenceHelper.getResumePlaybackEnabled(context)
|
||||
&& !samePlayQueue
|
||||
// !samePlayQueue
|
||||
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
|
||||
&& !newQueue.isEmpty()
|
||||
&& newQueue.getItem() != null
|
||||
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
|
||||
@@ -445,30 +525,30 @@ public final class Player implements PlaybackListener, Listener {
|
||||
newQueue.setRecovery(newQueue.getIndex(),
|
||||
state.getProgressMillis());
|
||||
}
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
},
|
||||
error -> {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Failed to start playback", error);
|
||||
}
|
||||
// In case any error we can start playback without history
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
},
|
||||
() -> {
|
||||
// Completed but not found in history
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
}
|
||||
));
|
||||
} else {
|
||||
// Good to go...
|
||||
// In a case of equal PlayQueues we can re-init old one but only when it is disposed
|
||||
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
|
||||
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void handleIntentPost(final PlayerType oldPlayerType) {
|
||||
if (oldPlayerType != playerType && playQueue != null) {
|
||||
// If playerType changes from one to another we should reload the player
|
||||
// (to disable/enable video stream or to set quality)
|
||||
@@ -479,6 +559,19 @@ public final class Player implements PlaybackListener, Listener {
|
||||
NavigationHelper.sendPlayerStartedEvent(context);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) {
|
||||
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
|
||||
if (queueCache == null) {
|
||||
return null;
|
||||
}
|
||||
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
|
||||
if (newQueue == null) {
|
||||
return null;
|
||||
}
|
||||
return newQueue;
|
||||
}
|
||||
|
||||
private void initUIsForCurrentPlayerType() {
|
||||
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||
@@ -512,16 +605,13 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
private void initPlayback(@NonNull final PlayQueue queue,
|
||||
@RepeatMode final int repeatMode,
|
||||
final float playbackSpeed,
|
||||
final float playbackPitch,
|
||||
final boolean playbackSkipSilence,
|
||||
final boolean playOnReady,
|
||||
final boolean isMuted) {
|
||||
final boolean playOnReady) {
|
||||
destroyPlayer();
|
||||
initPlayer(playOnReady);
|
||||
setRepeatMode(repeatMode);
|
||||
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
|
||||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||||
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
|
||||
setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence);
|
||||
|
||||
playQueue = queue;
|
||||
playQueue.init();
|
||||
@@ -529,7 +619,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
UIs.call(PlayerUi::initPlayback);
|
||||
|
||||
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
|
||||
simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
|
||||
notifyQueueUpdateToListeners();
|
||||
}
|
||||
|
||||
@@ -611,6 +701,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
databaseUpdateDisposable.clear();
|
||||
progressUpdateDisposable.set(null);
|
||||
streamItemDisposable.clear();
|
||||
cancelLoadingCurrentThumbnail();
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
@@ -764,7 +855,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
private void registerBroadcastReceiver() {
|
||||
// Try to unregister current first
|
||||
unregisterBroadcastReceiver();
|
||||
context.registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
}
|
||||
|
||||
private void unregisterBroadcastReceiver() {
|
||||
@@ -1176,16 +1268,25 @@ public final class Player implements PlaybackListener, Listener {
|
||||
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
|
||||
}
|
||||
|
||||
public void setRepeatMode(@RepeatMode final int repeatMode) {
|
||||
public void cycleNextRepeatMode() {
|
||||
if (!exoPlayerIsNull()) {
|
||||
@RepeatMode final int repeatMode;
|
||||
switch (simpleExoPlayer.getRepeatMode()) {
|
||||
case REPEAT_MODE_OFF:
|
||||
repeatMode = REPEAT_MODE_ONE;
|
||||
break;
|
||||
case REPEAT_MODE_ONE:
|
||||
repeatMode = REPEAT_MODE_ALL;
|
||||
break;
|
||||
case REPEAT_MODE_ALL:
|
||||
default:
|
||||
repeatMode = REPEAT_MODE_OFF;
|
||||
break;
|
||||
}
|
||||
simpleExoPlayer.setRepeatMode(repeatMode);
|
||||
}
|
||||
}
|
||||
|
||||
public void cycleNextRepeatMode() {
|
||||
setRepeatMode(nextRepeatMode(getRepeatMode()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||||
if (DEBUG) {
|
||||
@@ -1288,7 +1389,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
UserAction.PLAY_STREAM,
|
||||
"Loading failed for [" + currentMetadata.getTitle()
|
||||
+ "]: " + currentMetadata.getStreamUrl(),
|
||||
currentMetadata.getServiceId());
|
||||
currentMetadata.getServiceId(),
|
||||
currentMetadata.getStreamUrl());
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
@@ -1504,7 +1606,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.getErrorCodeName()
|
||||
+ "] occurred while playing " + currentMetadata.getStreamUrl(),
|
||||
currentMetadata.getServiceId());
|
||||
currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
|
||||
}
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.schabi.newpipe.player
|
||||
|
||||
import android.os.Parcelable
|
||||
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.
|
||||
enum class PlayerIntentType {
|
||||
Enqueue,
|
||||
EnqueueNext,
|
||||
TimestampChange,
|
||||
AllOthers
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp on the given was clicked and we should switch the playing stream to it.
|
||||
*/
|
||||
@Parcelize
|
||||
data class TimestampChangeData(
|
||||
val serviceId: Int,
|
||||
val url: String,
|
||||
val seconds: Int
|
||||
) : Parcelable
|
||||
@@ -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,23 +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) {
|
||||
player.handleIntent(intent);
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package org.schabi.newpipe.player.helper;
|
||||
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
|
||||
@@ -25,7 +22,6 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player.RepeatMode;
|
||||
import com.google.android.exoplayer2.SeekParameters;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
|
||||
@@ -300,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,
|
||||
@@ -410,23 +402,9 @@ public final class PlayerHelper {
|
||||
return singlePlayQueue;
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
// region Utils used by player
|
||||
|
||||
@RepeatMode
|
||||
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
|
||||
switch (repeatMode) {
|
||||
case REPEAT_MODE_OFF:
|
||||
return REPEAT_MODE_ONE;
|
||||
case REPEAT_MODE_ONE:
|
||||
return REPEAT_MODE_ALL;
|
||||
case REPEAT_MODE_ALL:
|
||||
default:
|
||||
return REPEAT_MODE_OFF;
|
||||
}
|
||||
}
|
||||
|
||||
@ResizeMode
|
||||
public static int retrieveResizeModeFromPrefs(final Player player) {
|
||||
return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode),
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
|
||||
@@ -84,7 +85,7 @@ class MediaBrowserPlaybackPreparer(
|
||||
},
|
||||
{ throwable ->
|
||||
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
|
||||
onPrepareError()
|
||||
onPrepareError(throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -115,9 +116,9 @@ class MediaBrowserPlaybackPreparer(
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPrepareError() {
|
||||
private fun onPrepareError(throwable: Throwable) {
|
||||
setMediaSessionError.accept(
|
||||
ContextCompat.getString(context, R.string.error_snackbar_message),
|
||||
ErrorInfo.getMessage(throwable, null, null).getString(context),
|
||||
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
||||
)
|
||||
}
|
||||
@@ -214,7 +215,7 @@ class MediaBrowserPlaybackPreparer(
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
return database.streamHistoryDAO().history
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
|
||||
@@ -147,18 +147,15 @@ internal class PackageValidator(context: Context) {
|
||||
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
|
||||
val packageInfo = getPackageInfo(callingPackage) ?: return null
|
||||
|
||||
val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
|
||||
val uid = packageInfo.applicationInfo.uid
|
||||
val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString()
|
||||
val uid = packageInfo.applicationInfo?.uid ?: -1
|
||||
val signature = getSignature(packageInfo)
|
||||
|
||||
val requestedPermissions = packageInfo.requestedPermissions
|
||||
val permissionFlags = packageInfo.requestedPermissionsFlags
|
||||
val activePermissions = mutableSetOf<String>()
|
||||
requestedPermissions?.forEachIndexed { index, permission ->
|
||||
if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
|
||||
activePermissions += permission
|
||||
}
|
||||
}
|
||||
val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty()
|
||||
val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty()
|
||||
val activePermissions = (requestedPermissions zip permissionFlags)
|
||||
.filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 }
|
||||
.mapTo(mutableSetOf()) { (permission, flag) -> permission }
|
||||
|
||||
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
|
||||
}
|
||||
@@ -189,12 +186,12 @@ internal class PackageValidator(context: Context) {
|
||||
*/
|
||||
@Suppress("deprecation")
|
||||
private fun getSignature(packageInfo: PackageInfo): String? =
|
||||
if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
|
||||
if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
|
||||
// Security best practices dictate that an app should be signed with exactly one (1)
|
||||
// signature. Because of this, if there are multiple signatures, reject it.
|
||||
null
|
||||
} else {
|
||||
val certificate = packageInfo.signatures[0].toByteArray()
|
||||
val certificate = packageInfo.signatures!![0].toByteArray()
|
||||
getSignatureSha256(certificate)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -23,6 +25,8 @@ import androidx.core.content.ContextCompat;
|
||||
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;
|
||||
|
||||
@@ -89,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
|
||||
@@ -107,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);
|
||||
@@ -167,19 +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();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
|
||||
}
|
||||
startForeground(player.getService(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
public void cancelNotificationAndStopForeground() {
|
||||
@@ -193,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
|
||||
/////////////////////////////////////////////////////
|
||||
@@ -256,7 +274,9 @@ public final class NotificationUtil {
|
||||
} else {
|
||||
// We are playing in fragment. Don't open another activity just show fragment. That's it
|
||||
final Intent intent = NavigationHelper.getPlayerIntent(
|
||||
player.getContext(), MainActivity.class, null, true);
|
||||
player.getContext(), MainActivity.class, null,
|
||||
PlayerIntentType.AllOthers);
|
||||
intent.putExtra(Player.RESUME_PLAYBACK, true);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intent.setAction(Intent.ACTION_MAIN);
|
||||
intent.addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
|
||||
@@ -291,6 +291,22 @@ public abstract class PlayQueue implements Serializable {
|
||||
broadcast(new AppendEvent(itemList.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given item after the current stream.
|
||||
*
|
||||
* @param item item to add.
|
||||
* @param skipIfSame if set, skip adding if the next stream is the same stream.
|
||||
*/
|
||||
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
|
||||
final int currentIndex = getIndex();
|
||||
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
||||
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
|
||||
return;
|
||||
}
|
||||
append(List.of(item));
|
||||
move(size() - 1, currentIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the item at the given index from the play queue.
|
||||
* <p>
|
||||
@@ -529,8 +545,7 @@ public abstract class PlayQueue implements Serializable {
|
||||
final PlayQueueItem stream = streams.get(i);
|
||||
final PlayQueueItem otherStream = other.streams.get(i);
|
||||
// Check is based on serviceId and URL
|
||||
if (stream.getServiceId() != otherStream.getServiceId()
|
||||
|| !stream.getUrl().equals(otherStream.getUrl())) {
|
||||
if (!stream.isSameItem(otherStream)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user