1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-01-14 02:32:40 +00:00

Compare commits

...

253 Commits
v0.28.0 ... dev

Author SHA1 Message Date
Tobi
2f063a78ba Merge pull request #13052 from RinZ27/fix/ci-shell-injection
ci: fix shell injection in backport workflow
2026-01-13 06:36:01 -08:00
RinCodeForge927
7dc38286c0 ci: fix shell injection in backport workflow 2026-01-13 20:42:10 +07:00
Tobi
77d62deeed Merge pull request #13049 from dustdfg/playlist_bug
Fix playlist item dragging video to only neighbor positions
2026-01-12 12:17:47 -08:00
Yevhen Babiichuk (DustDFG)
914feef5e9 Fix playlist item dragging video to only neighbor positions
Call `saveImmediate` only after used actually dropped item instead
of every time View is updated which happens several times to show
user a feedback where item would be moved
2026-01-12 20:35:14 +02:00
TobiGr
4ed2b9748f Merge branch 'master' into dev 2026-01-11 22:53:11 +01:00
Tobi
b8923187f9 Merge pull request #13038 from TobiGr/fix/minify
Fix R8 minify task by adding jsoup classes to proguard file
2026-01-11 13:16:53 -08:00
tobigr
6c5d58bed3 Fix minify step of release build by ignoring two classes
This fixes the following error during the minifyWithR8 task:

> Task :app:minifyReleaseWithR8 FAILED
ERROR: Missing classes detected while running R8. Please add the missing classes or apply additional keep rules that are generated in /home/runner/work/NewPipe/NewPipe/app/build/outputs/mapping/release/missing_rules.txt.
ERROR: R8: Missing class com.google.re2j.Matcher (referenced from: com.google.re2j.Matcher org.jsoup.helper.Re2jRegex$Re2jMatcher.delegate and 3 other contexts)
Missing class com.google.re2j.Pattern (referenced from: com.google.re2j.Pattern org.jsoup.helper.Re2jRegex.re2jPattern and 4 other contexts)
2026-01-11 22:06:32 +01:00
Tobi
98a1e656ce Merge pull request #12926 from TeamNewPipe/release-0-28.1
Release v0.28.1 (1006)
2026-01-11 08:58:05 -08:00
Hosted Weblate
56489e5ddd Translated using Weblate (Polish)
Currently translated at 56.3% (49 of 87 strings)

Translated using Weblate (French)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pl/
Translation: NewPipe/Metadata
2026-01-11 15:01:52 +01:00
Hosted Weblate
8e389c49e6 Translated using Weblate (Polish)
Currently translated at 56.3% (49 of 87 strings)

Translated using Weblate (French)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pl/
Translation: NewPipe/Metadata
2026-01-11 15:01:07 +01:00
tobigr
5d7a3f97cd Update extractor to v0.25.0 2026-01-11 14:58:38 +01:00
tobigr
9ba89d418b Remove script for fastlane check 2026-01-09 21:26:03 +01:00
tobigr
fc39bff77e Remove script for fastlane check 2026-01-09 21:25:52 +01:00
tobigr
11467a73b8 Fixed length of changelogs 2026-01-09 21:14:38 +01:00
Hosted Weblate
3323868042 Translated using Weblate (Bengali (India))
Currently translated at 39.2% (300 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 0.3% (3 of 764 strings)

Translated using Weblate (French)

Currently translated at 75.8% (66 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Tigrinya)

Currently translated at 21.8% (167 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.0% (757 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 3.4% (3 of 87 strings)

Translated using Weblate (Santali)

Currently translated at 1.1% (1 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 78.1% (68 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 68.9% (60 of 87 strings)

Co-authored-by: 135 <135135@users.noreply.hosted.weblate.org>
Co-authored-by: Balázs Meskó <meskobalazs@mailbox.org>
Co-authored-by: Ding User <dengus@users.noreply.hosted.weblate.org>
Co-authored-by: Dizro <weblate.delirium794@passmail.net>
Co-authored-by: Erenay <erenaydev@proton.me>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kuko <kuko7@protonmail.ch>
Co-authored-by: MatthieuPh <matthieu.philippe@protonmail.com>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Prasanta-Hembram <Prasantahembram720@gmail.com>
Co-authored-by: STV <steeven.lombardi@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Sumon Kayal <sumankayalsuman4@gmail.com>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/bg/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hi/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pa/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sat/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translation: NewPipe/Metadata
2026-01-09 21:14:38 +01:00
tobigr
27b8a72f19 Fixed length of changelogs 2026-01-09 21:13:57 +01:00
Hosted Weblate
df92431ee3 Translated using Weblate (Bengali (India))
Currently translated at 39.2% (300 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 0.3% (3 of 764 strings)

Translated using Weblate (French)

Currently translated at 75.8% (66 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Tigrinya)

Currently translated at 21.8% (167 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.0% (757 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 3.4% (3 of 87 strings)

Translated using Weblate (Santali)

Currently translated at 1.1% (1 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 78.1% (68 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 68.9% (60 of 87 strings)

Co-authored-by: 135 <135135@users.noreply.hosted.weblate.org>
Co-authored-by: Balázs Meskó <meskobalazs@mailbox.org>
Co-authored-by: Ding User <dengus@users.noreply.hosted.weblate.org>
Co-authored-by: Dizro <weblate.delirium794@passmail.net>
Co-authored-by: Erenay <erenaydev@proton.me>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kuko <kuko7@protonmail.ch>
Co-authored-by: MatthieuPh <matthieu.philippe@protonmail.com>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Prasanta-Hembram <Prasantahembram720@gmail.com>
Co-authored-by: STV <steeven.lombardi@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Sumon Kayal <sumankayalsuman4@gmail.com>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/bg/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hi/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pa/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sat/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translation: NewPipe/Metadata
2026-01-09 20:56:42 +01:00
Siddhesh Dhainje
a83762c4d7 Removed restoreInstanceState and resultServiceIntent condition
(cherry picked from commit 418e34172a)
2026-01-09 11:32:19 -08:00
Siddhesh Dhainje
381b383a43 Used requireArguments instead of getArguments
(cherry picked from commit a3673f8c3b)
2026-01-09 11:32:19 -08:00
Siddhesh Dhainje
d5d92e8340 Fix crash on screen rotation while entering SoundCloud import URL
(cherry picked from commit 23b7f21d7c)
2026-01-09 11:32:19 -08:00
Tobi
cafb1398cb Merge pull request #12995 from siddhesh-06/siddhesh-06/fix/crash-on-soundcloud-import
Fix crash on screen rotation while entering SoundCloud import URL
2026-01-09 10:44:03 -08:00
Justin L
dc5c5b6604 Fix download resume corruption when server returns HTTP 200
When resuming a download after interruption, if the server returns
HTTP 200 (full resource) instead of HTTP 206 (partial content), the
code correctly resets mMission.done but fails to reset the 'start'
variable. This causes the subsequent file seek to use a stale offset,
writing new data at incorrect positions.

This bug causes file corruption for large downloads (>5GB) that are
interrupted and resumed, particularly when:
- Switching between WiFi networks
- Server CDN returning different responses
- Connection drops during long downloads

The corruption manifests as duplicate data regions in the file,
which for MP4 downloads results in multiple MOOV atoms and
broken seek functionality.

Fix: Reset start=0 when HTTP 200 is received, ensuring the file
write position correctly restarts from the beginning of the current
resource.

(cherry picked from commit 08008ca6f9)
2026-01-08 19:07:09 -08:00
Tobi
694124814e Merge pull request #13014 from jloutsch/fix/download-resume-corruption
Fix download resume corruption when server returns HTTP 200
2026-01-08 19:05:49 -08:00
Tobi
e61bc012d9 Merge pull request #12990 from dustdfg/db_migration_kotlin
Use "factory" method for creating db migrations
2026-01-08 17:23:12 -08:00
Tobi
d36a9f01d3 Add workflow to backport PRs to another branch (#12964)
The workflow can be triggered by creating a comment on a merged PR: /backport <TARGET_BRANCH>
The backport can only be triggered by people with write access to the repository.

Co-authored-by: AbsurdlyLongUsername <22662897+absurdlylongusername@users.noreply.github.com>
2026-01-08 17:06:50 -08:00
Siddhesh Dhainje
418e34172a Removed restoreInstanceState and resultServiceIntent condition 2026-01-08 21:31:31 +05:30
Clippy
2704c20fea Merge pull request #13020 from absurdlylongusername/add-note-to-material-component
Add note to upgrade material components once they fix later versions
2026-01-07 23:31:17 +00:00
AbsurdlyLongUsername
a7e4afe7f7 Add note to upgrade material components once they fix later versions 2026-01-07 21:49:42 +00:00
Tobi
680235ad51 Merge pull request #12922 from TobiGr/enqueue-ui
[PlayQueue] Fix incorrect UI states of PlayQueue items
2026-01-07 04:01:51 -08:00
AbsurdlyLongUsername
952a1269c1 Revert Google Material Components to 1.11.0 2026-01-07 12:34:02 +01:00
Tobi
6dba8b3c44 Merge pull request #13018 from absurdlylongusername/revert-google-material-components
Revert Google Material Components to 1.11.0
2026-01-07 03:32:30 -08:00
Tobi
d4a124a3c4 Merge pull request #12931 from TobiGr/fix/audio-lang
[Player] Use original audio language by default
2026-01-07 03:21:39 -08:00
AbsurdlyLongUsername
20b43b521b Revert Google Material Components to 1.11.0 2026-01-07 07:48:32 +00:00
Tobi
635e6a56c7 Merge pull request #13008 from dustdfg/playlist_normal_drag
Allow dragging boorkmarked playlist in both directions
2026-01-06 09:11:37 -08:00
Justin L
08008ca6f9 Fix download resume corruption when server returns HTTP 200
When resuming a download after interruption, if the server returns
HTTP 200 (full resource) instead of HTTP 206 (partial content), the
code correctly resets mMission.done but fails to reset the 'start'
variable. This causes the subsequent file seek to use a stale offset,
writing new data at incorrect positions.

This bug causes file corruption for large downloads (>5GB) that are
interrupted and resumed, particularly when:
- Switching between WiFi networks
- Server CDN returning different responses
- Connection drops during long downloads

The corruption manifests as duplicate data regions in the file,
which for MP4 downloads results in multiple MOOV atoms and
broken seek functionality.

Fix: Reset start=0 when HTTP 200 is received, ensuring the file
write position correctly restarts from the beginning of the current
resource.
2026-01-06 09:03:44 -05:00
TobiGr
8f071bc8f9 Update extractor to latest version
fixes media.ccc.de kiosks
2026-01-06 12:41:12 +01:00
Yevhen Babiichuk (DustDFG)
02e559e57f Commit all the playlist changes to db immediately
+ some additional minor code cleanup in the file
2026-01-06 12:36:50 +01:00
Yevhen Babiichuk (DustDFG)
9998d99a04 Allow dragging boorkmarked playlist in both directions
Ctrl+C/Ctrl+V from newpipe/local/playlist/LocalPlaylistFragment
2026-01-06 10:01:59 +02:00
Tobi
25ea75f10e Merge pull request #13005 from dustdfg/db_immediate
Commit all the playlist changes to db immediately
2026-01-05 14:03:30 -08:00
Yevhen Babiichuk (DustDFG)
61c0d134d7 Commit all the playlist changes to db immediately
+ some additional minor code cleanup in the file
2026-01-05 22:59:14 +02:00
Siddhesh Dhainje
a3673f8c3b Used requireArguments instead of getArguments 2026-01-04 21:40:40 +05:30
Tobi
fc66bee429 Merge pull request #13000 from dustdfg/orphaned_history_entry_adapter
Delete long orphaned file
2026-01-04 04:26:03 -08:00
Yevhen Babiichuk (DustDFG)
35eb08baf0 Delete long orphaned file
Was oprhaned at 004c2fa55a
2026-01-04 13:53:43 +02:00
Siddhesh Dhainje
23b7f21d7c Fix crash on screen rotation while entering SoundCloud import URL 2026-01-04 01:06:18 +05:30
tobigr
5cefafa145 Prepare for version 0.28.1 (1006) 2026-01-03 11:55:59 +01:00
Mira
49aaaebd86 Feat: opus metadata encoding (#12974)
Feat: Downloading: Add opus audio metadata tags for title, author, date, and a comment tag with the originating URL

This removes the DownloadManagerService.EXTRA_SOURCE field, which is always inferred from the StreamInfo.
2026-01-03 11:55:59 +01:00
Yevhen Babiichuk (DustDFG)
0747b3a0a5 Use "factory" method for creating db migrations 2026-01-02 12:25:25 +02:00
Aayush Gupta
7283701073 Merge pull request #12978 from dustdfg/kotlin_merged
Conversion to kotlin of multiple files
2026-01-02 15:46:29 +08:00
Yevhen Babiichuk (DustDFG)
3ffcf11a3a Merge inheritors of newpipe/player/playqueue/PlayQueueEvent and
convert it to kotlin
2026-01-01 23:05:16 +02:00
Aayush Gupta
1fb2b4a42e Merge pull request #12981 from dustdfg/export_playlist_refactor
Refactor ExportPlaylist to use more idiomatic kotlin code
2026-01-01 21:40:05 +08:00
Yevhen Babiichuk (DustDFG)
83596ca907 Convert newpipe/settings/preferencesearch/PreferenceSearchResultListener to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
d9682f5e0a Convert newpipe/player/PlayerType to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
ab3314eb1c Convert newpipe/info_list/ItemViewMode to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
7d1d88fb87 Convert newpipe/local/playlist/PlayListShareMode to kotlin 2026-01-01 15:08:21 +02:00
Yevhen Babiichuk (DustDFG)
8379aa0a9d Refactor ExportPlaylist to use more idiomatic kotlin code 2026-01-01 15:03:29 +02:00
Tobi
7f57493da1 Merge pull request #12986 from TeamNewPipe/actions
workflows: Update actions versions
2026-01-01 02:25:30 -08:00
Yevhen Babiichuk (DustDFG)
cd4cb40e6d Convert newpipe/error/UserAction.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
f1b111212d Convert newpipe/util/FilenameUtils.java to kotlin
Co-authored-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
84c646713d Convert newpipe/settings/preferencesearch/PreferenceSearchItem.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
873b2be9ca Convert newpipe/util/text/TimestampLongPressClickableSpan.java to kotlin
Also convert one class used by it into java record
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
4ef4ed15f1 Convert newpipe/util/image/PreferredImageQuality to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
fef8a2455c Convert newpipe/util/image/ImageStrategy to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
3398b4cdc9 Convert newpipe/fragments/list/search/Suggestion{Item,ListAdapter} to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
74cf302bd6 Convert newpipe/local/playlist/RemotePlaylistManager to kotlin 2026-01-01 11:04:19 +02:00
Aayush Gupta
b4f526c2a5 workflows: Update actions versions
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-01 16:51:56 +08:00
Aayush Gupta
93166afde4 Merge pull request #12973 from dustdfg/require_preference
Replace findPreference(getString(resId) with its null safe shortcut
2026-01-01 13:36:26 +08:00
Tobi
8893a27668 Merge pull request #12977 from TeamNewPipe/minorFix
fixup! Convert newpipe/util/KioskTranslator.java to kotlin
2025-12-31 01:33:49 -08:00
Aayush Gupta
6c238fafbe fixup! Convert newpipe/util/KioskTranslator.java to kotlin
Copyright and license header are not doc-comments. Move them to the top of the file.
Also re-write it to follow the SPDX formatting as we have followed in the recent Kotlin
conversion.

Additionally, use a better name for the context parameter.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-31 17:05:17 +08:00
Tobi
e17b1a1fcc Merge pull request #12975 from dustdfg/playlist_button_work
Correct typo in playlist db sql query
2025-12-30 22:58:06 -08:00
Yevhen Babiichuk (DustDFG)
7806a708c2 Correct typo in playlist db sql query
Solve #12855
2025-12-31 05:23:14 +02:00
Yevhen Babiichuk (DustDFG)
f6085d0044 Replace findPreference(getString(resId) with its null safe shortcut 2025-12-30 13:30:37 +02:00
tobigr
0a65c862a3 Remove empty translations 2025-12-30 09:16:58 +01:00
Hosted Weblate
c9339a5a03 Translated using Weblate (French)
Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 97.2% (743 of 764 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (87 of 87 strings)

Added translation using Weblate (Romany)

Translated using Weblate (Korean)

Currently translated at 97.7% (85 of 87 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (87 of 87 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Madalin <madalin.mail09@gmail.com>
Co-authored-by: Mona Lisa <nickwick@users.noreply.hosted.weblate.org>
Co-authored-by: NormalRandomPeople <normal.scribe833@silomails.com>
Co-authored-by: TobiGr <TobiGr@users.noreply.github.com>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ko/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/uk/
Translation: NewPipe/Metadata
2025-12-29 22:06:50 +01:00
Aayush Gupta
824fc8fbe1 Merge pull request #12952 from TeamNewPipe/fix/NaN-on-minimize
fix(player): Fix scaleX being NaN on minimize to background app switch
2025-12-30 00:41:31 +08:00
tobigr
465979e677 Do not change the aspectRation if the renderer is disabled 2025-12-29 16:41:25 +01:00
TobiGr
7101aecc98 Try to prevent invalid aspectRatio of SurfaceView
If the video's hieght is 0, the aspectRatio is set to Float.NaN which can cause further issues. Do not assign invalid values for the aspectRatio.
2025-12-29 16:30:20 +01:00
TobiGr
718335d733 fix(player): Fix scaleX being NaN on minimize to background app switch
This aims to fix the following Exception which might occour when watching a live stream and switching the app while 'minimize to backgorund' is enabled:

java.lang.IllegalArgumentException: Cannot set 'scaleX' to Float.NaN
	at android.view.View.sanitizeFloatPropertyValue(View.java:17479)
	at android.view.View.sanitizeFloatPropertyValue(View.java:17453)
	at android.view.View.setScaleX(View.java:16822)
	at org.schabi.newpipe.views.ExpandableSurfaceView.onLayout(ExpandableSurfaceView.java:71)

scaleX is set in onMeasure() in which width could be 0 in theory and this leading to a division by zero of a float which results in an assignment of Float.NaN.
2025-12-29 16:30:20 +01:00
Yevhen Babiichuk (DustDFG)
2dde0fef58 Convert newpipe/util/KioskTranslator.java to kotlin
Cherry picked from #12947 / 4649e46d1e
2025-12-27 23:13:17 +01:00
Yevhen Babiichuk (DustDFG)
c2f526d5b3 Use context.getString() shorthand instead of context.getResources().getString()
Cherry-picked from #12949 / 4360c1b873
2025-12-27 23:12:37 +01:00
Tobi
c3c353f1b7 Merge pull request #12954 from TeamNewPipe/fix/subscriberCount
Fix retrieving incomplete data for SubscriptionEntity
2025-12-27 09:36:17 -08:00
TobiGr
58efbf4de8 Fix retrieving incomplete data for SubscriptionEntity
This fixes a visual bug in which the channels reported the the subscriber count was unavailable although stored in the db.

Closes #12953
2025-12-26 21:57:26 +01:00
Tobi
350d08ba9c Merge pull request #12909 from TeamNewPipe/no-ai
Add AI policy to contribution guidelines and PR template
2025-12-25 01:31:45 -08:00
Tobi
2ee5f99f09 Merge pull request #12934 from TobiGr/fix/open-download
Fix insufficient permissions on opening downloaded streams
2025-12-23 00:35:10 -08:00
tobigr
a7b226c354 [Player] Use original audio language by default 2025-12-22 10:15:18 +01:00
tobigr
3ffd194f78 Fix insufficient permissions on opening downloaded streams 2025-12-21 18:44:09 +01:00
Hosted Weblate
3c0e6adf2e Translated using Weblate (Hungarian)
Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (French)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Polish)

Currently translated at 58.6% (51 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (87 of 87 strings)

Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: tct123 <tct1234@protonmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/it/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pl/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hans/
Translation: NewPipe/Metadata
2025-12-20 21:54:22 +01:00
TobiGr
535f9da422 Fix rebase 2025-12-20 21:23:58 +01:00
TobiGr
5582eac1c4 Only show enqueue option if play queue is not empty in RouterActivity
Make enqueue option avilable for playlists as well
2025-12-20 20:59:32 +01:00
Hatake Kakashri
acaaec2cde Add enqueue option to router dialog
- This allows users to enqueue a stream directly to the current player queue when sharing a link with the app, improving the user experience for queue management.
- The 'Enqueue' option is now available in the action selection dialog and can also be set as the preferred open action in the settings.
2025-12-20 20:59:25 +01:00
TobiGr
f7ff5db4b5 [PlayQueue] Fix incorrect UI states of PlayQueue items
onNext() is called after onSubscribe() when creating a PlayQueueAdapter. For that reason the last broadcasted event is applied to the UI state although it is already reflected in the PlayQueue that was used to initialize the adapter.

This is the intended behavior of the previously used event broadcaster of the type BehaviorSubject<>. The broadcaster's type was changed to PublishSubject<> which does not emit the last event after onSubscribe().

Ref: https://github.com/TeamNewPipe/NewPipe/issues/9669
2025-12-20 18:28:45 +01:00
tobigr
86fb618f61 Update NewPipe Extractor 2025-12-19 12:01:38 +01:00
Damien Hardy
0607b14bb2 add indymotion.fr peertube instance on AndroidManifest.xml 2025-12-19 09:43:14 +01:00
Tobi
097c643d1b Add AI policy to contribution guidelines, PR and issue templates
The amount of AI generated PRs is increasing while their quality often remains poor. The introduced guidelines aim to reduce the number of poor quality AI PRs while simultaneously increasing the quality of high quality AI PRs.

New checkboxes for compliance with the AI policy and to increase the PR quality are introduced to the PR template.

Prohibit AI generated issue and PR descriptions
2025-12-16 21:34:25 +01:00
Tobi
2dc2b01d4d Merge pull request #12910 from TeamNewPipe/release-preparations
Add changelog for NewPipe 0.28.1 (1006) and update extractor
2025-12-16 00:21:58 -08:00
tobigr
698187d2d6 Update extractor to latest version 2025-12-15 22:02:53 +01:00
tobigr
16d0248039 Add fastlane changelog for NewPipe 0.28.1 (1006) 2025-12-15 22:02:53 +01:00
Hosted Weblate
f3876d1c4a Translated using Weblate (Danish)
Currently translated at 98.8% (755 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Lithuanian)

Currently translated at 99.2% (758 of 764 strings)

Translated using Weblate (Bengali)

Currently translated at 74.3% (568 of 764 strings)

Co-authored-by: Agustín Cantero <brahiancantero@gmail.com>
Co-authored-by: Dual_A <alaviabdullah782@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: MS-PC <MSPCtranslator@gmail.com>
Co-authored-by: cat <catsnote@proton.me>
Co-authored-by: gymka <gymka@archlinux.lt>
2025-12-15 22:02:28 +01:00
Tobi
c603c82cff Merge pull request #12781 from iampopovich/feat/similar-youtube-client-screen-rotation
Remember and restore orientation on fullscreen exit
2025-12-14 04:43:19 -08:00
Tobi
0c17956552 Merge pull request #12898 from TeamNewPipe/playQueue
Player: Enqueue next on the existing playQueue
2025-12-14 02:46:57 -08:00
Aayush Gupta
77bea1ac68 Player: Enqueue next on the existing playQueue
Fixes 150649aea9

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-13 17:03:54 +08:00
Tobi
40bc8c191e Merge pull request #12897 from TeamNewPipe/depUpdate
libs: Update dependencies to latest stable releases
2025-12-13 00:51:52 -08:00
Aayush Gupta
1212486adb libs: Update dependencies to latest stable releases
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-13 16:30:36 +08:00
Aayush Gupta
de6dc0289a Merge pull request #12885 from TobiGr/fix/serializable
Fix deprecation of Bundle.getSerializable(String) by using BundleCompat
2025-12-13 16:04:08 +08:00
TobiGr
17ce699037 Do not change orientation on TVs when entering fullscreen 2025-12-10 14:48:45 +01:00
TobiGr
d770c6fd88 Fix state access 2025-12-10 14:46:29 +01:00
TobiGr
7ffc513f46 Fix deprecation of Bundle.getSerializable(String) by using BundleCompat
This fixes the following warning during compilation:

file:app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt:36:55 'fun getSerializable(p0: String?): Serializable?' is deprecated. Deprecated in Java.
2025-12-10 14:42:52 +01:00
Aayush Gupta
3d1d7e0870 Merge pull request #12864 from TeamNewPipe/historyFixes
Fixes for history
2025-12-04 15:28:17 +08:00
Aayush Gupta
4a00dbb15d Don't swallow error when trying to mark stream as watched
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-03 17:23:53 +08:00
Aayush Gupta
5c9ac912ac StreamHistoryDAO: Latest entry can be null
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-03 16:44:41 +08:00
TobiGr
d1cbc17a09 Merge branch 'dev' into feat/similar-youtube-client-screen-rotation 2025-11-29 23:30:20 +01:00
Stypox
ffb82dc88c Merge pull request #12849 from TeamNewPipe/acraKSP
Fixes for ACRA with KSP
2025-11-29 19:43:23 +01:00
Aayush Gupta
e91d647b27 acra: Relocate autoservice dependencies under acra block
They are only used for ACRA

Ref: https://www.acra.ch/docs/Custom-Extensions#by-annotation

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-29 19:54:36 +08:00
Aayush Gupta
6055cf2938 acra: Switch to ZacSweers's fork of autoservice
Google has no plans to officially support KSP for autoservice

Ref: https://github.com/google/auto/issues/882

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-29 19:52:17 +08:00
Tobi
22dfe9519f Merge pull request #12840 from scola/rotate_on_androidtv_issue
Always do not rotate screen when Android TV
2025-11-28 02:22:56 -08:00
shaozheng
e045251b58 Always do not rotate screen when Android TV 2025-11-28 17:41:13 +08:00
Stypox
ebe07596ba Update NewPipeExtractor to fix build (Jitpack failures again) 2025-11-27 14:54:08 +01:00
Stypox
18f1cf2075 Merge pull request #12776 from TeamNewPipe/depUpdate 2025-11-26 12:53:06 +01:00
Aayush Gupta
03e963952c Ignore Kotlin compiler generated files
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:09:57 +08:00
Aayush Gupta
e5ed0b529f Bump ktlint to latest stable release and maven coordinate
Disable all new rules to avoid massive file-changes. All new rules should be
enabled one by one as per requirements in separate commit to make review easier.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:26 +08:00
Aayush Gupta
0131bb227f Silence warnings regarding new annotation property behavior
Ref: https://kotlinlang.org/docs/annotations.html#defaults-when-no-use-site-targets-are-specified

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:26 +08:00
Aayush Gupta
b06b7c35ca Relocate toml lint task to buildSrc and extend against default task
Fixes build errors after Gradle 9.x upgrade

Ref: https://docs.gradle.org/current/userguide/implementing_custom_tasks.html

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:26 +08:00
Aayush Gupta
0a0f28e801 Bump dependencies to possible stable releases
androidx has bumped minSdk to API 23 which makes us unable to use latest version of:
* room
* workmanager

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:22 +08:00
Aayush Gupta
4a8592c5ba Enable Gradle configuration cache
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:12 +08:00
Aayush Gupta
8f91f21f27 Bump Gradle to latest stable release
Also update the wrapper using the ./gradlew wrapper command

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-21 20:08:12 +08:00
Tobi
2ba87f7979 Merge pull request #12820 from TeamNewPipe/kapt
Partial-revert: Migrate from KAPT to KSP
2025-11-19 06:18:02 -08:00
Aayush Gupta
f4d138d06f Partial-revert: Migrate from KAPT to KSP
statesaver has been deprecated for ~ 6 years and incompatible with KSP

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-19 16:33:43 +08:00
Stypox
6c95c9c7dd Merge pull request #12811 from TeamNewPipe/pr-template-features
Update PR template to specify target branch for features
2025-11-18 09:00:24 +01:00
Stypox
153e4820e7 Merge pull request #12816 from TeamNewPipe/dbFix
Minor fixes for database
2025-11-18 08:57:00 +01:00
Aayush Gupta
93f03bab87 Call checkpoint creation from an executor
java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-18 12:03:32 +08:00
Aayush Gupta
9702189be4 Move latestEntry into SearchHistoryDao directly
The StreamHistoryDao one isn't used, so remove it and streamline the logic

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-18 12:03:32 +08:00
Aayush Gupta
85bd7c3351 HistoryDao: latestEntry can be null
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-18 12:03:32 +08:00
Tobi
b7bf07d5e4 Update PR template to specify target branch for features
Added a note to target the 'refactor' branch for features.
2025-11-17 01:05:24 -08:00
Stypox
89a68d0789 Merge pull request #12801 from TeamNewPipe/weblate 2025-11-16 13:18:22 +01:00
Hosted Weblate
eaafdb2570 Translated using Weblate (Turkish)
Currently translated at 50.0% (43 of 86 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 98.6% (754 of 764 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 0.2% (2 of 764 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 72.0% (62 of 86 strings)

Added translation using Weblate (Lombard)

Translated using Weblate (Korean)

Currently translated at 97.6% (84 of 86 strings)

Translated using Weblate (Danish)

Currently translated at 97.9% (748 of 764 strings)

Translated using Weblate (Romanian)

Currently translated at 97.9% (748 of 764 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.6% (731 of 764 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 99.8% (763 of 764 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 73.8% (564 of 764 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (763 of 764 strings)

Translated using Weblate (Georgian)

Currently translated at 92.8% (709 of 764 strings)

Translated using Weblate (Georgian)

Currently translated at 89.5% (77 of 86 strings)

Translated using Weblate (Thai)

Currently translated at 3.4% (3 of 86 strings)

Translated using Weblate (Thai)

Currently translated at 37.1% (284 of 764 strings)

Translated using Weblate (Catalan)

Currently translated at 96.3% (736 of 764 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Slovenian)

Currently translated at 53.0% (405 of 764 strings)

Translated using Weblate (Thai)

Currently translated at 36.7% (281 of 764 strings)

Translated using Weblate (Thai)

Currently translated at 2.3% (2 of 86 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.9% (756 of 764 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 5.8% (5 of 86 strings)

Translated using Weblate (Bulgarian)

Currently translated at 5.8% (5 of 86 strings)

Translated using Weblate (Bosnian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Bosnian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 90.6% (78 of 86 strings)

Translated using Weblate (Serbian)

Currently translated at 16.2% (14 of 86 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Croatian)

Currently translated at 95.4% (729 of 764 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Icelandic)

Currently translated at 98.4% (752 of 764 strings)

Translated using Weblate (Chinese (Traditional Han script, Hong Kong))

Currently translated at 36.0% (31 of 86 strings)

Translated using Weblate (German)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 97.1% (742 of 764 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hebrew)

Currently translated at 99.0% (757 of 764 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Korean)

Currently translated at 98.6% (754 of 764 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (French)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (German)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (French)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.7% (60 of 86 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (French)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (French)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (German)

Currently translated at 100.0% (759 of 759 strings)

Translated using Weblate (Kabyle)

Currently translated at 28.1% (212 of 754 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.2% (748 of 754 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (German)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.6% (751 of 754 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.6% (751 of 754 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Russian)

Currently translated at 99.3% (749 of 754 strings)

Translated using Weblate (Tigrinya)

Currently translated at 20.1% (152 of 754 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Tigrinya)

Currently translated at 18.1% (137 of 754 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (German)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (German)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Tigrinya)

Currently translated at 17.3% (131 of 754 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (French)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (French)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (German)

Currently translated at 99.8% (753 of 754 strings)

Translated using Weblate (German)

Currently translated at 99.8% (753 of 754 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Tigrinya)

Currently translated at 15.6% (118 of 754 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 9.3% (8 of 86 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 8.8% (67 of 754 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 98.8% (85 of 86 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Vietnamese)

Currently translated at 79.0% (68 of 86 strings)

Translated using Weblate (Portuguese)

Currently translated at 98.8% (85 of 86 strings)

Translated using Weblate (Greek)

Currently translated at 36.0% (31 of 86 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Sardinian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Vietnamese)

Currently translated at 97.3% (734 of 754 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.2% (748 of 754 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (French)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (754 of 754 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (748 of 748 strings)

Co-authored-by: 404px <limgu2010@gmail.com>
Co-authored-by: 439JBYL80IGQTF25UXNR0X1BG <439JBYL80IGQTF25UXNR0X1BG@users.noreply.hosted.weblate.org>
Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alex25820 <alexs25820@gmail.com>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: CJ Montero <cristlad@proton.me>
Co-authored-by: DB L <deblm@tutamail.com>
Co-authored-by: Daniel Mantilla <danielmantilladiez@gmail.com>
Co-authored-by: Dizro <weblate.delirium794@passmail.net>
Co-authored-by: Drugi Sapog <dindrugi@users.noreply.hosted.weblate.org>
Co-authored-by: EESF-2 <eesf-2@users.noreply.hosted.weblate.org>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: FrederikFinckh <frederik.finckh@gmx.de>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Hasan <hasanyildiz0@yaani.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hoàng Sơn <smgzk2000@gmail.com>
Co-authored-by: Igor Rückert <igorruckert@yahoo.com.br>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jeff Huang <s8321414@gmail.com>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: MS-PC <MSPCtranslator@gmail.com>
Co-authored-by: Maria Dimitrova <mimidimitrova07@gmail.com>
Co-authored-by: Matija Šuklje <matija@suklje.name>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Mona Lisa <nickwick@users.noreply.hosted.weblate.org>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Nick Wick <NickWick@users.noreply.hosted.weblate.org>
Co-authored-by: Nikoloz <nukushatugushi@gmail.com>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: René <ninso112@proton.me>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Ricard Rodríguez <rcard@insicuri.net>
Co-authored-by: Sarah O <epigenetastic@gmail.com>
Co-authored-by: SecularSteve <fairfull.playing@gmail.com>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: ThaiWithNoBraincell <altofskgd@gmail.com>
Co-authored-by: The Cats <philosoph@danwin1210.de>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: Tấn Lực Trương <september122022ios16@gmail.com>
Co-authored-by: Valer <122545522+Valer100@users.noreply.github.com>
Co-authored-by: Vasilis K <skyhirules@gmail.com>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: VisionR1 <25982450+VisionR1@users.noreply.github.com>
Co-authored-by: Xiao Ping <deceased-take-mold@duck.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: cat <catsnote@proton.me>
Co-authored-by: delvani <del.cidrak@gmail.com>
Co-authored-by: fool <thing-sauna-cussed@duck.com>
Co-authored-by: gbpu <gui.beppu@gmail.com>
Co-authored-by: ikanakova <ikanakova@users.noreply.hosted.weblate.org>
Co-authored-by: justcontributor <kty5663@gmail.com>
Co-authored-by: late <late@users.noreply.hosted.weblate.org>
Co-authored-by: nafanz <nafanz@mail.ru>
Co-authored-by: rehork <cooky@e.email>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: sudo-py-dev <sudopydev@gmail.com>
Co-authored-by: tct123 <tct1234@protonmail.com>
Co-authored-by: yummysheepouo <jerry88182821@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim0@gmail.com>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/bg/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/bs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/el/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/en_GB/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/es/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/id/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ka/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ko/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt_PT/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ru/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/th/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/vi/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hant_HK/
Translation: NewPipe/Metadata
2025-11-14 21:09:20 +01:00
Alex Popov
a4cc1d1ddf feat(player): Remember and restore orientation on fullscreen exit
- Store the original screen orientation when entering fullscreen.
- Restore the saved orientation when exiting fullscreen.
- On tablets, continue to just toggle the fullscreen UI without changing the device orientation.
2025-11-10 02:22:56 +07:00
Tobi
f836f5e75d Merge pull request #12746 from TeamNewPipe/kspMigration
Migrate from KAPT to KSP
2025-11-07 07:41:56 -08:00
Aayush Gupta
4826e5b3c5 Add missing annotations for columnInfo in PlaylistDuplicatesEntry
Fixes [ksp] app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt:140: The columns returned by the query does not have the fields [thumbnailUrl,isThumbnailPermanent,thumbnailStreamId,displayIndex,orderingName] in org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry even though they are annotated as non-null or primitive. Columns returned by the query: [uid,streamCount,timesStreamIsContained]

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-06 16:10:15 +08:00
Aayush Gupta
97e7272151 Removed badly hacked default playlist thumbnail icon
The defaults should be supplied to the image loading software not the database library.
This would also break when we shrink resources as that would rename the resources.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-06 15:43:07 +08:00
Aayush Gupta
7c76791db3 Handle null-safety error in FeedDao
The lastUpdated parameter can be null, adjust return types to signal that too

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-03 16:14:29 +08:00
Aayush Gupta
995a92b7a4 Migrate & adapt database tests to Kotlin as well
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-03 16:14:29 +08:00
Aayush Gupta
05b9ff49a2 Migrate from KAPT to KSP
Ref: https://developer.android.com/build/migrate-to-ksp

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-03 16:14:29 +08:00
Aayush Gupta
4422b55ab4 Migrate database logic to Kotlin
Room has been convereted into a KMP library in the latest stable releases and
annotation processing requires KSP which only generates kotlin classes

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-11-03 16:14:29 +08:00
Tobi
2fadaffb98 Merge pull request #12765 from TransZAllen/build_error_into_NewPipeExtractor
[Build] Local NewPipeExtractor build inclusion fails in settings.gradle.kts
2025-11-02 02:02:59 -08:00
TransZAllen
1314a21f71 fix: update commented example in settings.gradle.kts for Kotlin DSL
- Resolves build issue related to local NewPipeExtractor inclusion
- Related issue: https://github.com/TeamNewPipe/NewPipe/issues/12763
2025-11-02 16:01:39 +08:00
Tobi
650b51ffec Merge pull request #12694 from mjsir911/m/on.soundcloud
Support on.soundcloud link opening
2025-11-01 15:36:03 -07:00
Tobi
c03f405f8c Merge pull request #12716 from Isaac-75/12194-notification-prompt-after-rotation
Notifications are no longer requested again after rotating the phone
2025-11-01 15:24:31 -07:00
Tobi
0a89276b7a Merge pull request #12575 from TransZAllen/dev
[Bug] Fix missing subtitle text in manually downloaded *.SRT files. (issue #10030)
2025-10-30 14:27:39 -07:00
TransZAllen
300afde83d Update app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java
Co-authored-by: Tobi <TobiGr@users.noreply.github.com>
2025-10-29 22:34:47 +08:00
TransZAllen
d311faea58 improve comments on TTML → SRT conversion
- update class header with proper technical references and remove author tag.
- update comments of replacing NBSP('\u00A0'), especially adding examples
  of rendering incorrectly.
2025-10-29 19:25:43 +08:00
TransZAllen
71aa6d52d3 Update app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java
Co-authored-by: Tobi <TobiGr@users.noreply.github.com>
2025-10-28 17:39:04 +08:00
Isaac
88eb32be3a moved field as requested 2025-10-26 05:32:12 +11:00
Stypox
2a9c6f0538 Merge pull request #12729 from litetex/workaround-popup-player-ui-elements-being-pushed-out 2025-10-21 18:15:13 +02:00
litetex
c81148ae0a [Popup player] Workaround that UI elements are pushed off screen 2025-10-21 18:09:06 +02:00
Stypox
ecd3e85d49 Merge pull request #12714 from litetex/properly-layout-player-topbar 2025-10-21 18:03:39 +02:00
Stypox
f3ca5f659d Merge pull request #12684 from TobiGr/dependency-updates 2025-10-21 10:39:54 +02:00
Stypox
300f5abc70 Update NewPipeExtractor and restore Jitpack/using-locally comments
The comments were accidentally removed in #12706
2025-10-21 10:31:32 +02:00
tobigr
729702b420 Update dependencies
androidx.media:media:1.7.0 -> 1.7.1
androidx.viewpager2:viewpager2:1.1.0-beta02 -> 1.1.0
io.reactivex.rxjava3:rxjava:3.1.8 -> 3.1.12
org.jsoup:jsoup:1.17.2 -> 1.21.2
2025-10-21 10:22:37 +02:00
tobigr
42f909936b Bump checkstyle and make inner classes final
Updating checkstyle fixed a vulnerability and fixed a final class check in version 10.12.2 for local classes without constructor.  Local classes without a constructor should be marked as final. That is done in this commit.

For more info see https://github.com/checkstyle/checkstyle/releases/tag/checkstyle-10.12.2
2025-10-21 10:20:57 +02:00
Stypox
c8e294b1a3 Merge pull request #12706 from TeamNewPipe/buildImprovements 2025-10-21 01:32:02 +02:00
Isaac
c4e6e4d4c4 Notifications are no longer requested again after rotating the phone 2025-10-19 03:41:52 +11:00
litetex
41981902ab Limit height of wrapper 2025-10-17 20:33:56 +02:00
litetex
56a09220ee Remove not needed viability control
This is done by the parent
2025-10-17 20:15:24 +02:00
litetex
c4bfc119df Improve the alignment of titleTextView and audioTrackTextView
This fulfills the following:
* both should never push content outside of the view
* there should be no wasted space
* `audioTrackTextView` is always aligned to the right
* both should grow equally but also respect their respective contents size first

Caveats:
* Currently the layout weight is distributed using "NestedWeights" which require a widget to be measured twice. According to Android Studio this might cause an exponential performane impact, however there is currently just a single nested component so the effect should be not noticeable
2025-10-17 19:29:26 +02:00
litetex
c49e44443c Correctly name the preview 2025-10-17 19:17:17 +02:00
litetex
1014dd563f Correctly format player.xml
Otherwise it constantly switches the attributes which makes (re) viewing changes next to impossible
2025-10-17 19:08:49 +02:00
Aayush Gupta
ee01ba3209 Specify JDK toolchain directly
Specifying JDK toolchain in the java block lets us avoid specifying
same version again and again for different options while ensuring everything
is on the same version

Ref: https://developer.android.com/build/jdks#toolchain

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-10-17 16:28:00 +08:00
TransZAllen
3516667671 refactor(ttml): extract recursion into traverseChildNodesForNestedTags()
- Extracted child-node traversal logic from `extractText()`
  into a helper method `traverseChildNodesForNestedTags()`.
- No functional change.
2025-10-17 12:06:18 +08:00
TransZAllen
22ee01bcfb refactor(ttml): improve extractText() to preserve spaces and special characters
- Replaced `text()` with `getWholeText()`:
  - avoids losing whitespaces at the beginning, end, or within the text;
  - avoids merging two or more consecutive spaces into a single space ' ';
  - avoids converting '\r', '\n', and '\r\n' within the text into a single space ' ';
  For subtitle conversion, the goal is to preserve every character exactly as intended by the subtitle author.
- Normalized tabs, line breaks, and other special characters for SRT-safe output.
- Added comprehensive unit tests in `SrtFromTtmlWriterTest.java`, including cases for simple and nested tags.
2025-10-17 01:57:01 +08:00
Aayush Gupta
1bef2fdc25 Drop deprecated non-working archivesBaseName property
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-10-16 22:27:56 +08:00
Jie Li
061ce870ac Gradle script to enforce dependencies order
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-10-16 22:27:15 +08:00
Aayush Gupta
15089245bb Migrate to build version catalog
Ref: https://developer.android.com/build/migrate-to-catalogs

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-10-16 22:25:25 +08:00
Aayush Gupta
d99435c4ad Migrate build scripts to kotlin DSL
Ref: https://developer.android.com/build/migrate-to-kotlin-dsl

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-10-14 23:07:07 +08:00
Aayush Gupta
320c693636 Revert "Add snippet to ensure baseline.profm file is sorted"
This reverts commit 1db1a00581.

The issue has been long resolved making this fix no longer required.
2025-10-14 23:07:07 +08:00
Tobi
09e4bea205 Merge pull request #12699 from Zer0tier/issue#12499
Fix Long Audio/Dubs text label puses UI Controls on Player Off Screen in Portrait mode.
2025-10-14 04:21:56 -07:00
jianing liu
fbc664d0da [player] Prevent long audio track label from pushing controls off-screen
- audioTrackTextView: layout_width=0dp + layout_weight=1
- Make it singleLine with ellipsize="end"
- When not fullscreen, hide metadataView so an empty weighted container doesn’t reserve space
- Result: controls stay visible on small screens; longer labels can use space on larger screens
2025-10-14 12:58:51 +08:00
jianing liu
2dd4509b75 change marginRight to marginEnd 2025-10-11 21:12:57 +08:00
Tobi
eee1172e8a Merge pull request #12692 from Zer0tier/issue#12433
[Bug] Long-pressing Play All-button does nothing
2025-10-10 08:08:55 -07:00
Howar
0ebd01e65e fix(playback): handle long-press on “Play All” button for issue #12433 2025-10-11 02:01:52 +11:00
m
fd4f4737c2 Support on.soundcloud link opening 2025-10-10 03:22:23 -07:00
Stypox
965eea2124 Merge pull request #12676 from thonsi/dev 2025-10-02 16:22:14 +02:00
Thonsi
59dfdda95e remove isUsingDSP 2025-10-01 15:56:08 +00:00
Stypox
3a2d427a46 Merge pull request #12642 from Stypox/fireos-SAF 2025-10-01 16:28:12 +02:00
Tobi
c25f83da6c Merge pull request #12671 from TransZAllen/SRT_numbering
Fix initial numbering of frames in TTML to SRT converter
2025-10-01 02:42:33 -07:00
Stypox
e2026dc378 Merge pull request #12606 from Stypox/do-not-startService-randomly 2025-09-30 17:47:08 +02:00
Stypox
00f6203904 Merge pull request #12605 from TeamNewPipe/open-in-browser 2025-09-30 17:45:29 +02:00
TransZAllen
980e8f3951 [YouTube] *.srt numbering start at 1 instead of 0. (issue: https://github.com/TeamNewPipe/NewPipe/issues/12670)
- The SubRip (.srt) specification requires subtitle numbering to begin from 1.
- Please refer to https://en.wikipedia.org/wiki/SubRip
- Previously numbering started from 0, which is accepted by most
  players (tested on mpv, VLC, MPlayer, Totem) but not strictly compliant.
2025-09-29 18:04:35 +08:00
Stypox
4e9a480fdd Enforce using SAF on FireOS TVs with Android 10+
Even if SAF is bugged there, there is no other way to open a file dialog, since NewPipe does not have permissions, see #10643
2025-09-17 12:24:18 +02:00
Stypox
aa2b4821e2 Post dummy notification then close player service on invalid intent
This should solve "Context.startForegroundService() did not then call Service.startForeground()" according to https://github.com/TeamNewPipe/NewPipe/issues/12489#issuecomment-3290318112
2025-09-17 11:50:46 +02:00
Stypox
92a07a3445 Use tryBindIfNeeded(), send player started only if player!=null
This commit fixes one way ghost notifications could be produced (although I don't know if there are other ways). This is the call chain that would lead to ghost notifications being created:
1. the system starts `PlayerService` to query information from it, without providing `SHOULD_START_FOREGROUND_EXTRA=true`, so NewPipe does not start the player nor show any notification, as expected
2. the `PlayerHolder::serviceConnection.onServiceConnected()` gets called by the system to inform `PlayerHolder` that the player started
3. `PlayerHolder`  notifies `MainActivity` that the player has started (although in fact only the service has started), by sending a `ACTION_PLAYER_STARTED` broadcast
4. `MainActivity` receives the `ACTION_PLAYER_STARTED` broadcast and brings up the mini-player, but then also tries to make `PlayerHolder` bind to `PlayerService` just in case it was not bound yet, but does so using `PlayerHolder::startService()` instead of the more passive `PlayerHolder::tryBindIfNeeded()`
5. `PlayerHolder::startService()` sends an intent to the `PlayerService` again, this time with `startForegroundService` and with `SHOULD_START_FOREGROUND_EXTRA=true`
6. the `PlayerService` receives the intent and due to `SHOULD_START_FOREGROUND_EXTRA=true` decides to start up the player and show a dummy notification

Steps 3 and 4 are wrong, and this commit fixes them:
3. `PlayerHolder` will now broadcast `ACTION_PLAYER_STARTED` when the service connects, only if the player is not-null
4. `PlayerHolder::tryBindIfNeeded()` is now used to passively try to bind, instead of `PlayerHolder::startService()`
2025-09-17 11:49:16 +02:00
Tobi
eed09f8a1d Merge pull request #12550 from whistlingwoods/fix-downloads-lost-progress
Try to recover pending download missions when possible
2025-09-16 23:26:56 -07:00
Stypox
fd3f030d0b Merge pull request #12616 from Isira-Seneviratne/Bump-AGP 2025-09-10 09:18:32 +02:00
Profpatsch
45c22c0db8 Merge pull request #12615 from Isira-Seneviratne/Player-intent-refactor
Refactor player intent logic
2025-09-09 10:24:42 +02:00
Isira Seneviratne
2b7c72eb69 Update AGP to 8.13.0 2025-09-08 08:08:07 +05:30
Isira Seneviratne
89c4eb5237 Refactor player intent logic 2025-09-08 07:56:13 +05:30
Stypox
803aba4935 Merge pull request #12254 from TeamNewPipe/timestamp-keep-current-player 2025-09-06 17:51:36 +02:00
Profpatsch
1723bf0e8a Player/handleIntent: keep current player when clicking timestamp
This was always a bit weird, that clicking a timestamp would
unconditionally switch to the popup player.

With the new enum, it’s trivial to change it to always stay at the
selected player now ;)
2025-09-06 17:40:18 +02:00
whistlingwoods
21e24c9e34 Apply review suggestions 2025-09-06 19:14:15 +05:30
Profpatsch
eb277fe14b Player/handleIntent: call handleIntentPost unconditionally
We always need to handleIntentPost otherwise the VideoDetailFragment
is not setup correctly.
2025-09-06 15:31:14 +02:00
Profpatsch
d77771a60c Player/handleIntent: fix enqueue if player not running
In 063dcd41e57c46721f1691cd57d21fbde75a35ea I falsely claimed that the
fallthrough case is always degenerate, but it kinda somehow still
worked because if you long-click on e.g. the popup button, it would
call enqueue, but if nothing was running yet it would fallthrough to
the very last case and start the player with the video.

So let’s return to that and add a TODO for further refactoring in the
future.
2025-09-06 15:09:11 +02:00
Profpatsch
01f9a3de33 Fix Checkstyle & remove unused fields 2025-09-06 15:09:11 +02:00
Profpatsch
150649aea9 Player/handleIntent: Don’t delete queue when clicking on timestamp
Fixes https://github.com/TeamNewPipe/NewPipe/issues/11013

We finally are at the point where we can have good logic around
clicking on timestamps.

This is pretty straightforward:

1) if we are already playing the stream (usual case), we skip to the
   correct second directly
2) If we don’t have a queue yet, create a trivial one with the stream
3) If we have a queue, we insert the video as next item and start
  playing it.

The skipping logic in 1) is similar to the one further down in the old
optimization block, but will always correctly fire for timestamps now.
I copied it because it’s not quite the same code, and moving into a
separate method at this stage would complicate the code too much.
2025-09-06 15:09:11 +02:00
Profpatsch
3803d49489 Player/handleIntent: separate out the timestamp request into enum
Instead of implicitely reconstructing whether the intent was
intended (lol) to be a timestamp change, we create a new kind of
intent that *only* sets the data we need to switch to a new timestamp.

This means that the logic of what to do (opening a popup player) gets
moved from `InternalUrlsHandler.playOnPopup` to the
`Player.handleIntent` method, we only pass that we want to jump to a
new timestamp. Thus, the stream is now loaded *after* sending the
intent instead of before sending.

This is somewhat messy right now and still does not fix the issue of
queue deletion, but from now on the queue logic should get more
straightforward to implement.

In the end, everything should be a giant switch. Thus we don’t
fall-through anymore, but run the post-setup code manually by calling
`handeIntentPost` and then returning.
2025-09-06 15:06:53 +02:00
Profpatsch
25a4a9a253 Player/handleIntent: move prefs parameters into initPlayback
They are just read from the player preferences and don’t influence the
branching, no need to read them in the intent parsing logic.
2025-09-06 15:04:06 +02:00
Profpatsch
d534946550 Player: inline repeat mode cycling 2025-09-06 15:04:06 +02:00
Profpatsch
8fb3e90fe1 Player: remove unused REPEAT_MODE intent key 2025-09-06 15:04:06 +02:00
Profpatsch
5750ef6aa8 Player/handleIntent: start converting intent data to enum
The goal here is to convert all player intents to use a single enum
with extra data for each case. The queue ones are pretty easy, they
don’t carry any extra data. We fall through for everything else for
now.
2025-09-06 15:04:06 +02:00
Profpatsch
ab7d1377e5 Player/handleIntent: always early return on ENQUEUE an ENQUEUE_NEXT
We can do this, because:

1. if `playQueue` is not null, we return early
2. if `playQueue` is null and we need to enqueue:
  - the only “proper” case that could be triggered is
    the `RESUME_PLAYBACK` case, which is never `true` for the queuing
    intents, see the comment in `NavigationHelper.enqueueOnPlayer`
  - the generic `else` case is degenerate, because it would crash on
  `playQueue` being `null`.

This makes some sense, because there is no way to trigger the
enqueueing logic via the UI currently if there is no video playing
yet, in which case `playQueue` is not `null`.

So we need to transform this whole if desaster into a big switch.
2025-09-06 15:04:06 +02:00
Profpatsch
fd24c08529 Player/handleIntent: de morgan samePlayQueue
Okay, so this is the … only? branch in this if-chain that will
conditionally fire if `playQueue` *is* `null`, sometimes.

This is why the unconditional `initPlayback` in `else` is not passed a
`null` in many cases … because `RESUME_PLAYBACK` is `true` and
`playQueue` is `null`.

It’s gonna be hard to figure out which parts of that are intentional,
I say.
2025-09-06 15:04:06 +02:00
Profpatsch
e14ec3a4f9 NavigationHelper: inline trivial getPlayerIntent use 2025-09-06 15:04:06 +02:00
Profpatsch
b592403a66 NavigationHelper: push out resumePlayback one layer 2025-09-06 15:04:06 +02:00
Profpatsch
90e1ac56ef NavigationHelper: inline getPlayerEnqueueIntent
Funnily enough, I’m pretty sure that whole comment will be not
necessary, because we never check `resumePlayback` on handling the
intent anyway.
2025-09-06 15:04:06 +02:00
Profpatsch
32eb3afe16 Player/handleIntent: a few comments 2025-09-06 15:04:06 +02:00
Fynn Godau
83a0abddcc Fix and simplify openUrlInBrowser
The code was not previously working in case no default browser is set[1]
AND NewPipe is set as default handler for the link in question.

We improve it by telling the system to choose the target app as if the
URI was `http://`, which works even if the user has not set a default
browser.

[1]: also the case if platform refuses to tell an app what the user's
default browser is, which I observed on CalyxOS.
2025-09-05 17:49:58 +02:00
Profpatsch
35c7f2f5d1 Player: Remove unused IS_MUTED intent key
The only use of the key was removed in commit
2a2c82e73b
but the handling logic stayed around. So let’s get rid of it.
2025-09-05 16:57:27 +02:00
Stypox
8afb00d2f0 Merge pull request #12603 from Stypox/better-error-panel 2025-09-05 13:31:39 +02:00
Stypox
f27ec53c08 Even more centralized error handling in ErrorInfo 2025-09-05 12:39:16 +02:00
Stypox
a3ddd616f9 Merge pull request #12578 from Stypox/better-error-messages 2025-09-04 13:18:40 +02:00
Stypox
79980e2078 Address PR reviews 2025-09-04 13:17:45 +02:00
Isira Seneviratne
b204fad9d5 Merge pull request #12471 from Isira-Seneviratne/Fix-notifications
Fix foreground service issues
2025-09-01 05:05:47 +05:30
Isira Seneviratne
08f51abefb Added comments 2025-08-31 22:25:12 +05:30
Stypox
204df4c45a Fix test 2025-08-30 14:58:08 +02:00
Stypox
989c0cfd28 Fix REPORT in snackbar not opening ErrorActivity if MainActivity not shown
Bug caused by https://github.com/TeamNewPipe/NewPipe/pull/11789
2025-08-30 14:39:23 +02:00
Stypox
a369deeef4 Allow ErrorInfo messages with formatArgs
- ErrorInfo.getMessage() now returns an ErrorMessage instance that can be formatted into a string using a context (this allows the construction of an ErrorInfo to remain independent of a Context)
- now the service ID is used in ErrorInfo.getMessage() to customize some messages based on the currently selected service
- player HTTP invalid statuses are now included in the message
- building a custom error message for AccountTerminatedException was moved from ErrorPanelHelper to ErrorInfo
2025-08-30 14:36:27 +02:00
Stypox
1bde2dcd9f Fix ordering of error messages conditions 2025-08-28 17:06:10 +02:00
Stypox
29a3ca83b5 Show better information about player errors 2025-08-28 17:06:09 +02:00
Stypox
38064be702 Add more specific error messages and deduplicate their handling 2025-08-28 17:05:52 +02:00
Tobi
d17eae9bad Merge pull request #12253 from Profpatsch/popup-overlay-alert-dialog
Overlay Permission: display explanatory dialog for Android > R
2025-08-27 02:50:45 -07:00
TobiGr
74562db965 Use androidx compat alert dialog 2025-08-27 11:45:31 +02:00
Profpatsch
386d5197d8 Permission: display explanatory dialog for Android > R
On Android > R, ACTION_MANAGE_OVERLAY_PERMISSION always brings the
user to the app selection screen.

https://developer.android.com/about/versions/11/privacy/permissions#manage_overlay

This is highly confusing behaviour from the system, so let’s add an
instruction before navigating to the settings menu.
2025-08-27 11:38:25 +02:00
Tobi
ccd76dea1f Merge pull request #12544 from Stypox/download-options
Add option to delete a download without also deleting file
2025-08-27 02:31:14 -07:00
TobiGr
e1888ede87 Fix JDoc and apply suggestions 2025-08-27 10:38:13 +02:00
TransZAllen
2c35db7a07 [Bug] Fix missing subtitle text in manually downloaded *.SRT files. (issue #10030)
- Previously, *.SRT files only contained timestamps and sequence numbers, without the actual text content.
- Added recursive text extraction to handle nested tags in TTML
  files.(e.g.: <span> tags)
2025-08-27 14:03:42 +08:00
whistlingwoods
9282cce6a8 fix: unfinished downloads disappear from the downloads list after app gets killed
Author: InfinityLoop1308
Adapted for NewPipe from a fork's this commit 1cf059ce5e
2025-08-22 01:14:24 +05:30
Stypox
7644066c5a Add option to delete a download without also deleting file 2025-08-16 16:50:01 +02:00
Stypox
9bc8139b8c Merge pull request #12483 from TeamNewPipe/ignore-picasso-update 2025-08-11 17:48:30 +02:00
Tobi
ff3526b28d Merge pull request #12460 from Isira-Seneviratne/Short-count-refactor
Fix short count formatting for Android versions below 7.0
2025-08-01 10:56:41 -07:00
TobiGr
d6c0dc32d1 Correctly ignore new version check for picasso 2025-08-01 10:50:54 +02:00
Stypox
124ab56c5f Merge branch 'master' into dev 2025-07-31 23:52:01 +02:00
Stypox
56f79fac13 Merge branch 'release-0.28.0' into dev 2025-07-30 11:42:06 +02:00
Isira Seneviratne
ef29c318b0 Remove NewApi suppression 2025-07-29 06:18:27 +05:30
Isira Seneviratne
9f11db8e06 Improve scale display 2025-07-28 09:02:52 +05:30
Isira Seneviratne
fece0741e5 Suppress NewApi 2025-07-27 15:47:06 +05:30
Isira Seneviratne
b9b47fc520 Update manifest, startForeground call 2025-07-27 11:58:01 +05:30
Isira Seneviratne
59db955493 Fix new streams notification issue 2025-07-27 11:31:23 +05:30
Isira Seneviratne
22a709d53b Merge pull request #12388 from mikooomich/sdk35
Target SDK 35
2025-07-24 08:18:32 +05:30
Michael Zh
329d76c857 Bump emulator target 33 -> 35 2025-07-23 22:30:34 -04:00
Isira Seneviratne
9f526e8e8f Fix short count formatting for Android versions below 7.0 2025-07-24 07:56:44 +05:30
Michael Zh
50caba6606 Fix compile
Co-Authored-By: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>
2025-07-23 18:49:28 -04:00
Michael Zh
26443f9f14 WIP: Fix compile 2025-07-23 18:45:30 -04:00
Michael Zh
366129eee2 Fix error toast crash 2025-07-23 18:45:30 -04:00
Michael Zh
4c8d44b6ba Bump compileSdk to 36 and targetSdk to 35
* Sdk 36 requires edge to edge, so use 35 so we can opt out for now
2025-07-23 18:45:30 -04:00
Michael Zh
14cd562ebd Update manifest for sdk34 FGS changes 2025-07-23 18:45:30 -04:00
Michael Zh
04ef608f7a Specify RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTED for sdk34 2025-07-23 18:45:30 -04:00
553 changed files with 9561 additions and 5963 deletions

44
.editorconfig Normal file
View 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

View File

@@ -3,6 +3,19 @@
NewPipe contribution guidelines
===============================
## AI policy
* Using generative AI to develop new features or making larger code changes is generally prohibited. Please refrain from contributions which are heavily depending on AI generated source code because they are usually lacking a fundamental understanding of the overall project structure and thus come with poor quality. However, you are allowed to use gen. AI if you
* are aware of the project structure,
* ensure that the generated code follows the project structure,
* fully understand the generated code, and
* review the generated code completely.
* Using AI to find the root cause of bugs and generating small fixes might be acceptable. However, gen. AI often does not fix the underlying problem but is trying to fix the symptoms. If you are using AI to fix bugs, ensure that the root cause is tackled.
* The use of AI to generate documentation is allowed. We ask you to thoroughly check the quality of generated documentation wrong, misleading or uninformative documentation is useless and wastes the reader's time. Ensure that reasoning is documented.
* Using generative AI to write or fill in PR or issue templates is prohibited. Those texts are often lengthy and miss critical information.
* PRs and issues that do not follow this AI policy can be closed without further explanation.
## Crash reporting
Report crashes through the **automated crash report system** of NewPipe.

View File

@@ -26,6 +26,8 @@ body:
required: true
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
required: true
- label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this bug report is not generated by AI."
required: true
- type: input
id: app-version

View File

@@ -25,6 +25,8 @@ body:
required: true
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
required: true
- label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this request is not generated by AI."
required: true
- type: textarea

View File

@@ -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)
@@ -32,3 +32,5 @@ The APK can be found by going to the "Checks" tab below the title. On the left p
#### Due diligence
- [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md).
- [ ] The proposed changes follow the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md#ai-policy).
- [ ] I tested the changes using an emulator or a physical device.

48
.github/workflows/backport-pr.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Backport merged pull request
on:
issue_comment:
types: [created]
permissions:
contents: write # for comment creation on original PR
pull-requests: write
jobs:
backport:
name: Backport pull request
runs-on: ubuntu-latest
# Only run when the comment starts with the `/backport` command on a PR and
# the commenter has write access to the repository. We do not want to allow
# everybody to trigger backports and create branches in our repository.
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/backport ') &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'MEMBER'
)
steps:
- uses: actions/checkout@v4
- name: Get backport metadata
# the target branch is the first argument after `/backport`
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
body="$COMMENT_BODY"
line=${body%%$'\n'*} # Get the first line
if [[ $line =~ ^/backport[[:space:]]+([^[:space:]]+) ]]; then
echo "BACKPORT_TARGET=${BASH_REMATCH[1]}" >> "$GITHUB_ENV"
else
echo "Usage: /backport <target-branch>" >&2
exit 1
fi
- name: Create backport pull request
uses: korthout/backport-action@v4
with:
add_labels: 'backport'
copy_labels_pattern: '.*'
label_pattern: ''
target_branches: ${{ env.BACKPORT_TARGET }}

View File

@@ -7,11 +7,11 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: 'master'
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
@@ -32,7 +32,7 @@ jobs:
mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk"
- name: "Upload APK"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/release/*.apk

View File

@@ -37,8 +37,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- uses: actions/checkout@v6
- uses: gradle/actions/wrapper-validation@v4
- name: create and checkout branch
# push events already checked out the branch
@@ -48,7 +48,7 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@@ -58,7 +58,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/debug/*.apk
@@ -72,15 +72,15 @@ 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:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Enable KVM
run: |
@@ -89,7 +89,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@@ -104,7 +104,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: failure()
with:
name: android-test-report-api${{ matrix.api-level }}
@@ -118,19 +118,19 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
cache: 'gradle'
- name: Cache SonarCloud packages
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar

View File

@@ -17,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 16
@@ -27,7 +27,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images
uses: actions/github-script@v7
uses: actions/github-script@v8
timeout-minutes: 3
with:
script: |

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ captures/
*.iml
*~
.weblate
.kotlin
*.class
app/debug/
app/release/

View File

@@ -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 theres 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
View 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() ?: 1006
versionName = "0.28.1"
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)
}

View File

@@ -16,6 +16,11 @@
-dontwarn javax.script.**
-keep class jdk.dynalink.** { *; }
-dontwarn jdk.dynalink.**
# Rules for jsoup
# Ignore intended-to-be-optional re2j classes - only needed if using re2j for jsoup regex
# jsoup safely falls back to JDK regex if re2j not on classpath, but has concrete re2j refs
# See https://github.com/jhy/jsoup/issues/2459 - may be resolved in future, then this may be removed
-dontwarn com.google.re2j.**
## Rules for ExoPlayer
-keep class com.google.android.exoplayer2.** { *; }

View File

@@ -458,7 +458,7 @@
"notNull": true
},
{
"fieldPath": "name",
"fieldPath": "orderingName",
"columnName": "name",
"affinity": "TEXT",
"notNull": false

View File

@@ -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)

View File

@@ -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();
}

View File

@@ -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"),
)
}
}

View File

@@ -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))
}
}

View File

@@ -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>
@@ -368,6 +385,7 @@
<data android:host="eduvid.org" />
<data android:host="framatube.org" />
<data android:host="indymotion.fr" />
<data android:host="media.assassinate-you.net" />
<data android:host="media.fsfe.org" />
<data android:host="peertube.co.uk" />
@@ -421,6 +439,7 @@
</activity>
<service
android:name=".RouterActivity$FetcherService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- opting out of sending metrics to Google in Android System WebView -->

View File

@@ -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);

View File

@@ -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.

View File

@@ -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;
}
}
}
}
}

View 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
}
}
}
}
}

View File

@@ -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) {
@@ -346,7 +316,8 @@ public class RouterActivity extends AppCompatActivity {
if (choiceChecker.isAvailableAndSelected(
R.string.video_player_key,
R.string.background_player_key,
R.string.popup_player_key)) {
R.string.popup_player_key,
R.string.enqueue_key)) {
final String selectedChoice = choiceChecker.getSelectedChoiceKey();
@@ -359,6 +330,8 @@ public class RouterActivity extends AppCompatActivity {
|| selectedChoice.equals(getString(R.string.popup_player_key));
final boolean isAudioPlayerSelected =
selectedChoice.equals(getString(R.string.background_player_key));
final boolean isEnqueueSelected =
selectedChoice.equals(getString(R.string.enqueue_key));
if (currentLinkType != LinkType.STREAM
&& ((isExtAudioEnabled && isAudioPlayerSelected)
@@ -375,7 +348,9 @@ public class RouterActivity extends AppCompatActivity {
// Check if the service supports the choice
if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
|| (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
|| (isAudioPlayerSelected && capabilities.contains(AUDIO))
|| (isEnqueueSelected && (capabilities.contains(VIDEO)
|| capabilities.contains(AUDIO)))) {
handleChoice(selectedChoice);
} else {
handleChoice(getString(R.string.show_info_key));
@@ -556,7 +531,7 @@ public class RouterActivity extends AppCompatActivity {
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
service.getServiceInfo().getMediaCapabilities();
if (linkType == LinkType.STREAM) {
if (linkType == LinkType.STREAM || linkType == LinkType.PLAYLIST) {
if (capabilities.contains(VIDEO)) {
returnedItems.add(videoPlayer);
returnedItems.add(popupPlayer);
@@ -564,17 +539,28 @@ public class RouterActivity extends AppCompatActivity {
if (capabilities.contains(AUDIO)) {
returnedItems.add(backgroundPlayer);
}
// download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
// not supported )
returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
getString(R.string.download),
R.drawable.ic_file_download));
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
// not be added to a playlist
returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
getString(R.string.add_to_playlist),
R.drawable.ic_add));
// Enqueue is only shown if the current queue is not empty.
// However, if the playqueue or the player is cleared after this item was chosen and
// while the item is extracted, it will automatically fall back to background player.
if (PlayerHolder.getInstance().getQueueSize() > 0) {
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
getString(R.string.enqueue_stream), R.drawable.ic_add));
}
if (linkType == LinkType.STREAM) {
// download is redundant for linkType CHANNEL AND PLAYLIST
// (till playlist downloading is not supported )
returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
getString(R.string.download),
R.drawable.ic_file_download));
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType
// since those can not be added to a playlist
returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
getString(R.string.add_to_playlist),
R.drawable.ic_playlist_add));
}
} else {
// LinkType.NONE is never present because it's filtered out before
// channels and playlist can be played as they contain a list of videos
@@ -665,7 +651,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 +839,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 +982,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)));
}
}
@@ -1045,6 +1032,8 @@ public class RouterActivity extends AppCompatActivity {
NavigationHelper.playOnBackgroundPlayer(this, playQueue, true);
} else if (choice.playerChoice.equals(popupPlayerKey)) {
NavigationHelper.playOnPopupPlayer(this, playQueue, true);
} else if (choice.playerChoice.equals(getString(R.string.enqueue_key))) {
NavigationHelper.enqueueOnPlayer(this, playQueue);
}
};
}

View File

@@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -33,7 +34,9 @@ class LicenseFragment : Fragment() {
super.onCreate(savedInstanceState)
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
.sortedBy { it.name } // Sort components by name
activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
activeSoftwareComponent = savedInstanceState?.let {
BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java)
}
}
override fun onDestroy() {

View File

@@ -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();
}

View 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"
}
}

View File

@@ -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);
}

View 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>)
}

View File

@@ -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,
}
}

View 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,
}
}

View File

@@ -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() {
}
}

View File

@@ -0,0 +1,352 @@
/*
* 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 = Migration(DB_VER_1, DB_VER_2) { db ->
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 = Migration(DB_VER_2, DB_VER_3) { db ->
// 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 = Migration(DB_VER_3, DB_VER_4) { db ->
db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
}
val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db ->
db.execSQL(
"ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db ->
db.execSQL(
"ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db ->
// 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 = Migration(DB_VER_7, DB_VER_8) { db ->
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 = Migration(DB_VER_8, DB_VER_9) { db ->
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()
}
}
}

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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>>
}

View File

@@ -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();
}

View File

@@ -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>>
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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();
}

View File

@@ -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?
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}

View File

@@ -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 service_id = :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
}

View File

@@ -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);
}

View File

@@ -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>>
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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()
}
}

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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@SubscriptionEntity.subscriberCount ?: -1
description = this@SubscriptionEntity.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
)
}
}
}

View File

@@ -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() {
@@ -1136,7 +1133,7 @@ public class DownloadDialog extends DialogFragment
}
DownloadManagerService.startMission(context, urls, storage, kind, threads,
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show();

View File

@@ -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));
}
}

View File

@@ -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:

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -1,9 +1,14 @@
package org.schabi.newpipe.error;
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.error
/**
* The user actions that can cause an error.
*/
public enum UserAction {
enum class UserAction(val message: String) {
USER_REPORT("user report"),
UI_ERROR("ui error"),
DATABASE_IMPORT_EXPORT("database import or export"),
@@ -33,15 +38,7 @@ 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");
private final String message;
UserAction(final String message) {
this.message = message;
}
public String getMessage() {
return message;
}
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
PLAY_ON_POPUP("play on popup"),
SUBSCRIPTIONS("loading subscriptions");
}

View File

@@ -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;
@@ -205,6 +206,8 @@ public final class VideoDetailFragment
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
@State
protected boolean autoPlayEnabled = true;
@State
protected int originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
@Nullable
private StreamInfo currentInfo = null;
@@ -876,7 +879,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 +1169,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 +1232,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 +1424,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 +1434,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 +1604,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,22 +1908,29 @@ public final class VideoDetailFragment
@Override
public void onScreenRotationButtonClicked() {
// 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)
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
final Optional<MainPlayerUi> playerUi = player != null
? player.UIs().get(MainPlayerUi.class)
: Optional.empty();
if (playerUi.isEmpty()) {
return;
}
final int newOrientation = isLandscape
? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
// On tablets and TVs, just toggle fullscreen UI without orientation change.
if (DeviceUtils.isTablet(activity) || DeviceUtils.isTv(activity)) {
playerUi.get().toggleFullscreen();
return;
}
activity.setRequestedOrientation(newOrientation);
if (playerUi.get().isFullscreen()) {
// EXITING FULLSCREEN
playerUi.get().toggleFullscreen();
activity.setRequestedOrientation(originalOrientation);
} else {
// ENTERING FULLSCREEN
originalOrientation = activity.getRequestedOrientation();
playerUi.get().toggleFullscreen();
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
}
}
/*

View File

@@ -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));
}
}
}

View File

@@ -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

View File

@@ -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()));
}
}

View File

@@ -1,32 +0,0 @@
package org.schabi.newpipe.fragments.list.search;
import androidx.annotation.NonNull;
public class SuggestionItem {
final boolean fromHistory;
public final String query;
public SuggestionItem(final boolean fromHistory, final String query) {
this.fromHistory = fromHistory;
this.query = query;
}
@Override
public boolean equals(final Object o) {
if (o instanceof SuggestionItem) {
return query.equals(((SuggestionItem) o).query);
}
return false;
}
@Override
public int hashCode() {
return query.hashCode();
}
@NonNull
@Override
public String toString() {
return "[" + fromHistory + "" + query + "]";
}
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.fragments.list.search
class SuggestionItem(@JvmField val fromHistory: Boolean, @JvmField val query: String) {
override fun equals(other: Any?): Boolean {
if (other is SuggestionItem) {
return query == other.query
}
return false
}
override fun hashCode() = query.hashCode()
override fun toString() = "[$fromHistory$query]"
}

View File

@@ -1,93 +0,0 @@
package org.schabi.newpipe.fragments.list.search;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding;
public class SuggestionListAdapter
extends ListAdapter<SuggestionItem, SuggestionListAdapter.SuggestionItemHolder> {
private OnSuggestionItemSelected listener;
public SuggestionListAdapter() {
super(new SuggestionItemCallback());
}
public void setListener(final OnSuggestionItemSelected listener) {
this.listener = listener;
}
@NonNull
@Override
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int viewType) {
return new SuggestionItemHolder(ItemSearchSuggestionBinding
.inflate(LayoutInflater.from(parent.getContext()), parent, false));
}
@Override
public void onBindViewHolder(final SuggestionItemHolder holder, final int position) {
final SuggestionItem currentItem = getItem(position);
holder.updateFrom(currentItem);
holder.itemBinding.suggestionSearch.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemSelected(currentItem);
}
});
holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemLongClick(currentItem);
}
return true;
});
holder.itemBinding.suggestionInsert.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemInserted(currentItem);
}
});
}
public interface OnSuggestionItemSelected {
void onSuggestionItemSelected(SuggestionItem item);
void onSuggestionItemInserted(SuggestionItem item);
void onSuggestionItemLongClick(SuggestionItem item);
}
public static final class SuggestionItemHolder extends RecyclerView.ViewHolder {
private final ItemSearchSuggestionBinding itemBinding;
private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) {
super(binding.getRoot());
this.itemBinding = binding;
}
private void updateFrom(final SuggestionItem item) {
itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history
: R.drawable.ic_search);
itemBinding.itemSuggestionQuery.setText(item.query);
}
}
private static class SuggestionItemCallback extends DiffUtil.ItemCallback<SuggestionItem> {
@Override
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return oldItem.fromHistory == newItem.fromHistory
&& oldItem.query.equals(newItem.query);
}
@Override
public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return true; // items' contents never change; the list of items themselves does
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.fragments.list.search
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding
import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder
class SuggestionListAdapter :
ListAdapter<SuggestionItem, SuggestionItemHolder>(SuggestionItemCallback()) {
var listener: OnSuggestionItemSelected? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionItemHolder {
return SuggestionItemHolder(
ItemSearchSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) {
val currentItem = getItem(position)
holder.updateFrom(currentItem)
holder.binding.suggestionSearch.setOnClickListener {
listener?.onSuggestionItemSelected(currentItem)
}
holder.binding.suggestionSearch.setOnLongClickListener {
listener?.onSuggestionItemLongClick(currentItem)
true
}
holder.binding.suggestionInsert.setOnClickListener {
listener?.onSuggestionItemInserted(currentItem)
}
}
interface OnSuggestionItemSelected {
fun onSuggestionItemSelected(item: SuggestionItem)
fun onSuggestionItemInserted(item: SuggestionItem)
fun onSuggestionItemLongClick(item: SuggestionItem)
}
class SuggestionItemHolder(val binding: ItemSearchSuggestionBinding) :
RecyclerView.ViewHolder(binding.getRoot()) {
fun updateFrom(item: SuggestionItem) {
binding.itemSuggestionIcon.setImageResource(
if (item.fromHistory) {
R.drawable.ic_history
} else {
R.drawable.ic_search
}
)
binding.itemSuggestionQuery.text = item.query
}
}
private class SuggestionItemCallback : DiffUtil.ItemCallback<SuggestionItem>() {
override fun areItemsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
return oldItem.fromHistory == newItem.fromHistory && oldItem.query == newItem.query
}
override fun areContentsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
return true // items' contents never change; the list of items themselves does
}
}
}

View File

@@ -1,9 +1,14 @@
package org.schabi.newpipe.info_list;
/*
* SPDX-FileCopyrightText: 2023-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.info_list
/**
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
enum class ItemViewMode {
/**
* Default mode.
*/

View File

@@ -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()

View File

@@ -1,6 +1,7 @@
package org.schabi.newpipe.local.bookmark;
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.DialogInterface;
import android.os.Bundle;
@@ -140,7 +141,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 +149,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
fragmentManager,
entry.getServiceId(),
entry.getUrl(),
entry.getName());
entry.getOrderingName());
}
}
@@ -378,11 +379,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);
}
}
@@ -417,10 +418,11 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
// if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
// with an `if (shouldUseGridLayout()) ...`
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
ItemTouchHelper.ACTION_STATE_IDLE) {
int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
if (shouldUseGridLayout(requireContext())) {
directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
}
return new ItemTouchHelper.SimpleCallback(directions, ItemTouchHelper.ACTION_STATE_IDLE) {
@Override
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize,
@@ -487,7 +489,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 +510,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 +531,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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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())
}
)

View File

@@ -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 {

View File

@@ -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)
}
// /////////////////////////////////////////////////////////////////////////

View File

@@ -1,108 +0,0 @@
package org.schabi.newpipe.local.history;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.util.Localization;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
/**
* This is an adapter for history entries.
*
* @param <E> the type of the entries
* @param <VH> the type of the view holder
*/
public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
private final ArrayList<E> mEntries;
private final DateFormat mDateFormat;
private final Context mContext;
private OnHistoryItemClickListener<E> onHistoryItemClickListener = null;
public HistoryEntryAdapter(final Context context) {
super();
mContext = context;
mEntries = new ArrayList<>();
mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM,
Localization.getPreferredLocale(context));
}
public void setEntries(@NonNull final Collection<E> historyEntries) {
mEntries.clear();
mEntries.addAll(historyEntries);
notifyDataSetChanged();
}
public Collection<E> getItems() {
return mEntries;
}
public void clear() {
mEntries.clear();
notifyDataSetChanged();
}
protected String getFormattedDate(final Date date) {
return mDateFormat.format(date);
}
protected String getFormattedViewString(final long viewCount) {
return Localization.shortViewCount(mContext, viewCount);
}
@Override
public int getItemCount() {
return mEntries.size();
}
@Override
public void onBindViewHolder(final VH holder, final int position) {
final E entry = mEntries.get(position);
holder.itemView.setOnClickListener(v -> {
if (onHistoryItemClickListener != null) {
onHistoryItemClickListener.onHistoryItemClick(entry);
}
});
holder.itemView.setOnLongClickListener(view -> {
if (onHistoryItemClickListener != null) {
onHistoryItemClickListener.onHistoryItemLongClick(entry);
return true;
}
return false;
});
onBindViewHolder(holder, entry, position);
}
@Override
public void onViewRecycled(@NonNull final VH holder) {
super.onViewRecycled(holder);
holder.itemView.setOnClickListener(null);
}
abstract void onBindViewHolder(VH holder, E entry, int position);
public void setOnHistoryItemClickListener(
@Nullable final OnHistoryItemClickListener<E> onHistoryItemClickListener) {
this.onHistoryItemClickListener = onHistoryItemClickListener;
}
public boolean isEmpty() {
return mEntries.isEmpty();
}
public interface OnHistoryItemClickListener<E> {
void onHistoryItemClick(E item);
void onHistoryItemLongClick(E item);
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import android.content.Context
@@ -21,11 +26,7 @@ fun export(
}
}
fun exportWithTitles(
playlist: List<PlaylistStreamEntry>,
context: Context
): String {
private fun exportWithTitles(playlist: List<PlaylistStreamEntry>, context: Context): String {
return playlist.asSequence()
.map { it.streamEntity }
.map { entity ->
@@ -38,18 +39,14 @@ fun exportWithTitles(
.joinToString(separator = "\n")
}
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
return playlist.asSequence()
.map { it.streamEntity.url }
.joinToString(separator = "\n")
private fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
return playlist.joinToString(separator = "\n") { it.streamEntity.url }
}
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
private fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
val videoIDs = playlist.asReversed().asSequence()
.map { it.streamEntity.url }
.mapNotNull(::getYouTubeId)
.mapNotNull { getYouTubeId(it.streamEntity.url) }
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
.toList()
.asReversed()
@@ -58,7 +55,7 @@ fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
}
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
private val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
/**
* Gets the video id from a YouTube URL.
@@ -66,7 +63,7 @@ val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFacto
* @param url YouTube URL
* @return the video id
*/
fun getYouTubeId(url: String): String? {
private fun getYouTubeId(url: String): String? {
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
}

View File

@@ -111,7 +111,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null;
public static LocalPlaylistFragment getInstance(final long playlistId, final String name) {
final LocalPlaylistFragment instance = new LocalPlaylistFragment();
final var instance = new LocalPlaylistFragment();
instance.setInitialData(playlistId, name);
return instance;
}
@@ -180,9 +180,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
final StreamEntity item =
((PlaylistStreamEntry) selectedItem).getStreamEntity();
if (selectedItem instanceof PlaylistStreamEntry entry) {
final StreamEntity item = entry.getStreamEntity();
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
}
@@ -496,6 +495,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
debounceSaver.setHasChangesToSave();
saveImmediate();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
@@ -560,8 +560,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return;
}
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length());
@@ -667,6 +666,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.addItems(itemsToKeep);
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
saveImmediate();
hideLoading();
isRewritingPlaylist = false;
@@ -686,6 +686,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
saveImmediate();
}
/**
@@ -708,8 +709,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final List<LocalItem> items = itemListAdapter.getItemsList();
final List<Long> streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
if (item instanceof PlaylistStreamEntry) {
streamIds.add(((PlaylistStreamEntry) item).getStreamId());
if (item instanceof PlaylistStreamEntry entry) {
streamIds.add(entry.getStreamId());
}
}
@@ -771,6 +772,13 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return isSwapped;
}
@Override
public void clearView(@NonNull final RecyclerView recyclerView,
@NonNull final RecyclerView.ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
saveImmediate();
}
@Override
public boolean isLongPressDragEnabled() {
return false;

Some files were not shown because too many files have changed in this diff Show More