Merge branch 'refactor_downloader' into 'develop'

Refactor DownloadService

Closes #815 and #790

See merge request ultrasonic/ultrasonic!814
This commit is contained in:
Nite 2022-09-20 20:07:00 +00:00
commit 97ebed3d7a
14 changed files with 715 additions and 774 deletions

View File

@ -13,12 +13,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.CircularProgressIndicator
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadStatus import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
@ -34,8 +33,6 @@ const val INDICATOR_THICKNESS_DEFINITE = 10
*/ */
class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent { class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent {
private val downloader: Downloader by inject()
var entry: Track? = null var entry: Track? = null
private set private set
var check: CheckedTextView = view.findViewById(R.id.song_check) var check: CheckedTextView = view.findViewById(R.id.song_check)
@ -61,7 +58,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
} }
private var isMaximized = false private var isMaximized = false
private var cachedStatus = DownloadStatus.UNKNOWN private var cachedStatus = DownloadState.UNKNOWN
private var isPlayingCached = false private var isPlayingCached = false
private var rxBusSubscription: CompositeDisposable? = null private var rxBusSubscription: CompositeDisposable? = null
@ -98,7 +95,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
setupStarButtons(song, useFiveStarRating) setupStarButtons(song, useFiveStarRating)
} }
updateStatus(downloader.getDownloadState(song), null) updateStatus(DownloadService.getDownloadState(song), null)
if (useFiveStarRating) { if (useFiveStarRating) {
setFiveStars(entry?.userRating ?: 0) setFiveStars(entry?.userRating ?: 0)
@ -209,31 +206,32 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
} }
} }
private fun updateStatus(status: DownloadStatus, progress: Int?) { private fun updateStatus(status: DownloadState, progress: Int?) {
progressIndicator.progress = progress ?: 0 progressIndicator.progress = progress ?: 0
if (status == cachedStatus) return if (status == cachedStatus) return
cachedStatus = status cachedStatus = status
when (status) { when (status) {
DownloadStatus.DONE -> { DownloadState.DONE -> {
showStatusImage(imageHelper.downloadedImage) showStatusImage(imageHelper.downloadedImage)
} }
DownloadStatus.PINNED -> { DownloadState.PINNED -> {
showStatusImage(imageHelper.pinImage) showStatusImage(imageHelper.pinImage)
} }
DownloadStatus.FAILED, DownloadState.FAILED -> {
DownloadStatus.CANCELLED -> {
showStatusImage(imageHelper.errorImage) showStatusImage(imageHelper.errorImage)
} }
DownloadStatus.DOWNLOADING -> { DownloadState.DOWNLOADING -> {
showProgress() showProgress()
} }
DownloadStatus.RETRYING, DownloadState.RETRYING,
DownloadStatus.QUEUED -> { DownloadState.QUEUED -> {
showIndefiniteProgress() showIndefiniteProgress()
} }
else -> { else -> {
// This handles CANCELLED too.
// Usually it means no error, just that the track wasn't downloaded
showStatusImage(null) showStatusImage(null)
} }
} }

View File

@ -1,7 +1,6 @@
package org.moire.ultrasonic.di package org.moire.ultrasonic.di
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
@ -14,8 +13,7 @@ val mediaPlayerModule = module {
single { MediaPlayerLifecycleSupport() } single { MediaPlayerLifecycleSupport() }
single { PlaybackStateSerializer() } single { PlaybackStateSerializer() }
single { ExternalStorageMonitor() } single { ExternalStorageMonitor() }
single { Downloader(get()) }
// TODO Ideally this can be cleaned up when all circular references are removed. // TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerController(get(), get(), get(), get()) } single { MediaPlayerController(get(), get(), get()) }
} }

View File

@ -14,12 +14,11 @@ import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.model.GenericListModel import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
/** /**
@ -82,9 +81,7 @@ class DownloadsFragment : MultiListFragment<Track>() {
} }
class DownloadListModel(application: Application) : GenericListModel(application) { class DownloadListModel(application: Application) : GenericListModel(application) {
private val downloader by inject<Downloader>()
fun getList(): LiveData<List<Track>> { fun getList(): LiveData<List<Track>> {
return downloader.observableDownloads return DownloadService.observableDownloads
} }
} }

View File

@ -17,6 +17,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.model.ServerSettingsModel import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.ErrorDialog import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -101,7 +102,7 @@ class ServerSelectorFragment : Fragment() {
// If we are coming from offline there is no need to clear downloads etc. // If we are coming from offline there is no need to clear downloads etc.
if (oldId != OFFLINE_DB_ID) { if (oldId != OFFLINE_DB_ID) {
controller.removeIncompleteTracksFromPlaylist() controller.removeIncompleteTracksFromPlaylist()
controller.clearDownloads() DownloadService.requestStop()
} }
ActiveServerProvider.setActiveServerById(id) ActiveServerProvider.setActiveServerById(id)

View File

@ -37,8 +37,8 @@ import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.model.TrackCollectionModel import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.DownloadStatus import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
@ -80,7 +80,6 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
private var shareButton: MenuItem? = null private var shareButton: MenuItem? = null
internal val mediaPlayerController: MediaPlayerController by inject() internal val mediaPlayerController: MediaPlayerController by inject()
internal val downloader: Downloader by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val shareHandler: ShareHandler by inject() private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null internal var cancellationToken: CancellationToken? = null
@ -375,30 +374,24 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
var unpinEnabled = false var unpinEnabled = false
var deleteEnabled = false var deleteEnabled = false
var downloadEnabled = false var downloadEnabled = false
var isNotInProgress = true
val multipleSelection = viewAdapter.hasMultipleSelection() val multipleSelection = viewAdapter.hasMultipleSelection()
var pinnedCount = 0 var pinnedCount = 0
for (song in selection) { for (song in selection) {
val state = downloader.getDownloadState(song) val state = DownloadService.getDownloadState(song)
when (state) { when (state) {
DownloadStatus.DONE -> { DownloadState.DONE -> {
deleteEnabled = true deleteEnabled = true
} }
DownloadStatus.PINNED -> { DownloadState.PINNED -> {
deleteEnabled = true deleteEnabled = true
pinnedCount++ pinnedCount++
unpinEnabled = true unpinEnabled = true
} }
DownloadStatus.IDLE, DownloadStatus.FAILED -> { DownloadState.IDLE, DownloadState.FAILED -> {
downloadEnabled = true downloadEnabled = true
} }
DownloadStatus.DOWNLOADING,
DownloadStatus.QUEUED,
DownloadStatus.RETRYING -> {
isNotInProgress = false
}
else -> {} else -> {}
} }
} }
@ -406,11 +399,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
playNowButton?.isVisible = enabled playNowButton?.isVisible = enabled
playNextButton?.isVisible = enabled && multipleSelection playNextButton?.isVisible = enabled && multipleSelection
playLastButton?.isVisible = enabled && multipleSelection playLastButton?.isVisible = enabled && multipleSelection
pinButton?.isVisible = pinButton?.isVisible = enabled && !isOffline() && selection.size > pinnedCount
isNotInProgress && enabled && !isOffline() && selection.size > pinnedCount unpinButton?.isVisible = enabled && unpinEnabled
unpinButton?.isVisible = isNotInProgress && enabled && unpinEnabled downloadButton?.isVisible = enabled && downloadEnabled && !isOffline()
downloadButton?.isVisible = isNotInProgress && enabled && downloadEnabled && !isOffline() deleteButton?.isVisible = enabled && deleteEnabled
deleteButton?.isVisible = isNotInProgress && enabled && deleteEnabled
} }
private fun downloadBackground(save: Boolean) { private fun downloadBackground(save: Boolean) {

View File

@ -12,6 +12,8 @@ import android.os.Build
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.C.USAGE_MEDIA
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
@ -27,11 +29,14 @@ import org.moire.ultrasonic.activity.NavigationActivity
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.audiofx.EqualizerController
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
class PlaybackService : MediaLibraryService(), KoinComponent { class PlaybackService : MediaLibraryService(), KoinComponent {
@ -74,6 +79,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
// Broadcast that the service is being shutdown // Broadcast that the service is being shutdown
RxBus.stopServiceCommandPublisher.onNext(Unit) RxBus.stopServiceCommandPublisher.onNext(Unit)
player.removeListener(listener)
player.release() player.release()
mediaLibrarySession.release() mediaLibrarySession.release()
rxBusSubscription.dispose() rxBusSubscription.dispose()
@ -153,9 +159,31 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
onDestroy() onDestroy()
} }
player.addListener(listener)
isStarted = true isStarted = true
} }
private val listener: Player.Listener = object : Player.Listener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
cacheNextSongs()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
cacheNextSongs()
}
}
private fun cacheNextSongs() {
Timber.d("PlaybackService caching the next songs")
val nextSongs = Util.getPlayListFromTimeline(
player.currentTimeline,
player.shuffleModeEnabled,
player.currentMediaItemIndex,
Settings.preloadCount
).map { it.toTrack() }
DownloadService.download(nextSongs, save = false, isHighPriority = true)
}
private fun getPendingIntentForContent(): PendingIntent { private fun getPendingIntentForContent(): PendingIntent {
val intent = Intent(this, NavigationActivity::class.java) val intent = Intent(this, NavigationActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)

View File

@ -12,20 +12,40 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat import android.os.Looper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import java.util.concurrent.Semaphore import androidx.lifecycle.MutableLiveData
import java.util.concurrent.TimeUnit import com.google.common.util.concurrent.ListenableFuture
import org.koin.android.ext.android.inject import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import java.util.PriorityQueue
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SimpleServiceBinder import org.moire.ultrasonic.util.SimpleServiceBinder
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
private const val NOTIFICATION_ID = 3033
private const val CHECK_INTERVAL = 5000L
/** /**
* Android Foreground service which is used to download tracks even when the app is not visible * Android Foreground service which is used to download tracks even when the app is not visible
* *
@ -34,14 +54,14 @@ import timber.log.Timber
* *
* TODO: Migrate this to use the Media3 DownloadHelper * TODO: Migrate this to use the Media3 DownloadHelper
*/ */
class DownloadService : Service() { class DownloadService : Service(), KoinComponent {
private val storageMonitor: ExternalStorageMonitor by inject()
private val binder: IBinder = SimpleServiceBinder(this) private val binder: IBinder = SimpleServiceBinder(this)
private val downloader by inject<Downloader>()
private var mediaSession: MediaSessionCompat? = null
private var isInForeground = false private var isInForeground = false
private var wifiLock: WifiManager.WifiLock? = null
private var isShuttingDown = false
private var retrying = false
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
return binder return binder
@ -54,9 +74,13 @@ class DownloadService : Service() {
createNotificationChannel() createNotificationChannel()
updateNotification() updateNotification()
instance = this if (wifiLock == null) {
startedSemaphore.release() wifiLock = Util.createWifiLock(toString())
Timber.i("DownloadService initiated") wifiLock?.acquire()
}
startFuture?.set(this)
Timber.i("DownloadService created")
} }
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
@ -66,22 +90,116 @@ class DownloadService : Service() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
instance = null startFuture = null
try {
downloader.stop() isShuttingDown = true
isInForeground = false
stopForeground(true)
wifiLock?.release()
wifiLock = null
clearDownloads()
observableDownloads.value = listOf()
mediaSession?.release()
mediaSession = null
} catch (ignored: Throwable) {
}
Timber.i("DownloadService destroyed") Timber.i("DownloadService destroyed")
} }
fun notifyDownloaderStopped() { fun addTracks(tracks: List<DownloadableTrack>) {
Timber.i("DownloadService stopped") downloadQueue.addAll(tracks)
isInForeground = false tracks.forEach { postState(it.track, DownloadState.QUEUED) }
stopForeground(true) processNextTracks()
stopSelf() }
private fun processNextTracks() {
retrying = false
if (
!Util.isNetworkConnected() ||
!Util.isExternalStoragePresent() ||
!storageMonitor.isExternalStorageAvailable
) {
retryProcessNextTracks()
return
}
Timber.v("DownloadService processNextTracks checking downloads")
var listChanged = false
// Fill up active List with waiting tasks
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
val task = downloadQueue.remove()
val downloadTask = DownloadTask(task) { downloadableTrack, downloadState, progress ->
downloadStateChangedCallback(downloadableTrack, downloadState, progress)
}
activelyDownloading[task] = downloadTask
FileUtil.createDirectoryForParent(task.pinnedFile)
activelyDownloading[task]?.start()
listChanged = true
}
// Stop Executor service when done downloading
if (activelyDownloading.isEmpty()) {
stopSelf()
}
if (listChanged) {
updateLiveData()
}
}
private fun retryProcessNextTracks() {
if (isShuttingDown || retrying) return
retrying = true
Handler(Looper.getMainLooper()).postDelayed(
{ if (retrying) processNextTracks() },
CHECK_INTERVAL
)
}
private fun downloadStateChangedCallback(
item: DownloadableTrack,
downloadState: DownloadState,
progress: Int?
) {
postState(item.track, downloadState, progress)
if (downloadState.isFinalState()) {
activelyDownloading.remove(item)
processNextTracks()
}
when (downloadState) {
DownloadState.FAILED -> {
downloadQueue.remove(item)
failedList.add(item)
}
DownloadState.RETRYING -> {
item.tryCount++
downloadQueue.add(item)
}
else -> {}
}
}
private fun updateLiveData() {
val temp: MutableList<Track> = ArrayList()
temp.addAll(activelyDownloading.keys.map { x -> x.track })
temp.addAll(downloadQueue.map { x -> x.track })
observableDownloads.postValue(temp.distinct().sorted())
}
private fun clearDownloads() {
// Clear the pending queue
while (!downloadQueue.isEmpty()) {
postState(downloadQueue.remove().track, DownloadState.IDLE)
}
// Cancel all active downloads
for (download in activelyDownloading) {
download.value.cancel()
}
activelyDownloading.clear()
updateLiveData()
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {
@ -143,75 +261,170 @@ class DownloadService : Service() {
*/ */
@Suppress("SpreadOperator") @Suppress("SpreadOperator")
private fun buildForegroundNotification(): Notification { private fun buildForegroundNotification(): Notification {
notificationBuilder.setContentTitle(getString(R.string.notification_downloading_title))
if (downloader.started) {
// No song is playing, but Ultrasonic is downloading files
notificationBuilder.setContentTitle(
getString(R.string.notification_downloading_title)
)
}
return notificationBuilder.build() return notificationBuilder.build()
} }
@Suppress("MagicNumber") @Suppress("MagicNumber")
companion object { companion object {
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" private var startFuture: SettableFuture<DownloadService>? = null
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
private const val NOTIFICATION_ID = 3033
@Volatile private val downloadQueue = PriorityQueue<DownloadableTrack>()
private var instance: DownloadService? = null private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val instanceLock = Any() private val failedList = mutableListOf<DownloadableTrack>()
private val startedSemaphore: Semaphore = Semaphore(0)
@JvmStatic // The generic list models expect a LiveData, so even though we are using Rx for many events
fun getInstance(): DownloadService? { // surrounding playback the list of Downloads is published as LiveData.
val context = UApp.applicationContext() val observableDownloads = MutableLiveData<List<Track>>()
if (instance != null) return instance
synchronized(instanceLock) {
if (instance != null) return instance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(
Intent(context, DownloadService::class.java)
)
} else {
context.startService(Intent(context, DownloadService::class.java))
}
Timber.i("DownloadService starting...")
if (startedSemaphore.tryAcquire(10, TimeUnit.SECONDS)) {
Timber.i("DownloadService started")
return instance
}
Timber.w("DownloadService failed to start!")
return null
}
}
@JvmStatic private var backgroundPriorityCounter = 100
val runningInstance: DownloadService?
get() {
synchronized(instanceLock) { return instance }
}
@JvmStatic fun download(
fun executeOnStartedDownloadService( tracks: List<Track>,
taskToExecute: (DownloadService) -> Unit save: Boolean,
isHighPriority: Boolean = false
) { ) {
// First handle and filter out those tracks that are already completed
val t: Thread = object : Thread() { var filteredTracks: List<Track>
override fun run() { if (save) {
val instance = getInstance() tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track ->
if (instance == null) { Storage.getFromPath(track.getCompleteFile())?.let {
Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!") Storage.rename(it, track.getPinnedFile())
return postState(track, DownloadState.PINNED)
} else {
taskToExecute(instance)
} }
} }
filteredTracks = tracks.filter { !Storage.isPathExists(it.getPinnedFile()) }
} else {
tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track ->
Storage.getFromPath(track.getPinnedFile())?.let {
Storage.rename(it, track.getCompleteFile())
postState(track, DownloadState.DONE)
}
}
filteredTracks = tracks.filter { !Storage.isPathExists(it.getCompleteFile()) }
}
// Update Pinned flag of items in progress
downloadQueue.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
activelyDownloading.filter { item -> tracks.any { it.id == item.key.id } }
.forEach { it.key.pinned = save }
failedList.filter { item -> tracks.any { it.id == item.id } }
.forEach { it.pinned = save }
filteredTracks = filteredTracks.filter {
!downloadQueue.any { t ->
t.track.id == it.id
} && !activelyDownloading.any { t ->
t.key.track.id == it.id
}
}
// The remainder tracks should be added to the download queue
// By using the counter we ensure that the songs are added in the correct order
var priority = 0
val tracksToDownload =
filteredTracks.map {
DownloadableTrack(
it,
save,
0,
if (isHighPriority) priority++ else backgroundPriorityCounter++
)
}
if (tracksToDownload.isNotEmpty()) addTracks(tracksToDownload)
}
fun requestStop() {
val context = UApp.applicationContext()
val intent = Intent(context, DownloadService::class.java)
context.stopService(intent)
failedList.clear()
}
fun delete(track: Track) {
downloadQueue.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) }
failedList.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) }
cancelDownload(track)
Storage.delete(track.getPartialFile())
Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile())
postState(track, DownloadState.IDLE)
Util.scanMedia(track.getPinnedFile())
}
fun unpin(track: Track) {
// Update Pinned flag of items in progress
downloadQueue.singleOrNull { it.id == track.id }?.pinned = false
activelyDownloading.keys.singleOrNull { it.id == track.id }?.pinned = false
failedList.singleOrNull { it.id == track.id }?.pinned = false
val pinnedFile = track.getPinnedFile()
if (!Storage.isPathExists(pinnedFile)) return
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
Storage.rename(file, track.getCompleteFile())
postState(track, DownloadState.DONE)
}
@Suppress("ReturnCount")
fun getDownloadState(track: Track): DownloadState {
if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE
if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED
if (activelyDownloading.any { it.key.id == track.id }) return DownloadState.QUEUED
if (downloadQueue.any { it.id == track.id }) return DownloadState.QUEUED
val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id }
if (key != null) {
if (key.tryCount > 0) return DownloadState.RETRYING
return DownloadState.DOWNLOADING
}
if (failedList.any { it.track.id == track.id }) return DownloadState.FAILED
return DownloadState.IDLE
}
private fun addTracks(tracks: List<DownloadableTrack>) {
val serviceFuture = startFuture ?: requestStart()
serviceFuture.addListener({
val service = serviceFuture.get()
service.addTracks(tracks)
Timber.i("Added tracks to DownloadService")
}, MoreExecutors.directExecutor())
}
private fun cancelDownload(track: Track) {
val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return
activelyDownloading[key]?.cancel()
}
private fun postState(track: Track, state: DownloadState, progress: Int? = null) {
RxBus.trackDownloadStatePublisher.onNext(
RxBus.TrackDownloadState(
track.id,
state,
progress
)
)
}
private fun requestStart(): ListenableFuture<DownloadService> {
val future = SettableFuture.create<DownloadService>()
startFuture = future
startService()
return future
}
private fun startService() {
val context = UApp.applicationContext()
val intent = Intent(context, DownloadService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
} }
t.start()
} }
} }
} }

View File

@ -0,0 +1,25 @@
/*
* DownloadStatus.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
enum class DownloadState {
IDLE, QUEUED, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN;
companion object {
fun DownloadState.isFinalState(): Boolean {
return when (this) {
RETRYING,
FAILED,
CANCELLED,
DONE,
PINNED -> true
else -> false
}
}
}
}

View File

@ -0,0 +1,259 @@
/*
* DownloadTask.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.os.SystemClock
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
private const val MAX_RETRIES = 5
private const val REFRESH_INTERVAL = 50
class DownloadTask(
private val item: DownloadableTrack,
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit
) :
CancellableTask(), KoinComponent {
val musicService = MusicServiceFactory.getMusicService()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown")
override fun execute() {
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
if (Storage.isPathExists(item.pinnedFile)) {
Timber.i("%s already exists. Skipping.", item.pinnedFile)
stateChangedCallback(item, DownloadState.PINNED, null)
return
}
if (Storage.isPathExists(item.completeFile)) {
var newStatus: DownloadState = DownloadState.DONE
if (item.pinned) {
Storage.rename(
item.completeFile,
item.pinnedFile
)
newStatus = DownloadState.PINNED
} else {
Timber.i(
"%s already exists. Skipping.",
item.completeFile
)
}
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
try {
item.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
stateChangedCallback(item, newStatus, null)
return
}
stateChangedCallback(item, DownloadState.DOWNLOADING, null)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = item.track.duration
val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
needsDownloading = (duration == null || duration == 0 || fileLength == 0L)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
item.track, fileLength,
Settings.maxBitRate,
item.pinned
)
inputStream = inStream
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
.getFileOutputStream(isPartial)
var lastPostTime: Long = 0
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
// Manual throttling to avoid overloading Rx
if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) {
lastPostTime = SystemClock.elapsedRealtime()
stateChangedCallback(
item,
DownloadState.DOWNLOADING,
(totalBytesCopied * 100 / (item.track.size ?: 1)).toInt()
)
}
}
Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
inputStream.close()
outputStream.flush()
outputStream.close()
if (isCancelled) {
stateChangedCallback(item, DownloadState.CANCELLED, null)
throw RuntimeException(
String.format(
Locale.ROOT, "Download of '%s' was cancelled",
item
)
)
}
try {
item.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
}
if (item.pinned) {
Storage.rename(
item.partialFile,
item.pinnedFile
)
stateChangedCallback(item, DownloadState.PINNED, null)
Util.scanMedia(item.pinnedFile)
} else {
Storage.rename(
item.partialFile,
item.completeFile
)
stateChangedCallback(item, DownloadState.DONE, null)
}
} catch (all: Exception) {
outputStream.safeClose()
Storage.delete(item.completeFile)
Storage.delete(item.pinnedFile)
if (!isCancelled) {
if (item.tryCount < MAX_RETRIES) {
stateChangedCallback(item, DownloadState.RETRYING, null)
} else {
stateChangedCallback(item, DownloadState.FAILED, null)
}
Timber.w(all, "Failed to download '%s'.", item)
}
} finally {
inputStream.safeClose()
outputStream.safeClose()
CacheCleaner().cleanSpace()
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", item)
}
private fun Track.cacheMetadataAndArtwork() {
val onlineDB = activeServerProvider.getActiveMetaDatabase()
val offlineDB = activeServerProvider.offlineMetaDatabase
var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId
val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId
var album: Album? = null
// Sometime in compilation albums, the individual tracks won't have an Artist id
// In this case, try to get the ArtistId of the album...
if (artistId == null && albumId != null) {
album = musicService.getAlbum(albumId, null, false)
artistId = album?.artistId
}
// Cache the artist
if (artistId != null)
cacheArtist(onlineDB, offlineDB, artistId)
// Now cache the album
if (albumId != null) {
if (album == null) {
// This is a cached call
val albums = musicService.getAlbumsOfArtist(artistId!!, null, false)
album = albums.find { it.id == albumId }
}
if (album != null) {
// Often the album entity returned from the server won't have the path set.
if (album.path.isNullOrEmpty()) album.path = FileUtil.getParentPath(path)
offlineDB.albumDao().insert(album)
// If the album is a Compilation, also cache the Album artist
if (album.artistId != null && album.artistId != artistId)
cacheArtist(onlineDB, offlineDB, album.artistId!!)
}
}
// Now cache the track data
offlineDB.trackDao().insert(this)
// Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(this)
}
private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) {
var artist: Artist? = onlineDB.artistDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database.
if (artist == null) {
val artists: List<Artist> = musicService.getArtists(true)
artist = artists.find {
it.id == artistId
}
}
// If we have found an artist, cache it.
if (artist != null) {
offlineDB.artistDao().insert(artist)
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}

View File

@ -0,0 +1,32 @@
/*
* DownloadableTrack.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
class DownloadableTrack(
val track: Track,
var pinned: Boolean,
var tryCount: Int,
var priority: Int
) : Identifiable {
val pinnedFile = track.getPinnedFile()
val partialFile = track.getPartialFile()
val completeFile = track.getCompleteFile()
override val id: String
get() = track.id
override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack)
fun compareTo(other: DownloadableTrack): Int {
return priority.compareTo(other.priority)
}
}

View File

@ -1,587 +0,0 @@
/*
* Downloader.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.net.wifi.WifiManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock as SystemClock
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import java.util.PriorityQueue
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose
import org.moire.ultrasonic.util.shouldBePinned
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber
/**
* This class is responsible for maintaining the playlist and downloading
* its items from the network to the filesystem.
*
* TODO: Move entirely to subclass the Media3.DownloadService
*/
class Downloader(
private val storageMonitor: ExternalStorageMonitor,
) : KoinComponent {
// Dependencies
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val mediaController: MediaPlayerController by inject()
var started: Boolean = false
var shouldStop: Boolean = false
var isPolling: Boolean = false
private val downloadQueue = PriorityQueue<DownloadableTrack>()
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val failedList = mutableListOf<DownloadableTrack>()
// The generic list models expect a LiveData, so even though we are using Rx for many events
// surrounding playback the list of Downloads is published as LiveData.
val observableDownloads = MutableLiveData<List<Track>>()
private var handler: Handler = Handler(Looper.getMainLooper())
private var wifiLock: WifiManager.WifiLock? = null
private var backgroundPriorityCounter = 100
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
init {
Timber.i("Init called")
// Check downloads if the playlist changed
rxBusSubscription += RxBus.playlistObservable.subscribe {
Timber.v("Playlist has changed, checking Downloads...")
checkDownloads()
}
}
private var downloadChecker = object : Runnable {
override fun run() {
try {
Timber.w("Checking Downloads")
checkDownloadsInternal()
} catch (all: Exception) {
Timber.e(all, "checkDownloads() failed.")
} finally {
if (!isPolling) {
isPolling = true
if (!shouldStop) {
Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL)
} else {
shouldStop = false
isPolling = false
}
}
}
}
}
fun onDestroy() {
stop()
rxBusSubscription.dispose()
clearBackground()
observableDownloads.value = listOf()
Timber.i("Downloader destroyed")
}
@Synchronized
fun start() {
if (started) return
started = true
// Start our loop
handler.postDelayed(downloadChecker, 100)
if (wifiLock == null) {
wifiLock = Util.createWifiLock(toString())
wifiLock?.acquire()
}
}
fun stop() {
if (!started) return
started = false
shouldStop = true
wifiLock?.release()
wifiLock = null
handler.postDelayed(
Runnable { DownloadService.runningInstance?.notifyDownloaderStopped() },
100
)
Timber.i("Downloader stopped")
}
fun checkDownloads() {
if (!started) {
start()
} else {
try {
handler.postDelayed(downloadChecker, 100)
} catch (all: Exception) {
Timber.w(
all,
"checkDownloads() can't run, maybe the Downloader is shutting down..."
)
}
}
}
@Suppress("ComplexMethod", "ComplexCondition")
@Synchronized
private fun checkDownloadsInternal() {
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
return
}
if (JukeboxMediaPlayer.running.get() || !Util.isNetworkConnected()) {
return
}
Timber.v("Downloader checkDownloadsInternal checking downloads")
var listChanged = false
val playlist = mediaController.getNextPlaylistItemsInPlayOrder(Settings.preloadCount)
var priority = 0
for (item in playlist) {
val track = item.toTrack()
// Add file to queue if not in one of the queues already.
if (getDownloadState(track) == DownloadStatus.IDLE) {
listChanged = true
// If a track is already in the manual download queue,
// and is now due to be played soon we add it to the queue with high priority instead.
val existingItem = downloadQueue.firstOrNull { it.track.id == track.id }
if (existingItem != null) {
existingItem.priority = priority + 1
continue
}
// Set correct priority (the lower the number, the higher the priority)
downloadQueue.add(DownloadableTrack(track, item.shouldBePinned(), 0, priority++))
postState(track, DownloadStatus.QUEUED)
}
}
// Fill up active List with waiting tasks
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
val task = downloadQueue.remove()
val downloadTask = DownloadTask(task)
activelyDownloading[task] = downloadTask
startDownloadOnService(task)
listChanged = true
}
// Stop Executor service when done downloading
if (activelyDownloading.isEmpty()) {
stop()
}
if (listChanged) {
updateLiveData()
}
}
private fun updateLiveData() {
observableDownloads.postValue(downloads)
}
private fun startDownloadOnService(track: DownloadableTrack) {
DownloadService.executeOnStartedDownloadService {
FileUtil.createDirectoryForParent(track.pinnedFile)
activelyDownloading[track]?.start()
Timber.v("startDownloadOnService started downloading file ${track.completeFile}")
}
}
/*
* Returns a list of all DownloadFiles that are currently downloading or waiting for download,
*/
@get:Synchronized
val downloads: List<Track>
get() {
val temp: MutableList<Track> = ArrayList()
temp.addAll(activelyDownloading.keys.map { x -> x.track })
temp.addAll(downloadQueue.map { x -> x.track })
return temp.distinct().sorted()
}
@Synchronized
fun clearBackground() {
// Clear the pending queue
while (!downloadQueue.isEmpty()) {
postState(downloadQueue.remove().track, DownloadStatus.IDLE)
}
// Cancel all active downloads with a low priority
for (key in activelyDownloading.keys) {
if (key.priority >= 100) {
activelyDownloading[key]?.cancel()
activelyDownloading.remove(key)
}
}
backgroundPriorityCounter = 100
}
@Synchronized
fun clearActiveDownloads() {
// Cancel all active downloads
for (download in activelyDownloading) {
download.value.cancel()
}
activelyDownloading.clear()
updateLiveData()
}
@Synchronized
fun downloadBackground(tracks: List<Track>, save: Boolean) {
// By using the counter we ensure that the songs are added in the correct order
for (track in tracks) {
if (downloadQueue.any { t -> t.track.id == track.id } ||
activelyDownloading.any { t -> t.key.track.id == track.id }
) continue
val file = DownloadableTrack(track, save, 0, backgroundPriorityCounter++)
downloadQueue.add(file)
postState(track, DownloadStatus.QUEUED)
}
Timber.v("downloadBackground Checking Downloads")
checkDownloads()
}
fun delete(track: Track) {
cancelDownload(track)
Storage.delete(track.getPartialFile())
Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile())
postState(track, DownloadStatus.IDLE)
Util.scanMedia(track.getPinnedFile())
}
private fun cancelDownload(track: Track) {
val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return
activelyDownloading[key]?.cancel()
}
fun unpin(track: Track) {
val pinnedFile = track.getPinnedFile()
if (!Storage.isPathExists(pinnedFile)) return
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
Storage.rename(file, track.getCompleteFile())
postState(track, DownloadStatus.DONE)
}
@Suppress("ReturnCount")
fun getDownloadState(track: Track): DownloadStatus {
if (Storage.isPathExists(track.getCompleteFile())) return DownloadStatus.DONE
if (Storage.isPathExists(track.getPinnedFile())) return DownloadStatus.PINNED
if (downloads.any { it.id == track.id }) return DownloadStatus.QUEUED
val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id }
if (key != null) {
if (key.tryCount > 0) return DownloadStatus.RETRYING
return DownloadStatus.DOWNLOADING
}
if (failedList.any { it.track.id == track.id }) return DownloadStatus.FAILED
return DownloadStatus.IDLE
}
companion object {
const val CHECK_INTERVAL = 5000L
const val MAX_RETRIES = 5
const val REFRESH_INTERVAL = 50
}
private fun postState(track: Track, state: DownloadStatus, progress: Int? = null) {
RxBus.trackDownloadStatePublisher.onNext(
RxBus.TrackDownloadState(
track.id,
state,
progress
)
)
}
private inner class DownloadTask(private val item: DownloadableTrack) :
CancellableTask() {
val musicService = MusicServiceFactory.getMusicService()
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown")
override fun execute() {
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
if (Storage.isPathExists(item.pinnedFile)) {
Timber.i("%s already exists. Skipping.", item.pinnedFile)
postState(item.track, DownloadStatus.PINNED)
return
}
if (Storage.isPathExists(item.completeFile)) {
var newStatus: DownloadStatus = DownloadStatus.DONE
if (item.pinned) {
Storage.rename(
item.completeFile,
item.pinnedFile
)
newStatus = DownloadStatus.PINNED
} else {
Timber.i(
"%s already exists. Skipping.",
item.completeFile
)
}
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
try {
item.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
postState(item.track, newStatus)
return
}
postState(item.track, DownloadStatus.DOWNLOADING)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = item.track.duration
val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
needsDownloading = (duration == null || duration == 0 || fileLength == 0L)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
item.track, fileLength,
Settings.maxBitRate,
item.pinned
)
inputStream = inStream
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
.getFileOutputStream(isPartial)
var lastPostTime: Long = 0
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
// Manual throttling to avoid overloading Rx
if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) {
lastPostTime = SystemClock.elapsedRealtime()
postState(
item.track,
DownloadStatus.DOWNLOADING,
(totalBytesCopied * 100 / (item.track.size ?: 1)).toInt()
)
}
}
Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
inputStream.close()
outputStream.flush()
outputStream.close()
if (isCancelled) {
postState(item.track, DownloadStatus.CANCELLED)
throw RuntimeException(
String.format(
Locale.ROOT, "Download of '%s' was cancelled",
item
)
)
}
try {
item.track.cacheMetadataAndArtwork()
} catch (ignore: Exception) {
Timber.w(ignore)
}
}
if (item.pinned) {
Storage.rename(
item.partialFile,
item.pinnedFile
)
postState(item.track, DownloadStatus.PINNED)
Util.scanMedia(item.pinnedFile)
} else {
Storage.rename(
item.partialFile,
item.completeFile
)
postState(item.track, DownloadStatus.DONE)
}
} catch (all: Exception) {
outputStream.safeClose()
Storage.delete(item.completeFile)
Storage.delete(item.pinnedFile)
if (!isCancelled) {
if (item.tryCount < MAX_RETRIES) {
postState(item.track, DownloadStatus.RETRYING)
item.tryCount++
activelyDownloading.remove(item)
downloadQueue.add(item)
} else {
postState(item.track, DownloadStatus.FAILED)
activelyDownloading.remove(item)
downloadQueue.remove(item)
failedList.add(item)
}
Timber.w(all, "Failed to download '%s'.", item)
}
} finally {
activelyDownloading.remove(item)
inputStream.safeClose()
outputStream.safeClose()
CacheCleaner().cleanSpace()
Timber.v("DownloadTask checking downloads")
checkDownloads()
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", item)
}
private fun Track.cacheMetadataAndArtwork() {
val onlineDB = activeServerProvider.getActiveMetaDatabase()
val offlineDB = activeServerProvider.offlineMetaDatabase
var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId
val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId
var album: Album? = null
// Sometime in compilation albums, the individual tracks won't have an Artist id
// In this case, try to get the ArtistId of the album...
if (artistId == null && albumId != null) {
album = musicService.getAlbum(albumId, null, false)
artistId = album?.artistId
}
// Cache the artist
if (artistId != null)
cacheArtist(onlineDB, offlineDB, artistId)
// Now cache the album
if (albumId != null) {
if (album == null) {
// This is a cached call
val albums = musicService.getAlbumsOfArtist(artistId!!, null, false)
album = albums.find { it.id == albumId }
}
if (album != null) {
// Often the album entity returned from the server won't have the path set.
if (album.path.isNullOrEmpty()) album.path = FileUtil.getParentPath(path)
offlineDB.albumDao().insert(album)
// If the album is a Compilation, also cache the Album artist
if (album.artistId != null && album.artistId != artistId)
cacheArtist(onlineDB, offlineDB, album.artistId!!)
}
}
// Now cache the track data
offlineDB.trackDao().insert(this)
// Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(this)
}
private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) {
var artist: Artist? = onlineDB.artistDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database.
if (artist == null) {
val artists: List<Artist> = musicService.getArtists(true)
artist = artists.find {
it.id == artistId
}
}
// If we have found an artist, cache it.
if (artist != null) {
offlineDB.artistDao().insert(artist)
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}
private class DownloadableTrack(
val track: Track,
val pinned: Boolean,
var tryCount: Int,
var priority: Int
) : Identifiable {
val pinnedFile = track.getPinnedFile()
val partialFile = track.getPartialFile()
val completeFile = track.getCompleteFile()
override val id: String
get() = track.id
override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack)
fun compareTo(other: DownloadableTrack): Int {
return priority.compareTo(other.priority)
}
}
}
enum class DownloadStatus {
IDLE, QUEUED, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN
}

View File

@ -8,7 +8,6 @@ package org.moire.ultrasonic.service
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
@ -18,7 +17,6 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.REPEAT_MODE_OFF
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
@ -62,7 +60,6 @@ private const val VOLUME_DELTA = 0.05f
class MediaPlayerController( class MediaPlayerController(
private val playbackStateSerializer: PlaybackStateSerializer, private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor, private val externalStorageMonitor: ExternalStorageMonitor,
private val downloader: Downloader,
val context: Context val context: Context
) : KoinComponent { ) : KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
@ -205,23 +202,6 @@ class MediaPlayerController(
Timber.i("MediaPlayerController started") Timber.i("MediaPlayerController started")
} }
fun onDestroy() {
if (!created) return
// First stop listening to events
rxBusSubscription.dispose()
controller?.removeListener(listeners)
releaseController()
// Shutdown the rest
val context = UApp.applicationContext()
externalStorageMonitor.onDestroy()
context.stopService(Intent(context, DownloadService::class.java))
downloader.onDestroy()
created = false
Timber.i("MediaPlayerController destroyed")
}
private fun playerStateChangedHandler() { private fun playerStateChangedHandler() {
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
@ -276,6 +256,20 @@ class MediaPlayerController(
UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, true) UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, true)
} }
fun onDestroy() {
if (!created) return
// First stop listening to events
rxBusSubscription.dispose()
releaseController()
// Shutdown the rest
externalStorageMonitor.onDestroy()
DownloadService.requestStop()
created = false
Timber.i("MediaPlayerController destroyed")
}
@Synchronized @Synchronized
fun restore( fun restore(
state: PlaybackState, state: PlaybackState,
@ -413,7 +407,7 @@ class MediaPlayerController(
fun downloadBackground(songs: List<Track?>?, save: Boolean) { fun downloadBackground(songs: List<Track?>?, save: Boolean) {
if (songs == null) return if (songs == null) return
val filteredSongs = songs.filterNotNull() val filteredSongs = songs.filterNotNull()
downloader.downloadBackground(filteredSongs, save) DownloadService.download(filteredSongs, save)
} }
@set:Synchronized @set:Synchronized
@ -421,8 +415,6 @@ class MediaPlayerController(
get() = controller?.shuffleModeEnabled == true get() = controller?.shuffleModeEnabled == true
set(enabled) { set(enabled) {
controller?.shuffleModeEnabled = enabled controller?.shuffleModeEnabled = enabled
// Changing Shuffle may change the playlist, so the next tracks may need to be downloaded
downloader.checkDownloads()
} }
@Synchronized @Synchronized
@ -459,21 +451,15 @@ class MediaPlayerController(
} }
} }
@Synchronized
fun clearDownloads() {
downloader.clearActiveDownloads()
downloader.clearBackground()
}
@Synchronized @Synchronized
fun removeIncompleteTracksFromPlaylist() { fun removeIncompleteTracksFromPlaylist() {
val list = playlist.toList() val list = playlist.toList()
var removed = 0 var removed = 0
for ((index, item) in list.withIndex()) { for ((index, item) in list.withIndex()) {
val state = downloader.getDownloadState(item.toTrack()) val state = DownloadService.getDownloadState(item.toTrack())
// The track is not downloaded, remove it // The track is not downloaded, remove it
if (state != DownloadStatus.DONE && state != DownloadStatus.PINNED) { if (state != DownloadState.DONE && state != DownloadState.PINNED) {
removeFromPlaylist(index - removed) removeFromPlaylist(index - removed)
removed++ removed++
} }
@ -503,7 +489,7 @@ class MediaPlayerController(
// TODO: Make it require not null // TODO: Make it require not null
fun delete(tracks: List<Track?>) { fun delete(tracks: List<Track?>) {
for (track in tracks.filterNotNull()) { for (track in tracks.filterNotNull()) {
downloader.delete(track) DownloadService.delete(track)
} }
} }
@ -511,7 +497,7 @@ class MediaPlayerController(
// TODO: Make it require not null // TODO: Make it require not null
fun unpin(tracks: List<Track?>) { fun unpin(tracks: List<Track?>) {
for (track in tracks.filterNotNull()) { for (track in tracks.filterNotNull()) {
downloader.unpin(track) DownloadService.unpin(track)
} }
} }
@ -568,7 +554,7 @@ class MediaPlayerController(
val currentPlaylist = playlist val currentPlaylist = playlist
val currentIndex = controller?.currentMediaItemIndex ?: 0 val currentIndex = controller?.currentMediaItemIndex ?: 0
val currentPosition = controller?.currentPosition ?: 0 val currentPosition = controller?.currentPosition ?: 0
downloader.clearActiveDownloads() DownloadService.requestStop()
controller?.pause() controller?.pause()
controller?.stop() controller?.stop()
val oldController = controller val oldController = controller
@ -741,7 +727,7 @@ class MediaPlayerController(
val playlist: List<MediaItem> val playlist: List<MediaItem>
get() { get() {
return getPlayList(false) return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
} }
fun getMediaItemAt(index: Int): MediaItem? { fun getMediaItemAt(index: Int): MediaItem? {
@ -750,38 +736,12 @@ class MediaPlayerController(
val playlistInPlayOrder: List<MediaItem> val playlistInPlayOrder: List<MediaItem>
get() { get() {
return getPlayList(controller?.shuffleModeEnabled ?: false) return Util.getPlayListFromTimeline(
controller?.currentTimeline,
controller?.shuffleModeEnabled ?: false
)
} }
fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List<MediaItem> {
return getPlayList(
controller?.shuffleModeEnabled ?: false,
controller?.currentMediaItemIndex,
count
)
}
private fun getPlayList(
shuffle: Boolean,
firstIndex: Int? = null,
count: Int? = null
): List<MediaItem> {
if (controller?.currentTimeline == null) return emptyList()
if (controller!!.currentTimeline.windowCount < 1) return emptyList()
val timeline = controller!!.currentTimeline
val playlist: MutableList<MediaItem> = mutableListOf()
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
if (i == C.INDEX_UNSET) return emptyList()
while (i != C.INDEX_UNSET && (count != playlist.count())) {
val window = timeline.getWindow(i, Timeline.Window())
playlist.add(window.mediaItem)
i = timeline.getNextWindowIndex(i, REPEAT_MODE_OFF, shuffle)
}
return playlist
}
val playListDuration: Long val playListDuration: Long
get() = playlist.fold(0) { i, file -> get() = playlist.fold(0) { i, file ->
i + (file.mediaMetadata.extras?.getInt("duration") ?: 0) i + (file.mediaMetadata.extras?.getInt("duration") ?: 0)

View File

@ -95,7 +95,7 @@ class RxBus {
data class TrackDownloadState( data class TrackDownloadState(
val id: String, val id: String,
val state: DownloadStatus, val state: DownloadState,
val progress: Int? val progress: Int?
) )

View File

@ -33,6 +33,10 @@ import android.view.Gravity
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import java.io.Closeable import java.io.Closeable
import java.io.UnsupportedEncodingException import java.io.UnsupportedEncodingException
import java.security.MessageDigest import java.security.MessageDigest
@ -656,6 +660,27 @@ object Util {
) )
} }
fun getPlayListFromTimeline(
timeline: Timeline?,
shuffle: Boolean,
firstIndex: Int? = null,
count: Int? = null
): List<MediaItem> {
if (timeline == null) return emptyList()
if (timeline.windowCount < 1) return emptyList()
val playlist: MutableList<MediaItem> = mutableListOf()
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
if (i == C.INDEX_UNSET) return emptyList()
while (i != C.INDEX_UNSET && (count != playlist.count())) {
val window = timeline.getWindow(i, Timeline.Window())
playlist.add(window.mediaItem)
i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle)
}
return playlist
}
fun getPendingIntentToShowPlayer(context: Context): PendingIntent { fun getPendingIntentToShowPlayer(context: Context): PendingIntent {
val intent = Intent(context, NavigationActivity::class.java) val intent = Intent(context, NavigationActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)