1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 09:49:21 +02:00

Track downloaded streams and surface status on video detail

This commit is contained in:
Josh Mandel 2025-09-16 21:49:14 -04:00
parent abfde872f1
commit b5cb367edb
22 changed files with 1949 additions and 13 deletions

View file

@ -0,0 +1,856 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "92195bb0de0864bb1a0d7e4bbb16ec0f",
"entities": [
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatarUrl",
"columnName": "avatar_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "subscriberCount",
"columnName": "subscriber_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "notificationMode",
"columnName": "notification_mode",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_subscriptions_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "search_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "creationDate",
"columnName": "creation_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "search",
"columnName": "search",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_search_history_search",
"unique": false,
"columnNames": [
"search"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
}
],
"foreignKeys": []
},
{
"tableName": "streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamType",
"columnName": "stream_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploader_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "viewCount",
"columnName": "view_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "textualUploadDate",
"columnName": "textual_upload_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "upload_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isUploadDateApproximation",
"columnName": "is_upload_date_approximation",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_streams_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "stream_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessDate",
"columnName": "access_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatCount",
"columnName": "repeat_count",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"access_date"
]
},
"indices": [
{
"name": "index_stream_history_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "stream_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progressMillis",
"columnName": "progress_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailStreamId",
"columnName": "thumbnail_stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlist_stream_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "playlistUid",
"columnName": "playlist_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "join_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlist_id",
"join_index"
]
},
"indices": [
{
"name": "index_playlist_stream_join_playlist_id_join_index",
"unique": true,
"columnNames": [
"playlist_id",
"join_index"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
},
{
"name": "index_playlist_stream_join_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"playlist_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "remote_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamCount",
"columnName": "stream_count",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_remote_playlists_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "streamId",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_feed_group_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_group_subscription_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "feedGroupId",
"columnName": "group_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"group_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_group_subscription_join_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "feed_group",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"group_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_last_updated",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)",
"fields": [
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"subscription_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "downloaded_streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stream_uid` INTEGER NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `file_uri` TEXT NOT NULL, `parent_uri` TEXT, `display_name` TEXT, `mime` TEXT, `size_bytes` INTEGER, `quality_label` TEXT, `duration_ms` INTEGER, `status` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `last_checked_at` INTEGER, `missing_since` INTEGER, FOREIGN KEY(`stream_uid`) REFERENCES `streams`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamUid",
"columnName": "stream_uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileUri",
"columnName": "file_uri",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentUri",
"columnName": "parent_uri",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayName",
"columnName": "display_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mime",
"columnName": "mime",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sizeBytes",
"columnName": "size_bytes",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "qualityLabel",
"columnName": "quality_label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "durationMs",
"columnName": "duration_ms",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedAt",
"columnName": "added_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCheckedAt",
"columnName": "last_checked_at",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "missingSince",
"columnName": "missing_since",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_downloaded_streams_stream_uid",
"unique": true,
"columnNames": [
"stream_uid"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloaded_streams_stream_uid` ON `${TABLE_NAME}` (`stream_uid`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stream_uid"
],
"referencedColumns": [
"uid"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '92195bb0de0864bb1a0d7e4bbb16ec0f')"
]
}
}

View file

@ -25,6 +25,7 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.acra.ACRA.init import org.acra.ACRA.init
import org.acra.ACRA.isACRASenderServiceProcess import org.acra.ACRA.isACRASenderServiceProcess
import org.acra.config.CoreConfigurationBuilder import org.acra.config.CoreConfigurationBuilder
import org.schabi.newpipe.download.DownloadMaintenance
import org.schabi.newpipe.error.ReCaptchaActivity import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Downloader
@ -120,6 +121,8 @@ open class App :
configureRxJavaErrorHandler() configureRxJavaErrorHandler()
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl) YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl)
DownloadMaintenance.schedule(this)
} }
override fun newImageLoader(context: Context): ImageLoader = override fun newImageLoader(context: Context): ImageLoader =

View file

@ -9,6 +9,7 @@ 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_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9; import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
import static org.schabi.newpipe.database.Migrations.MIGRATION_9_10;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -29,7 +30,7 @@ public final class NewPipeDatabase {
return Room return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, .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) MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
.build(); .build();
} }

View file

@ -1,11 +1,13 @@
package org.schabi.newpipe.database; package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_9; import static org.schabi.newpipe.database.Migrations.DB_VER_10;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
import org.schabi.newpipe.database.download.DownloadedStreamEntity;
import org.schabi.newpipe.database.download.DownloadedStreamsDao;
import org.schabi.newpipe.database.feed.dao.FeedDAO; import org.schabi.newpipe.database.feed.dao.FeedDAO;
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO; import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
import org.schabi.newpipe.database.feed.model.FeedEntity; import org.schabi.newpipe.database.feed.model.FeedEntity;
@ -36,9 +38,9 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class FeedLastUpdatedEntity.class, DownloadedStreamEntity.class
}, },
version = DB_VER_9 version = DB_VER_10
) )
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db"; public static final String DATABASE_NAME = "newpipe.db";
@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract FeedGroupDAO feedGroupDAO(); public abstract FeedGroupDAO feedGroupDAO();
public abstract SubscriptionDAO subscriptionDAO(); public abstract SubscriptionDAO subscriptionDAO();
public abstract DownloadedStreamsDao downloadedStreamsDao();
} }

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.database package org.schabi.newpipe.database
import androidx.room.TypeConverter import androidx.room.TypeConverter
import org.schabi.newpipe.database.download.DownloadedStreamStatus
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon import org.schabi.newpipe.local.subscription.FeedGroupIcon
import java.time.Instant import java.time.Instant
@ -49,4 +50,14 @@ class Converters {
fun feedGroupIconOf(id: Int): FeedGroupIcon { fun feedGroupIconOf(id: Int): FeedGroupIcon {
return FeedGroupIcon.entries.first { it.id == id } return FeedGroupIcon.entries.first { it.id == id }
} }
@TypeConverter
fun downloadedStreamStatusOf(value: Int?): DownloadedStreamStatus? {
return value?.let { DownloadedStreamStatus.fromValue(it) }
}
@TypeConverter
fun integerOf(status: DownloadedStreamStatus?): Int? {
return status?.value
}
} }

View file

@ -27,6 +27,7 @@ public final class Migrations {
public static final int DB_VER_7 = 7; public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8; public static final int DB_VER_8 = 8;
public static final int DB_VER_9 = 9; public static final int DB_VER_9 = 9;
public static final int DB_VER_10 = 10;
private static final String TAG = Migrations.class.getName(); private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -302,6 +303,22 @@ public final class Migrations {
} }
}; };
public static final Migration MIGRATION_9_10 = new Migration(DB_VER_9, DB_VER_10) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS downloaded_streams "
+ "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "stream_uid INTEGER NOT NULL, service_id INTEGER NOT NULL, "
+ "url TEXT NOT NULL, file_uri TEXT NOT NULL, parent_uri TEXT, "
+ "display_name TEXT, mime TEXT, size_bytes INTEGER, quality_label TEXT, "
+ "duration_ms INTEGER, status INTEGER NOT NULL, added_at INTEGER NOT NULL, "
+ "last_checked_at INTEGER, missing_since INTEGER, FOREIGN KEY(stream_uid) "
+ "REFERENCES streams(uid) ON UPDATE CASCADE ON DELETE CASCADE)");
database.execSQL("CREATE UNIQUE INDEX index_downloaded_streams_stream_uid "
+ "ON downloaded_streams (stream_uid)");
}
};
private Migrations() { private Migrations() {
} }
} }

View file

@ -0,0 +1,103 @@
package org.schabi.newpipe.database.download
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_ADDED_AT
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_DISPLAY_NAME
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_DURATION_MS
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_FILE_URI
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_ID
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_LAST_CHECKED_AT
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_MIME
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_MISSING_SINCE
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_PARENT_URI
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_QUALITY_LABEL
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_SERVICE_ID
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_SIZE_BYTES
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_STATUS
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_STREAM_UID
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.COLUMN_URL
import org.schabi.newpipe.database.download.DownloadedStreamEntity.Companion.TABLE_NAME
import org.schabi.newpipe.database.stream.model.StreamEntity
@Entity(
tableName = TABLE_NAME,
indices = [Index(value = [COLUMN_STREAM_UID], unique = true)],
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = [StreamEntity.STREAM_ID],
childColumns = [COLUMN_STREAM_UID],
onDelete = ForeignKey.CASCADE
)
]
)
data class DownloadedStreamEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = COLUMN_ID)
var id: Long = 0,
@ColumnInfo(name = COLUMN_STREAM_UID)
var streamUid: Long,
@ColumnInfo(name = COLUMN_SERVICE_ID)
var serviceId: Int,
@ColumnInfo(name = COLUMN_URL)
var url: String,
@ColumnInfo(name = COLUMN_FILE_URI)
var fileUri: String,
@ColumnInfo(name = COLUMN_PARENT_URI)
var parentUri: String? = null,
@ColumnInfo(name = COLUMN_DISPLAY_NAME)
var displayName: String? = null,
@ColumnInfo(name = COLUMN_MIME)
var mime: String? = null,
@ColumnInfo(name = COLUMN_SIZE_BYTES)
var sizeBytes: Long? = null,
@ColumnInfo(name = COLUMN_QUALITY_LABEL)
var qualityLabel: String? = null,
@ColumnInfo(name = COLUMN_DURATION_MS)
var durationMs: Long? = null,
@ColumnInfo(name = COLUMN_STATUS)
var status: DownloadedStreamStatus,
@ColumnInfo(name = COLUMN_ADDED_AT)
var addedAt: Long,
@ColumnInfo(name = COLUMN_LAST_CHECKED_AT)
var lastCheckedAt: Long? = null,
@ColumnInfo(name = COLUMN_MISSING_SINCE)
var missingSince: Long? = null
) {
companion object {
const val TABLE_NAME = "downloaded_streams"
const val COLUMN_ID = "id"
const val COLUMN_STREAM_UID = "stream_uid"
const val COLUMN_SERVICE_ID = "service_id"
const val COLUMN_URL = "url"
const val COLUMN_FILE_URI = "file_uri"
const val COLUMN_PARENT_URI = "parent_uri"
const val COLUMN_DISPLAY_NAME = "display_name"
const val COLUMN_MIME = "mime"
const val COLUMN_SIZE_BYTES = "size_bytes"
const val COLUMN_QUALITY_LABEL = "quality_label"
const val COLUMN_DURATION_MS = "duration_ms"
const val COLUMN_STATUS = "status"
const val COLUMN_ADDED_AT = "added_at"
const val COLUMN_LAST_CHECKED_AT = "last_checked_at"
const val COLUMN_MISSING_SINCE = "missing_since"
}
}

View file

@ -0,0 +1,14 @@
package org.schabi.newpipe.database.download
enum class DownloadedStreamStatus(val value: Int) {
IN_PROGRESS(0),
AVAILABLE(1),
MISSING(2),
UNLINKED(3);
companion object {
fun fromValue(value: Int): DownloadedStreamStatus = entries.firstOrNull {
it.value == value
} ?: IN_PROGRESS
}
}

View file

@ -0,0 +1,58 @@
package org.schabi.newpipe.database.download
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
@Dao
interface DownloadedStreamsDao {
@Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1")
fun observeByStreamUid(streamUid: Long): Flowable<List<DownloadedStreamEntity>>
@Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1")
fun getByStreamUid(streamUid: Long): Maybe<DownloadedStreamEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(entity: DownloadedStreamEntity): Long
@Update
fun update(entity: DownloadedStreamEntity): Int
@Query("SELECT * FROM downloaded_streams WHERE stream_uid = :streamUid LIMIT 1")
fun findEntityByStreamUid(streamUid: Long): DownloadedStreamEntity?
@Query("SELECT * FROM downloaded_streams WHERE id = :id LIMIT 1")
fun findEntityById(id: Long): DownloadedStreamEntity?
@Transaction
fun insertOrUpdate(entity: DownloadedStreamEntity): Long {
val newId = insert(entity)
if (newId != -1L) {
entity.id = newId
return newId
}
update(entity)
return entity.id
}
@Query("UPDATE downloaded_streams SET status = :status, last_checked_at = :lastCheckedAt, missing_since = :missingSince WHERE id = :id")
fun updateStatus(id: Long, status: DownloadedStreamStatus, lastCheckedAt: Long?, missingSince: Long?)
@Query("UPDATE downloaded_streams SET file_uri = :fileUri WHERE id = :id")
fun updateFileUri(id: Long, fileUri: String)
@Delete
fun delete(entity: DownloadedStreamEntity)
@Query("DELETE FROM downloaded_streams WHERE stream_uid = :streamUid")
fun deleteByStreamUid(streamUid: Long): Int
@Query("SELECT * FROM downloaded_streams WHERE status = :status ORDER BY last_checked_at ASC LIMIT :limit")
fun listByStatus(status: DownloadedStreamStatus, limit: Int): List<DownloadedStreamEntity>
}

View file

@ -81,6 +81,14 @@ public class DownloadActivity extends AppCompatActivity {
return true; return true;
} }
@Override
protected void onResume() {
super.onResume();
new Thread(() ->
DownloadMaintenance.revalidateAvailable(DownloadActivity.this, 10)
).start();
}
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {

View file

@ -0,0 +1,33 @@
package org.schabi.newpipe.download
import android.content.Context
import android.net.Uri
import android.util.Log
import org.schabi.newpipe.BuildConfig
import java.io.File
object DownloadAvailabilityChecker {
private const val TAG = "DownloadAvailabilityChecker"
fun isReadable(context: Context, uri: Uri): Boolean {
val scheme = uri.scheme
return when {
scheme.equals("file", ignoreCase = true) ->
File(uri.path ?: return false).canRead()
scheme.equals("content", ignoreCase = true) ->
probeContentUri(context, uri)
else -> probeContentUri(context, uri)
}
}
private fun probeContentUri(context: Context, uri: Uri): Boolean {
return try {
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { true } ?: false
} catch (throwable: Throwable) {
if (BuildConfig.DEBUG) {
Log.w(TAG, "Failed to probe availability for $uri", throwable)
}
false
}
}
}

View file

@ -79,7 +79,9 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.postprocessing.Postprocessing;
@ -1132,12 +1134,70 @@ public class DownloadDialog extends DialogFragment
); );
} }
DownloadManagerService.startMission(context, urls, storage, kind, threads, final String qualityLabel = buildQualityLabel(selectedStream);
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); final MediaFormat selectedFormat = selectedStream.getFormat();
final String resolvedMime = selectedFormat != null ? selectedFormat.getMimeType()
: storage.getType();
final Long durationMs = currentInfo.getDuration() > 0
? TimeUnit.SECONDS.toMillis(currentInfo.getDuration()) : null;
final Long estimatedSize = nearLength > 0 ? nearLength : null;
final char missionKind = kind;
final int missionThreads = threads;
final String missionSourceUrl = currentInfo.getUrl();
final String missionPsName = psName;
final String[] missionPsArgs = psArgs;
final long missionNearLength = nearLength;
disposables.add(DownloadedStreamsRepository.INSTANCE
.upsertForEnqueued(requireContext(), currentInfo, storage, null, resolvedMime,
qualityLabel, durationMs, estimatedSize)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(association -> {
DownloadManagerService.startMission(
context,
urls,
storage,
missionKind,
missionThreads,
missionSourceUrl,
missionPsName,
missionPsArgs,
missionNearLength,
new ArrayList<>(recoveryInfo),
association.getStreamUid(),
association.getEntityId(),
currentInfo.getServiceId()
);
Toast.makeText(context, getString(R.string.download_has_started), Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
dismiss(); dismiss();
},
throwable -> ErrorUtil.createNotification(requireContext(),
new ErrorInfo(throwable, UserAction.DOWNLOAD_FAILED,
"Preparing download metadata", currentInfo))
));
}
@Nullable
private String buildQualityLabel(@NonNull final Stream stream) {
if (stream instanceof VideoStream) {
return ((VideoStream) stream).getResolution();
} else if (stream instanceof AudioStream) {
final int bitrate = ((AudioStream) stream).getAverageBitrate();
return bitrate > 0 ? bitrate + "kbps" : null;
} else if (stream instanceof SubtitlesStream) {
final SubtitlesStream subtitlesStream = (SubtitlesStream) stream;
final String language = subtitlesStream.getDisplayLanguageName();
if (subtitlesStream.isAutoGenerated()) {
return language + " (" + getString(R.string.caption_auto_generated) + ")";
}
return language;
}
final MediaFormat format = stream.getFormat();
return format != null ? format.getSuffix() : null;
} }
} }

View file

@ -0,0 +1,45 @@
package org.schabi.newpipe.download
import android.content.Context
import android.net.Uri
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.download.DownloadedStreamStatus
import java.util.concurrent.TimeUnit
object DownloadMaintenance {
private const val WORK_NAME = "download_revalidation"
@JvmStatic
fun revalidateAvailable(context: Context, limit: Int = 25) {
val dao = NewPipeDatabase.getInstance(context).downloadedStreamsDao()
val entries = dao.listByStatus(DownloadedStreamStatus.AVAILABLE, limit)
if (entries.isEmpty()) return
val now = System.currentTimeMillis()
for (entry in entries) {
val uriString = entry.fileUri
if (uriString.isBlank()) {
dao.updateStatus(entry.id, DownloadedStreamStatus.MISSING, now, entry.missingSince ?: now)
continue
}
val available = DownloadAvailabilityChecker.isReadable(context, Uri.parse(uriString))
if (available) {
dao.updateStatus(entry.id, DownloadedStreamStatus.AVAILABLE, now, null)
} else {
dao.updateStatus(entry.id, DownloadedStreamStatus.MISSING, now, entry.missingSince ?: now)
}
}
}
@JvmStatic
fun schedule(context: Context) {
val workRequest = PeriodicWorkRequestBuilder<DownloadRevalidationWorker>(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest)
}
}

View file

@ -0,0 +1,29 @@
package org.schabi.newpipe.download
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import org.schabi.newpipe.BuildConfig
class DownloadRevalidationWorker(
appContext: Context,
workerParams: WorkerParameters,
) : Worker(appContext, workerParams) {
override fun doWork(): Result {
return try {
DownloadMaintenance.revalidateAvailable(applicationContext)
Result.success()
} catch (throwable: Throwable) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Failed to revalidate downloads", throwable)
}
Result.retry()
}
}
private companion object {
private const val TAG = "DownloadRevalidation"
}
}

View file

@ -0,0 +1,238 @@
package org.schabi.newpipe.download
import android.content.Context
import android.net.Uri
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.download.DownloadedStreamEntity
import org.schabi.newpipe.database.download.DownloadedStreamStatus
import org.schabi.newpipe.database.download.DownloadedStreamsDao
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.streams.io.StoredFileHelper
object DownloadedStreamsRepository {
data class DownloadAssociation(
val streamUid: Long,
val entityId: Long
)
private fun database(context: Context): AppDatabase {
return NewPipeDatabase.getInstance(context)
}
private fun downloadedDao(context: Context): DownloadedStreamsDao {
return database(context).downloadedStreamsDao()
}
fun observeByStreamUid(context: Context, streamUid: Long): Flowable<List<DownloadedStreamEntity>> {
return downloadedDao(context)
.observeByStreamUid(streamUid)
.subscribeOn(Schedulers.io())
}
fun getByStreamUid(context: Context, streamUid: Long): Maybe<DownloadedStreamEntity> {
return downloadedDao(context)
.getByStreamUid(streamUid)
.subscribeOn(Schedulers.io())
}
fun ensureStreamEntry(context: Context, info: StreamInfo): Single<Long> {
return Single.fromCallable {
database(context).streamDAO().upsert(StreamEntity(info))
}.subscribeOn(Schedulers.io())
}
fun upsertForEnqueued(
context: Context,
info: StreamInfo,
storage: StoredFileHelper,
displayName: String?,
mime: String?,
qualityLabel: String?,
durationMs: Long?,
sizeBytes: Long?
): Single<DownloadAssociation> {
return Single.fromCallable {
val db = database(context)
db.runInTransaction<DownloadAssociation> {
val streamDao = db.streamDAO()
val dao = db.downloadedStreamsDao()
val streamId = streamDao.upsert(StreamEntity(info))
val now = System.currentTimeMillis()
val fileUri = storage.uriString()
val entity = dao.findEntityByStreamUid(streamId)
val resolvedDisplayName = displayName ?: storage.getName()
val resolvedMime = mime ?: storage.getType()
if (entity == null) {
val newEntity = DownloadedStreamEntity(
streamUid = streamId,
serviceId = info.serviceId,
url = info.url,
fileUri = fileUri,
parentUri = storage.parentUriString(),
displayName = resolvedDisplayName,
mime = resolvedMime,
sizeBytes = sizeBytes,
qualityLabel = qualityLabel,
durationMs = durationMs,
status = DownloadedStreamStatus.IN_PROGRESS,
addedAt = now,
lastCheckedAt = null,
missingSince = null
)
val insertedId = dao.insert(newEntity)
val resolvedId = if (insertedId == -1L) {
dao.findEntityByStreamUid(streamId)?.id
?: throw IllegalStateException("Failed to resolve downloaded stream entry")
} else {
insertedId
}
newEntity.id = resolvedId
DownloadAssociation(streamId, resolvedId)
} else {
entity.serviceId = info.serviceId
entity.url = info.url
entity.fileUri = fileUri
val parentUri = storage.parentUriString()
if (parentUri != null) {
entity.parentUri = parentUri
}
entity.displayName = resolvedDisplayName
entity.mime = resolvedMime
entity.sizeBytes = sizeBytes
entity.qualityLabel = qualityLabel
entity.durationMs = durationMs
entity.status = DownloadedStreamStatus.IN_PROGRESS
entity.lastCheckedAt = null
entity.missingSince = null
if (entity.addedAt <= 0) {
entity.addedAt = now
}
dao.update(entity)
DownloadAssociation(streamId, entity.id)
}
}
}.subscribeOn(Schedulers.io())
}
fun markFinished(
context: Context,
association: DownloadAssociation,
serviceId: Int,
url: String,
storage: StoredFileHelper,
mime: String?,
qualityLabel: String?,
durationMs: Long?,
sizeBytes: Long?
): Completable {
return Completable.fromAction {
val dao = downloadedDao(context)
val now = System.currentTimeMillis()
val entity = dao.findEntityById(association.entityId)
?: dao.findEntityByStreamUid(association.streamUid)
?: DownloadedStreamEntity(
streamUid = association.streamUid,
serviceId = serviceId,
url = url,
fileUri = storage.uriString(),
parentUri = storage.parentUriString(),
displayName = storage.getName(),
mime = mime ?: storage.getType(),
sizeBytes = sizeBytes,
qualityLabel = qualityLabel,
durationMs = durationMs,
status = DownloadedStreamStatus.IN_PROGRESS,
addedAt = now
)
entity.serviceId = serviceId
entity.url = url
entity.fileUri = storage.uriString()
storage.parentUriString()?.let { entity.parentUri = it }
entity.displayName = storage.getName()
val resolvedMime = mime ?: storage.getType() ?: entity.mime
entity.mime = resolvedMime
entity.sizeBytes = sizeBytes ?: storage.safeLength() ?: entity.sizeBytes
if (qualityLabel != null) {
entity.qualityLabel = qualityLabel
}
if (durationMs != null) {
entity.durationMs = durationMs
}
entity.status = DownloadedStreamStatus.AVAILABLE
entity.lastCheckedAt = now
entity.missingSince = null
if (entity.addedAt <= 0) {
entity.addedAt = now
}
if (entity.id == 0L) {
val newId = dao.insert(entity)
entity.id = newId
} else {
dao.update(entity)
}
}.subscribeOn(Schedulers.io())
}
fun updateStatus(
context: Context,
entityId: Long,
status: DownloadedStreamStatus,
lastCheckedAt: Long? = System.currentTimeMillis(),
missingSince: Long? = null
): Completable {
return Completable.fromAction {
downloadedDao(context).updateStatus(entityId, status, lastCheckedAt, missingSince)
}.subscribeOn(Schedulers.io())
}
fun updateFileUri(context: Context, entityId: Long, uri: Uri): Completable {
return Completable.fromAction {
downloadedDao(context).updateFileUri(entityId, uri.toString())
}.subscribeOn(Schedulers.io())
}
fun relink(context: Context, entity: DownloadedStreamEntity, uri: Uri): Completable {
return Single.fromCallable {
StoredFileHelper(context, uri, entity.mime ?: StoredFileHelper.DEFAULT_MIME)
}.flatMapCompletable { helper ->
val association = DownloadAssociation(entity.streamUid, entity.id)
markFinished(
context,
association,
entity.serviceId,
entity.url,
helper,
helper.type,
entity.qualityLabel,
entity.durationMs,
helper.safeLength()
)
}.subscribeOn(Schedulers.io())
}
fun deleteByStreamUid(context: Context, streamUid: Long): Completable {
return Completable.fromAction {
downloadedDao(context).deleteByStreamUid(streamUid)
}.subscribeOn(Schedulers.io())
}
private fun StoredFileHelper.uriString(): String = getUri().toString()
private fun StoredFileHelper.safeLength(): Long? {
return runCatching { length() }.getOrNull()
}
private fun StoredFileHelper.parentUriString(): String? {
return runCatching { getParentUri() }.getOrNull()?.toString()
}
}

View file

@ -4,6 +4,7 @@ import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
@ -12,14 +13,17 @@ import android.content.pm.ActivityInfo
import android.database.ContentObserver import android.database.ContentObserver
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.DocumentsContract
import android.provider.Settings import android.provider.Settings
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
@ -31,7 +35,9 @@ import android.view.WindowManager
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -44,6 +50,7 @@ import androidx.core.net.toUri
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil3.util.CoilUtils import coil3.util.CoilUtils
import com.evernote.android.state.State import com.evernote.android.state.State
@ -52,15 +59,22 @@ import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetDialog
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App import org.schabi.newpipe.App
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.download.DownloadedStreamEntity
import org.schabi.newpipe.database.download.DownloadedStreamStatus
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.databinding.DownloadStatusSheetBinding
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
import org.schabi.newpipe.download.DownloadActivity
import org.schabi.newpipe.download.DownloadAvailabilityChecker
import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.download.DownloadedStreamsRepository
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
@ -115,6 +129,7 @@ import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.KoreUtils
import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.image.CoilHelper import org.schabi.newpipe.util.image.CoilHelper
import java.io.File
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.abs import kotlin.math.abs
@ -181,6 +196,17 @@ class VideoDetailFragment :
private var currentWorker: Disposable? = null private var currentWorker: Disposable? = null
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
private var positionSubscriber: Disposable? = null private var positionSubscriber: Disposable? = null
private var downloadStatusDisposable: Disposable? = null
private var currentStreamUid: Long? = null
private var currentDownloadedStream: DownloadedStreamEntity? = null
private var pendingRelinkEntity: DownloadedStreamEntity? = null
private val relinkLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null && pendingRelinkEntity != null) {
handleRelinkResult(pendingRelinkEntity!!, uri)
}
pendingRelinkEntity = null
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Service management // Service management
@ -348,6 +374,13 @@ class VideoDetailFragment :
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
downloadStatusDisposable?.let {
disposables.remove(it)
it.dispose()
}
downloadStatusDisposable = null
currentDownloadedStream = null
currentStreamUid = null
nullableBinding = null nullableBinding = null
} }
@ -1366,6 +1399,9 @@ class VideoDetailFragment :
currentInfo = info currentInfo = info
setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) setInitialData(info.serviceId, info.originalUrl, info.name, playQueue)
updateDownloadChip(null)
observeDownloadStatus(info)
updateTabs(info) updateTabs(info)
binding.detailThumbnailPlayButton.animate(true, 200) binding.detailThumbnailPlayButton.animate(true, 200)
@ -1544,6 +1580,269 @@ class VideoDetailFragment :
} }
} }
private fun observeDownloadStatus(info: StreamInfo) {
val context = context ?: return
downloadStatusDisposable?.let {
disposables.remove(it)
it.dispose()
}
val disposable = DownloadedStreamsRepository.ensureStreamEntry(context, info)
.flatMapPublisher { streamUid: Long ->
currentStreamUid = streamUid
DownloadedStreamsRepository.observeByStreamUid(context, streamUid)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ entities: List<DownloadedStreamEntity> ->
val entity = entities.firstOrNull()
updateDownloadChip(entity)
},
{ throwable ->
if (DEBUG) {
Log.e(TAG, "Failed to observe download state", throwable)
}
updateDownloadChip(null)
}
)
downloadStatusDisposable = disposable
disposables.add(disposable)
}
private fun updateDownloadChip(entity: DownloadedStreamEntity?) {
if (nullableBinding == null) return
currentDownloadedStream = entity
val chip = binding.detailDownloadStatusChip ?: return
if (entity == null || entity.status == DownloadedStreamStatus.UNLINKED) {
chip.isGone = true
chip.setOnClickListener(null)
return
}
chip.isVisible = true
when (entity.status) {
DownloadedStreamStatus.IN_PROGRESS -> {
chip.text = getString(R.string.download_status_downloading)
chip.setOnClickListener { openDownloadsActivity() }
}
DownloadedStreamStatus.AVAILABLE,
DownloadedStreamStatus.MISSING -> {
chip.text = buildDownloadedLabel(entity)
chip.setOnClickListener { showDownloadOptions(entity) }
}
DownloadedStreamStatus.UNLINKED -> {
chip.isGone = true
chip.setOnClickListener(null)
}
}
}
private fun buildDownloadedLabel(entity: DownloadedStreamEntity): String {
val quality = entity.qualityLabel?.takeIf { it.isNotBlank() }
return if (quality != null) {
getString(R.string.download_status_downloaded, quality)
} else {
getString(R.string.download_status_downloaded_simple)
}
}
private fun showDownloadOptions(entity: DownloadedStreamEntity) {
val baseContext = requireContext()
val dialogTheme = ThemeHelper.getDialogTheme(baseContext)
val themedContext = ContextThemeWrapper(baseContext, dialogTheme)
val sheetBinding = DownloadStatusSheetBinding.inflate(LayoutInflater.from(themedContext))
val dialog = BottomSheetDialog(themedContext)
dialog.setContentView(sheetBinding.root)
val primaryTextColor = ThemeHelper.resolveColorFromAttr(themedContext, android.R.attr.textColorPrimary)
val secondaryTextColor = ThemeHelper.resolveColorFromAttr(themedContext, android.R.attr.textColorSecondary)
val backgroundDrawable = ThemeHelper.resolveDrawable(themedContext, android.R.attr.windowBackground)
val rippleDrawable = ThemeHelper.resolveDrawable(themedContext, R.attr.selector)
val accentColor = ThemeHelper.resolveColorFromAttr(themedContext, androidx.appcompat.R.attr.colorAccent)
sheetBinding.root.background = backgroundDrawable
sheetBinding.downloadStatusTitle.setTextColor(primaryTextColor)
sheetBinding.downloadStatusSubtitle.setTextColor(secondaryTextColor)
fun styleAction(textView: TextView) {
textView.setTextColor(primaryTextColor)
textView.background = rippleDrawable
}
styleAction(sheetBinding.downloadStatusOpen)
styleAction(sheetBinding.downloadStatusDelete)
styleAction(sheetBinding.downloadStatusShowInFolder)
sheetBinding.downloadStatusRemoveLink.apply {
setTextColor(accentColor)
background = rippleDrawable
}
val fileAvailable = entity.fileUri.takeUnless { it.isBlank() }
?.let { DownloadAvailabilityChecker.isReadable(baseContext, Uri.parse(it)) }
?: false
val title = entity.displayName?.takeIf { it.isNotBlank() }
?: currentInfo?.name
?: getString(R.string.download)
sheetBinding.downloadStatusTitle.text = title
val subtitleParts = mutableListOf<String>()
entity.qualityLabel?.takeIf { it.isNotBlank() }?.let(subtitleParts::add)
if (!fileAvailable) {
subtitleParts.add(getString(R.string.download_status_missing))
}
if (subtitleParts.isEmpty()) {
sheetBinding.downloadStatusSubtitle.isGone = true
} else {
sheetBinding.downloadStatusSubtitle.isVisible = true
sheetBinding.downloadStatusSubtitle.text = subtitleParts.joinToString("")
}
sheetBinding.downloadStatusOpen.text = getString(R.string.download_action_open)
sheetBinding.downloadStatusDelete.text = getString(R.string.download_action_delete)
sheetBinding.downloadStatusShowInFolder.text = getString(R.string.download_action_show_in_folder)
sheetBinding.downloadStatusRemoveLink.text = getString(R.string.download_action_remove_link)
sheetBinding.downloadStatusOpen.isVisible = fileAvailable
sheetBinding.downloadStatusDelete.isVisible = fileAvailable
sheetBinding.downloadStatusShowInFolder.isVisible = fileAvailable && !entity.parentUri.isNullOrBlank()
sheetBinding.downloadStatusRemoveLink.isVisible = true
sheetBinding.downloadStatusOpen.setOnClickListener {
dialog.dismiss()
openDownloaded(entity)
}
sheetBinding.downloadStatusDelete.setOnClickListener {
dialog.dismiss()
deleteDownloadedFile(entity)
}
sheetBinding.downloadStatusShowInFolder.setOnClickListener {
dialog.dismiss()
showInFolder(entity)
}
sheetBinding.downloadStatusRemoveLink.setOnClickListener {
dialog.dismiss()
removeDownloadAssociation(entity)
}
dialog.show()
}
private fun openDownloaded(entity: DownloadedStreamEntity) {
val uri = entity.fileUri.takeUnless { it.isBlank() }?.let(Uri::parse) ?: return
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, entity.mime ?: "*/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
runCatching { startActivity(intent) }
.onFailure {
if (DEBUG) Log.e(TAG, "Failed to open downloaded file", it)
Toast.makeText(requireContext(), R.string.download_open_failed, Toast.LENGTH_SHORT).show()
}
}
private fun showInFolder(entity: DownloadedStreamEntity) {
val parent = entity.parentUri?.takeIf { it.isNotBlank() }?.let(Uri::parse)
if (parent == null) {
Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show()
return
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(parent, DocumentsContract.Document.MIME_TYPE_DIR)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
runCatching { startActivity(intent) }
.onFailure {
val treeIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, parent)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
runCatching { startActivity(treeIntent) }
.onFailure { throwable ->
if (DEBUG) Log.e(TAG, "Failed to open folder", throwable)
Toast.makeText(requireContext(), R.string.download_folder_open_failed, Toast.LENGTH_SHORT).show()
}
}
}
private fun removeDownloadAssociation(entity: DownloadedStreamEntity) {
val context = requireContext()
disposables.add(
DownloadedStreamsRepository.deleteByStreamUid(context, entity.streamUid)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ Toast.makeText(context, R.string.download_link_removed, Toast.LENGTH_SHORT).show() },
{ throwable ->
if (DEBUG) Log.e(TAG, "Failed to remove download link", throwable)
showUiErrorSnackbar(this, "Removing download link", throwable)
}
)
)
}
private fun deleteDownloadedFile(entity: DownloadedStreamEntity) {
val context = requireContext()
val uriString = entity.fileUri.takeUnless { it.isBlank() }
if (uriString.isNullOrBlank()) {
Toast.makeText(context, R.string.download_delete_failed, Toast.LENGTH_SHORT).show()
return
}
val uri = Uri.parse(uriString)
val deleted = when (uri.scheme?.lowercase()) {
ContentResolver.SCHEME_CONTENT -> DocumentFile.fromSingleUri(context, uri)?.delete() ?: false
ContentResolver.SCHEME_FILE -> uri.path?.let { File(it).delete() } ?: false
else -> runCatching { context.contentResolver.delete(uri, null, null) > 0 }.getOrDefault(false)
}
if (!deleted) {
Toast.makeText(context, R.string.download_delete_failed, Toast.LENGTH_SHORT).show()
return
}
removeDownloadAssociation(entity)
Toast.makeText(context, R.string.download_deleted, Toast.LENGTH_SHORT).show()
}
private fun handleRelinkResult(entity: DownloadedStreamEntity, uri: Uri) {
val context = requireContext()
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
disposables.add(
DownloadedStreamsRepository.relink(context, entity, uri)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ Toast.makeText(context, R.string.download_relinked, Toast.LENGTH_SHORT).show() },
{ throwable ->
if (DEBUG) Log.e(TAG, "Failed to relink download", throwable)
Toast.makeText(context, R.string.download_relink_failed, Toast.LENGTH_SHORT).show()
}
)
)
}
private fun openDownloadsActivity() {
val context = requireContext()
val intent = Intent(context, DownloadActivity::class.java)
runCatching { startActivity(intent) }
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Stream Results // Stream Results
////////////////////////////////////////////////////////////////////////// */ ////////////////////////////////////////////////////////////////////////// */
@ -2270,6 +2569,7 @@ class VideoDetailFragment :
private const val MAX_OVERLAY_ALPHA = 0.9f private const val MAX_OVERLAY_ALPHA = 0.9f
private const val MAX_PLAYER_HEIGHT = 0.7f private const val MAX_PLAYER_HEIGHT = 0.7f
private val AVAILABILITY_CHECK_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5)
const val ACTION_SHOW_MAIN_PLAYER: String = const val ACTION_SHOW_MAIN_PLAYER: String =
App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"

View file

@ -134,6 +134,10 @@ public class DownloadMission extends Mission {
*/ */
public MissionRecoveryInfo[] recoveryInfo; public MissionRecoveryInfo[] recoveryInfo;
public long streamUid = -1;
public long downloadedEntityId = -1;
public int serviceId = -1;
private transient int finishCount; private transient int finishCount;
public transient volatile boolean running; public transient volatile boolean running;
public boolean enqueued; public boolean enqueued;

View file

@ -39,6 +39,8 @@ import androidx.core.content.IntentCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.download.DownloadedStreamsRepository;
import org.schabi.newpipe.download.DownloadedStreamsRepository.DownloadAssociation;
import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.player.helper.LockManager;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
@ -56,6 +58,8 @@ import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManager.NetworkState; import us.shandian.giga.service.DownloadManager.NetworkState;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class DownloadManagerService extends Service { public class DownloadManagerService extends Service {
private static final String TAG = "DownloadManagerService"; private static final String TAG = "DownloadManagerService";
@ -80,6 +84,9 @@ public class DownloadManagerService extends Service {
private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath";
private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag";
private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo";
private static final String EXTRA_STREAM_UID = "DownloadManagerService.extra.streamUid";
private static final String EXTRA_DOWNLOADED_ID = "DownloadManagerService.extra.downloadedId";
private static final String EXTRA_SERVICE_ID = "DownloadManagerService.extra.serviceId";
private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished";
private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished";
@ -118,6 +125,8 @@ public class DownloadManagerService extends Service {
private PendingIntent mOpenDownloadList; private PendingIntent mOpenDownloadList;
private final CompositeDisposable disposables = new CompositeDisposable();
/** /**
* notify media scanner on downloaded media file ... * notify media scanner on downloaded media file ...
* *
@ -244,6 +253,7 @@ public class DownloadManagerService extends Service {
if (icLauncher != null) icLauncher.recycle(); if (icLauncher != null) icLauncher.recycle();
mHandler = null; mHandler = null;
disposables.clear();
mManager.pauseAllMissions(true); mManager.pauseAllMissions(true);
} }
@ -259,6 +269,18 @@ public class DownloadManagerService extends Service {
switch (msg.what) { switch (msg.what) {
case MESSAGE_FINISHED: case MESSAGE_FINISHED:
if (mission.streamUid >= 0) {
DownloadAssociation association =
new DownloadAssociation(mission.streamUid, mission.downloadedEntityId);
disposables.add(DownloadedStreamsRepository.INSTANCE
.markFinished(this, association, mission.serviceId, mission.source,
mission.storage, null, null, null, null)
.subscribe(
() -> { },
throwable -> Log.e(TAG,
"Failed to update downloaded stream entry", throwable)
));
}
notifyMediaScanner(mission.storage.getUri()); notifyMediaScanner(mission.storage.getUri());
notifyFinishedDownload(mission.storage.getName()); notifyFinishedDownload(mission.storage.getName());
mManager.setFinished(mission); mManager.setFinished(mission);
@ -361,7 +383,8 @@ public class DownloadManagerService extends Service {
public static void startMission(Context context, String[] urls, StoredFileHelper storage, public static void startMission(Context context, String[] urls, StoredFileHelper storage,
char kind, int threads, String source, String psName, char kind, int threads, String source, String psName,
String[] psArgs, long nearLength, String[] psArgs, long nearLength,
ArrayList<MissionRecoveryInfo> recoveryInfo) { ArrayList<MissionRecoveryInfo> recoveryInfo,
long streamUid, long downloadedEntityId, int serviceId) {
final Intent intent = new Intent(context, DownloadManagerService.class) final Intent intent = new Intent(context, DownloadManagerService.class)
.setAction(Intent.ACTION_RUN) .setAction(Intent.ACTION_RUN)
.putExtra(EXTRA_URLS, urls) .putExtra(EXTRA_URLS, urls)
@ -374,7 +397,10 @@ public class DownloadManagerService extends Service {
.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo)
.putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) .putExtra(EXTRA_PARENT_PATH, storage.getParentUri())
.putExtra(EXTRA_PATH, storage.getUri()) .putExtra(EXTRA_PATH, storage.getUri())
.putExtra(EXTRA_STORAGE_TAG, storage.getTag()); .putExtra(EXTRA_STORAGE_TAG, storage.getTag())
.putExtra(EXTRA_STREAM_UID, streamUid)
.putExtra(EXTRA_DOWNLOADED_ID, downloadedEntityId)
.putExtra(EXTRA_SERVICE_ID, serviceId);
context.startService(intent); context.startService(intent);
} }
@ -390,6 +416,9 @@ public class DownloadManagerService extends Service {
String source = intent.getStringExtra(EXTRA_SOURCE); String source = intent.getStringExtra(EXTRA_SOURCE);
long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0);
String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG);
long streamUid = intent.getLongExtra(EXTRA_STREAM_UID, -1L);
long downloadedEntityId = intent.getLongExtra(EXTRA_DOWNLOADED_ID, -1L);
int serviceId = intent.getIntExtra(EXTRA_SERVICE_ID, -1);
final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO,
MissionRecoveryInfo.class); MissionRecoveryInfo.class);
Objects.requireNonNull(recovery); Objects.requireNonNull(recovery);
@ -412,6 +441,9 @@ public class DownloadManagerService extends Service {
mission.source = source; mission.source = source;
mission.nearLength = nearLength; mission.nearLength = nearLength;
mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
mission.streamUid = streamUid;
mission.downloadedEntityId = downloadedEntityId;
mission.serviceId = serviceId;
if (ps != null) if (ps != null)
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/contrast_background_color"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/download_status_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
android:textColor="?android:textColorPrimary"
tools:text="Sample Video" />
<TextView
android:id="@+id/download_status_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:paddingBottom="12dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="?android:textColorSecondary"
tools:text="1080p • 12 Apr 2024" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/separator_color" />
<TextView
android:id="@+id/download_status_open"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Open downloaded" />
<TextView
android:id="@+id/download_status_delete"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Delete file" />
<TextView
android:id="@+id/download_status_show_in_folder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?android:textColorPrimary"
tools:text="Show in folder" />
<TextView
android:id="@+id/download_status_remove_link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selector"
android:paddingVertical="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:textColor="?attr/colorAccent"
tools:text="Remove link" />
</LinearLayout>

View file

@ -274,6 +274,7 @@
</FrameLayout> </FrameLayout>
<LinearLayout <LinearLayout
android:id="@+id/detail_primary_control_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
@ -550,10 +551,27 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.chip.Chip
android:id="@+id/detail_download_status_chip"
style="@style/Widget.MaterialComponents.Chip.Action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/detail_control_panel"
android:layout_marginStart="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="12dp"
android:theme="@style/Theme.MaterialComponents.DayNight.Bridge"
android:visibility="gone"
app:chipIconVisible="false"
app:chipStrokeWidth="0dp"
tools:text="Downloaded • 1080p"
tools:visibility="visible" />
<View <View
android:id="@+id/detail_meta_info_separator" android:id="@+id/detail_meta_info_separator"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1px" android:layout_height="1px"
android:layout_below="@id/detail_download_status_chip"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="?attr/separator_color" /> android:background="?attr/separator_color" />
@ -562,6 +580,7 @@
android:id="@+id/detail_meta_info_text_view" android:id="@+id/detail_meta_info_text_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/detail_meta_info_separator"
android:gravity="center" android:gravity="center"
android:padding="12dp" android:padding="12dp"
android:textSize="@dimen/video_item_detail_description_text_size" android:textSize="@dimen/video_item_detail_description_text_size"
@ -570,6 +589,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1px" android:layout_height="1px"
android:layout_below="@id/detail_meta_info_text_view"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="?attr/separator_color" /> android:background="?attr/separator_color" />

View file

@ -16,6 +16,21 @@
<string name="share">Share</string> <string name="share">Share</string>
<string name="download">Download</string> <string name="download">Download</string>
<string name="controls_download_desc">Download stream file</string> <string name="controls_download_desc">Download stream file</string>
<string name="download_status_downloaded">Downloaded • %1$s</string>
<string name="download_status_downloaded_simple">Downloaded</string>
<string name="download_status_downloading">Downloading…</string>
<string name="download_status_missing">Previously downloaded file missing</string>
<string name="download_action_open">Open file</string>
<string name="download_action_show_in_folder">Show in folder</string>
<string name="download_action_delete">Delete file</string>
<string name="download_action_remove_link">Remove link</string>
<string name="download_link_removed">Download link removed</string>
<string name="download_relinked">Download relinked</string>
<string name="download_open_failed">Unable to open downloaded file</string>
<string name="download_folder_open_failed">Unable to open folder</string>
<string name="download_relink_failed">Unable to relink file</string>
<string name="download_delete_failed">Unable to delete downloaded file</string>
<string name="download_deleted">Deleted downloaded file</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="search_with_service_name">Search %1$s</string> <string name="search_with_service_name">Search %1$s</string>
<string name="search_with_service_name_and_filter">Search %1$s (%2$s)</string> <string name="search_with_service_name_and_filter">Search %1$s (%2$s)</string>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MaterialComponents.DayNight.BottomSheet" parent="Theme.MaterialComponents.DayNight">
<item name="textAppearanceBody1">@style/TextAppearance.MaterialComponents.Body1</item>
<item name="textAppearanceBody2">@style/TextAppearance.MaterialComponents.Body2</item>
<item name="textAppearanceHeadline6">@style/TextAppearance.MaterialComponents.Headline6</item>
<item name="textAppearanceSubtitle1">@style/TextAppearance.MaterialComponents.Body2</item>
</style>
</resources>