6.7.0 commit

This commit is contained in:
Xilin Jia 2024-09-17 23:11:10 +01:00
parent 338788a8ad
commit 692426d114
10 changed files with 324 additions and 109 deletions

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020252 versionCode 3020253
versionName "6.6.7" versionName "6.7.0"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""

View File

@ -3,6 +3,8 @@ package ac.mdiq.podcini.net.download.service
import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.showStackTrace
import android.os.Bundle import android.os.Bundle
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
@ -120,6 +122,8 @@ class DownloadRequest private constructor(
} }
fun setLastModified(lastModified: String?): DownloadRequest { fun setLastModified(lastModified: String?): DownloadRequest {
Logd("DownloadRequest", "setLastModified: $lastModified")
// showStackTrace()
this.lastModified = lastModified this.lastModified = lastModified
return this return this
} }
@ -143,7 +147,6 @@ class DownloadRequest private constructor(
this.feedfileId = media.id this.feedfileId = media.id
this.feedfileType = media.getTypeAsInt() this.feedfileType = media.getTypeAsInt()
} }
constructor(destination: String, feed: Feed) { constructor(destination: String, feed: Feed) {
this.destination = destination this.destination = destination
this.source = when { this.source = when {
@ -156,27 +159,22 @@ class DownloadRequest private constructor(
this.feedfileType = feed.getTypeAsInt() this.feedfileType = feed.getTypeAsInt()
arguments.putInt(REQUEST_ARG_PAGE_NR, feed.pageNr) arguments.putInt(REQUEST_ARG_PAGE_NR, feed.pageNr)
} }
fun withInitiatedByUser(initiatedByUser: Boolean): Builder { fun withInitiatedByUser(initiatedByUser: Boolean): Builder {
this.initiatedByUser = initiatedByUser this.initiatedByUser = initiatedByUser
return this return this
} }
fun setForce(force: Boolean) { fun setForce(force: Boolean) {
if (force) lastModified = null if (force) lastModified = null
} }
fun lastModified(lastModified: String?): Builder { fun lastModified(lastModified: String?): Builder {
this.lastModified = lastModified this.lastModified = lastModified
return this return this
} }
fun withAuthentication(username: String?, password: String?): Builder { fun withAuthentication(username: String?, password: String?): Builder {
this.username = username this.username = username
this.password = password this.password = password
return this return this
} }
fun build(): DownloadRequest { fun build(): DownloadRequest {
return DownloadRequest(this) return DownloadRequest(this)
} }

View File

@ -134,8 +134,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
} }
progressUpdaterThread.start() progressUpdaterThread.start()
var result: Result var result: Result
try { try { result = performDownload(media, request)
result = performDownload(media, request)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
result = Result.failure() result = Result.failure()
@ -170,32 +169,23 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
} }
val dest = File(request.destination) val dest = File(request.destination)
if (!dest.exists()) { if (!dest.exists()) {
try { try { dest.createNewFile() } catch (e: IOException) { Log.e(TAG, "performDownload Unable to create file") }
dest.createNewFile()
} catch (e: IOException) {
Log.e(TAG, "performDownload Unable to create file")
}
} }
if (dest.exists()) { if (dest.exists()) {
try { try {
var episode = realm.query(Episode::class).query("id == ${media.id}").first().find() var episode = realm.query(Episode::class).query("id == ${media.id}").first().find()
if (episode != null) { if (episode != null) {
episode = upsertBlk(episode) { episode = upsertBlk(episode) { it.media?.setfileUrlOrNull(request.destination) }
it.media?.setfileUrlOrNull(request.destination)
}
EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.updated(episode)) EventFlow.postEvent(FlowEvent.EpisodeMediaEvent.updated(episode))
} else Log.e(TAG, "performDownload media.episode is null") } else Log.e(TAG, "performDownload media.episode is null")
} catch (e: Exception) { } catch (e: Exception) { Log.e(TAG, "performDownload Exception in writeFileUrl: " + e.message) }
Log.e(TAG, "performDownload Exception in writeFileUrl: " + e.message)
}
} }
downloader = DefaultDownloaderFactory().create(request) downloader = DefaultDownloaderFactory().create(request)
if (downloader == null) { if (downloader == null) {
Log.e(TAG, "performDownload Unable to create downloader") Log.e(TAG, "performDownload Unable to create downloader")
return Result.failure() return Result.failure()
} }
try { try { downloader!!.call()
downloader!!.call()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "failed performDownload exception on downloader!!.call() ${e.message}") Log.e(TAG, "failed performDownload exception on downloader!!.call() ${e.message}")
LogsAndStats.addDownloadStatus(downloader!!.result) LogsAndStats.addDownloadStatus(downloader!!.result)
@ -328,8 +318,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
if (durationStr != null) it.media?.setDuration(durationStr!!.toInt()) if (durationStr != null) it.media?.setDuration(durationStr!!.toInt())
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) { Logd(TAG, "Invalid file duration: $durationStr")
Logd(TAG, "Invalid file duration: $durationStr")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Get duration failed", e) Log.e(TAG, "Get duration failed", e)
it.media?.setDuration(30000) it.media?.setDuration(30000)

View File

@ -195,8 +195,7 @@ class HttpDownloader(request: DownloadRequest) : Downloader(request) {
@Throws(IOException::class) @Throws(IOException::class)
private fun newCall(httpReq: Request.Builder): Response { private fun newCall(httpReq: Request.Builder): Response {
var httpClient = getHttpClient() var httpClient = getHttpClient()
try { try { return httpClient.newCall(httpReq.build()).execute()
return httpClient.newCall(httpReq.build()).execute()
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, e.toString()) Log.e(TAG, e.toString())
if (e.message != null && e.message!!.contains("PROTOCOL_ERROR")) { if (e.message != null && e.message!!.contains("PROTOCOL_ERROR")) {

View File

@ -148,25 +148,25 @@ object FeedUpdateManager {
@UnstableApi @UnstableApi
override fun doWork(): Result { override fun doWork(): Result {
ClientConfigurator.initialize(applicationContext) ClientConfigurator.initialize(applicationContext)
val toUpdate: MutableList<Feed> val feedsToUpdate: MutableList<Feed>
val feedId = inputData.getLong(EXTRA_FEED_ID, -1L) val feedId = inputData.getLong(EXTRA_FEED_ID, -1L)
var allAreLocal = true var allAreLocal = true
var force = false var force = false
if (feedId == -1L) { // Update all if (feedId == -1L) { // Update all
toUpdate = Feeds.getFeedList().toMutableList() feedsToUpdate = Feeds.getFeedList().toMutableList()
val itr = toUpdate.iterator() val itr = feedsToUpdate.iterator()
while (itr.hasNext()) { while (itr.hasNext()) {
val feed = itr.next() val feed = itr.next()
if (feed.preferences?.keepUpdated == false) itr.remove() if (feed.preferences?.keepUpdated == false) itr.remove()
if (!feed.isLocalFeed) allAreLocal = false if (!feed.isLocalFeed) allAreLocal = false
} }
toUpdate.shuffle() // If the worker gets cancelled early, every feed has a chance to be updated feedsToUpdate.shuffle() // If the worker gets cancelled early, every feed has a chance to be updated
} else { } else {
val feed = Feeds.getFeed(feedId) ?: return Result.success() val feed = Feeds.getFeed(feedId) ?: return Result.success()
Logd(TAG, "doWork feed.downloadUrl: ${feed.downloadUrl}") Logd(TAG, "doWork feed.downloadUrl: ${feed.downloadUrl}")
if (!feed.isLocalFeed) allAreLocal = false if (!feed.isLocalFeed) allAreLocal = false
toUpdate = ArrayList() feedsToUpdate = mutableListOf(feed)
toUpdate.add(feed) // Needs to be updatable, so no singletonList // feedsToUpdate.add(feed) // Needs to be updatable, so no singletonList
force = true force = true
} }
if (!inputData.getBoolean(EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) { if (!inputData.getBoolean(EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) {
@ -175,10 +175,10 @@ object FeedUpdateManager {
return Result.retry() return Result.retry()
} }
} }
refreshFeeds(toUpdate, force) refreshFeeds(feedsToUpdate, force)
notificationManager.cancel(R.id.notification_updating_feeds) notificationManager.cancel(R.id.notification_updating_feeds)
autodownloadEpisodeMedia(applicationContext, toUpdate.toList()) autodownloadEpisodeMedia(applicationContext, feedsToUpdate.toList())
toUpdate.clear() feedsToUpdate.clear()
return Result.success() return Result.success()
} }
private fun createNotification(toUpdate: List<Feed?>?): Notification { private fun createNotification(toUpdate: List<Feed?>?): Notification {
@ -203,7 +203,7 @@ object FeedUpdateManager {
return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null))) return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)))
} }
@UnstableApi @UnstableApi
private fun refreshFeeds(toUpdate: MutableList<Feed>, force: Boolean) { private fun refreshFeeds(feedsToUpdate: MutableList<Feed>, force: Boolean) {
if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext, if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this.applicationContext,
Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling // TODO: Consider calling
@ -219,10 +219,10 @@ object FeedUpdateManager {
return return
} }
var i = 0 var i = 0
while (i < toUpdate.size) { while (i < feedsToUpdate.size) {
if (isStopped) return if (isStopped) return
notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate)) notificationManager.notify(R.id.notification_updating_feeds, createNotification(feedsToUpdate))
val feed = unmanaged(toUpdate[i++]) val feed = unmanaged(feedsToUpdate[i++])
try { try {
Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}") Logd(TAG, "updating local feed? ${feed.isLocalFeed} ${feed.title}")
when { when {

View File

@ -2097,15 +2097,8 @@ class PlaybackService : MediaLibraryService() {
private var positionSaverFuture: ScheduledFuture<*>? = null private var positionSaverFuture: ScheduledFuture<*>? = null
private var widgetUpdaterFuture: ScheduledFuture<*>? = null private var widgetUpdaterFuture: ScheduledFuture<*>? = null
private var sleepTimerFuture: ScheduledFuture<*>? = null private var sleepTimerFuture: ScheduledFuture<*>? = null
// @Volatile
// private var chapterLoaderFuture: Disposable? = null
private var sleepTimer: SleepTimer? = null private var sleepTimer: SleepTimer? = null
/**
* Returns true if the sleep timer is currently active.
*/
@get:Synchronized @get:Synchronized
val isSleepTimerActive: Boolean val isSleepTimerActive: Boolean
get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0 get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0
@ -2124,16 +2117,10 @@ class PlaybackService : MediaLibraryService() {
val isWidgetUpdaterActive: Boolean val isWidgetUpdaterActive: Boolean
get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone get() = widgetUpdaterFuture != null && !widgetUpdaterFuture!!.isCancelled && !widgetUpdaterFuture!!.isDone
/**
* Returns true if the position saver is currently running.
*/
@get:Synchronized @get:Synchronized
val isPositionSaverActive: Boolean val isPositionSaverActive: Boolean
get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone get() = positionSaverFuture != null && !positionSaverFuture!!.isCancelled && !positionSaverFuture!!.isDone
/**
* Starts the position saver task. If the position saver is already active, nothing will happen.
*/
@Synchronized @Synchronized
fun startPositionSaver() { fun startPositionSaver() {
if (!isPositionSaverActive) { if (!isPositionSaverActive) {
@ -2145,9 +2132,6 @@ class PlaybackService : MediaLibraryService() {
} else Logd(TAG, "Call to startPositionSaver was ignored.") } else Logd(TAG, "Call to startPositionSaver was ignored.")
} }
/**
* Cancels the position saver. If the position saver is not running, nothing will happen.
*/
@Synchronized @Synchronized
fun cancelPositionSaver() { fun cancelPositionSaver() {
if (isPositionSaverActive) { if (isPositionSaverActive) {
@ -2156,9 +2140,6 @@ class PlaybackService : MediaLibraryService() {
} }
} }
/**
* Starts the widget updater task. If the widget updater is already active, nothing will happen.
*/
@Synchronized @Synchronized
fun startWidgetUpdater() { fun startWidgetUpdater() {
if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) { if (!isWidgetUpdaterActive && !schedExecutor.isShutdown) {
@ -2184,13 +2165,11 @@ class PlaybackService : MediaLibraryService() {
* Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
* cancelled first. * cancelled first.
* After waitingTime has elapsed, onSleepTimerExpired() will be called. * After waitingTime has elapsed, onSleepTimerExpired() will be called.
*
* @throws java.lang.IllegalArgumentException if waitingTime <= 0 * @throws java.lang.IllegalArgumentException if waitingTime <= 0
*/ */
@Synchronized @Synchronized
fun setSleepTimer(waitingTime: Long) { fun setSleepTimer(waitingTime: Long) {
require(waitingTime > 0) { "Waiting time <= 0" } require(waitingTime > 0) { "Waiting time <= 0" }
Logd(TAG, "Setting sleep timer to $waitingTime milliseconds") Logd(TAG, "Setting sleep timer to $waitingTime milliseconds")
if (isSleepTimerActive) sleepTimerFuture!!.cancel(true) if (isSleepTimerActive) sleepTimerFuture!!.cancel(true)
sleepTimer = SleepTimer(waitingTime) sleepTimer = SleepTimer(waitingTime)
@ -2198,9 +2177,6 @@ class PlaybackService : MediaLibraryService() {
EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime)) EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.justEnabled(waitingTime))
} }
/**
* Disables the sleep timer. If the sleep timer is not active, nothing will happen.
*/
@Synchronized @Synchronized
fun disableSleepTimer() { fun disableSleepTimer() {
if (isSleepTimerActive) { if (isSleepTimerActive) {
@ -2209,9 +2185,6 @@ class PlaybackService : MediaLibraryService() {
} }
} }
/**
* Restarts the sleep timer. If the sleep timer is not active, nothing will happen.
*/
@Synchronized @Synchronized
fun restartSleepTimer() { fun restartSleepTimer() {
if (isSleepTimerActive) { if (isSleepTimerActive) {
@ -2353,8 +2326,7 @@ class PlaybackService : MediaLibraryService() {
fun onChapterLoaded(media: Playable?) fun onChapterLoaded(media: Playable?)
} }
internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) : internal class ShakeListener(private val mContext: Context, private val mSleepTimer: SleepTimer) : SensorEventListener {
SensorEventListener {
private var mAccelerometer: Sensor? = null private var mAccelerometer: Sensor? = null
private var mSensorMgr: SensorManager? = null private var mSensorMgr: SensorManager? = null
@ -2389,14 +2361,10 @@ class PlaybackService : MediaLibraryService() {
} }
} }
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
companion object {
private val TAG: String = ShakeListener::class.simpleName ?: "Anonymous"
}
} }
companion object { companion object {
private val TAG: String = TaskManager::class.simpleName ?: "Anonymous" private val TAG: String = TaskManager::class.simpleName ?: "Anonymous"
private const val SCHED_EX_POOL_SIZE = 2 private const val SCHED_EX_POOL_SIZE = 2
private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds

View File

@ -9,6 +9,10 @@ import ac.mdiq.podcini.preferences.UserPreferences.isAutoDelete
import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal import ac.mdiq.podcini.preferences.UserPreferences.isAutoDeleteLocal
import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes import ac.mdiq.podcini.storage.database.Episodes.deleteEpisodes
import ac.mdiq.podcini.storage.database.Feeds.EpisodeAssistant.searchEpisodeByIdentifyingValue import ac.mdiq.podcini.storage.database.Feeds.EpisodeAssistant.searchEpisodeByIdentifyingValue
import ac.mdiq.podcini.storage.database.Feeds.EpisodeDuplicateGuesser.canonicalizeTitle
import ac.mdiq.podcini.storage.database.Feeds.EpisodeDuplicateGuesser.datesLookSimilar
import ac.mdiq.podcini.storage.database.Feeds.EpisodeDuplicateGuesser.durationsLookSimilar
import ac.mdiq.podcini.storage.database.Feeds.EpisodeDuplicateGuesser.mimeTypeLooksSimilar
import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus import ac.mdiq.podcini.storage.database.LogsAndStats.addDownloadStatus
import ac.mdiq.podcini.storage.database.Queues.addToQueueSync import ac.mdiq.podcini.storage.database.Queues.addToQueueSync
import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet
@ -192,6 +196,142 @@ object Feeds {
* I.e. episodes are removed from the database if they are not in this episode list. * I.e. episodes are removed from the database if they are not in this episode list.
* @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise. * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise.
*/ */
// @Synchronized
// fun updateFeed0(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? {
// Logd(TAG, "updateFeed called")
// var resultFeed: Feed?
// val unlistedItems: MutableList<Episode> = ArrayList()
//
// // Look up feed in the feedslist
// val savedFeed = searchFeedByIdentifyingValueOrID(newFeed, true)
// if (savedFeed == null) {
// Logd(TAG, "Found no existing Feed with title ${newFeed.title}. Adding as new one.")
// Logd(TAG, "newFeed.episodes: ${newFeed.episodes.size}")
// resultFeed = newFeed
// try {
// addNewFeedsSync(context, newFeed)
// // Update with default values that are set in database
// resultFeed = searchFeedByIdentifyingValueOrID(newFeed)
// if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() }
// } catch (e: InterruptedException) { e.printStackTrace()
// } catch (e: ExecutionException) { e.printStackTrace() }
// return resultFeed
// }
//
// Logd(TAG, "Feed with title " + newFeed.title + " already exists. Syncing new with existing one.")
// newFeed.episodes.sortWith(EpisodePubdateComparator())
// if (newFeed.pageNr == savedFeed.pageNr) {
// if (savedFeed.compareWithOther(newFeed)) {
// Logd(TAG, "Feed has updated attribute values. Updating old feed's attributes")
// savedFeed.updateFromOther(newFeed)
// }
// } else {
// Logd(TAG, "New feed has a higher page number.")
// savedFeed.nextPageLink = newFeed.nextPageLink
// }
//// appears not useful
//// if (savedFeed.preferences != null && savedFeed.preferences!!.compareWithOther(newFeed.preferences)) {
//// Logd(TAG, "Feed has updated preferences. Updating old feed's preferences")
//// savedFeed.preferences!!.updateFromOther(newFeed.preferences)
//// }
// val priorMostRecent = savedFeed.mostRecentItem
// val priorMostRecentDate: Date? = priorMostRecent?.getPubDate()
// var idLong = Feed.newId()
// // Look for new or updated Items
// for (idx in newFeed.episodes.indices) {
// val episode = newFeed.episodes[idx]
// val possibleDuplicate = EpisodeAssistant.searchEpisodeGuessDuplicate(newFeed.episodes, episode)
// if (!newFeed.isLocalFeed && possibleDuplicate != null && episode !== possibleDuplicate) {
// // Canonical episode is the first one returned (usually oldest)
// addDownloadStatus(DownloadResult(savedFeed.id, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
// """
// The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it.
//
// Original episode:
// ${EpisodeAssistant.duplicateEpisodeDetails(episode)}
//
// Second episode that is also in the feed:
// ${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)}
// """.trimIndent()))
// continue
// }
// var oldItem = searchEpisodeByIdentifyingValue(savedFeed.episodes, episode)
// if (!newFeed.isLocalFeed && oldItem == null) {
// oldItem = EpisodeAssistant.searchEpisodeGuessDuplicate(savedFeed.episodes, episode)
// if (oldItem != null) {
// Logd(TAG, "Repaired duplicate: $oldItem, $episode")
// addDownloadStatus(DownloadResult(savedFeed.id,
// episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
// """
// The podcast host changed the ID of an existing episode instead of just updating the episode itself. Podcini still refreshed the feed and attempted to repair it.
//
// Original episode:
// ${EpisodeAssistant.duplicateEpisodeDetails(oldItem)}
//
// Now the feed contains:
// ${EpisodeAssistant.duplicateEpisodeDetails(episode)}
// """.trimIndent()))
// oldItem.identifier = episode.identifier
// if (needSynch() && oldItem.isPlayed() && oldItem.media != null) {
// val durs = oldItem.media!!.getDuration() / 1000
// val action = EpisodeAction.Builder(oldItem, EpisodeAction.PLAY)
// .currentTimestamp()
// .started(durs)
// .position(durs)
// .total(durs)
// .build()
// SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, action)
// }
// }
// }
// if (oldItem != null) oldItem.updateFromOther(episode)
// else {
// Logd(TAG, "Found new episode: ${episode.title}")
// episode.feed = savedFeed
// episode.id = idLong++
// episode.feedId = savedFeed.id
// if (episode.media != null) {
// episode.media!!.id = episode.id
// if (!savedFeed.hasVideoMedia && episode.media!!.getMediaType() == MediaType.VIDEO) savedFeed.hasVideoMedia = true
// }
// if (idx >= savedFeed.episodes.size) savedFeed.episodes.add(episode)
// else savedFeed.episodes.add(idx, episode)
//
// val pubDate = episode.getPubDate()
// if (pubDate == null || priorMostRecentDate == null || priorMostRecentDate.before(pubDate) || priorMostRecentDate == pubDate) {
// Logd(TAG, "Marking episode published on $pubDate new, prior most recent date = $priorMostRecentDate")
// episode.setNew()
// if (savedFeed.preferences?.autoAddNewToQueue == true) {
// val q = savedFeed.preferences?.queue
// if (q != null) runOnIOScope { addToQueueSync(false, episode, q) }
// }
// }
// }
// }
// // identify episodes to be removed
// if (removeUnlistedItems) {
// val it = savedFeed.episodes.toMutableList().iterator()
// while (it.hasNext()) {
// val feedItem = it.next()
// if (searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) {
// unlistedItems.add(feedItem)
// it.remove()
// }
// }
// }
// // update attributes
// savedFeed.lastUpdate = newFeed.lastUpdate
// savedFeed.type = newFeed.type
// savedFeed.lastUpdateFailed = false
// resultFeed = savedFeed
// try {
// upsertBlk(savedFeed) {}
// if (removeUnlistedItems) runBlocking { deleteEpisodes(context, unlistedItems).join() }
// } catch (e: InterruptedException) { e.printStackTrace()
// } catch (e: ExecutionException) { e.printStackTrace() }
// return resultFeed
// }
@Synchronized @Synchronized
fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? { fun updateFeed(context: Context, newFeed: Feed, removeUnlistedItems: Boolean): Feed? {
Logd(TAG, "updateFeed called") Logd(TAG, "updateFeed called")
@ -233,27 +373,32 @@ object Feeds {
val priorMostRecent = savedFeed.mostRecentItem val priorMostRecent = savedFeed.mostRecentItem
val priorMostRecentDate: Date? = priorMostRecent?.getPubDate() val priorMostRecentDate: Date? = priorMostRecent?.getPubDate()
var idLong = Feed.newId() var idLong = Feed.newId()
Logd(TAG, "updateFeed building newFeedAssistant")
val newFeedAssistant = FeedAssistant(newFeed, savedFeed.id)
Logd(TAG, "updateFeed building savedFeedAssistant")
val savedFeedAssistant = FeedAssistant(savedFeed)
// Look for new or updated Items // Look for new or updated Items
for (idx in newFeed.episodes.indices) { for (idx in newFeed.episodes.indices) {
val episode = newFeed.episodes[idx] val episode = newFeed.episodes[idx]
val possibleDuplicate = EpisodeAssistant.searchEpisodeGuessDuplicate(newFeed.episodes, episode) // val possibleDuplicate = EpisodeAssistant.searchEpisodeGuessDuplicate(newFeed.episodes, episode)
if (!newFeed.isLocalFeed && possibleDuplicate != null && episode !== possibleDuplicate) { // if (!newFeed.isLocalFeed && possibleDuplicate != null && episode !== possibleDuplicate) {
// Canonical episode is the first one returned (usually oldest) // // Canonical episode is the first one returned (usually oldest)
addDownloadStatus(DownloadResult(savedFeed.id, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false, // addDownloadStatus(DownloadResult(savedFeed.id, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
""" // """
The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it. // The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it.
//
Original episode: // Original episode:
${EpisodeAssistant.duplicateEpisodeDetails(episode)} // ${EpisodeAssistant.duplicateEpisodeDetails(episode)}
//
Second episode that is also in the feed: // Second episode that is also in the feed:
${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)} // ${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)}
""".trimIndent())) // """.trimIndent()))
continue // continue
} // }
var oldItem = searchEpisodeByIdentifyingValue(savedFeed.episodes, episode) var oldItem = savedFeedAssistant.searchEpisodeByIdentifyingValue(episode)
if (!newFeed.isLocalFeed && oldItem == null) { if (!newFeed.isLocalFeed && oldItem == null) {
oldItem = EpisodeAssistant.searchEpisodeGuessDuplicate(savedFeed.episodes, episode) oldItem = savedFeedAssistant.searchEpisodeGuessDuplicate(episode)
if (oldItem != null) { if (oldItem != null) {
Logd(TAG, "Repaired duplicate: $oldItem, $episode") Logd(TAG, "Repaired duplicate: $oldItem, $episode")
addDownloadStatus(DownloadResult(savedFeed.id, addDownloadStatus(DownloadResult(savedFeed.id,
@ -304,17 +449,21 @@ object Feeds {
} }
} }
} }
savedFeedAssistant.clear()
// identify episodes to be removed // identify episodes to be removed
if (removeUnlistedItems) { if (removeUnlistedItems) {
val it = savedFeed.episodes.toMutableList().iterator() val it = savedFeed.episodes.toMutableList().iterator()
while (it.hasNext()) { while (it.hasNext()) {
val feedItem = it.next() val feedItem = it.next()
if (searchEpisodeByIdentifyingValue(newFeed.episodes, feedItem) == null) { if (newFeedAssistant.searchEpisodeByIdentifyingValue(feedItem) == null) {
unlistedItems.add(feedItem) unlistedItems.add(feedItem)
it.remove() it.remove()
} }
} }
} }
newFeedAssistant.clear()
// update attributes // update attributes
savedFeed.lastUpdate = newFeed.lastUpdate savedFeed.lastUpdate = newFeed.lastUpdate
savedFeed.type = newFeed.type savedFeed.type = newFeed.type
@ -421,7 +570,7 @@ object Feeds {
return !feed.isLocalFeed || isAutoDeleteLocal return !feed.isLocalFeed || isAutoDeleteLocal
} }
fun getYoutubeSyndicate(video: Boolean, music: Boolean): Feed { private fun getYoutubeSyndicate(video: Boolean, music: Boolean): Feed {
var feedId: Long = if (video) 1 else 2 var feedId: Long = if (video) 1 else 2
if (music) feedId += 2 // music feed takes ids 3 and 4 if (music) feedId += 2 // music feed takes ids 3 and 4
var feed = getFeed(feedId, true) var feed = getFeed(feedId, true)
@ -467,6 +616,104 @@ object Feeds {
} }
} }
class FeedAssistant(val feed: Feed, val feedId: Long = 0L) {
val map = mutableMapOf<String, Episode>()
init {
for (e in feed.episodes) {
if (!e.identifier.isNullOrEmpty()) {
if (map.containsKey(e.identifier!!)) {
// TODO: add addDownloadStatus
Logd(TAG, "FeedAssistant init identifier duplicate: ${e.identifier} ${e.title}")
addDownloadStatus(e, map[e.identifier!!]!!)
continue
}
map[e.identifier!!] = e
}
val idv = e.identifyingValue
if (idv != e.identifier && !idv.isNullOrEmpty()) {
if (map.containsKey(idv)) {
// TODO: add addDownloadStatus
Logd(TAG, "FeedAssistant init identifyingValue duplicate: $idv ${e.title}")
addDownloadStatus(e, map[idv]!!)
continue
}
map[idv] = e
}
val url = e.media?.getStreamUrl()
if (url != idv && !url.isNullOrEmpty()) {
if (map.containsKey(url)) {
// TODO: add addDownloadStatus
Logd(TAG, "FeedAssistant init url duplicate: $url ${e.title}")
addDownloadStatus(e, map[url]!!)
continue
}
map[url] = e
}
val title = canonicalizeTitle(e.title)
if (title != idv && title.isNotEmpty()) {
if (map.containsKey(title)) {
// TODO: add addDownloadStatus
val episode = map[title]
if (episode != null) {
val media1 = episode.media
val media2 = e.media
if (media1 != null && media2 != null && datesLookSimilar(episode, e) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2)) {
Logd(TAG, "FeedAssistant init title duplicate: $title ${e.title}")
addDownloadStatus(e, episode)
continue
}
}
}
// TODO: does it mean there are duplicate titles?
map[title] = e
}
}
}
private fun addDownloadStatus(episode: Episode, possibleDuplicate: Episode) {
val feedId_ = if (feedId > 0) feedId else feed.id
addDownloadStatus(DownloadResult(feedId_, episode.title ?: "", DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE, false,
"""
The podcast host appears to have added the same episode twice. Podcini still refreshed the feed and attempted to repair it.
Original episode:
${EpisodeAssistant.duplicateEpisodeDetails(episode)}
Second episode that is also in the feed:
${EpisodeAssistant.duplicateEpisodeDetails(possibleDuplicate)}
""".trimIndent()))
}
fun searchEpisodeByIdentifyingValue(item: Episode): Episode? {
return map[item.identifyingValue]
}
fun searchEpisodeGuessDuplicate(item: Episode): Episode? {
var episode = map[item.identifier]
if (episode != null) return episode
val url = item.media?.getStreamUrl()
if (!url.isNullOrEmpty()) {
episode = map[url]
if (episode != null) return episode
}
val title = canonicalizeTitle(item.title)
if (title.isNotEmpty()) {
episode = map[title]
if (episode != null) {
val media1 = episode.media
val media2 = item.media
if (media1 == null || media2 == null) return null
if (datesLookSimilar(episode, item) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2)) return episode
}
}
return null
}
fun clear() {
map.clear()
}
}
private object EpisodeAssistant { private object EpisodeAssistant {
fun searchEpisodeByIdentifyingValue(episodes: List<Episode>?, searchItem: Episode): Episode? { fun searchEpisodeByIdentifyingValue(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null if (episodes.isNullOrEmpty()) return null
@ -479,16 +726,16 @@ object Feeds {
* Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value. * Guess if one of the episodes could actually mean the searched episode, even if it uses another identifying value.
* This is to work around podcasters breaking their GUIDs. * This is to work around podcasters breaking their GUIDs.
*/ */
fun searchEpisodeGuessDuplicate(episodes: List<Episode>?, searchItem: Episode): Episode? { // fun searchEpisodeGuessDuplicate(episodes: List<Episode>?, searchItem: Episode): Episode? {
if (episodes.isNullOrEmpty()) return null // if (episodes.isNullOrEmpty()) return null
for (episode in episodes) { // for (episode in episodes) {
if (EpisodeDuplicateGuesser.sameAndNotEmpty(episode.identifier, searchItem.identifier)) return episode // if (EpisodeDuplicateGuesser.sameAndNotEmpty(episode.identifier, searchItem.identifier)) return episode
} // }
for (episode in episodes) { // for (episode in episodes) {
if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode // if (EpisodeDuplicateGuesser.seemDuplicates(episode, searchItem)) return episode
} // }
return null // return null
} // }
fun duplicateEpisodeDetails(episode: Episode): String { fun duplicateEpisodeDetails(episode: Episode): String {
return (""" return ("""
Title: ${episode.title} Title: ${episode.title}
@ -506,6 +753,7 @@ object Feeds {
* even if their feed explicitly says that the episodes are different. * even if their feed explicitly says that the episodes are different.
*/ */
object EpisodeDuplicateGuesser { object EpisodeDuplicateGuesser {
// only used in test
fun seemDuplicates(item1: Episode, item2: Episode): Boolean { fun seemDuplicates(item1: Episode, item2: Episode): Boolean {
if (sameAndNotEmpty(item1.identifier, item2.identifier)) return true if (sameAndNotEmpty(item1.identifier, item2.identifier)) return true
val media1 = item1.media val media1 = item1.media
@ -514,21 +762,21 @@ object Feeds {
if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) return true if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) return true
return (titlesLookSimilar(item1, item2) && datesLookSimilar(item1, item2) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2)) return (titlesLookSimilar(item1, item2) && datesLookSimilar(item1, item2) && durationsLookSimilar(media1, media2) && mimeTypeLooksSimilar(media1, media2))
} }
fun sameAndNotEmpty(string1: String?, string2: String?): Boolean { private fun sameAndNotEmpty(string1: String?, string2: String?): Boolean {
if (string1.isNullOrEmpty() || string2.isNullOrEmpty()) return false if (string1.isNullOrEmpty() || string2.isNullOrEmpty()) return false
return string1 == string2 return string1 == string2
} }
private fun datesLookSimilar(item1: Episode, item2: Episode): Boolean { internal fun datesLookSimilar(item1: Episode, item2: Episode): Boolean {
if (item1.getPubDate() == null || item2.getPubDate() == null) return false if (item1.getPubDate() == null || item2.getPubDate() == null) return false
val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) // MM/DD/YY val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US) // MM/DD/YY
val dateOriginal = dateFormat.format(item2.getPubDate()!!) val dateOriginal = dateFormat.format(item2.getPubDate()!!)
val dateNew = dateFormat.format(item1.getPubDate()!!) val dateNew = dateFormat.format(item1.getPubDate()!!)
return dateOriginal == dateNew // Same date; time is ignored. return dateOriginal == dateNew // Same date; time is ignored.
} }
private fun durationsLookSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean { internal fun durationsLookSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
return abs((media1.getDuration() - media2.getDuration()).toDouble()) < 10 * 60L * 1000L return abs((media1.getDuration() - media2.getDuration()).toDouble()) < 10 * 60L * 1000L
} }
private fun mimeTypeLooksSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean { internal fun mimeTypeLooksSimilar(media1: EpisodeMedia, media2: EpisodeMedia): Boolean {
var mimeType1 = media1.mimeType var mimeType1 = media1.mimeType
var mimeType2 = media2.mimeType var mimeType2 = media2.mimeType
if (mimeType1 == null || mimeType2 == null) return true if (mimeType1 == null || mimeType2 == null) return true
@ -541,7 +789,7 @@ object Feeds {
private fun titlesLookSimilar(item1: Episode, item2: Episode): Boolean { private fun titlesLookSimilar(item1: Episode, item2: Episode): Boolean {
return sameAndNotEmpty(canonicalizeTitle(item1.title), canonicalizeTitle(item2.title)) return sameAndNotEmpty(canonicalizeTitle(item1.title), canonicalizeTitle(item2.title))
} }
private fun canonicalizeTitle(title: String?): String { internal fun canonicalizeTitle(title: String?): String {
if (title == null) return "" if (title == null) return ""
return title return title
.trim { it <= ' ' } .trim { it <= ' ' }

View File

@ -4,11 +4,17 @@ enum class MediaType {
AUDIO, VIDEO, UNKNOWN; AUDIO, VIDEO, UNKNOWN;
companion object { companion object {
private val AUDIO_APPLICATION_MIME_STRINGS: Set<String> = HashSet(mutableListOf( // private val AUDIO_APPLICATION_MIME_STRINGS: Set<String> = HashSet(mutableListOf(
// "application/ogg",
// "application/opus",
// "application/x-flac"
// ))
private val AUDIO_APPLICATION_MIME_STRINGS: HashSet<String> = hashSetOf(
"application/ogg", "application/ogg",
"application/opus", "application/opus",
"application/x-flac" "application/x-flac"
)) )
fun fromMimeType(mimeType: String?): MediaType { fun fromMimeType(mimeType: String?): MediaType {
return when { return when {

View File

@ -1,3 +1,7 @@
# 6.7.0
* largely improved efficiency of podcasts refresh, no more massive list searches
# 6.6.7 # 6.6.7
* volume adaptation numbers were changed to 0.2, 0.5, 1, 1.6, 2.4, 3.6 to avoid much distortion * volume adaptation numbers were changed to 0.2, 0.5, 1, 1.6, 2.4, 3.6 to avoid much distortion

View File

@ -0,0 +1,3 @@
Version 6.7.0:
* largely improved efficiency of podcasts refresh, no more massive list searches